From 897b4b98215aecb86144c20b1ed91f1370f16d2f Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 00:02:42 -0230 Subject: [PATCH 01/34] feat: add askUserQuestion tool --- src/tool/ask_user_question.rs | 318 ++++++++++++++++++++++++++++ src/tool/ask_user_question_tests.rs | 137 ++++++++++++ src/tool/mod.rs | 7 + 3 files changed, 462 insertions(+) create mode 100644 src/tool/ask_user_question.rs create mode 100644 src/tool/ask_user_question_tests.rs diff --git a/src/tool/ask_user_question.rs b/src/tool/ask_user_question.rs new file mode 100644 index 000000000..59805e161 --- /dev/null +++ b/src/tool/ask_user_question.rs @@ -0,0 +1,318 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::bus::{Bus, BusEvent, SidePanelUpdated}; +use anyhow::{Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +pub struct AskUserQuestionTool; + +impl AskUserQuestionTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct AskUserQuestionInput { + /// Short natural-language label for compact tool display. + #[serde(default)] + intent: Option, + /// The question to ask the user. + question: String, + /// Optional context shown above the choices. + #[serde(default)] + context: Option, + /// Candidate answers. Exactly one should normally be marked recommended. + options: Vec, + /// Allow the user to select more than one option. + #[serde(default)] + allow_multiple: bool, + /// Optional side panel page id. Defaults to `ask-user-question` so repeated calls update the same page. + #[serde(default)] + page_id: Option, + /// Optional side panel title. + #[serde(default)] + title: Option, + /// Focus the generated side-panel page. Defaults to true. + #[serde(default)] + focus: Option, + /// Optional reply instructions. Defaults to a concise option-id reply format. + #[serde(default)] + reply_instructions: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct QuestionOption { + /// Stable choice id shown to the user, such as `A`, `B`, `keep`, or `rec`. + #[serde(default)] + id: Option, + /// Human-readable option label. + label: String, + /// Optional exact value the agent should apply if this option is selected. + #[serde(default)] + value: Option, + /// Optional explanation/notes for this option. + #[serde(default)] + description: Option, + /// Whether this is the agent's recommended option. + #[serde(default)] + recommended: bool, + /// Why this option is recommended. Displayed only for recommended options. + #[serde(default)] + recommendation_reason: Option, +} + +#[async_trait] +impl Tool for AskUserQuestionTool { + fn name(&self) -> &str { + "askUserQuestion" + } + + fn description(&self) -> &str { + concat!( + "Ask the user a structured multiple-choice question by creating a focused side-panel quiz. ", + "Use this when user confirmation or preference selection would be easier as choices. ", + "Highlight one recommended option and explain why. The user answers in chat with the option id or custom value." + ) + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["question", "options"], + "properties": { + "intent": super::intent_schema_property(), + "question": { + "type": "string", + "description": "The question to ask the user." + }, + "context": { + "type": "string", + "description": "Optional context shown before the choices." + }, + "options": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["label"], + "properties": { + "id": { + "type": "string", + "description": "Stable option id, e.g. A, B, keep, rec. Auto-generated as A/B/C if omitted." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + }, + "value": { + "type": "string", + "description": "Exact value to apply if selected." + }, + "description": { + "type": "string", + "description": "Optional explanation/notes for this option." + }, + "recommended": { + "type": "boolean", + "description": "Whether this option is recommended. Prefer exactly one recommended option." + }, + "recommendation_reason": { + "type": "string", + "description": "Why this option is recommended." + } + } + } + }, + "allow_multiple": { + "type": "boolean", + "description": "Allow multiple options to be selected. Defaults to false." + }, + "page_id": { + "type": "string", + "description": "Side panel page id. Defaults to ask-user-question." + }, + "title": { + "type": "string", + "description": "Side panel title. Defaults to Question." + }, + "focus": { + "type": "boolean", + "description": "Focus the side panel page. Defaults to true." + }, + "reply_instructions": { + "type": "string", + "description": "Optional instructions for how the user should answer." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let params: AskUserQuestionInput = serde_json::from_value(input)?; + if params.options.is_empty() { + bail!("askUserQuestion requires at least one option"); + } + + let page_id = params + .page_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .unwrap_or("ask-user-question"); + let title = params + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or("Question"); + let focus = params.focus.unwrap_or(true); + let content = render_question_markdown(¶ms); + + let snapshot = crate::side_panel::write_markdown_page( + &ctx.session_id, + page_id, + Some(title), + &content, + focus, + )?; + Bus::global().publish(BusEvent::SidePanelUpdated(SidePanelUpdated { + session_id: ctx.session_id.clone(), + snapshot: snapshot.clone(), + })); + + let recommended = params + .options + .iter() + .enumerate() + .filter(|(_, option)| option.recommended) + .map(|(idx, option)| option_id(idx, option)) + .collect::>(); + let response_hint = params.reply_instructions.clone().unwrap_or_else(|| { + if params.allow_multiple { + "Ask the user to reply with one or more option ids, or a custom value.".to_string() + } else { + "Ask the user to reply with one option id, or a custom value.".to_string() + } + }); + + Ok(ToolOutput::new(format!( + "Question displayed in side panel page `{page_id}`. Recommended: {}. {response_hint}", + if recommended.is_empty() { + "none".to_string() + } else { + recommended.join(", ") + } + )) + .with_title("askUserQuestion") + .with_metadata(json!({ + "page_id": page_id, + "title": title, + "recommended": recommended, + "allow_multiple": params.allow_multiple, + "question": params.question, + "options": params.options, + }))) + } +} + +fn render_question_markdown(params: &AskUserQuestionInput) -> String { + let mut out = String::new(); + out.push_str("# Question\n\n"); + out.push_str(¶ms.question); + out.push_str("\n\n"); + + if let Some(context) = params + .context + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + out.push_str("## Context\n\n"); + out.push_str(context); + out.push_str("\n\n"); + } + + out.push_str("## Options\n\n"); + for (idx, option) in params.options.iter().enumerate() { + let id = option_id(idx, option); + if option.recommended { + out.push_str(&format!( + "### ✅ {id}. {} **(recommended)**\n\n", + option.label + )); + } else { + out.push_str(&format!("### {id}. {}\n\n", option.label)); + } + + if let Some(value) = option + .value + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + out.push_str(&format!("- Value: `{value}`\n")); + } + if let Some(description) = option + .description + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + out.push_str(&format!("- Notes: {description}\n")); + } + if option.recommended { + if let Some(reason) = option + .recommendation_reason + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + out.push_str(&format!("- Why recommended: {reason}\n")); + } + } + out.push('\n'); + } + + out.push_str("## How to answer\n\n"); + if let Some(instructions) = params + .reply_instructions + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + out.push_str(instructions); + } else if params.allow_multiple { + out.push_str("Reply in chat with one or more option IDs, for example: `A C`, or provide a custom value."); + } else { + out.push_str( + "Reply in chat with one option ID, for example: `A`, or provide a custom value.", + ); + } + out.push('\n'); + out +} + +fn option_id(idx: usize, option: &QuestionOption) -> String { + option + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| auto_option_id(idx)) +} + +fn auto_option_id(idx: usize) -> String { + // A..Z, then 27, 28, ... to avoid surprising AA-style ids in a compact UI. + if idx < 26 { + ((b'A' + idx as u8) as char).to_string() + } else { + (idx + 1).to_string() + } +} + +#[cfg(test)] +#[path = "ask_user_question_tests.rs"] +mod ask_user_question_tests; diff --git a/src/tool/ask_user_question_tests.rs b/src/tool/ask_user_question_tests.rs new file mode 100644 index 000000000..6e228a1f7 --- /dev/null +++ b/src/tool/ask_user_question_tests.rs @@ -0,0 +1,137 @@ +use super::*; +use serde_json::json; + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set_path(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + crate::env::set_var(self.key, previous); + } else { + crate::env::remove_var(self.key); + } + } +} + +fn test_ctx() -> ToolContext { + ToolContext { + session_id: "ses_ask_user_question_tool".to_string(), + message_id: "msg1".to_string(), + tool_call_id: "tool1".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::AgentTurn, + } +} + +#[tokio::test] +async fn ask_user_question_writes_recommended_quiz_page() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + + let tool = AskUserQuestionTool::new(); + let output = tool + .execute( + json!({ + "question": "Set diagram rendering?", + "context": "We are tuning Jcode config.", + "page_id": "config-question", + "title": "Config Question", + "options": [ + {"id": "keep", "label": "Keep current", "value": "none"}, + { + "id": "rec", + "label": "Use inline diagrams", + "value": "inline", + "recommended": true, + "recommendation_reason": "Mermaid diagrams render directly in the chat/side panel." + } + ] + }), + test_ctx(), + ) + .await + .expect("tool execute"); + + assert!(output.output.contains("config-question")); + assert!(output.output.contains("Recommended: rec")); + assert_eq!(output.title.as_deref(), Some("askUserQuestion")); + + let snapshot = + crate::side_panel::snapshot_for_session("ses_ask_user_question_tool").expect("snapshot"); + assert_eq!(snapshot.focused_page_id.as_deref(), Some("config-question")); + let page = snapshot + .pages + .iter() + .find(|page| page.id == "config-question") + .expect("question page"); + assert_eq!(page.title, "Config Question"); + assert!(page.content.contains("# Question")); + assert!(page.content.contains("Set diagram rendering?")); + assert!( + page.content + .contains("### ✅ rec. Use inline diagrams **(recommended)**") + ); + assert!(page.content.contains("Why recommended")); + assert!(page.content.contains("Reply in chat with one option ID")); +} + +#[tokio::test] +async fn ask_user_question_generates_option_ids_and_rejects_empty_options() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + + let tool = AskUserQuestionTool::new(); + let output = tool + .execute( + json!({ + "question": "Pick one", + "options": [ + {"label": "Alpha"}, + {"label": "Beta", "recommended": true} + ], + "allow_multiple": true + }), + test_ctx(), + ) + .await + .expect("tool execute"); + + assert!(output.output.contains("Recommended: B")); + let snapshot = + crate::side_panel::snapshot_for_session("ses_ask_user_question_tool").expect("snapshot"); + let page = snapshot + .pages + .iter() + .find(|page| page.id == "ask-user-question") + .expect("default page"); + assert!(page.content.contains("### A. Alpha")); + assert!(page.content.contains("### ✅ B. Beta **(recommended)**")); + assert!(page.content.contains("one or more option IDs")); + + let err = tool + .execute( + json!({ + "question": "Empty?", + "options": [] + }), + test_ctx(), + ) + .await + .expect_err("empty options should fail"); + assert!(err.to_string().contains("at least one option")); +} diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 9ce518a8f..d8bbc2768 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -1,6 +1,7 @@ mod agentgrep; pub mod ambient; mod apply_patch; +mod ask_user_question; mod bash; mod batch; mod bg; @@ -127,6 +128,12 @@ impl Registry { "side_panel", side_panel::SidePanelTool::new, ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "askUserQuestion", + ask_user_question::AskUserQuestionTool::new, + ); Self::insert_tool_timed(&mut m, &mut timings, "edit", edit::EditTool::new); Self::insert_tool_timed( &mut m, From 3b1b691d0309e6257043d098b45d186505f91b0b Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 09:40:17 -0230 Subject: [PATCH 02/34] feat: implement ToolSearch deferred-tools registry Adds a real ToolSearch implementation that lets Anthropic OAuth sessions discover and unlock registry tools at runtime, including the previously unreachable askUserQuestion. - New tool: src/tool/tool_search.rs (15-entry curated deferred registry, scored matching, per-session unlock state) - Registry::definitions_for_names: resolve specific tools by registry key - Agent::tool_definitions appends unlocked tools (works around the locked_tools cache so unlocks mid-session reach the API) - AnthropicProvider::format_tools OAuth branch now passes through any unlocked tools alongside the 10 hardcoded Claude Code tools, with ephemeral cache_control moved to the actual last tool Tests: 5 tool_search unit tests + existing ask_user_question tests pass. --- src/agent/turn_execution.rs | 38 +++- src/provider/anthropic.rs | 29 ++- src/tool/mod.rs | 30 +++ src/tool/tool_search.rs | 357 ++++++++++++++++++++++++++++++++++++ 4 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 src/tool/tool_search.rs diff --git a/src/agent/turn_execution.rs b/src/agent/turn_execution.rs index 748c86317..ff1deae86 100644 --- a/src/agent/turn_execution.rs +++ b/src/agent/turn_execution.rs @@ -282,7 +282,27 @@ impl Agent { // Return locked tools if available (prevents cache invalidation from // MCP tools arriving asynchronously after the first API request) if let Some(ref locked) = self.locked_tools { - return locked.clone(); + // Even when the base list is locked, append any tools the agent + // unlocked via ToolSearch since the lock was taken. Without this, + // tools unlocked mid-session would never reach the API. + let unlocked = + crate::tool::tool_search::unlocked_for_session(&self.session.id); + if unlocked.is_empty() { + return locked.clone(); + } + let already: std::collections::HashSet<&str> = + locked.iter().map(|t| t.name.as_str()).collect(); + let mut tools = locked.clone(); + let extra = self + .registry + .definitions_for_names(&unlocked) + .await; + for def in extra { + if !already.contains(def.name.as_str()) { + tools.push(def); + } + } + return tools; } let mut tools = self.registry.definitions(self.allowed_tools.as_ref()).await; @@ -290,6 +310,22 @@ impl Agent { tools.retain(|tool| tool.name != "selfdev"); } + // Append any tools unlocked via ToolSearch that aren't already in the + // base list. For non-OAuth providers this is a no-op since everything + // is already advertised; for OAuth this is what makes deferred tools + // reachable after a ToolSearch call. + let unlocked = crate::tool::tool_search::unlocked_for_session(&self.session.id); + if !unlocked.is_empty() { + let already: std::collections::HashSet = + tools.iter().map(|t| t.name.clone()).collect(); + let extra = self.registry.definitions_for_names(&unlocked).await; + for def in extra { + if !already.contains(&def.name) { + tools.push(def); + } + } + } + // Lock the tool list on first call to prevent cache invalidation // when MCP tools arrive asynchronously mid-session logging::info(&format!( diff --git a/src/provider/anthropic.rs b/src/provider/anthropic.rs index a146df83c..ba7078890 100644 --- a/src/provider/anthropic.rs +++ b/src/provider/anthropic.rs @@ -771,7 +771,7 @@ impl AnthropicProvider { /// Adds cache_control to the last tool for prompt caching fn format_tools(&self, tools: &[ToolDefinition], is_oauth: bool) -> Vec { if is_oauth { - return vec![ + let mut hardcoded = vec![ ApiTool { name: "Agent".to_string(), description: "Launch a new agent to handle complex, multi-step tasks." @@ -834,9 +834,34 @@ impl AnthropicProvider { name: "Write".to_string(), description: "Writes a file to the local filesystem.".to_string(), input_schema: json!({"type":"object","properties":{"file_path":{"type":"string"},"content":{"type":"string"}},"required":["file_path","content"],"additionalProperties":false}), - cache_control: Some(CacheControlParam::ephemeral()), + cache_control: None, }, ]; + + // Append any deferred tools the agent has unlocked via ToolSearch. + // The agent passes them through `tools` (in addition to the hardcoded + // OAuth surface) and we forward only the ones not already advertised. + let hardcoded_names: std::collections::HashSet = + hardcoded.iter().map(|t| t.name.clone()).collect(); + for tool in tools { + if hardcoded_names.contains(&tool.name) { + continue; + } + hardcoded.push(ApiTool { + name: tool.name.clone(), + description: tool.description.clone(), + input_schema: tool.input_schema.clone(), + cache_control: None, + }); + } + + // Move ephemeral cache_control to the actual last tool so caching + // still works across the dynamic suffix. + if let Some(last) = hardcoded.last_mut() { + last.cache_control = Some(CacheControlParam::ephemeral()); + } + + return hardcoded; } let len = tools.len(); diff --git a/src/tool/mod.rs b/src/tool/mod.rs index d8bbc2768..10e5b4162 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -30,6 +30,7 @@ mod side_panel; mod skill; mod task; mod todo; +pub mod tool_search; mod webfetch; mod websearch; mod write; @@ -134,6 +135,12 @@ impl Registry { "askUserQuestion", ask_user_question::AskUserQuestionTool::new, ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "ToolSearch", + tool_search::ToolSearchTool::new, + ); Self::insert_tool_timed(&mut m, &mut timings, "edit", edit::EditTool::new); Self::insert_tool_timed( &mut m, @@ -302,6 +309,29 @@ impl Registry { tools.keys().cloned().collect() } + /// Resolve a specific subset of tools by registry key. Unknown names are + /// silently skipped. Used by ToolSearch to surface deferred tools. + pub async fn definitions_for_names( + &self, + names: &HashSet, + ) -> Vec { + let tools = self.tools.read().await; + let mut defs: Vec = names + .iter() + .filter_map(|name| { + tools.get(name).map(|tool| { + let mut def = tool.to_definition(); + if def.name != *name { + def.name = name.clone(); + } + def + }) + }) + .collect(); + defs.sort_by(|a, b| a.name.cmp(&b.name)); + defs + } + /// Enable test mode for memory tools (isolated storage) /// Called when session is marked as debug pub async fn enable_memory_test_mode(&self) { diff --git a/src/tool/tool_search.rs b/src/tool/tool_search.rs new file mode 100644 index 000000000..0afa58241 --- /dev/null +++ b/src/tool/tool_search.rs @@ -0,0 +1,357 @@ +//! ToolSearch: deferred-tool discovery. +//! +//! Under Anthropic OAuth (Claude Code), only a fixed set of tools is advertised +//! up front. ToolSearch lets the model discover and unlock additional tools +//! from the local registry at runtime by querying with a natural-language +//! string. Unlocked tools are then included in the next API request's tool +//! list so the model can actually call them. + +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, OnceLock}; + +/// Session-scoped registry of tools that have been unlocked via ToolSearch. +/// +/// Keyed by `session_id`. The agent reads from this when assembling the tool +/// list for the next API request. +fn unlocked_store() -> &'static Mutex>> { + static STORE: OnceLock>>> = OnceLock::new(); + STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Tools that ToolSearch is allowed to surface. Excludes the always-on OAuth +/// hardcoded tools (those are already callable) and excludes internal tools +/// that shouldn't be model-callable directly. +/// +/// Names here are the registry keys (the names the model should use when +/// calling the tool). Descriptions and keywords are used for matching. +fn searchable_registry() -> Vec<(&'static str, &'static str, &'static str)> { + // (registry_name, short_description, search_keywords) + vec![ + ( + "askUserQuestion", + "Ask the user a structured multiple-choice question with a recommended option.", + "ask user question prompt choose confirm preference quiz options recommend", + ), + ( + "webfetch", + "Fetch a URL and return its contents.", + "fetch http url web download page content webfetch", + ), + ( + "websearch", + "Search the web and return results.", + "search web google find query results websearch", + ), + ( + "open", + "Open a file, URL, or application using the system handler.", + "open launch file url application reveal", + ), + ( + "todo", + "Manage a structured todo list for the current task.", + "todo task list plan steps checklist progress todowrite", + ), + ( + "batch", + "Run multiple tool calls in a single batched invocation.", + "batch parallel multiple tools group", + ), + ( + "patch", + "Apply a patch to a file using a structured diff.", + "patch diff apply file edit", + ), + ( + "multiedit", + "Perform multiple edits to one file in a single call.", + "multi edit multiple changes file replace multiedit", + ), + ( + "apply_patch", + "Apply a v4a-format patch across one or more files.", + "apply patch diff files multi-file change", + ), + ( + "lsp", + "Query the language server for symbols, references, diagnostics.", + "lsp language server symbol reference diagnostic definition hover", + ), + ( + "codesearch", + "Semantic code search over the workspace.", + "code search semantic find symbol function codesearch", + ), + ( + "conversation_search", + "Search past conversations and journal entries.", + "conversation search history past journal", + ), + ( + "side_panel", + "Create or update a side-panel page with rich markdown content.", + "side panel page markdown ui display sidepanel", + ), + ( + "memory", + "Read, write, and manage long-term memory entries.", + "memory remember recall note long term storage", + ), + ( + "goal", + "Manage long-running goals with milestones and checkpoints.", + "goal milestone checkpoint long task plan", + ), + ] +} + +/// Map a ToolSearch-surfaced name to the registry key. Currently a no-op +/// because ToolSearch surfaces registry keys directly, but kept as a hook for +/// future display-name vs registry-key divergence. +pub fn registry_key_for_search_name(name: &str) -> &str { + name +} + +/// Mark `tool_name` as unlocked for `session_id`. +pub fn unlock_tool(session_id: &str, tool_name: &str) { + let mut map = unlocked_store().lock().expect("unlocked tools mutex"); + map.entry(session_id.to_string()) + .or_default() + .insert(tool_name.to_string()); +} + +/// Get a snapshot of unlocked tools for `session_id` (registry-key form). +pub fn unlocked_for_session(session_id: &str) -> HashSet { + let map = unlocked_store().lock().expect("unlocked tools mutex"); + map.get(session_id).cloned().unwrap_or_default() +} + +/// Clear unlocked tools for `session_id` (e.g. on session reset). +#[allow(dead_code)] +pub fn clear_session(session_id: &str) { + let mut map = unlocked_store().lock().expect("unlocked tools mutex"); + map.remove(session_id); +} + +pub struct ToolSearchTool; + +impl ToolSearchTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct ToolSearchInput { + #[serde(default)] + intent: Option, + /// Free-text query, e.g. "ask the user a question" or "fetch a URL". + query: String, + /// Maximum number of results to return. Defaults to 5. + #[serde(default)] + max_results: Option, +} + +#[async_trait] +impl Tool for ToolSearchTool { + fn name(&self) -> &str { + "ToolSearch" + } + + fn description(&self) -> &str { + concat!( + "Fetches full schema definitions for deferred tools so they can be called. ", + "Use this when you need a capability beyond the always-available core tools ", + "(Bash, Read, Write, Edit, Glob, Grep, Agent, Skill, ScheduleWakeup). ", + "Returns matching tool names with their input schemas. ", + "After ToolSearch returns, the matched tools become callable on the next turn." + ) + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["query", "max_results"], + "properties": { + "intent": super::intent_schema_property(), + "query": { + "type": "string", + "description": "Natural-language description of the capability you need." + }, + "max_results": { + "type": "number", + "description": "Maximum number of results to return.", + "default": 5 + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let params: ToolSearchInput = serde_json::from_value(input)?; + let max_results = params.max_results.unwrap_or(5).max(1).min(20); + let query = params.query.trim().to_lowercase(); + + let scored = score_matches(&query, &searchable_registry(), max_results); + + if scored.is_empty() { + return Ok(ToolOutput::new(format!( + "No deferred tools matched query: {:?}. Core tools (Bash, Read, Write, Edit, Glob, Grep, Agent, Skill, ScheduleWakeup) are always available.", + params.query + )) + .with_title("ToolSearch")); + } + + // Unlock matched tools for this session so the next API request + // includes them in the tools array. + let mut matched_summaries: Vec = Vec::with_capacity(scored.len()); + for entry in &scored { + let registry_key = registry_key_for_search_name(entry.name); + unlock_tool(&ctx.session_id, registry_key); + matched_summaries.push(json!({ + "name": entry.name, + "description": entry.description, + "score": entry.score, + })); + } + + let mut text = String::new(); + text.push_str(&format!( + "Found {} matching tool(s) for {:?}. These tools are now callable on subsequent turns:\n\n", + scored.len(), + params.query + )); + for entry in &scored { + text.push_str(&format!("- `{}` — {}\n", entry.name, entry.description)); + } + text.push_str("\nCall any of these tools by name in your next tool_use block."); + + Ok(ToolOutput::new(text) + .with_title("ToolSearch") + .with_metadata(json!({ + "query": params.query, + "matches": matched_summaries, + "unlocked_for_session": ctx.session_id, + }))) + } +} + +struct ScoredEntry { + name: &'static str, + description: &'static str, + score: i64, +} + +fn score_matches( + query: &str, + entries: &[(&'static str, &'static str, &'static str)], + max_results: usize, +) -> Vec { + let q_terms: Vec<&str> = query + .split(|c: char| !c.is_alphanumeric()) + .filter(|t| !t.is_empty()) + .collect(); + if q_terms.is_empty() { + return Vec::new(); + } + + let mut scored: Vec = entries + .iter() + .filter_map(|(name, desc, keywords)| { + let haystack = format!( + "{} {} {}", + name.to_lowercase(), + desc.to_lowercase(), + keywords.to_lowercase() + ); + let mut score: i64 = 0; + for term in &q_terms { + if haystack.contains(term) { + score += 10; + if name.to_lowercase().contains(term) { + score += 20; + } + } + } + if score > 0 { + Some(ScoredEntry { + name, + description: desc, + score, + }) + } else { + None + } + }) + .collect(); + + scored.sort_by(|a, b| b.score.cmp(&a.score).then(a.name.cmp(b.name))); + scored.truncate(max_results); + scored +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_ask_user_question_for_natural_language_queries() { + let entries = searchable_registry(); + let cases = [ + "ask the user a question", + "ask user question", + "prompt the user for confirmation", + "askuserquestion", + ]; + for q in cases { + let results = score_matches(&q.to_lowercase(), &entries, 5); + assert!( + results.iter().any(|r| r.name == "askUserQuestion"), + "query {:?} did not surface askUserQuestion (got {:?})", + q, + results.iter().map(|r| r.name).collect::>() + ); + } + } + + #[test] + fn finds_webfetch_for_fetch_query() { + let entries = searchable_registry(); + let results = score_matches("fetch a url", &entries, 5); + assert!(results.iter().any(|r| r.name == "webfetch")); + } + + #[test] + fn empty_query_returns_nothing() { + let entries = searchable_registry(); + let results = score_matches("", &entries, 5); + assert!(results.is_empty()); + } + + #[test] + fn unlock_and_read_roundtrip() { + let sid = "test-session-tool-search-unlock"; + clear_session(sid); + assert!(unlocked_for_session(sid).is_empty()); + unlock_tool(sid, "askUserQuestion"); + unlock_tool(sid, "webfetch"); + let set = unlocked_for_session(sid); + assert!(set.contains("askUserQuestion")); + assert!(set.contains("webfetch")); + clear_session(sid); + assert!(unlocked_for_session(sid).is_empty()); + } + + #[test] + fn registry_key_mapping_covers_all_entries() { + for (name, _, _) in searchable_registry() { + let key = registry_key_for_search_name(name); + assert!(!key.is_empty(), "no registry key for search name {}", name); + } + } +} From c2888788d51bd4343f004829dfc60cbe37f6d55d Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 10:24:06 -0230 Subject: [PATCH 03/34] feat: interactive TUI modal for askUserQuestion Replace the side-panel markdown rendering with a real blocking overlay: - Arrow keys / j-k to navigate options, Enter to pick - Recommended option pre-selected for fast Enter-to-confirm - Synthetic "Other (type custom answer)" row switches into free-form text input mode where the user can type any reply - Space toggles options in multi-select mode - Letter shortcuts (A, B, ...) jump to the matching option id - Esc cancels and the tool returns a Canceled outcome - 1h timeout fallback if no answer arrives Architecture: - New crate::ask_user module exposes a global pending-request registry keyed by request_id with a oneshot sender/receiver pair, mirroring the existing StdinInputRequest pattern. - BusEvent::AskUserQuestionOpened lets the tool publish a question for the TUI to render without holding direct App state. - src/tui/ask_user_modal.rs is the ratatui overlay widget; centered, uses Clear for full opacity, drawn last in draw() so it sits on top of chat + side panel. - src/tui/app/ask_user_modal_app.rs is the App glue (open / dispatch keys / submit answer). - handle_modal_key short-circuits to the modal when visible so the user gets full input focus. Side-panel close: Alt+M already toggles visibility (documented in help). Mermaid render: gated behind JCODE_ENABLE_MERMAID=1 and the mermaid-renderer feature in jcode-tui-markdown which is not enabled by default. Not addressed in this commit; tracked separately. 17 tests pass: 9 modal unit tests + 4 tool integration tests with unique session ids + 3 ask_user registry tests + 1 tool_search test. --- Cargo.toml | 5 + crates/jcode-provider-core/src/models.rs | 13 + crates/jcode-provider-core/src/pricing.rs | 2 +- src/ask_user.rs | 155 +++++ src/bin/publish_selfdev.rs | 9 + src/bin/tui_bench.rs | 6 + src/bus.rs | 4 + src/lib.rs | 1 + src/tool/ask_user_question.rs | 305 +++++----- src/tool/ask_user_question_tests.rs | 222 ++++--- src/tui/app.rs | 3 + src/tui/app/ask_user_modal_app.rs | 81 +++ src/tui/app/conversation_state.rs | 4 + src/tui/app/input.rs | 8 + src/tui/app/local.rs | 8 + src/tui/app/tui_lifecycle.rs | 2 + src/tui/app/tui_state.rs | 6 + src/tui/ask_user_modal.rs | 697 ++++++++++++++++++++++ src/tui/mod.rs | 4 + src/tui/ui.rs | 7 + src/tui/ui_tests/mod.rs | 5 + 21 files changed, 1294 insertions(+), 253 deletions(-) create mode 100644 src/ask_user.rs create mode 100644 src/bin/publish_selfdev.rs create mode 100644 src/tui/app/ask_user_modal_app.rs create mode 100644 src/tui/ask_user_modal.rs diff --git a/Cargo.toml b/Cargo.toml index d20afc0d4..f11bac502 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,11 @@ name = "tui_bench" path = "src/bin/tui_bench.rs" required-features = ["dev-bins"] +[[bin]] +name = "publish_selfdev" +path = "src/bin/publish_selfdev.rs" +required-features = ["dev-bins"] + [dependencies] # Memory allocator (reduces fragmentation for long-running server) tikv-jemallocator = { version = "0.6", features = ["unprefixed_malloc_on_supported_platforms"], optional = true } diff --git a/crates/jcode-provider-core/src/models.rs b/crates/jcode-provider-core/src/models.rs index 9003810b0..5c703c0ad 100644 --- a/crates/jcode-provider-core/src/models.rs +++ b/crates/jcode-provider-core/src/models.rs @@ -1,5 +1,9 @@ /// Available Claude models used by model lists and provider routing. pub const ALL_CLAUDE_MODELS: &[&str] = &[ + "claude-opus-4-7", + "claude-opus-4-7[1m]", + "claude-sonnet-4-7", + "claude-sonnet-4-7[1m]", "claude-opus-4-6", "claude-opus-4-6[1m]", "claude-sonnet-4-6", @@ -14,6 +18,7 @@ pub const ALL_CLAUDE_MODELS: &[&str] = &[ pub const ALL_OPENAI_MODELS: &[&str] = &[ "gpt-5.5", "gpt-5.4", + "gpt-5.4-mini", "gpt-5.4-pro", "gpt-5.3-codex", "gpt-5.3-codex-spark", @@ -174,6 +179,14 @@ pub fn context_limit_for_model_with_provider_and_cache( return Some(272_000); } + if model.starts_with("claude-opus-4-7") || model.starts_with("claude-opus-4.7") { + return Some(if is_1m { 1_048_576 } else { 200_000 }); + } + + if model.starts_with("claude-sonnet-4-7") || model.starts_with("claude-sonnet-4.7") { + return Some(if is_1m { 1_048_576 } else { 200_000 }); + } + if model.starts_with("claude-opus-4-6") || model.starts_with("claude-opus-4.6") { return Some(if is_1m { 1_048_576 } else { 200_000 }); } diff --git a/crates/jcode-provider-core/src/pricing.rs b/crates/jcode-provider-core/src/pricing.rs index f895fa4bc..ca2ef7b8a 100644 --- a/crates/jcode-provider-core/src/pricing.rs +++ b/crates/jcode-provider-core/src/pricing.rs @@ -126,7 +126,7 @@ pub fn anthropic_oauth_pricing(model: &str, subscription: Option<&str>) -> Route pub fn openai_api_pricing(model: &str) -> Option { let base = model.strip_suffix("[1m]").unwrap_or(model); match base { - "gpt-5.5" | "gpt-5.4" | "gpt-5.4-pro" => Some(RouteCheapnessEstimate::metered( + "gpt-5.5" | "gpt-5.4" | "gpt-5.4-pro" | "gpt-5.4-mini" => Some(RouteCheapnessEstimate::metered( RouteCostSource::PublicApiPricing, RouteCostConfidence::High, usd_to_micros(2.5), diff --git a/src/ask_user.rs b/src/ask_user.rs new file mode 100644 index 000000000..715aa6c9f --- /dev/null +++ b/src/ask_user.rs @@ -0,0 +1,155 @@ +//! Global pending-question registry for the `askUserQuestion` tool. +//! +//! When the tool is invoked it stages an `AskUserQuestionRequest` in this +//! registry, publishes a `BusEvent::AskUserQuestionOpened` so the TUI can +//! display its modal overlay, and `await`s on a oneshot receiver. When the +//! user answers (or cancels) via the modal, the TUI calls +//! [`submit_answer`] which removes the entry and fulfils the receiver. +//! +//! The mechanism mirrors the existing `StdinInputRequest` pattern but is +//! routed through a global map keyed by request_id so the tool execute +//! method does not need direct access to TUI state. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use tokio::sync::oneshot; + +/// A single answer option offered to the user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserOption { + /// Stable choice id (A, B, keep, rec, ...). + pub id: String, + /// Human-readable label. + pub label: String, + /// Optional explanation/notes shown under the label. + pub description: Option, + /// Optional "exact value" the agent receives if this option is picked. + pub value: Option, + /// True if this is the agent's recommended option. + pub recommended: bool, + /// Reason for the recommendation, displayed only on the recommended row. + pub recommendation_reason: Option, +} + +/// Payload describing a pending question for the TUI to render. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserQuestion { + pub request_id: String, + pub session_id: String, + pub question: String, + pub context: Option, + pub options: Vec, + pub allow_multiple: bool, + pub reply_instructions: Option, + pub title: Option, +} + +/// Final answer returned to the tool. +/// +/// `kind` discriminates how the user responded so the agent can format the +/// downstream tool result appropriately. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserAnswer { + pub request_id: String, + pub kind: AskUserAnswerKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AskUserAnswerKind { + /// User picked one or more pre-defined options. + Options { + /// Option ids (preserving display order). + ids: Vec, + /// Labels for the picked ids (for display in the tool result). + labels: Vec, + /// `value` fields for the picked ids when set (parallel to `ids`). + values: Vec>, + }, + /// User typed a free-form answer instead of (or in addition to) picking. + Custom { text: String }, + /// User dismissed the modal (Esc) without answering. + Canceled, +} + +/// Process-wide registry of in-flight ask-user requests. +fn registry() -> &'static Mutex>> { + static R: OnceLock>>> = OnceLock::new(); + R.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Register a pending question and return the receiver half. The caller +/// should then publish `BusEvent::AskUserQuestionOpened` so the TUI can +/// render the modal, and `await` on the returned receiver. +pub fn register_pending(request_id: String) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + if let Ok(mut map) = registry().lock() { + map.insert(request_id, tx); + } + rx +} + +/// Submit an answer for a previously registered request. Returns true if the +/// request existed and was answered; false if it had already been answered +/// or canceled. +pub fn submit_answer(answer: AskUserAnswer) -> bool { + let tx = match registry().lock() { + Ok(mut map) => map.remove(&answer.request_id), + Err(_) => return false, + }; + match tx { + Some(tx) => tx.send(answer).is_ok(), + None => false, + } +} + +/// Discard a pending request (e.g. session reset) without answering. Any +/// awaiter will observe a closed channel and surface a cancellation error. +#[allow(dead_code)] +pub fn drop_pending(request_id: &str) { + if let Ok(mut map) = registry().lock() { + map.remove(request_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn submit_round_trip() { + let id = "test-ask-user-1".to_string(); + let rx = register_pending(id.clone()); + let ok = submit_answer(AskUserAnswer { + request_id: id.clone(), + kind: AskUserAnswerKind::Custom { + text: "hello".into(), + }, + }); + assert!(ok); + let got = rx.await.expect("answer should arrive"); + assert_eq!(got.request_id, id); + match got.kind { + AskUserAnswerKind::Custom { text } => assert_eq!(text, "hello"), + other => panic!("unexpected kind: {:?}", other), + } + } + + #[tokio::test] + async fn submit_unknown_id_is_false() { + let ok = submit_answer(AskUserAnswer { + request_id: "nope".into(), + kind: AskUserAnswerKind::Canceled, + }); + assert!(!ok); + } + + #[tokio::test] + async fn drop_pending_closes_channel() { + let id = "test-ask-user-drop".to_string(); + let rx = register_pending(id.clone()); + drop_pending(&id); + assert!(rx.await.is_err(), "awaiter should observe closed channel"); + } +} diff --git a/src/bin/publish_selfdev.rs b/src/bin/publish_selfdev.rs new file mode 100644 index 000000000..aa1fbcf88 --- /dev/null +++ b/src/bin/publish_selfdev.rs @@ -0,0 +1,9 @@ +fn main() -> anyhow::Result<()> { + let repo = std::env::args() + .nth(1) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let p = jcode_build_support::publish_local_current_build(&repo)?; + println!("published: {}", p.display()); + Ok(()) +} diff --git a/src/bin/tui_bench.rs b/src/bin/tui_bench.rs index 97d4527ea..943ef25f9 100644 --- a/src/bin/tui_bench.rs +++ b/src/bin/tui_bench.rs @@ -1139,6 +1139,12 @@ impl TuiState for BenchState { None } + fn ask_user_overlay( + &self, + ) -> Option<&std::cell::RefCell> { + None + } + fn working_dir(&self) -> Option { None } diff --git a/src/bus.rs b/src/bus.rs index 72f4f0535..8a1905af4 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -378,6 +378,10 @@ pub enum BusEvent { SidePanelUpdated(SidePanelUpdated), /// Deferred Mermaid rendering completed and cached content may now be visible MermaidRenderCompleted, + /// `askUserQuestion` tool wants the TUI to open a modal for the user to + /// answer interactively. The tool then awaits the answer via + /// [`crate::ask_user::register_pending`] / `submit_answer`. + AskUserQuestionOpened(crate::ask_user::AskUserQuestion), } pub struct Bus { diff --git a/src/lib.rs b/src/lib.rs index 54d25a081..1ca7941d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod agent; pub mod ambient; pub mod ambient_runner; pub mod ambient_scheduler; +pub mod ask_user; pub mod auth; pub mod background; pub mod browser; diff --git a/src/tool/ask_user_question.rs b/src/tool/ask_user_question.rs index 59805e161..aa8269510 100644 --- a/src/tool/ask_user_question.rs +++ b/src/tool/ask_user_question.rs @@ -1,9 +1,18 @@ use super::{Tool, ToolContext, ToolOutput}; -use crate::bus::{Bus, BusEvent, SidePanelUpdated}; +use crate::ask_user::{ + AskUserAnswerKind, AskUserOption, AskUserQuestion, register_pending, +}; +use crate::bus::{Bus, BusEvent}; use anyhow::{Result, bail}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use std::time::Duration; + +/// Maximum time the tool will wait for the user to respond before giving up +/// and returning a "no response" tool result. Generous because we expect the +/// user to genuinely answer; the tool also returns early on Esc / disconnect. +const ASK_USER_TIMEOUT: Duration = Duration::from_secs(60 * 60); pub struct AskUserQuestionTool; @@ -28,18 +37,12 @@ struct AskUserQuestionInput { /// Allow the user to select more than one option. #[serde(default)] allow_multiple: bool, - /// Optional side panel page id. Defaults to `ask-user-question` so repeated calls update the same page. + /// Optional reply instructions / hint shown in the modal footer. #[serde(default)] - page_id: Option, - /// Optional side panel title. + reply_instructions: Option, + /// Optional modal title. #[serde(default)] title: Option, - /// Focus the generated side-panel page. Defaults to true. - #[serde(default)] - focus: Option, - /// Optional reply instructions. Defaults to a concise option-id reply format. - #[serde(default)] - reply_instructions: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -71,9 +74,13 @@ impl Tool for AskUserQuestionTool { fn description(&self) -> &str { concat!( - "Ask the user a structured multiple-choice question by creating a focused side-panel quiz. ", - "Use this when user confirmation or preference selection would be easier as choices. ", - "Highlight one recommended option and explain why. The user answers in chat with the option id or custom value." + "Ask the user an interactive multiple-choice question via a TUI modal overlay. ", + "Use this when user confirmation or preference selection would be clearer as a ", + "small set of choices rather than free-form chat. The user navigates with the ", + "arrow keys, presses Enter to pick an option, or selects \"Other\" to type a ", + "custom free-form answer. The tool blocks until the user responds or cancels. ", + "Mark exactly one option `recommended:true` when you have a preferred answer; ", + "the modal highlights it and pre-selects it for fast Enter-to-confirm." ) } @@ -89,7 +96,7 @@ impl Tool for AskUserQuestionTool { }, "context": { "type": "string", - "description": "Optional context shown before the choices." + "description": "Optional context shown above the choices." }, "options": { "type": "array", @@ -108,15 +115,15 @@ impl Tool for AskUserQuestionTool { }, "value": { "type": "string", - "description": "Exact value to apply if selected." + "description": "Exact value the agent receives if this option is selected." }, "description": { "type": "string", - "description": "Optional explanation/notes for this option." + "description": "Optional explanation shown under the option label." }, "recommended": { "type": "boolean", - "description": "Whether this option is recommended. Prefer exactly one recommended option." + "description": "Whether this is the agent's recommended option. Prefer exactly one recommended option." }, "recommendation_reason": { "type": "string", @@ -129,21 +136,13 @@ impl Tool for AskUserQuestionTool { "type": "boolean", "description": "Allow multiple options to be selected. Defaults to false." }, - "page_id": { + "reply_instructions": { "type": "string", - "description": "Side panel page id. Defaults to ask-user-question." + "description": "Optional hint shown in the modal footer." }, "title": { "type": "string", - "description": "Side panel title. Defaults to Question." - }, - "focus": { - "type": "boolean", - "description": "Focus the side panel page. Defaults to true." - }, - "reply_instructions": { - "type": "string", - "description": "Optional instructions for how the user should answer." + "description": "Optional modal title. Defaults to Question." } } }) @@ -155,146 +154,141 @@ impl Tool for AskUserQuestionTool { bail!("askUserQuestion requires at least one option"); } - let page_id = params - .page_id - .as_deref() - .map(str::trim) - .filter(|id| !id.is_empty()) - .unwrap_or("ask-user-question"); - let title = params - .title - .as_deref() - .map(str::trim) - .filter(|title| !title.is_empty()) - .unwrap_or("Question"); - let focus = params.focus.unwrap_or(true); - let content = render_question_markdown(¶ms); - - let snapshot = crate::side_panel::write_markdown_page( - &ctx.session_id, - page_id, - Some(title), - &content, - focus, - )?; - Bus::global().publish(BusEvent::SidePanelUpdated(SidePanelUpdated { - session_id: ctx.session_id.clone(), - snapshot: snapshot.clone(), - })); - - let recommended = params + // Normalize options: assign auto ids where missing, capture parallel + // data for the response mapping. + let normalized: Vec = params .options .iter() .enumerate() - .filter(|(_, option)| option.recommended) - .map(|(idx, option)| option_id(idx, option)) - .collect::>(); - let response_hint = params.reply_instructions.clone().unwrap_or_else(|| { - if params.allow_multiple { - "Ask the user to reply with one or more option ids, or a custom value.".to_string() - } else { - "Ask the user to reply with one option id, or a custom value.".to_string() - } - }); - - Ok(ToolOutput::new(format!( - "Question displayed in side panel page `{page_id}`. Recommended: {}. {response_hint}", - if recommended.is_empty() { - "none".to_string() - } else { - recommended.join(", ") - } - )) - .with_title("askUserQuestion") - .with_metadata(json!({ - "page_id": page_id, - "title": title, - "recommended": recommended, - "allow_multiple": params.allow_multiple, - "question": params.question, - "options": params.options, - }))) - } -} - -fn render_question_markdown(params: &AskUserQuestionInput) -> String { - let mut out = String::new(); - out.push_str("# Question\n\n"); - out.push_str(¶ms.question); - out.push_str("\n\n"); - - if let Some(context) = params - .context - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - out.push_str("## Context\n\n"); - out.push_str(context); - out.push_str("\n\n"); - } + .map(|(idx, option)| AskUserOption { + id: assigned_option_id(idx, option), + label: option.label.clone(), + description: option + .description + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + value: option + .value + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + recommended: option.recommended, + recommendation_reason: option + .recommendation_reason + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + }) + .collect(); - out.push_str("## Options\n\n"); - for (idx, option) in params.options.iter().enumerate() { - let id = option_id(idx, option); - if option.recommended { - out.push_str(&format!( - "### ✅ {id}. {} **(recommended)**\n\n", - option.label - )); - } else { - out.push_str(&format!("### {id}. {}\n\n", option.label)); - } + let request_id = format!( + "ask-user-{}-{}", + ctx.tool_call_id, + chrono::Utc::now().timestamp_millis() + ); + let receiver = register_pending(request_id.clone()); - if let Some(value) = option - .value - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - out.push_str(&format!("- Value: `{value}`\n")); - } - if let Some(description) = option - .description - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - out.push_str(&format!("- Notes: {description}\n")); - } - if option.recommended { - if let Some(reason) = option - .recommendation_reason + let question = AskUserQuestion { + request_id: request_id.clone(), + session_id: ctx.session_id.clone(), + question: params.question.clone(), + context: params + .context + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + options: normalized.clone(), + allow_multiple: params.allow_multiple, + reply_instructions: params + .reply_instructions + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + title: params + .title .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) - { - out.push_str(&format!("- Why recommended: {reason}\n")); + .map(ToOwned::to_owned), + }; + + Bus::global().publish(BusEvent::AskUserQuestionOpened(question)); + + let answer = match tokio::time::timeout(ASK_USER_TIMEOUT, receiver).await { + Ok(Ok(answer)) => answer, + Ok(Err(_recv_err)) => { + // Sender dropped (e.g. session reset) before answering. + return Ok(ToolOutput::new( + "User did not answer (modal was closed without selection).", + ) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "closed", + }))); } - } - out.push('\n'); - } + Err(_timeout) => { + // Try to clean up the pending entry to avoid leaking. + crate::ask_user::drop_pending(&request_id); + return Ok(ToolOutput::new( + "User did not answer within the timeout. Continue with a sensible default or ask again.", + ) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "timeout", + }))); + } + }; - out.push_str("## How to answer\n\n"); - if let Some(instructions) = params - .reply_instructions - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - out.push_str(instructions); - } else if params.allow_multiple { - out.push_str("Reply in chat with one or more option IDs, for example: `A C`, or provide a custom value."); - } else { - out.push_str( - "Reply in chat with one option ID, for example: `A`, or provide a custom value.", - ); + match &answer.kind { + AskUserAnswerKind::Options { ids, labels, values } => { + let pretty_choices = ids + .iter() + .zip(labels.iter()) + .map(|(id, label)| format!("{id} ({label})")) + .collect::>() + .join(", "); + let text = format!("User chose: {}", pretty_choices); + Ok(ToolOutput::new(text) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "selected", + "selected_ids": ids, + "selected_labels": labels, + "selected_values": values, + }))) + } + AskUserAnswerKind::Custom { text } => Ok(ToolOutput::new(format!( + "User typed a custom answer:\n{}", + text + )) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "custom", + "custom_text": text, + }))), + AskUserAnswerKind::Canceled => Ok(ToolOutput::new( + "User canceled the question (pressed Esc). Proceed without an answer or ask again with different framing.", + ) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "canceled", + }))), + } } - out.push('\n'); - out } -fn option_id(idx: usize, option: &QuestionOption) -> String { +fn assigned_option_id(idx: usize, option: &QuestionOption) -> String { option .id .as_deref() @@ -305,7 +299,6 @@ fn option_id(idx: usize, option: &QuestionOption) -> String { } fn auto_option_id(idx: usize) -> String { - // A..Z, then 27, 28, ... to avoid surprising AA-style ids in a compact UI. if idx < 26 { ((b'A' + idx as u8) as char).to_string() } else { diff --git a/src/tool/ask_user_question_tests.rs b/src/tool/ask_user_question_tests.rs index 6e228a1f7..01c2bd357 100644 --- a/src/tool/ask_user_question_tests.rs +++ b/src/tool/ask_user_question_tests.rs @@ -1,34 +1,23 @@ use super::*; +use crate::ask_user::{AskUserAnswer, AskUserAnswerKind, submit_answer}; +use crate::bus::{Bus, BusEvent}; use serde_json::json; -struct EnvVarGuard { - key: &'static str, - previous: Option, +fn unique_session_id(label: &str) -> String { + format!( + "ses_aq_{label}_{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) + ) } -impl EnvVarGuard { - fn set_path(key: &'static str, value: &std::path::Path) -> Self { - let previous = std::env::var_os(key); - crate::env::set_var(key, value); - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(previous) = &self.previous { - crate::env::set_var(self.key, previous); - } else { - crate::env::remove_var(self.key); - } - } -} - -fn test_ctx() -> ToolContext { +fn test_ctx_with_session(session_id: String) -> ToolContext { ToolContext { - session_id: "ses_ask_user_question_tool".to_string(), + session_id, message_id: "msg1".to_string(), - tool_call_id: "tool1".to_string(), + tool_call_id: format!( + "tool_aq_{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) + ), working_dir: None, stdin_request_tx: None, graceful_shutdown_signal: None, @@ -36,102 +25,143 @@ fn test_ctx() -> ToolContext { } } -#[tokio::test] -async fn ask_user_question_writes_recommended_quiz_page() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().expect("tempdir"); - let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); +/// Wait for an `AskUserQuestionOpened` event matching `session_id`. Other +/// concurrent tests share the global bus, so we must filter by session to +/// avoid grabbing an unrelated request_id. +async fn wait_for_question( + rx: &mut tokio::sync::broadcast::Receiver, + session_id: &str, +) -> String { + loop { + match rx.recv().await { + Ok(BusEvent::AskUserQuestionOpened(q)) if q.session_id == session_id => { + return q.request_id; + } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(e) => panic!("bus dropped before opening event: {e}"), + } + } +} +#[tokio::test] +async fn rejects_empty_options() { let tool = AskUserQuestionTool::new(); - let output = tool + let err = tool .execute( json!({ - "question": "Set diagram rendering?", - "context": "We are tuning Jcode config.", - "page_id": "config-question", - "title": "Config Question", + "question": "Empty?", + "options": [] + }), + test_ctx_with_session(unique_session_id("reject")), + ) + .await + .expect_err("empty options should fail"); + assert!(err.to_string().contains("at least one option")); +} + +#[tokio::test] +async fn publishes_question_and_resolves_with_options_answer() { + let mut rx = Bus::global().subscribe(); + let tool = AskUserQuestionTool::new(); + let session_id = unique_session_id("opts"); + let session_id_for_exec = session_id.clone(); + + let exec = tokio::spawn(async move { + tool.execute( + json!({ + "question": "Pick one", "options": [ - {"id": "keep", "label": "Keep current", "value": "none"}, - { - "id": "rec", - "label": "Use inline diagrams", - "value": "inline", - "recommended": true, - "recommendation_reason": "Mermaid diagrams render directly in the chat/side panel." - } - ] + {"label": "Alpha"}, + {"label": "Beta", "recommended": true, "value": "beta-val"} + ], }), - test_ctx(), + test_ctx_with_session(session_id_for_exec), ) .await - .expect("tool execute"); + }); + + let request_id = wait_for_question(&mut rx, &session_id).await; - assert!(output.output.contains("config-question")); - assert!(output.output.contains("Recommended: rec")); - assert_eq!(output.title.as_deref(), Some("askUserQuestion")); + let ok = submit_answer(AskUserAnswer { + request_id: request_id.clone(), + kind: AskUserAnswerKind::Options { + ids: vec!["B".into()], + labels: vec!["Beta".into()], + values: vec![Some("beta-val".into())], + }, + }); + assert!(ok, "submit_answer should succeed for known request"); - let snapshot = - crate::side_panel::snapshot_for_session("ses_ask_user_question_tool").expect("snapshot"); - assert_eq!(snapshot.focused_page_id.as_deref(), Some("config-question")); - let page = snapshot - .pages - .iter() - .find(|page| page.id == "config-question") - .expect("question page"); - assert_eq!(page.title, "Config Question"); - assert!(page.content.contains("# Question")); - assert!(page.content.contains("Set diagram rendering?")); + let output = exec.await.expect("join").expect("tool execute"); assert!( - page.content - .contains("### ✅ rec. Use inline diagrams **(recommended)**") + output.output.contains("User chose: B (Beta)"), + "tool output did not include selection summary: {}", + output.output ); - assert!(page.content.contains("Why recommended")); - assert!(page.content.contains("Reply in chat with one option ID")); + let metadata = output.metadata.expect("metadata"); + assert_eq!(metadata["outcome"], "selected"); + assert_eq!(metadata["selected_ids"][0], "B"); + assert_eq!(metadata["selected_values"][0], "beta-val"); } #[tokio::test] -async fn ask_user_question_generates_option_ids_and_rejects_empty_options() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().expect("tempdir"); - let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); - +async fn custom_answer_reaches_tool_output() { + let mut rx = Bus::global().subscribe(); let tool = AskUserQuestionTool::new(); - let output = tool - .execute( + let session_id = unique_session_id("custom"); + let session_id_for_exec = session_id.clone(); + let exec = tokio::spawn(async move { + tool.execute( json!({ - "question": "Pick one", - "options": [ - {"label": "Alpha"}, - {"label": "Beta", "recommended": true} - ], - "allow_multiple": true + "question": "What now?", + "options": [{"label": "Whatever"}] }), - test_ctx(), + test_ctx_with_session(session_id_for_exec), ) .await - .expect("tool execute"); + }); - assert!(output.output.contains("Recommended: B")); - let snapshot = - crate::side_panel::snapshot_for_session("ses_ask_user_question_tool").expect("snapshot"); - let page = snapshot - .pages - .iter() - .find(|page| page.id == "ask-user-question") - .expect("default page"); - assert!(page.content.contains("### A. Alpha")); - assert!(page.content.contains("### ✅ B. Beta **(recommended)**")); - assert!(page.content.contains("one or more option IDs")); + let request_id = wait_for_question(&mut rx, &session_id).await; + let ok = submit_answer(AskUserAnswer { + request_id, + kind: AskUserAnswerKind::Custom { + text: "do the thing".into(), + }, + }); + assert!(ok); - let err = tool - .execute( + let output = exec.await.expect("join").expect("execute"); + assert!(output.output.contains("do the thing")); + let metadata = output.metadata.expect("metadata"); + assert_eq!(metadata["outcome"], "custom"); + assert_eq!(metadata["custom_text"], "do the thing"); +} + +#[tokio::test] +async fn canceled_answer_is_surfaced() { + let mut rx = Bus::global().subscribe(); + let tool = AskUserQuestionTool::new(); + let session_id = unique_session_id("cancel"); + let session_id_for_exec = session_id.clone(); + let exec = tokio::spawn(async move { + tool.execute( json!({ - "question": "Empty?", - "options": [] + "question": "Anything?", + "options": [{"label": "Whatever"}] }), - test_ctx(), + test_ctx_with_session(session_id_for_exec), ) .await - .expect_err("empty options should fail"); - assert!(err.to_string().contains("at least one option")); + }); + + let request_id = wait_for_question(&mut rx, &session_id).await; + submit_answer(AskUserAnswer { + request_id, + kind: AskUserAnswerKind::Canceled, + }); + let output = exec.await.expect("join").expect("execute"); + assert!(output.output.contains("canceled")); + let metadata = output.metadata.expect("metadata"); + assert_eq!(metadata["outcome"], "canceled"); } diff --git a/src/tui/app.rs b/src/tui/app.rs index 38eea9b75..9a6d2e626 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -51,6 +51,7 @@ pub enum AppRuntimeMode { mod auth; mod auth_account_picker_saved_accounts; +mod ask_user_modal_app; mod catchup; mod commands; mod commands_improve; @@ -980,6 +981,8 @@ pub struct App { account_picker_overlay: Option>, /// Usage overlay (None = not visible) usage_overlay: Option>, + /// `askUserQuestion` modal overlay (None = not visible) + ask_user_overlay: Option>, /// Whether a usage refresh request is currently in flight. usage_report_refreshing: bool, /// Last time the passive overnight progress card polled its run files. diff --git a/src/tui/app/ask_user_modal_app.rs b/src/tui/app/ask_user_modal_app.rs new file mode 100644 index 000000000..49dfe94b9 --- /dev/null +++ b/src/tui/app/ask_user_modal_app.rs @@ -0,0 +1,81 @@ +//! `App` glue for the `askUserQuestion` modal overlay: open, dispatch keys, +//! and submit the picked answer back through `crate::ask_user`. + +use super::*; +use crate::ask_user::{AskUserAnswer, AskUserAnswerKind, AskUserQuestion, submit_answer}; +use crate::tui::ask_user_modal::{AskUserModal, AskUserModalOutcome}; +use crossterm::event::{KeyCode, KeyModifiers}; +use std::cell::RefCell; + +impl App { + /// Open the ask-user modal for `question`. If a modal is already open for + /// a different request_id, cancel the previous one so the new one can + /// proceed; this preserves the invariant that only one ask-user modal is + /// ever pending at a time and prevents stuck states. + pub(crate) fn open_ask_user_modal(&mut self, question: AskUserQuestion) { + if let Some(existing) = self.ask_user_overlay.take() { + let prev_request_id = existing.borrow().request_id().to_string(); + if prev_request_id != question.request_id { + submit_answer(AskUserAnswer { + request_id: prev_request_id, + kind: AskUserAnswerKind::Canceled, + }); + } + } + let modal = AskUserModal::from_question(question); + self.ask_user_overlay = Some(RefCell::new(modal)); + self.set_status_notice("Agent is asking you a question."); + } + + /// Dispatch a key while the ask-user modal is visible. Returns true if + /// the key was consumed. + pub(crate) fn handle_ask_user_modal_key( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) -> bool { + let outcome = { + let Some(cell) = self.ask_user_overlay.as_ref() else { + return false; + }; + let mut modal = cell.borrow_mut(); + modal.handle_key(code, modifiers) + }; + + match outcome { + AskUserModalOutcome::Continue => {} + AskUserModalOutcome::Done(answer) => { + self.ask_user_overlay = None; + // Submit even if the channel was already closed; submit_answer + // tolerates unknown ids. + submit_answer(answer); + self.clear_status_notice(); + } + } + true + } + + /// Render the ask-user modal overlay if visible. + #[allow(dead_code)] // direct render path; currently driven via TuiState trait + pub(crate) fn render_ask_user_modal(&self, frame: &mut ratatui::Frame) { + if let Some(cell) = self.ask_user_overlay.as_ref() { + cell.borrow().render(frame); + } + } + + /// Cancel and dismiss any active modal (used on session reset / cleanup). + #[allow(dead_code)] // not yet wired into session reset path + pub(crate) fn cancel_ask_user_modal(&mut self) { + if let Some(cell) = self.ask_user_overlay.take() { + let request_id = cell.borrow().request_id().to_string(); + submit_answer(AskUserAnswer { + request_id, + kind: AskUserAnswerKind::Canceled, + }); + } + } + + pub(crate) fn ask_user_modal_visible(&self) -> bool { + self.ask_user_overlay.is_some() + } +} diff --git a/src/tui/app/conversation_state.rs b/src/tui/app/conversation_state.rs index 448987c97..94d5ae539 100644 --- a/src/tui/app/conversation_state.rs +++ b/src/tui/app/conversation_state.rs @@ -401,6 +401,10 @@ impl App { self.status_notice = Some((text.into(), Instant::now())); } + pub fn clear_status_notice(&mut self) { + self.status_notice = None; + } + pub(crate) fn set_remote_startup_phase(&mut self, phase: super::RemoteStartupPhase) { let changed = self.remote_startup_phase.as_ref() != Some(&phase); self.remote_startup_phase = Some(phase); diff --git a/src/tui/app/input.rs b/src/tui/app/input.rs index 7fda59a79..a137795ba 100644 --- a/src/tui/app/input.rs +++ b/src/tui/app/input.rs @@ -1174,6 +1174,14 @@ pub(super) fn handle_modal_key( code: KeyCode, modifiers: KeyModifiers, ) -> Result { + // The ask-user modal is a blocking overlay; capture all keys while it + // is visible so the user can navigate, type, or cancel without other + // shortcuts interfering. + if app.ask_user_modal_visible() { + app.handle_ask_user_modal_key(code, modifiers); + return Ok(true); + } + if app.changelog_scroll.is_some() { app.handle_changelog_key(code)?; return Ok(true); diff --git a/src/tui/app/local.rs b/src/tui/app/local.rs index 0dd481a27..3ec3d76ee 100644 --- a/src/tui/app/local.rs +++ b/src/tui/app/local.rs @@ -226,6 +226,14 @@ pub(super) fn handle_bus_event( false } } + Ok(BusEvent::AskUserQuestionOpened(question)) => { + if question.session_id == app.session.id { + app.open_ask_user_modal(question); + true + } else { + false + } + } Ok(BusEvent::TodoUpdated(event)) => { if event.session_id == app.session.id { app.refresh_todos_view_now() diff --git a/src/tui/app/tui_lifecycle.rs b/src/tui/app/tui_lifecycle.rs index bf5700224..06de43b73 100644 --- a/src/tui/app/tui_lifecycle.rs +++ b/src/tui/app/tui_lifecycle.rs @@ -542,6 +542,7 @@ impl App { login_picker_overlay: None, account_picker_overlay: None, usage_overlay: None, + ask_user_overlay: None, usage_report_refreshing: false, last_overnight_card_refresh: None, }; @@ -907,6 +908,7 @@ impl App { login_picker_overlay: None, account_picker_overlay: None, usage_overlay: None, + ask_user_overlay: None, usage_report_refreshing: false, last_overnight_card_refresh: None, }; diff --git a/src/tui/app/tui_state.rs b/src/tui/app/tui_state.rs index bbc82224d..a99547ba5 100644 --- a/src/tui/app/tui_state.rs +++ b/src/tui/app/tui_state.rs @@ -1260,6 +1260,12 @@ impl crate::tui::TuiState for App { self.usage_overlay.as_ref() } + fn ask_user_overlay( + &self, + ) -> Option<&RefCell> { + self.ask_user_overlay.as_ref() + } + fn working_dir(&self) -> Option { self.session.working_dir.clone() } diff --git a/src/tui/ask_user_modal.rs b/src/tui/ask_user_modal.rs new file mode 100644 index 000000000..e622550a6 --- /dev/null +++ b/src/tui/ask_user_modal.rs @@ -0,0 +1,697 @@ +//! TUI modal overlay for the `askUserQuestion` tool. +//! +//! Renders a centered overlay with: +//! - The question (and optional context) at the top +//! - A list of options the user navigates with arrow keys / j-k +//! - A pre-selected recommended option (if any) for quick Enter-to-confirm +//! - A final synthetic "Other (type custom answer)" entry that switches the +//! modal into a text-input mode where the user types a free-form reply +//! - Esc cancels and submits an `AskUserAnswerKind::Canceled` answer +//! +//! All state needed to fulfil the pending oneshot lives in the modal itself +//! plus the `request_id`. When the modal closes via Enter/Esc the host App +//! calls [`AskUserModal::take_pending_answer`] and submits it to the +//! `crate::ask_user` registry. +//! +//! ## Multi-select +//! When `allow_multiple` is true, Space toggles individual options on/off and +//! Enter submits the accumulated set; otherwise Enter directly submits the +//! single highlighted option (matching the common "pick one" pattern). + +use crate::ask_user::{AskUserAnswer, AskUserAnswerKind, AskUserOption, AskUserQuestion}; +use crossterm::event::{KeyCode, KeyModifiers}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, +}; + +const PANEL_BG: Color = Color::Rgb(24, 28, 40); +const PANEL_BORDER: Color = Color::Rgb(120, 140, 190); +const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); +const SELECTED_BG: Color = Color::Rgb(38, 42, 56); +const SELECTED_BG_RECOMMENDED: Color = Color::Rgb(38, 56, 50); +const RECOMMENDED_FG: Color = Color::Rgb(120, 230, 170); +const MUTED: Color = Color::Rgb(140, 146, 163); +const MUTED_DARK: Color = Color::Rgb(100, 106, 122); +const OPTION_FG: Color = Color::Rgb(220, 225, 240); +const CUSTOM_HINT_FG: Color = Color::Rgb(190, 170, 240); + +const OVERLAY_PERCENT_X: u16 = 78; +const OVERLAY_MIN_HEIGHT: u16 = 14; + +/// What the modal wants the host App to do after handling a key. +pub enum AskUserModalOutcome { + /// Modal stays open; redraw. + Continue, + /// Modal should be removed and the contained answer submitted to the + /// `crate::ask_user` registry. + Done(AskUserAnswer), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Arrow-key navigation over the option list (and the synthetic Other row). + Choosing, + /// Free-form text input for the user's custom answer. + Typing, +} + +pub struct AskUserModal { + request_id: String, + title: String, + question: String, + context: Option, + options: Vec, + /// Whether more than one option may be picked simultaneously. + allow_multiple: bool, + /// Footer hint shown beneath the option list. + reply_instructions: Option, + /// Index of the focused row. Indices `0..options.len()` map to options; + /// `options.len()` is the synthetic "Other" row. + cursor: usize, + /// Picked options when `allow_multiple` is true (set of indices). + picked: Vec, + mode: Mode, + /// Free-form custom answer buffer when `mode == Typing`. + typed: String, +} + +impl AskUserModal { + pub fn from_question(question: AskUserQuestion) -> Self { + let picked = vec![false; question.options.len()]; + // Default the cursor to the first recommended option if any, else 0. + let recommended_idx = question + .options + .iter() + .position(|opt| opt.recommended) + .unwrap_or(0); + Self { + request_id: question.request_id, + title: question.title.unwrap_or_else(|| "Question".to_string()), + question: question.question, + context: question.context, + options: question.options, + allow_multiple: question.allow_multiple, + reply_instructions: question.reply_instructions, + cursor: recommended_idx, + picked, + mode: Mode::Choosing, + typed: String::new(), + } + } + + pub fn request_id(&self) -> &str { + &self.request_id + } + + /// Index of the synthetic "Other" row. + fn other_row(&self) -> usize { + self.options.len() + } + + /// Total number of navigable rows including "Other". + fn rows(&self) -> usize { + self.options.len() + 1 + } + + fn move_cursor(&mut self, delta: isize) { + let n = self.rows() as isize; + if n == 0 { + return; + } + let mut next = self.cursor as isize + delta; + if next < 0 { + next += n; + } + next %= n; + self.cursor = next as usize; + } + + fn build_options_answer(&self) -> AskUserAnswerKind { + let mut ids = Vec::new(); + let mut labels = Vec::new(); + let mut values = Vec::new(); + + if self.allow_multiple { + for (idx, picked) in self.picked.iter().enumerate() { + if *picked && idx < self.options.len() { + let opt = &self.options[idx]; + ids.push(opt.id.clone()); + labels.push(opt.label.clone()); + values.push(opt.value.clone()); + } + } + // Fallback: if user pressed Enter without toggling anything, treat + // the current row as the single selection. + if ids.is_empty() && self.cursor < self.options.len() { + let opt = &self.options[self.cursor]; + ids.push(opt.id.clone()); + labels.push(opt.label.clone()); + values.push(opt.value.clone()); + } + } else if self.cursor < self.options.len() { + let opt = &self.options[self.cursor]; + ids.push(opt.id.clone()); + labels.push(opt.label.clone()); + values.push(opt.value.clone()); + } + + AskUserAnswerKind::Options { + ids, + labels, + values, + } + } + + /// Process a keystroke and report the resulting modal outcome. + pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> AskUserModalOutcome { + if matches!(self.mode, Mode::Typing) { + return self.handle_key_typing(code, modifiers); + } + self.handle_key_choosing(code, modifiers) + } + + fn handle_key_choosing( + &mut self, + code: KeyCode, + _modifiers: KeyModifiers, + ) -> AskUserModalOutcome { + match code { + KeyCode::Esc => AskUserModalOutcome::Done(AskUserAnswer { + request_id: self.request_id.clone(), + kind: AskUserAnswerKind::Canceled, + }), + KeyCode::Up | KeyCode::Char('k') => { + self.move_cursor(-1); + AskUserModalOutcome::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_cursor(1); + AskUserModalOutcome::Continue + } + KeyCode::Home | KeyCode::Char('g') => { + self.cursor = 0; + AskUserModalOutcome::Continue + } + KeyCode::End | KeyCode::Char('G') => { + self.cursor = self.rows().saturating_sub(1); + AskUserModalOutcome::Continue + } + // Space toggles in multi-select mode (and is a no-op otherwise). + KeyCode::Char(' ') if self.allow_multiple && self.cursor < self.options.len() => { + let flipped = !self.picked[self.cursor]; + self.picked[self.cursor] = flipped; + AskUserModalOutcome::Continue + } + // Tab also moves down for quick navigation parity with form widgets. + KeyCode::Tab => { + self.move_cursor(1); + AskUserModalOutcome::Continue + } + KeyCode::BackTab => { + self.move_cursor(-1); + AskUserModalOutcome::Continue + } + KeyCode::Enter => { + if self.cursor == self.other_row() { + // Switch to free-form text input. + self.mode = Mode::Typing; + self.typed.clear(); + AskUserModalOutcome::Continue + } else { + AskUserModalOutcome::Done(AskUserAnswer { + request_id: self.request_id.clone(), + kind: self.build_options_answer(), + }) + } + } + // Quick-select by typing the option id letter when ids are A,B,C,... + KeyCode::Char(c) if c.is_ascii_alphanumeric() => { + let needle = c.to_ascii_uppercase().to_string(); + if let Some(idx) = self + .options + .iter() + .position(|opt| opt.id.eq_ignore_ascii_case(&needle)) + { + self.cursor = idx; + if self.allow_multiple { + // Toggle when multi-select; otherwise the user still + // needs to press Enter to confirm. + self.picked[idx] = !self.picked[idx]; + } + } + AskUserModalOutcome::Continue + } + _ => AskUserModalOutcome::Continue, + } + } + + fn handle_key_typing(&mut self, code: KeyCode, modifiers: KeyModifiers) -> AskUserModalOutcome { + match code { + KeyCode::Esc => { + // Bail back to choosing without discarding typed text so the + // user can return and finish if Esc was a slip. + self.mode = Mode::Choosing; + self.cursor = self.other_row(); + AskUserModalOutcome::Continue + } + KeyCode::Enter => { + let text = self.typed.trim(); + if text.is_empty() { + // Disallow empty submissions: keep modal open. + return AskUserModalOutcome::Continue; + } + AskUserModalOutcome::Done(AskUserAnswer { + request_id: self.request_id.clone(), + kind: AskUserAnswerKind::Custom { + text: text.to_string(), + }, + }) + } + KeyCode::Backspace => { + self.typed.pop(); + AskUserModalOutcome::Continue + } + KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => { + self.typed.push(c); + AskUserModalOutcome::Continue + } + _ => AskUserModalOutcome::Continue, + } + } + + pub fn render(&self, frame: &mut Frame) { + let area = centered_rect(OVERLAY_PERCENT_X, frame.area()); + + // Clear underlying widgets so the modal is fully opaque. + frame.render_widget(Clear, area); + + let title = Line::from(vec![ + Span::styled( + format!(" {} ", self.title), + Style::default().fg(Color::White).bold(), + ), + ]); + let footer = self.footer_line(); + let outer = Block::default() + .title(title) + .title_bottom(footer) + .borders(Borders::ALL) + .border_style(Style::default().fg(PANEL_BORDER)) + .style(Style::default().bg(PANEL_BG)); + frame.render_widget(&outer, area); + let inner = outer.inner(area); + + let context_h = self + .context + .as_deref() + .map(|s| ((s.len() as u16 / inner.width.max(1)) + 1).min(4)) + .unwrap_or(0); + let question_h = ((self.question.len() as u16 / inner.width.max(1)) + 1).min(4); + + // Top-down layout: question, optional context divider, options list, + // bottom typing pane (only when active). + let typing_h = if matches!(self.mode, Mode::Typing) { + 5 + } else { + 0 + }; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(question_h.max(1)), + Constraint::Length(context_h), + Constraint::Length(1), // divider + Constraint::Min(3), + Constraint::Length(typing_h), + ]) + .split(inner); + + // Question text. + let question_p = Paragraph::new(Line::from(Span::styled( + self.question.clone(), + Style::default().fg(Color::White).bold(), + ))) + .wrap(Wrap { trim: false }); + frame.render_widget(question_p, chunks[0]); + + // Optional context. + if let Some(ctx) = &self.context { + let context_p = Paragraph::new(Line::from(Span::styled( + ctx.clone(), + Style::default().fg(MUTED), + ))) + .wrap(Wrap { trim: false }); + frame.render_widget(context_p, chunks[1]); + } + + // Divider. + let div = Paragraph::new(Line::from(Span::styled( + "─".repeat(inner.width as usize), + Style::default().fg(SECTION_BORDER), + ))); + frame.render_widget(div, chunks[2]); + + self.render_options(frame, chunks[3]); + + if matches!(self.mode, Mode::Typing) { + self.render_typing(frame, chunks[4]); + } + } + + fn render_options(&self, frame: &mut Frame, area: Rect) { + let mut lines: Vec> = Vec::with_capacity(self.rows()); + for (idx, opt) in self.options.iter().enumerate() { + lines.push(self.render_option_row(idx, opt)); + if let Some(desc) = opt.description.as_deref() { + lines.push(Line::from(Span::styled( + format!(" {}", desc), + Style::default().fg(MUTED), + ))); + } + if opt.recommended { + if let Some(reason) = opt.recommendation_reason.as_deref() { + lines.push(Line::from(Span::styled( + format!(" why: {}", reason), + Style::default().fg(MUTED_DARK).italic(), + ))); + } + } + } + lines.push(self.render_other_row()); + + // Optional reply hint just below the rows. + if let Some(hint) = self.reply_instructions.as_deref() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("hint: {}", hint), + Style::default().fg(MUTED_DARK).italic(), + ))); + } + + let para = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(para, area); + } + + fn render_option_row(&self, idx: usize, opt: &AskUserOption) -> Line<'static> { + let selected = self.cursor == idx; + let picked = self.allow_multiple && self.picked.get(idx).copied().unwrap_or(false); + + let arrow = if selected { "❯ " } else { " " }; + let check = if self.allow_multiple { + if picked { "[x] " } else { "[ ] " } + } else { + "" + }; + let id_span = format!("{}.", opt.id); + let recommended_tag = if opt.recommended { " (recommended)" } else { "" }; + + let row_bg = if selected { + if opt.recommended { + SELECTED_BG_RECOMMENDED + } else { + SELECTED_BG + } + } else { + PANEL_BG + }; + + let row_fg = if opt.recommended { + RECOMMENDED_FG + } else { + OPTION_FG + }; + + Line::from(vec![ + Span::styled( + format!("{arrow}{check}"), + Style::default().fg(row_fg).bg(row_bg), + ), + Span::styled( + format!("{id_span} "), + Style::default().fg(row_fg).bg(row_bg).bold(), + ), + Span::styled(opt.label.clone(), Style::default().fg(row_fg).bg(row_bg)), + Span::styled( + recommended_tag, + Style::default().fg(RECOMMENDED_FG).bg(row_bg).italic(), + ), + ]) + } + + fn render_other_row(&self) -> Line<'static> { + let selected = self.cursor == self.other_row(); + let arrow = if selected { "❯ " } else { " " }; + let check = if self.allow_multiple { " " } else { "" }; + let bg = if selected { SELECTED_BG } else { PANEL_BG }; + + Line::from(vec![ + Span::styled( + format!("{arrow}{check}"), + Style::default().fg(CUSTOM_HINT_FG).bg(bg), + ), + Span::styled( + "Other (type custom answer)", + Style::default().fg(CUSTOM_HINT_FG).bg(bg).italic(), + ), + ]) + } + + fn render_typing(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(Span::styled( + " Custom answer ", + Style::default().fg(CUSTOM_HINT_FG).bold(), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(CUSTOM_HINT_FG)); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Display typed text plus a blinking-style caret. + let mut text = self.typed.clone(); + text.push('▏'); + let para = + Paragraph::new(Line::from(Span::styled(text, Style::default().fg(Color::White)))) + .wrap(Wrap { trim: false }); + frame.render_widget(para, inner); + } + + fn footer_line(&self) -> Line<'static> { + if matches!(self.mode, Mode::Typing) { + Line::from(vec![ + hotkey(" Enter "), + Span::styled(" submit ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" back to options ", Style::default().fg(MUTED_DARK)), + ]) + } else if self.allow_multiple { + Line::from(vec![ + hotkey(" Up/Down "), + Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), + hotkey(" Space "), + Span::styled(" toggle ", Style::default().fg(MUTED_DARK)), + hotkey(" Enter "), + Span::styled(" submit ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" cancel ", Style::default().fg(MUTED_DARK)), + ]) + } else { + Line::from(vec![ + hotkey(" Up/Down "), + Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), + hotkey(" Enter "), + Span::styled(" pick ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" cancel ", Style::default().fg(MUTED_DARK)), + ]) + } + } +} + +fn hotkey(label: &str) -> Span<'static> { + Span::styled( + label.to_string(), + Style::default() + .bg(Color::Rgb(60, 70, 95)) + .fg(Color::White) + .bold(), + ) +} + +fn centered_rect(percent_x: u16, area: Rect) -> Rect { + let width = (area.width as u32 * percent_x as u32 / 100) as u16; + let width = width.clamp(40, area.width.saturating_sub(2).max(40)); + // Modal height grows with content but bounded to half the screen. + let height = OVERLAY_MIN_HEIGHT + .max(area.height / 2) + .min(area.height.saturating_sub(2)); + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + Rect { + x, + y, + width, + height, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ask_user::AskUserOption; + + fn sample_question() -> AskUserQuestion { + AskUserQuestion { + request_id: "req".into(), + session_id: "ses".into(), + question: "Pick".into(), + context: Some("Why".into()), + options: vec![ + AskUserOption { + id: "A".into(), + label: "Alpha".into(), + description: None, + value: None, + recommended: false, + recommendation_reason: None, + }, + AskUserOption { + id: "B".into(), + label: "Beta".into(), + description: Some("preferred".into()), + value: Some("b-value".into()), + recommended: true, + recommendation_reason: Some("safer".into()), + }, + ], + allow_multiple: false, + reply_instructions: None, + title: None, + } + } + + #[test] + fn cursor_starts_on_recommended() { + let m = AskUserModal::from_question(sample_question()); + assert_eq!(m.cursor, 1); + } + + #[test] + fn arrow_keys_wrap() { + let mut m = AskUserModal::from_question(sample_question()); + // 2 options + 1 other row = 3 rows. Starting at cursor=1 (recommended). + m.move_cursor(1); + assert_eq!(m.cursor, 2); // Other + m.move_cursor(1); + assert_eq!(m.cursor, 0); // wraps to first option + m.move_cursor(-1); + assert_eq!(m.cursor, 2); // wraps backwards to Other + } + + #[test] + fn enter_on_option_submits_options_answer() { + let mut m = AskUserModal::from_question(sample_question()); + m.cursor = 1; + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + let AskUserModalOutcome::Done(answer) = out else { + panic!("expected Done"); + }; + match answer.kind { + AskUserAnswerKind::Options { + ids, + labels, + values, + } => { + assert_eq!(ids, vec!["B"]); + assert_eq!(labels, vec!["Beta"]); + assert_eq!(values, vec![Some("b-value".into())]); + } + other => panic!("unexpected kind: {other:?}"), + } + } + + #[test] + fn esc_cancels() { + let mut m = AskUserModal::from_question(sample_question()); + let out = m.handle_key(KeyCode::Esc, KeyModifiers::NONE); + let AskUserModalOutcome::Done(answer) = out else { + panic!("expected Done"); + }; + assert!(matches!(answer.kind, AskUserAnswerKind::Canceled)); + } + + #[test] + fn other_row_switches_to_typing_then_submits_custom() { + let mut m = AskUserModal::from_question(sample_question()); + // Move to Other row. + m.cursor = m.other_row(); + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert!(matches!(out, AskUserModalOutcome::Continue)); + assert!(matches!(m.mode, Mode::Typing)); + + // Type "hi" then Enter. + m.handle_key(KeyCode::Char('h'), KeyModifiers::NONE); + m.handle_key(KeyCode::Char('i'), KeyModifiers::NONE); + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + let AskUserModalOutcome::Done(answer) = out else { + panic!("expected Done"); + }; + match answer.kind { + AskUserAnswerKind::Custom { text } => assert_eq!(text, "hi"), + other => panic!("unexpected kind: {other:?}"), + } + } + + #[test] + fn empty_custom_does_not_submit() { + let mut m = AskUserModal::from_question(sample_question()); + m.cursor = m.other_row(); + m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert!(matches!(out, AskUserModalOutcome::Continue)); + } + + #[test] + fn typing_esc_returns_to_choosing() { + let mut m = AskUserModal::from_question(sample_question()); + m.cursor = m.other_row(); + m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + m.handle_key(KeyCode::Char('x'), KeyModifiers::NONE); + let out = m.handle_key(KeyCode::Esc, KeyModifiers::NONE); + assert!(matches!(out, AskUserModalOutcome::Continue)); + assert!(matches!(m.mode, Mode::Choosing)); + assert_eq!(m.typed, "x"); // preserves text in case user comes back + } + + #[test] + fn quick_select_by_id_letter() { + let mut m = AskUserModal::from_question(sample_question()); + m.cursor = 0; + m.handle_key(KeyCode::Char('b'), KeyModifiers::NONE); + assert_eq!(m.cursor, 1); + } + + #[test] + fn multi_select_space_toggles() { + let mut q = sample_question(); + q.allow_multiple = true; + let mut m = AskUserModal::from_question(q); + m.cursor = 0; + m.handle_key(KeyCode::Char(' '), KeyModifiers::NONE); + assert!(m.picked[0]); + m.cursor = 1; + m.handle_key(KeyCode::Char(' '), KeyModifiers::NONE); + assert!(m.picked[1]); + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + let AskUserModalOutcome::Done(answer) = out else { + panic!("expected Done"); + }; + match answer.kind { + AskUserAnswerKind::Options { ids, .. } => { + assert_eq!(ids, vec!["A", "B"]); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5088cab81..e12897c4c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,5 +1,6 @@ pub mod account_picker; mod app; +pub mod ask_user_modal; pub mod backend; pub(crate) mod color_support; mod core; @@ -286,6 +287,9 @@ pub trait TuiState { fn account_picker_overlay(&self) -> Option<&std::cell::RefCell>; /// Usage overlay for /usage command fn usage_overlay(&self) -> Option<&std::cell::RefCell>; + /// `askUserQuestion` modal overlay (None = not visible). + fn ask_user_overlay(&self) + -> Option<&std::cell::RefCell>; /// Working directory for this session fn working_dir(&self) -> Option; /// Monotonic clock for viewport animations diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 28dcc10c4..42829019b 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -2220,6 +2220,13 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { visual_debug::record_frame(capture.build()); } + // Top-most overlay: askUserQuestion modal. Draw last so it sits on top + // of everything else (chat, side panel, status line). The modal uses + // ratatui's `Clear` internally to ensure full opacity. + if let Some(modal_cell) = app.ask_user_overlay() { + modal_cell.borrow().render(frame); + } + finalize_frame_metrics( app, total_start, diff --git a/src/tui/ui_tests/mod.rs b/src/tui/ui_tests/mod.rs index 1505e75a2..4c8aea10f 100644 --- a/src/tui/ui_tests/mod.rs +++ b/src/tui/ui_tests/mod.rs @@ -403,6 +403,11 @@ impl crate::tui::TuiState for TestState { ) -> Option<&std::cell::RefCell> { None } + fn ask_user_overlay( + &self, + ) -> Option<&std::cell::RefCell> { + None + } fn working_dir(&self) -> Option { None } From febae6be586a469fc46a64a072909cbf481c6825 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 13:38:38 -0230 Subject: [PATCH 04/34] feat: forward askUserQuestion over the shared-server wire The askUserQuestion tool runs inside the shared server process while the TUI is a remote-client. Bus events do not cross the IPC boundary, so the previous commit's modal would never open for remote-mode clients. This commit adds the missing wire round-trip: Server -> Client (push the question): - New jcode_protocol::AskUserQuestion/Option/Answer payload types shared by both ends as the wire schema and by crate::ask_user as the in-process bus payload (one source of truth via type aliases). - New ServerEvent::AskUserQuestionOpened variant. - client_lifecycle forwards BusEvent::AskUserQuestionOpened to the active client when session_id matches, mirroring SidePanelUpdated. - remote/server_events.rs dispatches the event by opening the modal in-place via App::open_ask_user_modal. Client -> Server (return the answer): - New Request::SubmitAskUserAnswer variant (tagged lightweight so it cuts through processing-state queues). - RemoteConnection::submit_ask_user_answer uses the detached send path so the synchronous key handler does not have to await. - client_lifecycle dispatches SubmitAskUserAnswer to crate::ask_user::submit_answer to wake the pending oneshot. Modal-side glue: - AskUserModalApp now enqueues each completed answer into a new App::pending_ask_user_answers vec in addition to calling local submit_answer (so in-process / test paths still work uniformly). - remote::handle_tick drains the queue every tick and dispatches each answer via remote.submit_ask_user_answer. Result: the modal opens in the remote TUI and the picked answer reaches the agent's tool task across the socket. Verified end-to-end against the live shared-server build (4s reload). --- crates/jcode-protocol/src/lib.rs | 77 ++++++++++++++++++++++++++ src/ask_user.rs | 83 ++++++----------------------- src/server/client_lifecycle.rs | 20 +++++++ src/tool/ask_user_question.rs | 6 +++ src/tui/app.rs | 3 ++ src/tui/app/ask_user_modal_app.rs | 26 ++++++--- src/tui/app/remote.rs | 7 +++ src/tui/app/remote/key_handling.rs | 10 ++++ src/tui/app/remote/server_events.rs | 8 +++ src/tui/app/tui_lifecycle.rs | 2 + src/tui/backend.rs | 16 ++++++ 11 files changed, 185 insertions(+), 73 deletions(-) diff --git a/crates/jcode-protocol/src/lib.rs b/crates/jcode-protocol/src/lib.rs index 3a65de0dc..ed0c4dd8f 100644 --- a/crates/jcode-protocol/src/lib.rs +++ b/crates/jcode-protocol/src/lib.rs @@ -155,6 +155,68 @@ impl AuthChanged { pub type ReloadRecoverySnapshot = jcode_selfdev_types::ReloadRecoveryDirective; +// --- askUserQuestion wire payloads --------------------------------------- +// These mirror crate::ask_user::{AskUserOption,AskUserQuestion,AskUserAnswer, +// AskUserAnswerKind} so the binary can re-export the wire types directly and +// keep one source of truth. Keeping them in `jcode-protocol` lets the server +// and remote client share serde shapes without pulling the whole jcode crate. + +/// A single option offered to the user inside an `askUserQuestion` modal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserOptionPayload { + pub id: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recommended: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recommendation_reason: Option, +} + +/// The full question payload pushed from the server to the client for +/// rendering in the modal overlay. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserQuestionPayload { + pub request_id: String, + pub session_id: String, + pub question: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + pub options: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_multiple: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// The user's response to an `askUserQuestion` modal, sent client to server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserAnswerPayload { + pub request_id: String, + pub kind: AskUserAnswerKindPayload, +} + +/// Discriminated kinds of answer payloads. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AskUserAnswerKindPayload { + /// Option(s) selected. + Options { + ids: Vec, + labels: Vec, + values: Vec>, + }, + /// Free-form text answer. + Custom { text: String }, + /// User dismissed without answering. + Canceled, +} + /// Client request to server #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -204,6 +266,12 @@ pub enum Request { #[serde(rename = "rewind_undo")] RewindUndo { id: u64 }, + /// Submit the user's answer to an outstanding `askUserQuestion` modal. + /// The `request_id` must match the value the server included in the + /// preceding `ServerEvent::AskUserQuestionOpened` event. + #[serde(rename = "submit_ask_user_answer")] + SubmitAskUserAnswer { id: u64, answer: AskUserAnswerPayload }, + /// Health check #[serde(rename = "ping")] Ping { id: u64 }, @@ -1086,6 +1154,13 @@ pub enum ServerEvent { #[serde(rename = "side_panel_state")] SidePanelState { snapshot: SidePanelSnapshot }, + /// `askUserQuestion` tool wants the user to answer a question via the + /// TUI modal overlay. The client is expected to render the modal, capture + /// the user's response, and submit it back via + /// `Request::SubmitAskUserAnswer` carrying the same `request_id`. + #[serde(rename = "ask_user_question_opened")] + AskUserQuestionOpened { question: AskUserQuestionPayload }, + /// Server is reloading (clients should reconnect) #[serde(rename = "reloading")] Reloading { @@ -2048,6 +2123,7 @@ impl Request { Request::CommSubscribeChannel { id, .. } => *id, Request::CommUnsubscribeChannel { id, .. } => *id, Request::CommAwaitMembers { id, .. } => *id, + Request::SubmitAskUserAnswer { id, .. } => *id, } } @@ -2079,6 +2155,7 @@ impl Request { | Request::CommSubscribeChannel { .. } | Request::CommUnsubscribeChannel { .. } | Request::CommAwaitMembers { .. } + | Request::SubmitAskUserAnswer { .. } ) } } diff --git a/src/ask_user.rs b/src/ask_user.rs index 715aa6c9f..792b1529e 100644 --- a/src/ask_user.rs +++ b/src/ask_user.rs @@ -1,77 +1,26 @@ //! Global pending-question registry for the `askUserQuestion` tool. //! -//! When the tool is invoked it stages an `AskUserQuestionRequest` in this -//! registry, publishes a `BusEvent::AskUserQuestionOpened` so the TUI can -//! display its modal overlay, and `await`s on a oneshot receiver. When the -//! user answers (or cancels) via the modal, the TUI calls -//! [`submit_answer`] which removes the entry and fulfils the receiver. +//! When the tool is invoked it stages an `AskUserQuestion` in this registry, +//! publishes a `BusEvent::AskUserQuestionOpened` so the server can forward it +//! to the active TUI client (local or remote), and `await`s on a oneshot +//! receiver. When the user answers (or cancels) via the modal, the host +//! calls [`submit_answer`] which removes the entry and fulfils the receiver. //! -//! The mechanism mirrors the existing `StdinInputRequest` pattern but is -//! routed through a global map keyed by request_id so the tool execute -//! method does not need direct access to TUI state. +//! The wire-level types live in `jcode_protocol`; this module re-exports them +//! under shorter names so call sites can use one canonical type for both the +//! in-process bus event and the cross-process protocol payload. -use serde::{Deserialize, Serialize}; +use jcode_protocol::{ + AskUserAnswerKindPayload, AskUserAnswerPayload, AskUserOptionPayload, AskUserQuestionPayload, +}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use tokio::sync::oneshot; -/// A single answer option offered to the user. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AskUserOption { - /// Stable choice id (A, B, keep, rec, ...). - pub id: String, - /// Human-readable label. - pub label: String, - /// Optional explanation/notes shown under the label. - pub description: Option, - /// Optional "exact value" the agent receives if this option is picked. - pub value: Option, - /// True if this is the agent's recommended option. - pub recommended: bool, - /// Reason for the recommendation, displayed only on the recommended row. - pub recommendation_reason: Option, -} - -/// Payload describing a pending question for the TUI to render. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AskUserQuestion { - pub request_id: String, - pub session_id: String, - pub question: String, - pub context: Option, - pub options: Vec, - pub allow_multiple: bool, - pub reply_instructions: Option, - pub title: Option, -} - -/// Final answer returned to the tool. -/// -/// `kind` discriminates how the user responded so the agent can format the -/// downstream tool result appropriately. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AskUserAnswer { - pub request_id: String, - pub kind: AskUserAnswerKind, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum AskUserAnswerKind { - /// User picked one or more pre-defined options. - Options { - /// Option ids (preserving display order). - ids: Vec, - /// Labels for the picked ids (for display in the tool result). - labels: Vec, - /// `value` fields for the picked ids when set (parallel to `ids`). - values: Vec>, - }, - /// User typed a free-form answer instead of (or in addition to) picking. - Custom { text: String }, - /// User dismissed the modal (Esc) without answering. - Canceled, -} +pub type AskUserOption = AskUserOptionPayload; +pub type AskUserQuestion = AskUserQuestionPayload; +pub type AskUserAnswer = AskUserAnswerPayload; +pub type AskUserAnswerKind = AskUserAnswerKindPayload; /// Process-wide registry of in-flight ask-user requests. fn registry() -> &'static Mutex>> { @@ -80,7 +29,7 @@ fn registry() -> &'static Mutex>> } /// Register a pending question and return the receiver half. The caller -/// should then publish `BusEvent::AskUserQuestionOpened` so the TUI can +/// should then publish `BusEvent::AskUserQuestionOpened` so the host can /// render the modal, and `await` on the returned receiver. pub fn register_pending(request_id: String) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); diff --git a/src/server/client_lifecycle.rs b/src/server/client_lifecycle.rs index 688153297..e477bcb61 100644 --- a/src/server/client_lifecycle.rs +++ b/src/server/client_lifecycle.rs @@ -1198,6 +1198,17 @@ pub(super) async fn handle_client( }); } } + Ok(BusEvent::AskUserQuestionOpened(question)) => { + crate::logging::warn(&format!( + "[ask_user] server saw bus event request_id={} question_session={} client_session={}", + question.request_id, question.session_id, client_session_id + )); + if question.session_id == client_session_id { + let _ = client_event_tx.send( + ServerEvent::AskUserQuestionOpened { question }, + ); + } + } Ok(BusEvent::CompactionFinished) => { let agent = Arc::clone(&agent); let tx = client_event_tx.clone(); @@ -1483,6 +1494,15 @@ pub(super) async fn handle_client( } } + Request::SubmitAskUserAnswer { id, answer } => { + // Submit the user's answer to the pending registry. The tool + // task awaiting on the oneshot will wake and continue. Ack + // immediately; if no matching pending request exists we still + // ack (the tool may have timed out or been cancelled). + let _ = id; + crate::ask_user::submit_answer(answer); + } + Request::Ping { id } => { let json = encode_event(&ServerEvent::Pong { id }); let mut w = writer.lock().await; diff --git a/src/tool/ask_user_question.rs b/src/tool/ask_user_question.rs index aa8269510..8bf648416 100644 --- a/src/tool/ask_user_question.rs +++ b/src/tool/ask_user_question.rs @@ -218,6 +218,12 @@ impl Tool for AskUserQuestionTool { .map(ToOwned::to_owned), }; + crate::logging::warn(&format!( + "[ask_user] publishing AskUserQuestionOpened request_id={} session_id={} options={}", + request_id, + ctx.session_id, + question.options.len() + )); Bus::global().publish(BusEvent::AskUserQuestionOpened(question)); let answer = match tokio::time::timeout(ASK_USER_TIMEOUT, receiver).await { diff --git a/src/tui/app.rs b/src/tui/app.rs index 9a6d2e626..10fc8c834 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -983,6 +983,9 @@ pub struct App { usage_overlay: Option>, /// `askUserQuestion` modal overlay (None = not visible) ask_user_overlay: Option>, + /// Outbound queue of ask-user answers to forward to the server on the + /// next remote tick. Populated synchronously from the modal key handler. + pending_ask_user_answers: Vec, /// Whether a usage refresh request is currently in flight. usage_report_refreshing: bool, /// Last time the passive overnight progress card polled its run files. diff --git a/src/tui/app/ask_user_modal_app.rs b/src/tui/app/ask_user_modal_app.rs index 49dfe94b9..da4a0773a 100644 --- a/src/tui/app/ask_user_modal_app.rs +++ b/src/tui/app/ask_user_modal_app.rs @@ -16,10 +16,12 @@ impl App { if let Some(existing) = self.ask_user_overlay.take() { let prev_request_id = existing.borrow().request_id().to_string(); if prev_request_id != question.request_id { - submit_answer(AskUserAnswer { + let cancel = AskUserAnswer { request_id: prev_request_id, kind: AskUserAnswerKind::Canceled, - }); + }; + self.pending_ask_user_answers.push(cancel.clone()); + submit_answer(cancel); } } let modal = AskUserModal::from_question(question); @@ -46,8 +48,11 @@ impl App { AskUserModalOutcome::Continue => {} AskUserModalOutcome::Done(answer) => { self.ask_user_overlay = None; - // Submit even if the channel was already closed; submit_answer - // tolerates unknown ids. + // In remote-client mode the actual pending registry lives in + // the server process; queue the answer for the next tick to + // forward via Request::SubmitAskUserAnswer. We also call the + // local submit so in-process / test contexts work uniformly. + self.pending_ask_user_answers.push(answer.clone()); submit_answer(answer); self.clear_status_notice(); } @@ -68,13 +73,22 @@ impl App { pub(crate) fn cancel_ask_user_modal(&mut self) { if let Some(cell) = self.ask_user_overlay.take() { let request_id = cell.borrow().request_id().to_string(); - submit_answer(AskUserAnswer { + let cancel = AskUserAnswer { request_id, kind: AskUserAnswerKind::Canceled, - }); + }; + self.pending_ask_user_answers.push(cancel.clone()); + submit_answer(cancel); } } + /// Drain queued answers that need to be forwarded to the server. + pub(crate) fn drain_pending_ask_user_answers( + &mut self, + ) -> Vec { + std::mem::take(&mut self.pending_ask_user_answers) + } + pub(crate) fn ask_user_modal_visible(&self) -> bool { self.ask_user_overlay.is_some() } diff --git a/src/tui/app/remote.rs b/src/tui/app/remote.rs index 42cec041c..34f306947 100644 --- a/src/tui/app/remote.rs +++ b/src/tui/app/remote.rs @@ -65,6 +65,13 @@ pub(super) async fn handle_tick(app: &mut App, remote: &mut RemoteConnection) -> app.maybe_capture_runtime_memory_heartbeat(); app.progress_mouse_scroll_animation(); needs_redraw |= dispatch_compacted_history_load(app, remote).await; + + // Forward any queued ask-user-modal answers to the server. The modal's + // synchronous key handler enqueues these so we don't need to await inside + // the input loop. + for answer in app.drain_pending_ask_user_answers() { + remote.submit_ask_user_answer(answer); + } if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); needs_redraw = true; diff --git a/src/tui/app/remote/key_handling.rs b/src/tui/app/remote/key_handling.rs index 759f1aaec..842381b9a 100644 --- a/src/tui/app/remote/key_handling.rs +++ b/src/tui/app/remote/key_handling.rs @@ -235,6 +235,16 @@ async fn handle_remote_key_internal( let mut modifiers = modifiers; ctrl_bracket_fallback_to_esc(&mut code, &mut modifiers); + // Ask-user modal is a blocking overlay: capture every key so the user + // can navigate / type / cancel before anything else interferes. + if app.ask_user_modal_visible() { + app.handle_ask_user_modal_key(code, modifiers); + for answer in app.drain_pending_ask_user_answers() { + remote.submit_ask_user_answer(answer); + } + return Ok(()); + } + if app.changelog_scroll.is_some() { return app.handle_changelog_key(code); } diff --git a/src/tui/app/remote/server_events.rs b/src/tui/app/remote/server_events.rs index 7a67ad241..289740e29 100644 --- a/src/tui/app/remote/server_events.rs +++ b/src/tui/app/remote/server_events.rs @@ -878,6 +878,14 @@ pub(in crate::tui::app) fn handle_server_event( app.set_side_panel_snapshot(snapshot); false } + ServerEvent::AskUserQuestionOpened { question } => { + crate::logging::warn(&format!( + "[ask_user] client received ServerEvent request_id={} session_id={}; opening modal", + question.request_id, question.session_id + )); + app.open_ask_user_modal(question); + true + } ServerEvent::SwarmStatus { members } => { if app.swarm_enabled { app.remote_swarm_members = members; diff --git a/src/tui/app/tui_lifecycle.rs b/src/tui/app/tui_lifecycle.rs index 06de43b73..9509ce084 100644 --- a/src/tui/app/tui_lifecycle.rs +++ b/src/tui/app/tui_lifecycle.rs @@ -543,6 +543,7 @@ impl App { account_picker_overlay: None, usage_overlay: None, ask_user_overlay: None, + pending_ask_user_answers: Vec::new(), usage_report_refreshing: false, last_overnight_card_refresh: None, }; @@ -909,6 +910,7 @@ impl App { account_picker_overlay: None, usage_overlay: None, ask_user_overlay: None, + pending_ask_user_answers: Vec::new(), usage_report_refreshing: false, last_overnight_card_refresh: None, }; diff --git a/src/tui/backend.rs b/src/tui/backend.rs index b7e942432..d6f07d5e0 100644 --- a/src/tui/backend.rs +++ b/src/tui/backend.rs @@ -698,6 +698,22 @@ impl RemoteConnection { self.send_request(request).await } + /// Submit the user's answer to an outstanding `askUserQuestion` modal. + /// Fire-and-forget: the server applies it to its pending registry which + /// in turn wakes the tool task. We use the detached path so the modal + /// dispatch from the synchronous key handler does not need an .await. + pub fn submit_ask_user_answer( + &mut self, + answer: jcode_protocol::AskUserAnswerPayload, + ) { + let id = self.next_request_id; + self.next_request_id += 1; + self.send_request_detached( + Request::SubmitAskUserAnswer { id, answer }, + "submit_ask_user_answer", + ); + } + /// Split the current session — ask server to clone conversation into a new session pub async fn split(&mut self) -> Result { let id = self.next_request_id; From f227db6734e823330266673e937bca62ef16307e Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 13:53:21 -0230 Subject: [PATCH 05/34] Upgrade jcode-desktop to winit 0.30 for macOS 26 compat --- Cargo.lock | 885 +++++++++-- crates/jcode-desktop/Cargo.toml | 6 +- crates/jcode-desktop/src/main.rs | 1301 +++++++++-------- .../src/single_session_render.rs | 27 +- 4 files changed, 1537 insertions(+), 682 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 895c13c14..30ce4cffe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,17 +91,36 @@ dependencies = [ "bitflags 2.10.0", "cc", "cesu8", - "jni", + "jni 0.21.1", "jni-sys 0.3.1", "libc", "log", - "ndk", + "ndk 0.8.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.5.0+25.2.9519653", "num_enum", "thiserror 1.0.69", ] +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.17", +] + [[package]] name = "android-properties" version = "0.2.2" @@ -204,10 +223,10 @@ dependencies = [ "image", "log", "objc2 0.6.3", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", "windows-sys 0.60.2", @@ -241,6 +260,15 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading 0.8.9", +] + [[package]] name = "async-compression" version = "0.4.41" @@ -855,6 +883,15 @@ dependencies = [ "bit-vec 0.6.3", ] +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec 0.7.0", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -870,6 +907,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + [[package]] name = "bit-vec" version = "0.8.0" @@ -946,6 +989,15 @@ dependencies = [ "objc2 0.4.1", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1036,13 +1088,39 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.10.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + [[package]] name = "calloop-wayland-source" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" dependencies = [ - "calloop", + "calloop 0.12.4", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", @@ -1373,19 +1451,21 @@ dependencies = [ [[package]] name = "cosmic-text" -version = "0.10.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75acbfb314aeb4f5210d379af45ed1ec2c98c7f1790bf57b8a4c562ac0c51b71" +checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" dependencies = [ - "fontdb 0.15.0", - "libm", + "bitflags 2.10.0", + "fontdb 0.16.2", "log", "rangemap", - "rustc-hash", - "rustybuzz 0.11.0", + "rayon", + "rustc-hash 1.1.0", + "rustybuzz 0.14.1", "self_cell", "swash", "sys-locale", + "ttf-parser 0.21.1", "unicode-bidi", "unicode-linebreak", "unicode-script", @@ -1564,6 +1644,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "d3d12" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" +dependencies = [ + "bitflags 2.10.0", + "libloading 0.8.9", + "winapi", +] + [[package]] name = "darling" version = "0.20.11" @@ -1845,6 +1936,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dunce" version = "1.0.5" @@ -2169,16 +2266,16 @@ dependencies = [ [[package]] name = "fontdb" -version = "0.15.0" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020e203f177c0fb250fb19455a252e838d2bbbce1f80f25ecc42402aafa8cd38" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" dependencies = [ "fontconfig-parser", "log", - "memmap2 0.8.0", + "memmap2", "slotmap", "tinyvec", - "ttf-parser 0.19.2", + "ttf-parser 0.20.0", ] [[package]] @@ -2189,7 +2286,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", - "memmap2 0.9.9", + "memmap2", "slotmap", "tinyvec", "ttf-parser 0.25.1", @@ -2450,7 +2547,7 @@ dependencies = [ "crossbeam-channel", "keyboard-types", "objc2 0.6.3", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "once_cell", "thiserror 2.0.17", "windows-sys 0.59.0", @@ -2492,16 +2589,26 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "glyphon" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a62d0338e4056db6a73221c2fb2e30619452f6ea9651bac4110f51b0f7a7581" +checksum = "b11b1afb04c1a1be055989042258499473d0a9447f16450b433aba10bc2a46e7" dependencies = [ "cosmic-text", "etagere", "lru 0.12.5", - "wgpu", + "rustc-hash 2.1.2", + "wgpu 22.1.0", ] [[package]] @@ -2536,6 +2643,19 @@ dependencies = [ "windows 0.52.0", ] +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + [[package]] name = "gpu-descriptor" version = "0.2.4" @@ -2543,10 +2663,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" dependencies = [ "bitflags 2.10.0", - "gpu-descriptor-types", + "gpu-descriptor-types 0.1.2", "hashbrown 0.14.5", ] +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types 0.2.0", + "hashbrown 0.15.5", +] + [[package]] name = "gpu-descriptor-types" version = "0.1.2" @@ -2556,6 +2687,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "group" version = "0.12.1" @@ -2633,8 +2773,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2938,7 +3076,7 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" dependencies = [ - "block2", + "block2 0.3.0", "dispatch", "objc2 0.4.1", ] @@ -3440,6 +3578,7 @@ name = "jcode-config-types" version = "0.1.0" dependencies = [ "serde", + "toml", ] [[package]] @@ -3466,9 +3605,9 @@ dependencies = [ "pollster", "pulldown-cmark", "serde_json", - "wgpu", + "wgpu 22.1.0", "whoami", - "winit", + "winit 0.30.13", ] [[package]] @@ -3542,8 +3681,8 @@ dependencies = [ "serde_json", "tempfile", "tokio", - "wgpu", - "winit", + "wgpu 0.19.4", + "winit 0.29.15", ] [[package]] @@ -3873,6 +4012,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.17", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -4208,9 +4377,6 @@ name = "lru" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] [[package]] name = "lru" @@ -4297,15 +4463,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "memmap2" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" -dependencies = [ - "libc", -] - [[package]] name = "memmap2" version = "0.9.9" @@ -4364,6 +4521,21 @@ dependencies = [ "paste", ] +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -4443,7 +4615,28 @@ dependencies = [ "indexmap", "log", "num-traits", - "rustc-hash", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set 0.6.0", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", "spirv", "termcolor", "thiserror 1.0.69", @@ -4491,7 +4684,22 @@ dependencies = [ "bitflags 2.10.0", "jni-sys 0.3.1", "log", - "ndk-sys", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -4512,6 +4720,15 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nix" version = "0.29.0" @@ -4665,6 +4882,16 @@ dependencies = [ "objc2-encode 3.0.0", ] +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode 4.1.0", +] + [[package]] name = "objc2" version = "0.6.3" @@ -4674,6 +4901,22 @@ dependencies = [ "objc2-encode 4.1.0", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + [[package]] name = "objc2-app-kit" version = "0.3.2" @@ -4683,7 +4926,43 @@ dependencies = [ "bitflags 2.10.0", "objc2 0.6.3", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -4711,17 +4990,54 @@ dependencies = [ ] [[package]] -name = "objc2-encode" -version = "3.0.0" +name = "objc2-core-image" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] [[package]] -name = "objc2-encode" -version = "4.1.0" +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + +[[package]] +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -4744,6 +5060,98 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -5729,6 +6137,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5935,6 +6352,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -6063,17 +6486,17 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustybuzz" -version = "0.11.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee8fe2a8461a0854a37101fe7a1b13998d0cfa987e43248e81d2a5f4570f6fa" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "bytemuck", "libm", "smallvec", - "ttf-parser 0.20.0", - "unicode-bidi-mirroring 0.1.0", - "unicode-ccc 0.1.2", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring 0.2.0", + "unicode-ccc 0.2.0", "unicode-properties", "unicode-script", ] @@ -6168,8 +6591,21 @@ checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" dependencies = [ "ab_glyph", "log", - "memmap2 0.9.9", - "smithay-client-toolkit", + "memmap2", + "smithay-client-toolkit 0.18.1", + "tiny-skia", +] + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", "tiny-skia", ] @@ -6415,6 +6851,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -6474,20 +6926,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" dependencies = [ "bitflags 2.10.0", - "calloop", - "calloop-wayland-source", + "calloop 0.12.4", + "calloop-wayland-source 0.2.0", "cursor-icon", "libc", "log", - "memmap2 0.9.9", + "memmap2", "rustix 0.38.44", "thiserror 1.0.69", "wayland-backend", "wayland-client", "wayland-csd-frame", "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", + "wayland-protocols 0.31.2", + "wayland-protocols-wlr 0.2.0", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols 0.32.12", + "wayland-protocols-wlr 0.3.12", "wayland-scanner", "xkeysym", ] @@ -7415,7 +7892,7 @@ dependencies = [ "bytes", "derive-new", "log", - "memmap2 0.9.9", + "memmap2", "num-integer", "prost", "smallvec", @@ -7456,15 +7933,15 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttf-parser" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" [[package]] name = "ttf-parser" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" [[package]] name = "ttf-parser" @@ -7580,9 +8057,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi-mirroring" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d12260fb92d52f9008be7e4bca09f584780eb2266dc8fecc6a192bec561694" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" [[package]] name = "unicode-bidi-mirroring" @@ -7592,9 +8069,9 @@ checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" [[package]] name = "unicode-ccc" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2520efa644f8268dce4dcd3050eaa7fc044fca03961e9998ac7e2e92b77cf1" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" [[package]] name = "unicode-ccc" @@ -8021,6 +8498,18 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.2.0" @@ -8030,7 +8519,20 @@ dependencies = [ "bitflags 2.10.0", "wayland-backend", "wayland-client", - "wayland-protocols", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", "wayland-scanner", ] @@ -8043,7 +8545,20 @@ dependencies = [ "bitflags 2.10.0", "wayland-backend", "wayland-client", - "wayland-protocols", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", "wayland-scanner", ] @@ -8090,6 +8605,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -8188,7 +8713,32 @@ dependencies = [ "cfg_aliases 0.1.1", "js-sys", "log", - "naga", + "naga 0.19.2", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core 0.19.4", + "wgpu-hal 0.19.5", + "wgpu-types 0.19.2", +] + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga 22.1.0", "parking_lot", "profiling", "raw-window-handle", @@ -8197,9 +8747,9 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", + "wgpu-core 22.1.0", + "wgpu-hal 22.0.0", + "wgpu-types 22.0.0", ] [[package]] @@ -8215,17 +8765,42 @@ dependencies = [ "codespan-reporting", "indexmap", "log", - "naga", + "naga 0.19.2", "once_cell", "parking_lot", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", "web-sys", - "wgpu-hal", - "wgpu-types", + "wgpu-hal 0.19.5", + "wgpu-types 0.19.2", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec 0.7.0", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga 22.1.0", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal 22.0.0", + "wgpu-types 22.0.0", ] [[package]] @@ -8236,27 +8811,27 @@ checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" dependencies = [ "android_system_properties", "arrayvec", - "ash", + "ash 0.37.3+1.3.251", "bit-set 0.5.3", "bitflags 2.10.0", "block", "cfg_aliases 0.1.1", "core-graphics-types", - "d3d12", + "d3d12 0.19.0", "glow", - "glutin_wgl_sys", + "glutin_wgl_sys 0.5.0", "gpu-alloc", - "gpu-allocator", - "gpu-descriptor", + "gpu-allocator 0.25.0", + "gpu-descriptor 0.2.4", "hassle-rs", "js-sys", "khronos-egl", "libc", "libloading 0.8.9", "log", - "metal", - "naga", - "ndk-sys", + "metal 0.27.0", + "naga 0.19.2", + "ndk-sys 0.5.0+25.2.9519653", "objc", "once_cell", "parking_lot", @@ -8264,12 +8839,57 @@ dependencies = [ "range-alloc", "raw-window-handle", "renderdoc-sys", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", "wasm-bindgen", "web-sys", - "wgpu-types", + "wgpu-types 0.19.2", + "winapi", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash 0.38.0+1.3.281", + "bit-set 0.6.0", + "bitflags 2.10.0", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types", + "d3d12 22.0.0", + "glow", + "glutin_wgl_sys 0.6.1", + "gpu-alloc", + "gpu-allocator 0.26.0", + "gpu-descriptor 0.3.2", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal 0.29.0", + "naga 22.1.0", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types 22.0.0", "winapi", ] @@ -8284,6 +8904,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.10.0", + "js-sys", + "web-sys", +] + [[package]] name = "whoami" version = "1.6.1" @@ -8799,11 +9430,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" dependencies = [ "ahash", - "android-activity", + "android-activity 0.5.2", "atomic-waker", "bitflags 2.10.0", "bytemuck", - "calloop", + "calloop 0.12.4", "cfg_aliases 0.1.1", "core-foundation 0.9.4", "core-graphics", @@ -8812,9 +9443,9 @@ dependencies = [ "js-sys", "libc", "log", - "memmap2 0.9.9", - "ndk", - "ndk-sys", + "memmap2", + "ndk 0.8.0", + "ndk-sys 0.5.0+25.2.9519653", "objc2 0.4.1", "once_cell", "orbclient", @@ -8822,24 +9453,76 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.3.5", "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit", + "sctk-adwaita 0.8.3", + "smithay-client-toolkit 0.18.1", "smol_str", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", "wayland-backend", "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", + "wayland-protocols 0.31.2", + "wayland-protocols-plasma 0.2.0", "web-sys", - "web-time", + "web-time 0.2.4", "windows-sys 0.48.0", "x11-dl", "x11rb", "xkbcommon-dl", ] +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity 0.6.1", + "atomic-waker", + "bitflags 2.10.0", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita 0.10.1", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", + "wayland-protocols-plasma 0.3.12", + "web-sys", + "web-time 1.1.0", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.5.40" diff --git a/crates/jcode-desktop/Cargo.toml b/crates/jcode-desktop/Cargo.toml index 2347cc53b..b518d8405 100644 --- a/crates/jcode-desktop/Cargo.toml +++ b/crates/jcode-desktop/Cargo.toml @@ -9,14 +9,14 @@ anyhow = "1" arboard = "3" base64 = "0.22" bytemuck = { version = "1", features = ["derive"] } -glyphon = "0.5" +glyphon = "0.6" image = { version = "0.25", default-features = false, features = ["png"] } libc = "0.2" pollster = "0.3" pulldown-cmark = "0.12" serde_json = "1" -wgpu = "0.19" -winit = "0.29" +wgpu = "22" +winit = "0.30" [target.'cfg(any(target_os = "macos", windows))'.dependencies] whoami = "1" diff --git a/crates/jcode-desktop/src/main.rs b/crates/jcode-desktop/src/main.rs index 74bbec082..ea413b115 100644 --- a/crates/jcode-desktop/src/main.rs +++ b/crates/jcode-desktop/src/main.rs @@ -13,8 +13,8 @@ use anyhow::{Context, Result}; use base64::Engine; use bytemuck::{Pod, Zeroable}; use glyphon::{ - Attrs, Buffer, Color as TextColor, Family, FontSystem, Metrics, Resolution, Shaping, - SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Wrap, + Attrs, Buffer, Cache, Color as TextColor, Family, FontSystem, Metrics, Resolution, Shaping, + SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Wrap, }; use image::RgbaImage; use render_helpers::*; @@ -26,11 +26,12 @@ use single_session::{ }; use single_session_render::*; use wgpu::{CompositeAlphaMode, PresentMode, SurfaceError, TextureUsages}; +use winit::application::ApplicationHandler; use winit::dpi::{LogicalSize, PhysicalSize}; -use winit::event::{ElementState, Event, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent}; -use winit::event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy}; +use winit::event::{ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; use winit::keyboard::{Key, ModifiersState, NamedKey}; -use winit::window::{Fullscreen, Window, WindowBuilder}; +use winit::window::{Fullscreen, Window, WindowId}; use workspace::{InputMode, KeyInput, KeyOutcome, PanelSizePreset, Workspace}; use std::collections::hash_map::DefaultHasher; @@ -226,30 +227,17 @@ async fn run() -> Result<()> { "pid": std::process::id(), }), ); - let event_loop = EventLoopBuilder::::with_user_event() + let event_loop = EventLoop::::with_user_event() .build() .context("failed to create event loop")?; let event_loop_proxy = event_loop.create_proxy(); startup_trace.mark("event loop created"); - let mut window_builder = WindowBuilder::new() - .with_title("Jcode Desktop") - .with_inner_size(LogicalSize::new( - DEFAULT_WINDOW_WIDTH, - DEFAULT_WINDOW_HEIGHT, - )); - - if fullscreen { - window_builder = window_builder.with_fullscreen(Some(Fullscreen::Borderless(None))); - } - let window: &'static Window = Box::leak(Box::new( - window_builder - .build(&event_loop) - .context("failed to create desktop window")?, - )); - startup_trace.mark("window created"); + let preferences_save_tx = spawn_desktop_preferences_saver(); + let (session_event_tx, session_event_rx) = mpsc::channel(); + spawn_session_event_forwarder(session_event_rx, event_loop_proxy.clone()); - let mut app = if desktop_mode == DesktopMode::WorkspacePrototype { + let initial_app = if desktop_mode == DesktopMode::WorkspacePrototype { let session_cards = load_session_cards_for_desktop(); let mut workspace = Workspace::from_session_cards(session_cards); if let Some(preferences) = load_desktop_preferences() { @@ -260,38 +248,137 @@ async fn run() -> Result<()> { initial_single_session_app(resume_session_id.as_deref()) }; startup_trace.mark("app state initialized"); - window.set_title(&app.status_title()); - let mut canvas = Canvas::new(window, startup_trace).await?; - startup_trace.mark("canvas ready"); - let mut modifiers = ModifiersState::empty(); - let mut cursor_position = winit::dpi::PhysicalPosition::new(0.0, 0.0); - let mut selecting_body = false; - let mut selecting_draft = false; - let mut scroll_accumulator = ScrollLineAccumulator::default(); - let mut scroll_metrics_cache = SingleSessionScrollMetricsCache::default(); - let mut hot_reloader = DesktopHotReloader::new(); - let preferences_save_tx = spawn_desktop_preferences_saver(); - let mut power_inhibitor = power_inhibit::PowerInhibitor::new(); - let (session_event_tx, session_event_rx) = mpsc::channel(); - spawn_session_event_forwarder(session_event_rx, event_loop_proxy.clone()); - let mut recovery_scan_pending = app.is_single_session(); - let mut first_frame_presented = false; - let mut interaction_latency = DesktopInteractionLatencyProfiler::new(); - let mut no_paint_watchdog = DesktopNoPaintWatchdog::new(); - let mut last_backend_redraw_request: Option = None; - let mut pending_backend_redraw_since: Option = None; - - event_loop.run(move |event, target| { + let recovery_scan_pending = initial_app.is_single_session(); + + let mut handler = DesktopHandler { + app: initial_app, + window: None, + canvas: None, + startup_trace, + startup_benchmark, + fullscreen, + modifiers: ModifiersState::empty(), + cursor_position: winit::dpi::PhysicalPosition::new(0.0, 0.0), + selecting_body: false, + selecting_draft: false, + scroll_accumulator: ScrollLineAccumulator::default(), + scroll_metrics_cache: SingleSessionScrollMetricsCache::default(), + hot_reloader: DesktopHotReloader::new(), + preferences_save_tx, + power_inhibitor: power_inhibit::PowerInhibitor::new(), + session_event_tx, + event_loop_proxy, + recovery_scan_pending, + first_frame_presented: false, + interaction_latency: DesktopInteractionLatencyProfiler::new(), + no_paint_watchdog: DesktopNoPaintWatchdog::new(), + last_backend_redraw_request: None, + pending_backend_redraw_since: None, + exit_requested: false, + }; + + event_loop + .run_app(&mut handler) + .context("event loop terminated with error")?; + + Ok(()) +} + +struct DesktopHandler { + app: DesktopApp, + window: Option<&'static Window>, + canvas: Option>, + startup_trace: DesktopStartupTrace, + startup_benchmark: bool, + fullscreen: bool, + modifiers: ModifiersState, + cursor_position: winit::dpi::PhysicalPosition, + selecting_body: bool, + selecting_draft: bool, + scroll_accumulator: ScrollLineAccumulator, + scroll_metrics_cache: SingleSessionScrollMetricsCache, + hot_reloader: DesktopHotReloader, + preferences_save_tx: Option>, + power_inhibitor: power_inhibit::PowerInhibitor, + session_event_tx: mpsc::Sender, + event_loop_proxy: EventLoopProxy, + recovery_scan_pending: bool, + first_frame_presented: bool, + interaction_latency: DesktopInteractionLatencyProfiler, + no_paint_watchdog: DesktopNoPaintWatchdog, + last_backend_redraw_request: Option, + pending_backend_redraw_since: Option, + exit_requested: bool, +} + +impl DesktopHandler { + fn ensure_window_and_canvas(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let mut attrs = Window::default_attributes() + .with_title("Jcode Desktop") + .with_inner_size(LogicalSize::new( + DEFAULT_WINDOW_WIDTH, + DEFAULT_WINDOW_HEIGHT, + )); + if self.fullscreen { + attrs = attrs.with_fullscreen(Some(Fullscreen::Borderless(None))); + } + let window = match event_loop.create_window(attrs) { + Ok(window) => window, + Err(error) => { + eprintln!("jcode-desktop: failed to create desktop window: {error:#}"); + event_loop.exit(); + return; + } + }; + let window: &'static Window = Box::leak(Box::new(window)); + self.startup_trace.mark("window created"); + window.set_title(&self.app.status_title()); + let canvas = match pollster::block_on(Canvas::new(window, self.startup_trace)) { + Ok(canvas) => canvas, + Err(error) => { + eprintln!("jcode-desktop: failed to create canvas: {error:#}"); + event_loop.exit(); + return; + } + }; + self.startup_trace.mark("canvas ready"); + self.window = Some(window); + self.canvas = Some(canvas); + window.request_redraw(); + } + + fn maybe_exit(&mut self, event_loop: &ActiveEventLoop) { + if self.exit_requested { + event_loop.exit(); + } + } +} + +impl ApplicationHandler for DesktopHandler { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + self.ensure_window_and_canvas(event_loop); + } + + fn new_events( + &mut self, + event_loop: &ActiveEventLoop, + _cause: winit::event::StartCause, + ) { + let Some(window) = self.window else { return; }; let event_loop_now = Instant::now(); - let has_background_work = app.has_background_work(); - power_inhibitor.set_active(has_background_work); - let default_wake = if has_background_work || app.has_frame_animation() { + let has_background_work = self.app.has_background_work(); + self.power_inhibitor.set_active(has_background_work); + let default_wake = if has_background_work || self.app.has_frame_animation() { Some(event_loop_now + BACKGROUND_POLL_INTERVAL) } else { None }; - let backend_wake = pending_backend_redraw_since - .and_then(|_| last_backend_redraw_request) + let backend_wake = self + .pending_backend_redraw_since + .and_then(|_| self.last_backend_redraw_request) .map(|last| last + BACKEND_REDRAW_FRAME_INTERVAL); let wake = match (default_wake, backend_wake) { (Some(default_wake), Some(backend_wake)) => Some(default_wake.min(backend_wake)), @@ -299,24 +386,24 @@ async fn run() -> Result<()> { (None, None) => None, }; if let Some(wake) = wake { - target.set_control_flow(ControlFlow::WaitUntil(wake)); + event_loop.set_control_flow(ControlFlow::WaitUntil(wake)); } else { - target.set_control_flow(ControlFlow::Wait); + event_loop.set_control_flow(ControlFlow::Wait); } - let pending_interaction_kind = interaction_latency.pending_kind(); - let frame_animation_active = app.has_frame_animation(); - let pending_backend_redraw = pending_backend_redraw_since.is_some(); - let no_paint_active = !first_frame_presented + let pending_interaction_kind = self.interaction_latency.pending_kind(); + let frame_animation_active = self.app.has_frame_animation(); + let pending_backend_redraw = self.pending_backend_redraw_since.is_some(); + let no_paint_active = !self.first_frame_presented || has_background_work || frame_animation_active || pending_backend_redraw || pending_interaction_kind.is_some(); - if no_paint_watchdog.observe_active_tick( + if self.no_paint_watchdog.observe_active_tick( event_loop_now, NoPaintWatchdogContext { active: no_paint_active, - mode: app.mode(), + mode: self.app.mode(), has_background_work, frame_animation_active, pending_backend_redraw, @@ -325,427 +412,451 @@ async fn run() -> Result<()> { ) { window.request_redraw(); } + } + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + let Some(window) = self.window else { return; }; + if window_id != window.id() { + return; + } + let Some(canvas) = self.canvas.as_mut() else { return; }; match event { - Event::WindowEvent { event, window_id } if window_id == window.id() => match event { - WindowEvent::CloseRequested => target.exit(), - WindowEvent::Resized(size) => { - canvas.resize(size); - scroll_metrics_cache.clear(); - window.request_redraw(); - } - WindowEvent::ScaleFactorChanged { .. } => { - canvas.resize(window.inner_size()); - scroll_metrics_cache.clear(); - window.request_redraw(); + WindowEvent::CloseRequested => { + event_loop.exit(); + } + WindowEvent::Resized(size) => { + canvas.resize(size); + self.scroll_metrics_cache.clear(); + window.request_redraw(); + } + WindowEvent::ScaleFactorChanged { .. } => { + canvas.resize(window.inner_size()); + self.scroll_metrics_cache.clear(); + window.request_redraw(); + } + WindowEvent::ModifiersChanged(new_modifiers) => { + self.modifiers = new_modifiers.state(); + } + WindowEvent::MouseWheel { delta, phase, .. } => { + let size = window.inner_size(); + let now = Instant::now(); + let previous_smooth_scroll = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ); + let mut should_redraw = false; + if !self.app.is_single_session() { + self.scroll_accumulator.reset(); + self.scroll_metrics_cache.clear(); + } else if let Some(lines) = self.scroll_accumulator.scroll_lines(delta, now) { + should_redraw |= self + .app + .scroll_single_session_body(lines, size, &mut self.scroll_metrics_cache); } - WindowEvent::ModifiersChanged(new_modifiers) => { - modifiers = new_modifiers.state(); + if matches!(phase, TouchPhase::Cancelled) { + self.scroll_accumulator.reset(); } - WindowEvent::MouseWheel { delta, phase, .. } => { - let size = window.inner_size(); - let now = Instant::now(); - let previous_smooth_scroll = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ); - let mut should_redraw = false; - if !app.is_single_session() { - scroll_accumulator.reset(); - scroll_metrics_cache.clear(); - } else if let Some(lines) = scroll_accumulator.scroll_lines(delta, now) { - should_redraw |= - app.scroll_single_session_body(lines, size, &mut scroll_metrics_cache); - } - if matches!(phase, TouchPhase::Cancelled) { - scroll_accumulator.reset(); - } - let next_smooth_scroll = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ); - should_redraw |= (next_smooth_scroll - previous_smooth_scroll).abs() - >= SCROLL_FRACTIONAL_EPSILON; - if should_redraw { - interaction_latency.mark("mouse_wheel", now); - window.request_redraw(); - } + let next_smooth_scroll = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ); + should_redraw |= (next_smooth_scroll - previous_smooth_scroll).abs() + >= SCROLL_FRACTIONAL_EPSILON; + if should_redraw { + self.interaction_latency.mark("mouse_wheel", now); + window.request_redraw(); } - WindowEvent::CursorMoved { position, .. } => { - let cursor_started = Instant::now(); - cursor_position = position; - if selecting_draft - && app.update_single_session_draft_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, - window.inner_size(), - ) - { - interaction_latency.mark("draft_selection_drag", cursor_started); - window.request_redraw(); - } else if selecting_body - && app.update_single_session_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, - window.inner_size(), - ) - { - interaction_latency.mark("body_selection_drag", cursor_started); - window.request_redraw(); - } + } + WindowEvent::CursorMoved { position, .. } => { + let cursor_started = Instant::now(); + self.cursor_position = position; + if self.selecting_draft + && self.app.update_single_session_draft_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, + window.inner_size(), + ) + { + self.interaction_latency + .mark("draft_selection_drag", cursor_started); + window.request_redraw(); + } else if self.selecting_body + && self.app.update_single_session_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, + window.inner_size(), + ) + { + self.interaction_latency + .mark("body_selection_drag", cursor_started); + window.request_redraw(); } - WindowEvent::MouseInput { - state, - button: MouseButton::Left, - .. - } => { - let mouse_started = Instant::now(); - match state { - ElementState::Pressed => { - if app.begin_single_session_draft_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, + } + WindowEvent::MouseInput { + state, + button: MouseButton::Left, + .. + } => { + let mouse_started = Instant::now(); + match state { + ElementState::Pressed => { + if self.app.begin_single_session_draft_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, window.inner_size(), ) { - selecting_body = false; - selecting_draft = true; - window.set_title(&app.status_title()); - interaction_latency.mark("mouse_press", mouse_started); + self.selecting_body = false; + self.selecting_draft = true; + window.set_title(&self.app.status_title()); + self.interaction_latency.mark("mouse_press", mouse_started); window.request_redraw(); return; } - selecting_draft = false; - selecting_body = app.begin_single_session_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, + self.selecting_draft = false; + self.selecting_body = self.app.begin_single_session_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, window.inner_size(), ); - if selecting_body { - interaction_latency.mark("mouse_press", mouse_started); + if self.selecting_body { + self.interaction_latency.mark("mouse_press", mouse_started); window.request_redraw(); } } ElementState::Released => { - if selecting_draft { - app.update_single_session_draft_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, + if self.selecting_draft { + self.app.update_single_session_draft_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, window.inner_size(), ); - selecting_draft = false; - let selected = app.selected_single_session_draft_text(); + self.selecting_draft = false; + let selected = self.app.selected_single_session_draft_text(); if let Some(text) = selected { - copy_text_to_clipboard(&text, "copied input selection", &mut app); + copy_text_to_clipboard( + &text, + "copied input selection", + &mut self.app, + ); } - window.set_title(&app.status_title()); - interaction_latency.mark("mouse_release", mouse_started); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("mouse_release", mouse_started); window.request_redraw(); - } else if selecting_body { - app.update_single_session_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, + } else if self.selecting_body { + self.app.update_single_session_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, window.inner_size(), ); - selecting_body = false; - let selected = app.selected_single_session_text(window.inner_size()); + self.selecting_body = false; + let selected = self + .app + .selected_single_session_text(window.inner_size()); if let Some(text) = selected { - copy_text_to_clipboard(&text, "copied selection", &mut app); + copy_text_to_clipboard(&text, "copied selection", &mut self.app); } - window.set_title(&app.status_title()); - interaction_latency.mark("mouse_release", mouse_started); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("mouse_release", mouse_started); window.request_redraw(); } } - } } - WindowEvent::KeyboardInput { event, .. } - if event.state == ElementState::Pressed => - { - let keyboard_started = Instant::now(); - let size = window.inner_size(); - let had_smooth_scroll = app - .single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ) - .abs() - >= SCROLL_FRACTIONAL_EPSILON; - scroll_accumulator.reset(); - if had_smooth_scroll { + } + WindowEvent::KeyboardInput { event, .. } + if event.state == ElementState::Pressed => + { + let keyboard_started = Instant::now(); + let size = window.inner_size(); + let had_smooth_scroll = self + .app + .single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ) + .abs() + >= SCROLL_FRACTIONAL_EPSILON; + self.scroll_accumulator.reset(); + if had_smooth_scroll { + window.request_redraw(); + } + let key_input = to_key_input(&event.logical_key, self.modifiers); + let key_debug = format!("{key_input:?}"); + self.interaction_latency.mark("keyboard_input", keyboard_started); + if key_input == KeyInput::RefreshSessions && self.app.is_workspace() { + spawn_session_cards_load( + DesktopSessionCardsPurpose::WorkspaceRefresh, + self.event_loop_proxy.clone(), + Duration::ZERO, + ); + window.request_redraw(); + return; + } + + match self.app.handle_key(key_input) { + KeyOutcome::Exit => event_loop.exit(), + KeyOutcome::Redraw => { + if let DesktopApp::Workspace(workspace) = &self.app { + queue_desktop_preferences_save(workspace, &self.preferences_save_tx); + } + window.set_title(&self.app.status_title()); window.request_redraw(); } - let key_input = to_key_input(&event.logical_key, modifiers); - let key_debug = format!("{key_input:?}"); - interaction_latency.mark("keyboard_input", keyboard_started); - if key_input == KeyInput::RefreshSessions && app.is_workspace() { - spawn_session_cards_load( - DesktopSessionCardsPurpose::WorkspaceRefresh, - event_loop_proxy.clone(), - Duration::ZERO, - ); - window.request_redraw(); - return; + KeyOutcome::OpenSession { session_id, title } => { + if let DesktopApp::Workspace(workspace) = &self.app { + queue_desktop_preferences_save(workspace, &self.preferences_save_tx); + } + if let Err(error) = + session_launch::launch_validated_resume_session(&session_id, &title) + { + eprintln!( + "jcode-desktop: failed to open session {session_id}: {error:#}" + ); + } } - - match app.handle_key(key_input) { - KeyOutcome::Exit => target.exit(), - KeyOutcome::Redraw => { - if let DesktopApp::Workspace(workspace) = &app { - queue_desktop_preferences_save(workspace, &preferences_save_tx); - } - window.set_title(&app.status_title()); + KeyOutcome::SpawnSession => { + if let DesktopApp::SingleSession(single_session_app) = &mut self.app { + single_session_app.reset_fresh_session(); + window.set_title(&self.app.status_title()); window.request_redraw(); + return; } - KeyOutcome::OpenSession { session_id, title } => { - if let DesktopApp::Workspace(workspace) = &app { - queue_desktop_preferences_save(workspace, &preferences_save_tx); - } - if let Err(error) = - session_launch::launch_validated_resume_session(&session_id, &title) - { - eprintln!( - "jcode-desktop: failed to open session {session_id}: {error:#}" - ); - } - } - KeyOutcome::SpawnSession => { - if let DesktopApp::SingleSession(app) = &mut app { - app.reset_fresh_session(); - window.set_title(&app.status_title()); - window.request_redraw(); - return; - } - if let Err(error) = session_launch::launch_new_session() { - eprintln!("jcode-desktop: failed to spawn session: {error:#}"); - } else { - spawn_session_cards_load( - DesktopSessionCardsPurpose::WorkspaceRefresh, - event_loop_proxy.clone(), - SESSION_SPAWN_REFRESH_DELAY, - ); - window.request_redraw(); - } - } - KeyOutcome::SendDraft { - session_id, - title, - message, - images, - } => { - if app.is_single_session() { - match session_launch::spawn_message_to_session( - session_id.clone(), - message, - images, - session_event_tx.clone(), - ) { - Ok(handle) => app.set_single_session_handle(handle), - Err(error) => apply_single_session_error(&mut app, error), - } - window.set_title(&app.status_title()); - window.request_redraw(); - } else if !images.is_empty() { - match session_launch::spawn_message_to_session( - session_id.clone(), - message, - images, - session_event_tx.clone(), - ) { - Ok(_handle) => { - spawn_session_cards_load( - DesktopSessionCardsPurpose::WorkspaceRefresh, - event_loop_proxy.clone(), - SESSION_SPAWN_REFRESH_DELAY, - ); - window.request_redraw(); - } - Err(error) => eprintln!( - "jcode-desktop: failed to send image draft to {session_id}: {error:#}" - ), - } - } else if let Err(error) = session_launch::send_message_to_session( - &session_id, - &title, - &message, - ) { - eprintln!( - "jcode-desktop: failed to send draft to {session_id}: {error:#}" - ); - } else { - spawn_session_cards_load( - DesktopSessionCardsPurpose::WorkspaceRefresh, - event_loop_proxy.clone(), - SESSION_SPAWN_REFRESH_DELAY, - ); - window.request_redraw(); - } + if let Err(error) = session_launch::launch_new_session() { + eprintln!("jcode-desktop: failed to spawn session: {error:#}"); + } else { + spawn_session_cards_load( + DesktopSessionCardsPurpose::WorkspaceRefresh, + self.event_loop_proxy.clone(), + SESSION_SPAWN_REFRESH_DELAY, + ); + window.request_redraw(); } - KeyOutcome::StartFreshSession { message, images } => { - match session_launch::spawn_fresh_server_session( + } + KeyOutcome::SendDraft { + session_id, + title, + message, + images, + } => { + if self.app.is_single_session() { + match session_launch::spawn_message_to_session( + session_id.clone(), message, images, - session_event_tx.clone(), + self.session_event_tx.clone(), ) { - Ok(handle) => app.set_single_session_handle(handle), - Err(error) => apply_single_session_error(&mut app, error), + Ok(handle) => self.app.set_single_session_handle(handle), + Err(error) => apply_single_session_error(&mut self.app, error), } - window.set_title(&app.status_title()); - window.request_redraw(); - } - KeyOutcome::CancelGeneration => { - app.cancel_single_session_generation(); - window.set_title(&app.status_title()); - window.request_redraw(); - } - KeyOutcome::CopyLatestResponse(text) => { - copy_text_to_clipboard(&text, "copied latest response", &mut app); - window.set_title(&app.status_title()); + window.set_title(&self.app.status_title()); window.request_redraw(); - } - KeyOutcome::CutDraftToClipboard(text) => { - copy_text_to_clipboard(&text, "cut input line", &mut app); - window.set_title(&app.status_title()); - window.request_redraw(); - } - KeyOutcome::CycleModel(direction) => { - if let Err(error) = session_launch::spawn_cycle_model( - direction, - app.single_session_live_id(), - session_event_tx.clone(), + } else if !images.is_empty() { + match session_launch::spawn_message_to_session( + session_id.clone(), + message, + images, + self.session_event_tx.clone(), ) { - apply_single_session_error(&mut app, error); - } else { - app.apply_session_event( - session_launch::DesktopSessionEvent::Status( - "switching model".to_string(), - ), - ); + Ok(_handle) => { + spawn_session_cards_load( + DesktopSessionCardsPurpose::WorkspaceRefresh, + self.event_loop_proxy.clone(), + SESSION_SPAWN_REFRESH_DELAY, + ); + window.request_redraw(); + } + Err(error) => eprintln!( + "jcode-desktop: failed to send image draft to {session_id}: {error:#}" + ), } - window.set_title(&app.status_title()); + } else if let Err(error) = + session_launch::send_message_to_session(&session_id, &title, &message) + { + eprintln!( + "jcode-desktop: failed to send draft to {session_id}: {error:#}" + ); + } else { + spawn_session_cards_load( + DesktopSessionCardsPurpose::WorkspaceRefresh, + self.event_loop_proxy.clone(), + SESSION_SPAWN_REFRESH_DELAY, + ); window.request_redraw(); } - KeyOutcome::CycleReasoningEffort(direction) => { - if let Err(error) = session_launch::spawn_cycle_reasoning_effort( - direction, - app.single_session_live_id(), - session_event_tx.clone(), - ) { - apply_single_session_error(&mut app, error); - } else { - app.apply_session_event( - session_launch::DesktopSessionEvent::Status( - "switching reasoning effort".to_string(), - ), - ); - } - window.set_title(&app.status_title()); - window.request_redraw(); + } + KeyOutcome::StartFreshSession { message, images } => { + match session_launch::spawn_fresh_server_session( + message, + images, + self.session_event_tx.clone(), + ) { + Ok(handle) => self.app.set_single_session_handle(handle), + Err(error) => apply_single_session_error(&mut self.app, error), } - KeyOutcome::LoadModelCatalog => { - if let Err(error) = session_launch::spawn_load_model_catalog( - app.single_session_live_id(), - session_event_tx.clone(), - ) { - apply_single_session_error(&mut app, error); - } - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CancelGeneration => { + self.app.cancel_single_session_generation(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CopyLatestResponse(text) => { + copy_text_to_clipboard(&text, "copied latest response", &mut self.app); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CutDraftToClipboard(text) => { + copy_text_to_clipboard(&text, "cut input line", &mut self.app); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CycleModel(direction) => { + if let Err(error) = session_launch::spawn_cycle_model( + direction, + self.app.single_session_live_id(), + self.session_event_tx.clone(), + ) { + apply_single_session_error(&mut self.app, error); + } else { + self.app.apply_session_event( + session_launch::DesktopSessionEvent::Status( + "switching model".to_string(), + ), + ); } - KeyOutcome::LoadSessionSwitcher => { - spawn_session_cards_load( - DesktopSessionCardsPurpose::SingleSessionSwitcher, - event_loop_proxy.clone(), - Duration::ZERO, + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CycleReasoningEffort(direction) => { + if let Err(error) = session_launch::spawn_cycle_reasoning_effort( + direction, + self.app.single_session_live_id(), + self.session_event_tx.clone(), + ) { + apply_single_session_error(&mut self.app, error); + } else { + self.app.apply_session_event( + session_launch::DesktopSessionEvent::Status( + "switching reasoning effort".to_string(), + ), ); - window.set_title(&app.status_title()); - window.request_redraw(); } - KeyOutcome::RestoreCrashedSessions => { - spawn_restore_crashed_sessions(event_loop_proxy.clone()); - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::LoadModelCatalog => { + if let Err(error) = session_launch::spawn_load_model_catalog( + self.app.single_session_live_id(), + self.session_event_tx.clone(), + ) { + apply_single_session_error(&mut self.app, error); } - KeyOutcome::SetModel(model) => { - if let Err(error) = session_launch::spawn_set_model( - model, - app.single_session_live_id(), - session_event_tx.clone(), - ) { - apply_single_session_error(&mut app, error); - } else { - app.apply_session_event( - session_launch::DesktopSessionEvent::Status( - "switching model".to_string(), - ), - ); - } - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::LoadSessionSwitcher => { + spawn_session_cards_load( + DesktopSessionCardsPurpose::SingleSessionSwitcher, + self.event_loop_proxy.clone(), + Duration::ZERO, + ); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::RestoreCrashedSessions => { + spawn_restore_crashed_sessions(self.event_loop_proxy.clone()); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::SetModel(model) => { + if let Err(error) = session_launch::spawn_set_model( + model, + self.app.single_session_live_id(), + self.session_event_tx.clone(), + ) { + apply_single_session_error(&mut self.app, error); + } else { + self.app.apply_session_event( + session_launch::DesktopSessionEvent::Status( + "switching model".to_string(), + ), + ); } - KeyOutcome::SendStdinResponse { request_id, input } => { - if let Err(error) = app.send_single_session_stdin_response(request_id, input) - { - apply_single_session_error(&mut app, error); - } - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::SendStdinResponse { request_id, input } => { + if let Err(error) = + self.app.send_single_session_stdin_response(request_id, input) + { + apply_single_session_error(&mut self.app, error); } - KeyOutcome::AttachClipboardImage => { - match clipboard_image_png_base64() { - Ok((media_type, base64_data)) => { - app.attach_clipboard_image(media_type, base64_data); - } - Err(error) => apply_single_session_error(&mut app, error), + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::AttachClipboardImage => { + match clipboard_image_png_base64() { + Ok((media_type, base64_data)) => { + self.app.attach_clipboard_image(media_type, base64_data); } - window.set_title(&app.status_title()); - window.request_redraw(); + Err(error) => apply_single_session_error(&mut self.app, error), } - KeyOutcome::PasteText => { - if let Err(error) = paste_clipboard_into_app(&mut app) { - apply_single_session_error(&mut app, error); - } - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::PasteText => { + if let Err(error) = paste_clipboard_into_app(&mut self.app) { + apply_single_session_error(&mut self.app, error); } - KeyOutcome::None => {} + window.set_title(&self.app.status_title()); + window.request_redraw(); } - log_desktop_slow_interaction( - "keyboard_input", - keyboard_started.elapsed(), - serde_json::json!({ "key": key_debug }), - ); + KeyOutcome::None => {} } - WindowEvent::RedrawRequested => { - let smooth_scroll_lines = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - window.inner_size(), - &mut scroll_metrics_cache, - ); - match canvas.render( - &app, - window.current_monitor().map(|monitor| monitor.size()), - smooth_scroll_lines, - ) { + log_desktop_slow_interaction( + "keyboard_input", + keyboard_started.elapsed(), + serde_json::json!({ "key": key_debug }), + ); + } + WindowEvent::RedrawRequested => { + let smooth_scroll_lines = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + window.inner_size(), + &mut self.scroll_metrics_cache, + ); + match canvas.render( + &self.app, + window.current_monitor().map(|monitor| monitor.size()), + smooth_scroll_lines, + ) { Ok(frame) => { - no_paint_watchdog.observe_presented(Instant::now(), &frame); - interaction_latency.observe_presented(&frame); - if !first_frame_presented { - first_frame_presented = true; - startup_trace.mark("first frame presented"); - if startup_benchmark { - target.exit(); + self.no_paint_watchdog.observe_presented(Instant::now(), &frame); + self.interaction_latency.observe_presented(&frame); + if !self.first_frame_presented { + self.first_frame_presented = true; + self.startup_trace.mark("first frame presented"); + if self.startup_benchmark { + event_loop.exit(); return; } - if recovery_scan_pending { - recovery_scan_pending = false; + if self.recovery_scan_pending { + self.recovery_scan_pending = false; spawn_recovery_session_count_scan( - event_loop_proxy.clone(), - startup_trace, + self.event_loop_proxy.clone(), + self.startup_trace, ); } } @@ -757,59 +868,65 @@ async fn run() -> Result<()> { canvas.resize(window.inner_size()); window.request_redraw(); } - Err(SurfaceError::OutOfMemory) => target.exit(), + Err(SurfaceError::OutOfMemory) => event_loop.exit(), Err(SurfaceError::Timeout) => { window.request_redraw(); } - } } - _ => {} - }, - Event::UserEvent(DesktopUserEvent::RecoveryCount(recovery_count)) => { - if let DesktopApp::SingleSession(single_session) = &mut app { + } + _ => {} + } + } + + fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: DesktopUserEvent) { + let Some(window) = self.window else { return; }; + match event { + DesktopUserEvent::RecoveryCount(recovery_count) => { + if let DesktopApp::SingleSession(single_session) = &mut self.app { single_session.set_recovery_session_count(recovery_count); - window.set_title(&app.status_title()); - interaction_latency.mark("recovery_count", Instant::now()); + window.set_title(&self.app.status_title()); + self.interaction_latency.mark("recovery_count", Instant::now()); window.request_redraw(); } } - Event::UserEvent(DesktopUserEvent::SessionCardsLoaded { + DesktopUserEvent::SessionCardsLoaded { purpose, cards, loaded_in, - }) => { + } => { let card_count = cards.len(); let mut applied = false; match purpose { DesktopSessionCardsPurpose::WorkspaceRefresh => { - if let DesktopApp::Workspace(workspace) = &mut app { + if let DesktopApp::Workspace(workspace) = &mut self.app { workspace.replace_session_cards(cards); - queue_desktop_preferences_save(workspace, &preferences_save_tx); + queue_desktop_preferences_save(workspace, &self.preferences_save_tx); applied = true; } } DesktopSessionCardsPurpose::SingleSessionSwitcher => { - if app.is_single_session() { - app.apply_single_session_switcher_cards(cards); + if self.app.is_single_session() { + self.app.apply_single_session_switcher_cards(cards); applied = true; } } } log_desktop_session_cards_load_profile(purpose, loaded_in, card_count, applied); if applied { - window.set_title(&app.status_title()); - interaction_latency.mark("session_cards_load", Instant::now()); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("session_cards_load", Instant::now()); window.request_redraw(); } } - Event::UserEvent(DesktopUserEvent::SessionCardLoaded { + DesktopUserEvent::SessionCardLoaded { session_id, card, loaded_in, - }) => { + } => { let card_found = card.is_some(); let mut applied = false; - if let DesktopApp::SingleSession(single_session) = &mut app + if let DesktopApp::SingleSession(single_session) = &mut self.app && single_session.live_session_id.as_deref() == Some(session_id.as_str()) && let Some(card) = card { @@ -823,16 +940,17 @@ async fn run() -> Result<()> { applied, ); if applied { - window.set_title(&app.status_title()); - interaction_latency.mark("session_card_refresh", Instant::now()); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("session_card_refresh", Instant::now()); window.request_redraw(); } } - Event::UserEvent(DesktopUserEvent::CrashedSessionsRestoreFinished { + DesktopUserEvent::CrashedSessionsRestoreFinished { restored, errors, elapsed, - }) => { + } => { log_desktop_crashed_sessions_restore_profile(restored, errors.len(), elapsed); if restored == 0 { let message = if errors.is_empty() { @@ -840,24 +958,28 @@ async fn run() -> Result<()> { } else { format!("failed to restore crashed sessions: {}", errors.join("; ")) }; - apply_single_session_error(&mut app, anyhow::anyhow!(message)); - } else if let DesktopApp::SingleSession(single_session) = &mut app { + apply_single_session_error(&mut self.app, anyhow::anyhow!(message)); + } else if let DesktopApp::SingleSession(single_session) = &mut self.app { single_session.set_recovery_session_count(0); - single_session.apply_session_event(session_launch::DesktopSessionEvent::Status( - format!("restored {restored} crashed session(s)"), - )); + single_session.apply_session_event( + session_launch::DesktopSessionEvent::Status(format!( + "restored {restored} crashed session(s)" + )), + ); } - window.set_title(&app.status_title()); - interaction_latency.mark("restore_crashed_sessions", Instant::now()); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("restore_crashed_sessions", Instant::now()); window.request_redraw(); } - Event::UserEvent(DesktopUserEvent::SessionEvents(batch)) => { + DesktopUserEvent::SessionEvents(batch) => { let ui_received_at = Instant::now(); let accumulated_for = batch.accumulated_for(); let raw_event_count = batch.raw_event_count; let raw_payload_bytes = batch.raw_payload_bytes; let forwarded_at = batch.forwarded_at; - let apply_stats = apply_desktop_session_event_batch_with_stats(&mut app, batch.events); + let apply_stats = + apply_desktop_session_event_batch_with_stats(&mut self.app, batch.events); let ui_queue_delay = ui_received_at.saturating_duration_since(forwarded_at); let mut redraw_requested = false; let mut redraw_deferred = false; @@ -865,43 +987,46 @@ async fn run() -> Result<()> { if apply_stats.visible_changed { let now = Instant::now(); if apply_stats.session_card_refresh_requested - && let Some(session_id) = app.single_session_live_id() + && let Some(session_id) = self.app.single_session_live_id() { - spawn_single_session_card_refresh(session_id, event_loop_proxy.clone()); + spawn_single_session_card_refresh(session_id, self.event_loop_proxy.clone()); session_card_refresh_spawned = true; } - if let Some((message, images)) = app.take_next_queued_single_session_draft() { - let result = if let Some(session_id) = app.single_session_live_id() { + if let Some((message, images)) = + self.app.take_next_queued_single_session_draft() + { + let result = if let Some(session_id) = self.app.single_session_live_id() { session_launch::spawn_message_to_session( session_id, message, images, - session_event_tx.clone(), + self.session_event_tx.clone(), ) } else { session_launch::spawn_fresh_server_session( message, images, - session_event_tx.clone(), + self.session_event_tx.clone(), ) }; match result { - Ok(handle) => app.set_single_session_handle(handle), - Err(error) => apply_single_session_error(&mut app, error), + Ok(handle) => self.app.set_single_session_handle(handle), + Err(error) => apply_single_session_error(&mut self.app, error), } } - window.set_title(&app.status_title()); - let redraw_due = last_backend_redraw_request.is_none_or(|last| { + window.set_title(&self.app.status_title()); + let redraw_due = self.last_backend_redraw_request.is_none_or(|last| { now.saturating_duration_since(last) >= BACKEND_REDRAW_FRAME_INTERVAL }); if redraw_due { - let first_pending = pending_backend_redraw_since.take().unwrap_or(now); - interaction_latency.mark("backend_events", first_pending); - last_backend_redraw_request = Some(now); + let first_pending = + self.pending_backend_redraw_since.take().unwrap_or(now); + self.interaction_latency.mark("backend_events", first_pending); + self.last_backend_redraw_request = Some(now); window.request_redraw(); redraw_requested = true; } else { - pending_backend_redraw_since.get_or_insert(now); + self.pending_backend_redraw_since.get_or_insert(now); redraw_deferred = true; } } @@ -916,69 +1041,74 @@ async fn run() -> Result<()> { session_card_refresh_spawned, ); } - Event::AboutToWait => { - if app.is_single_session() { - let about_to_wait_started = Instant::now(); - let size = window.inner_size(); - let previous_smooth_scroll = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ); - let frame = scroll_accumulator.frame(Instant::now()); - if let Some(lines) = frame.scroll_lines - && !app.scroll_single_session_body(lines, size, &mut scroll_metrics_cache) - { - scroll_accumulator.stop(); - } - let next_smooth_scroll = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ); - if frame.active - || (next_smooth_scroll - previous_smooth_scroll).abs() - >= SCROLL_FRACTIONAL_EPSILON - { - interaction_latency.mark("scroll_momentum", about_to_wait_started); - window.request_redraw(); - } - } else if scroll_accumulator.is_active() { - scroll_accumulator.reset(); - scroll_metrics_cache.clear(); - } - if let Some(first_pending_backend_redraw) = pending_backend_redraw_since { - let now = Instant::now(); - if last_backend_redraw_request.is_none_or(|last| { - now.saturating_duration_since(last) >= BACKEND_REDRAW_FRAME_INTERVAL - }) { - pending_backend_redraw_since = None; - interaction_latency.mark("backend_events", first_pending_backend_redraw); - last_backend_redraw_request = Some(now); - window.request_redraw(); - } - } - if let Some(relaunch) = hot_reloader.poll() { - if let Err(error) = relaunch.spawn() { - eprintln!("jcode-desktop: failed to hot reload desktop: {error:#}"); - } else { - target.exit(); - return; - } - } + } + } - if canvas.needs_initial_frame { - canvas.needs_initial_frame = false; - window.request_redraw(); - } else if app.has_frame_animation() { - window.request_redraw(); - } + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + let Some(window) = self.window else { return; }; + if self.app.is_single_session() { + let about_to_wait_started = Instant::now(); + let size = window.inner_size(); + let previous_smooth_scroll = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ); + let frame = self.scroll_accumulator.frame(Instant::now()); + if let Some(lines) = frame.scroll_lines + && !self + .app + .scroll_single_session_body(lines, size, &mut self.scroll_metrics_cache) + { + self.scroll_accumulator.stop(); + } + let next_smooth_scroll = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ); + if frame.active + || (next_smooth_scroll - previous_smooth_scroll).abs() >= SCROLL_FRACTIONAL_EPSILON + { + self.interaction_latency + .mark("scroll_momentum", about_to_wait_started); + window.request_redraw(); + } + } else if self.scroll_accumulator.is_active() { + self.scroll_accumulator.reset(); + self.scroll_metrics_cache.clear(); + } + if let Some(first_pending_backend_redraw) = self.pending_backend_redraw_since { + let now = Instant::now(); + if self.last_backend_redraw_request.is_none_or(|last| { + now.saturating_duration_since(last) >= BACKEND_REDRAW_FRAME_INTERVAL + }) { + self.pending_backend_redraw_since = None; + self.interaction_latency + .mark("backend_events", first_pending_backend_redraw); + self.last_backend_redraw_request = Some(now); + window.request_redraw(); + } + } + if let Some(relaunch) = self.hot_reloader.poll() { + if let Err(error) = relaunch.spawn() { + eprintln!("jcode-desktop: failed to hot reload desktop: {error:#}"); + } else { + event_loop.exit(); + return; } - _ => {} } - })?; - Ok(()) + if let Some(canvas) = self.canvas.as_mut() { + if canvas.needs_initial_frame { + canvas.needs_initial_frame = false; + window.request_redraw(); + } else if self.app.has_frame_animation() { + window.request_redraw(); + } + } + self.maybe_exit(event_loop); + } } fn load_session_cards_for_desktop() -> Vec { @@ -1308,6 +1438,7 @@ async fn render_hero_frame_to_image( label: Some("jcode-desktop-hero-capture-device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), }, None, ) @@ -1331,6 +1462,7 @@ async fn render_hero_frame_to_image( module: &shader, entry_point: "vs_main", buffers: &[Vertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, @@ -1340,6 +1472,7 @@ async fn render_hero_frame_to_image( blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], + compilation_options: wgpu::PipelineCompilationOptions::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, @@ -1353,6 +1486,7 @@ async fn render_hero_frame_to_image( depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, + cache: None, }); let texture = device.create_texture(&wgpu::TextureDescriptor { @@ -1373,7 +1507,9 @@ async fn render_hero_frame_to_image( let mut font_system = create_desktop_font_system(); let mut swash_cache = SwashCache::new(); - let mut text_atlas = TextAtlas::new(&device, &queue, format); + let glyphon_cache = Cache::new(&device); + let mut text_viewport = Viewport::new(&device, &glyphon_cache); + let mut text_atlas = TextAtlas::new(&device, &queue, &glyphon_cache, format); let mut text_renderer = TextRenderer::new( &mut text_atlas, &device, @@ -1404,16 +1540,20 @@ async fn render_hero_frame_to_image( ) }; if !text_areas.is_empty() { + text_viewport.update( + &queue, + Resolution { + width: size.width, + height: size.height, + }, + ); text_renderer .prepare( &device, &queue, &mut font_system, &mut text_atlas, - Resolution { - width: size.width, - height: size.height, - }, + &text_viewport, text_areas, &mut swash_cache, ) @@ -1500,7 +1640,7 @@ async fn render_hero_frame_to_image( render_pass.draw(0..vertices.len() as u32, 0..1); if !text_buffers.is_empty() { text_renderer - .render(&text_atlas, &mut render_pass) + .render(&text_atlas, &text_viewport, &mut render_pass) .context("failed to render hero capture text")?; } } @@ -2360,11 +2500,9 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { visible_whole_line_app.text_scale(), ); body_buffer.set_scroll( - initial_visible_viewport + glyphon::cosmic_text::Scroll { line: initial_visible_viewport .start_line - .saturating_sub(visible_window_start) - .min(i32::MAX as usize) as i32, - ); + .saturating_sub(visible_window_start), vertical: 0.0, horizontal: 0.0 }); } let mut visible_viewport_ms = 0.0; let mut visible_window_ms = 0.0; @@ -2407,11 +2545,9 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { if viewport.start_line != visible_whole_line_start { if let Some(body_buffer) = visible_whole_line_buffers.get_mut(1) { body_buffer.set_scroll( - viewport + glyphon::cosmic_text::Scroll { line: viewport .start_line - .saturating_sub(visible_window_start) - .min(i32::MAX as usize) as i32, - ); + .saturating_sub(visible_window_start), vertical: 0.0, horizontal: 0.0 }); } visible_whole_line_start = viewport.start_line; } @@ -2613,11 +2749,9 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { streaming_app.text_scale(), ); body_buffer.set_scroll( - streaming_initial_viewport + glyphon::cosmic_text::Scroll { line: streaming_initial_viewport .start_line - .saturating_sub(streaming_window_start) - .min(i32::MAX as usize) as i32, - ); + .saturating_sub(streaming_window_start), vertical: 0.0, horizontal: 0.0 }); } let mut streaming_previous_key = Some(streaming_initial_key); let mut streaming_tail_text_key = None; @@ -2719,11 +2853,9 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { ); if let Some(body_buffer) = streaming_buffers.get_mut(1) { body_buffer.set_scroll( - viewport + glyphon::cosmic_text::Scroll { line: viewport .start_line - .saturating_sub(streaming_window_start) - .min(i32::MAX as usize) as i32, - ); + .saturating_sub(streaming_window_start), vertical: 0.0, horizontal: 0.0 }); } let streaming_start_line = streaming_base_len.saturating_add(usize::from(!streaming_app.messages.is_empty())); @@ -2988,11 +3120,9 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { ); if let Some(body_buffer) = hero_buffers.get_mut(1) { body_buffer.set_scroll( - viewport + glyphon::cosmic_text::Scroll { line: viewport .start_line - .saturating_sub(hero_window_start) - .min(i32::MAX as usize) as i32, - ); + .saturating_sub(hero_window_start), vertical: 0.0, horizontal: 0.0 }); } let hero_visible = key.fresh_welcome_visible; hero_previous_key = Some(key); @@ -3132,11 +3262,9 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { ); if let Some(body_buffer) = action_buffers.get_mut(1) { body_buffer.set_scroll( - viewport + glyphon::cosmic_text::Scroll { line: viewport .start_line - .saturating_sub(action_window_start) - .min(i32::MAX as usize) as i32, - ); + .saturating_sub(action_window_start), vertical: 0.0, horizontal: 0.0 }); } action_previous_key = Some(key); action_text_cache_ms += phase_started.elapsed().as_secs_f64() * 1000.0; @@ -3564,6 +3692,7 @@ fn prewarm_desktop_text_renderer( swash_cache: &mut SwashCache, text_atlas: &mut TextAtlas, text_renderer: &mut TextRenderer, + text_viewport: &mut Viewport, device: &wgpu::Device, queue: &wgpu::Queue, size: PhysicalSize, @@ -3575,15 +3704,19 @@ fn prewarm_desktop_text_renderer( if text_areas.is_empty() { return; } - if let Err(error) = text_renderer.prepare( - device, + text_viewport.update( queue, - font_system, - text_atlas, Resolution { width: size.width, height: size.height, }, + ); + if let Err(error) = text_renderer.prepare( + device, + queue, + font_system, + text_atlas, + text_viewport, text_areas, swash_cache, ) { @@ -5526,6 +5659,8 @@ struct Canvas<'window> { render_pipeline: wgpu::RenderPipeline, font_system: Option, swash_cache: SwashCache, + glyphon_cache: Cache, + text_viewport: Viewport, text_atlas: Option, text_renderer: Option, text_needs_prepare: bool, @@ -5588,6 +5723,7 @@ impl<'window> Canvas<'window> { label: Some("jcode-desktop-device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), }, None, ) @@ -5643,6 +5779,7 @@ impl<'window> Canvas<'window> { module: &shader, entry_point: "vs_main", buffers: &[Vertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, @@ -5652,6 +5789,7 @@ impl<'window> Canvas<'window> { blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], + compilation_options: wgpu::PipelineCompilationOptions::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, @@ -5665,13 +5803,16 @@ impl<'window> Canvas<'window> { depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, + cache: None, }); startup_trace.mark("primitive pipeline ready"); let mut font_system = font_system_loader .take() .and_then(|loader| loader.join().ok()) .unwrap_or_else(create_desktop_font_system); - let mut text_atlas = TextAtlas::new(&device, &queue, format); + let glyphon_cache = Cache::new(&device); + let mut text_viewport = Viewport::new(&device, &glyphon_cache); + let mut text_atlas = TextAtlas::new(&device, &queue, &glyphon_cache, format); let text_renderer = TextRenderer::new( &mut text_atlas, &device, @@ -5686,6 +5827,7 @@ impl<'window> Canvas<'window> { &mut swash_cache, &mut text_atlas, &mut text_renderer, + &mut text_viewport, &device, &queue, size, @@ -5699,6 +5841,8 @@ impl<'window> Canvas<'window> { render_pipeline, font_system: Some(font_system), swash_cache, + glyphon_cache, + text_viewport, text_atlas: Some(text_atlas), text_renderer: Some(text_renderer), text_needs_prepare: true, @@ -5993,10 +6137,8 @@ impl<'window> Canvas<'window> { } if let Some(body_buffer) = self.single_session_text_buffers.get_mut(1) { body_buffer.set_scroll( - start_line - .saturating_sub(window_start) - .min(i32::MAX as usize) as i32, - ); + glyphon::cosmic_text::Scroll { line: start_line + .saturating_sub(window_start), vertical: 0.0, horizontal: 0.0 }); self.single_session_body_text_scroll_start = Some(start_line); self.text_needs_prepare = true; } @@ -6013,7 +6155,12 @@ impl<'window> Canvas<'window> { if self.text_renderer.is_some() { return; } - let mut text_atlas = TextAtlas::new(&self.device, &self.queue, self.config.format); + let mut text_atlas = TextAtlas::new( + &self.device, + &self.queue, + &self.glyphon_cache, + self.config.format, + ); let text_renderer = TextRenderer::new( &mut text_atlas, &self.device, @@ -6029,7 +6176,12 @@ impl<'window> Canvas<'window> { if self.streaming_text_renderer.is_some() { return; } - let mut text_atlas = TextAtlas::new(&self.device, &self.queue, self.config.format); + let mut text_atlas = TextAtlas::new( + &self.device, + &self.queue, + &self.glyphon_cache, + self.config.format, + ); let text_renderer = TextRenderer::new( &mut text_atlas, &self.device, @@ -6239,6 +6391,15 @@ impl<'window> Canvas<'window> { if welcome_hero_reveal_active { self.text_needs_prepare = true; } + if self.text_needs_prepare || self.streaming_text_needs_prepare { + self.text_viewport.update( + &self.queue, + Resolution { + width: self.config.width, + height: self.config.height, + }, + ); + } if self.text_needs_prepare { let text_areas = if let DesktopApp::SingleSession(single_session) = app { single_session_text_areas_for_app_with_cached_body_viewport_and_reveal( @@ -6277,10 +6438,7 @@ impl<'window> Canvas<'window> { &self.queue, font_system, text_atlas, - Resolution { - width: self.config.width, - height: self.config.height, - }, + &self.text_viewport, text_areas, &mut self.swash_cache, ) { @@ -6337,10 +6495,7 @@ impl<'window> Canvas<'window> { &self.queue, font_system, text_atlas, - Resolution { - width: self.config.width, - height: self.config.height, - }, + &self.text_viewport, streaming_text_areas, &mut self.swash_cache, ) { @@ -6453,7 +6608,8 @@ impl<'window> Canvas<'window> { if has_text_buffers && let (Some(text_renderer), Some(text_atlas)) = (self.text_renderer.as_mut(), self.text_atlas.as_ref()) - && let Err(error) = text_renderer.render(text_atlas, &mut render_pass) + && let Err(error) = + text_renderer.render(text_atlas, &self.text_viewport, &mut render_pass) { eprintln!("jcode-desktop: failed to render text: {error:?}"); } @@ -6462,7 +6618,8 @@ impl<'window> Canvas<'window> { self.streaming_text_renderer.as_mut(), self.streaming_text_atlas.as_ref(), ) - && let Err(error) = text_renderer.render(text_atlas, &mut render_pass) + && let Err(error) = + text_renderer.render(text_atlas, &self.text_viewport, &mut render_pass) { eprintln!("jcode-desktop: failed to render streaming text: {error:?}"); } diff --git a/crates/jcode-desktop/src/single_session_render.rs b/crates/jcode-desktop/src/single_session_render.rs index 27ea382d6..d60bfb55d 100644 --- a/crates/jcode-desktop/src/single_session_render.rs +++ b/crates/jcode-desktop/src/single_session_render.rs @@ -4696,7 +4696,7 @@ pub(crate) fn single_session_body_text_buffer_from_lines( content_width, (size.height as f32 - 150.0).max(1.0), ); - buffer.shape_until(font_system, i32::MAX); + buffer.shape_until_scroll(font_system, false); buffer } @@ -5083,7 +5083,7 @@ fn single_session_text_buffer_with_family( height: f32, ) -> Buffer { let mut buffer = Buffer::new(font_system, Metrics::new(font_size, line_height)); - buffer.set_size(font_system, width, height); + buffer.set_size(font_system, Some(width), Some(height)); buffer.set_wrap(font_system, Wrap::Word); buffer.set_text( font_system, @@ -5091,7 +5091,7 @@ fn single_session_text_buffer_with_family( Attrs::new().family(Family::Name(family)), desktop_text_shaping(text), ); - buffer.shape_until_scroll(font_system); + buffer.shape_until_scroll(font_system, false); buffer } @@ -5104,7 +5104,7 @@ fn single_session_styled_text_buffer( height: f32, ) -> Buffer { let mut buffer = Buffer::new(font_system, Metrics::new(font_size, line_height)); - buffer.set_size(font_system, width, height); + buffer.set_size(font_system, Some(width), Some(height)); let segments = single_session_styled_text_segments(lines); let shaping = if segments .iter() @@ -5114,8 +5114,13 @@ fn single_session_styled_text_buffer( } else { Shaping::Basic }; - buffer.set_rich_text(font_system, segments.iter().copied(), shaping); - buffer.shape_until_scroll(font_system); + buffer.set_rich_text( + font_system, + segments.iter().copied(), + Attrs::new(), + shaping, + ); + buffer.shape_until_scroll(font_system, false); buffer } @@ -5470,6 +5475,7 @@ pub(crate) fn single_session_hero_font_target_text_areas<'a>( bottom: hero_max[1].ceil() as i32, }, default_color: text_color(WELCOME_HANDWRITING_COLOR), + custom_glyphs: &[], }] } @@ -5501,6 +5507,7 @@ pub(crate) fn single_session_streaming_text_area_for_cached_body_viewport<'a>( as i32, }, default_color: text_color(ASSISTANT_TEXT_COLOR), + custom_glyphs: &[], } } @@ -5614,6 +5621,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom, }, default_color: text_color(STATUS_TEXT_ACCENT_COLOR), + custom_glyphs: &[], }); } else if !welcome_handoff_visible { areas.push(TextArea { @@ -5628,6 +5636,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom, }, default_color: text_color(PANEL_SECTION_COLOR), + custom_glyphs: &[], }); } @@ -5644,6 +5653,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: draft_top as i32, }, default_color: text_color(PANEL_SECTION_COLOR), + custom_glyphs: &[], }); } @@ -5659,6 +5669,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: 64, }, default_color: text_color(PANEL_TITLE_COLOR), + custom_glyphs: &[], }); areas.push(TextArea { buffer: &buffers[4], @@ -5672,6 +5683,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: version_bounds_bottom, }, default_color: text_color(META_TEXT_COLOR), + custom_glyphs: &[], }); areas.push(TextArea { buffer: &buffers[1], @@ -5685,6 +5697,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: body_bottom, }, default_color: text_color(ASSISTANT_TEXT_COLOR), + custom_glyphs: &[], }); if welcome_chrome_visible @@ -5704,6 +5717,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: (hero_max[1] + welcome_chrome_offset_pixels).ceil() as i32, }, default_color: text_color(WELCOME_HANDWRITING_COLOR), + custom_glyphs: &[], }); } @@ -5725,6 +5739,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: inline_bounds_bottom, }, default_color: text_color(ASSISTANT_TEXT_COLOR), + custom_glyphs: &[], }); } From 553a5fa83054ac7f7f2b7c1f5b7e6e6d6c4067db Mon Sep 17 00:00:00 2001 From: jcode-agent Date: Wed, 13 May 2026 13:56:29 -0230 Subject: [PATCH 06/34] Add per-provider model allowlist Introduces [provider.model_allowlist] in config.toml so users can restrict the model picker and `/model` command to a chosen subset for each built-in provider. Previously the allowlist field on [providers..models] only applied to custom openai-compatible profiles, so OAuth providers (claude/openai/gemini/antigravity) always exposed the full vendor catalog. The new field is a BTreeMap> keyed by the provider canonical name (anthropic/openai/gemini/antigravity, with "claude" as an alias for anthropic). Substring + case-insensitive matching keeps patterns short. Filtering is applied at every visible surface that lists models: - Agent::available_models_display() and Agent::model_routes() - CLI `jcode model list` - TUI slash-command suggestion list - TUI inline model picker route fetch Round-trip is covered by new tests in jcode-config-types. --- crates/jcode-config-types/Cargo.toml | 3 + crates/jcode-config-types/src/lib.rs | 51 +++++++++++++++ src/agent/provider.rs | 9 ++- src/cli/commands.rs | 7 ++- src/provider_catalog.rs | 90 +++++++++++++++++++++++++++ src/tui/app/inline_interactive.rs | 10 ++- src/tui/app/state_ui_input_helpers.rs | 6 +- 7 files changed, 170 insertions(+), 6 deletions(-) diff --git a/crates/jcode-config-types/Cargo.toml b/crates/jcode-config-types/Cargo.toml index a3c17c356..5bdd85a4b 100644 --- a/crates/jcode-config-types/Cargo.toml +++ b/crates/jcode-config-types/Cargo.toml @@ -6,3 +6,6 @@ publish = false [dependencies] serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +toml = "0.8" diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 7f8963d9f..548889e7b 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; /// Compaction mode #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] @@ -611,6 +612,14 @@ pub struct ProviderConfig { /// Copilot premium request mode: "normal", "one", or "zero" /// "zero" means all requests are free (no premium requests consumed) pub copilot_premium: Option, + /// Per-provider model allowlist. Maps the built-in provider key + /// (e.g. "anthropic", "openai", "gemini", "antigravity") to a list of + /// allowed model identifiers. When a provider has a non-empty entry, + /// the model picker and `/model` command only expose the listed models + /// (substring match, case-insensitive). Providers absent from this map + /// or with an empty list are unrestricted. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub model_allowlist: BTreeMap>, } impl Default for ProviderConfig { @@ -626,6 +635,7 @@ impl Default for ProviderConfig { cross_provider_failover: CrossProviderFailoverMode::Countdown, same_provider_account_failover: true, copilot_premium: None, + model_allowlist: BTreeMap::new(), } } } @@ -773,3 +783,44 @@ impl Default for GatewayConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_model_allowlist_round_trips_through_toml() { + let mut allow = BTreeMap::new(); + allow.insert( + "anthropic".to_string(), + vec!["claude-opus-4-7".to_string(), "claude-sonnet-4-6".to_string()], + ); + allow.insert("openai".to_string(), vec!["gpt-5.5".to_string()]); + + let cfg = ProviderConfig { + model_allowlist: allow, + ..ProviderConfig::default() + }; + + let serialized = toml::to_string(&cfg).expect("serialize ProviderConfig"); + assert!( + serialized.contains("model_allowlist"), + "expected model_allowlist in serialized output, got: {serialized}" + ); + + let parsed: ProviderConfig = + toml::from_str(&serialized).expect("deserialize ProviderConfig"); + assert_eq!(parsed.model_allowlist.get("anthropic").unwrap().len(), 2); + assert_eq!(parsed.model_allowlist.get("openai").unwrap()[0], "gpt-5.5"); + } + + #[test] + fn provider_model_allowlist_default_skipped_when_empty() { + let cfg = ProviderConfig::default(); + let serialized = toml::to_string(&cfg).expect("serialize ProviderConfig"); + assert!( + !serialized.contains("model_allowlist"), + "empty allowlist should be omitted from TOML output, got: {serialized}" + ); + } +} diff --git a/src/agent/provider.rs b/src/agent/provider.rs index e60520e21..904cc5db6 100644 --- a/src/agent/provider.rs +++ b/src/agent/provider.rs @@ -22,15 +22,18 @@ impl Agent { } pub fn available_models_for_switching(&self) -> Vec { - self.provider.available_models_for_switching() + let models = self.provider.available_models_for_switching(); + crate::provider_catalog::filter_models_by_allowlist(self.provider.name(), models) } pub fn available_models_display(&self) -> Vec { - self.provider.available_models_display() + let models = self.provider.available_models_display(); + crate::provider_catalog::filter_models_by_allowlist(self.provider.name(), models) } pub fn model_routes(&self) -> Vec { - self.provider.model_routes() + let routes = self.provider.model_routes(); + crate::provider_catalog::filter_model_routes_by_allowlist(self.provider.name(), routes) } pub fn registry(&self) -> Registry { diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 98a3ef235..74d3149d8 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1215,9 +1215,14 @@ pub async fn run_model_command( } let routes = provider.model_routes(); + let routes = crate::provider_catalog::filter_model_routes_by_allowlist(provider.name(), routes); let filtered_routes = filter_cli_model_routes_for_choice(choice, &routes); let models = if filtered_routes.len() == routes.len() { - collect_cli_model_names(&routes, provider.available_models_display()) + let display = crate::provider_catalog::filter_models_by_allowlist( + provider.name(), + provider.available_models_display(), + ); + collect_cli_model_names(&routes, display) } else { collect_cli_model_names(&filtered_routes, Vec::new()) }; diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index 61931f1f1..14d257916 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -145,6 +145,96 @@ pub fn runtime_provider_display_name(provider_name: &str) -> String { } } +/// Look up the per-provider model allowlist from `[provider.model_allowlist]`. +/// +/// Returns `Some(patterns)` only when the provider has at least one configured +/// pattern. An empty list (or missing entry) means "no restriction". +/// +/// The lookup is keyed by the raw built-in provider key returned from +/// `Provider::name()` (e.g. `anthropic`, `openai`, `gemini`, `antigravity`) +/// but is also accepted under common aliases such as `claude`. +pub fn provider_model_allowlist_patterns(provider_name: &str) -> Option> { + let cfg = crate::config::config(); + if cfg.provider.model_allowlist.is_empty() { + return None; + } + + let canonical = canonical_allowlist_key(provider_name); + let mut keys: Vec = vec![canonical.clone()]; + if canonical == "anthropic" { + keys.push("claude".to_string()); + } else if canonical == "claude" { + keys.push("anthropic".to_string()); + } + + for key in &keys { + if let Some(patterns) = cfg.provider.model_allowlist.get(key) { + let cleaned: Vec = patterns + .iter() + .map(|pattern| pattern.trim().to_string()) + .filter(|pattern| !pattern.is_empty()) + .collect(); + if !cleaned.is_empty() { + return Some(cleaned); + } + } + } + None +} + +fn canonical_allowlist_key(provider_name: &str) -> String { + let trimmed = provider_name.trim().to_ascii_lowercase(); + match trimmed.as_str() { + "claude" => "anthropic".to_string(), + _ => trimmed, + } +} + +/// Filter a list of model names against the configured allowlist for `provider_name`. +/// +/// Matching is case-insensitive and accepts either an exact match or a +/// substring match against any configured pattern. When no allowlist is +/// configured for the provider, the input is returned unchanged. +pub fn filter_models_by_allowlist(provider_name: &str, models: Vec) -> Vec { + let Some(patterns) = provider_model_allowlist_patterns(provider_name) else { + return models; + }; + let lower_patterns: Vec = patterns + .iter() + .map(|p| p.to_ascii_lowercase()) + .collect(); + models + .into_iter() + .filter(|model| model_matches_any(&model.to_ascii_lowercase(), &lower_patterns)) + .collect() +} + +/// Filter `ModelRoute`s against the configured allowlist for `provider_name`. +/// +/// Returns the original list when no allowlist is configured. +pub fn filter_model_routes_by_allowlist( + provider_name: &str, + routes: Vec, +) -> Vec { + let Some(patterns) = provider_model_allowlist_patterns(provider_name) else { + return routes; + }; + let lower_patterns: Vec = patterns + .iter() + .map(|p| p.to_ascii_lowercase()) + .collect(); + routes + .into_iter() + .filter(|route| model_matches_any(&route.model.to_ascii_lowercase(), &lower_patterns)) + .collect() +} + +fn model_matches_any(model_lower: &str, lower_patterns: &[String]) -> bool { + lower_patterns + .iter() + .any(|pattern| model_lower == pattern || model_lower.contains(pattern)) +} + pub fn openai_compatible_profile_by_id(id: &str) -> Option { let normalized = id.trim().to_ascii_lowercase(); openai_compatible_profiles() diff --git a/src/tui/app/inline_interactive.rs b/src/tui/app/inline_interactive.rs index eb5f88bd7..bb6467114 100644 --- a/src/tui/app/inline_interactive.rs +++ b/src/tui/app/inline_interactive.rs @@ -132,7 +132,11 @@ impl App { let auth = crate::auth::AuthStatus::check_fast(); let mut routes = Vec::new(); - for model in self.provider.available_models_display() { + let display = crate::provider_catalog::filter_models_by_allowlist( + self.provider.name(), + self.provider.available_models_display(), + ); + for model in display { if !model.contains('/') && crate::provider::provider_for_model(&model) == Some("openai") { if auth.openai_has_oauth { @@ -384,6 +388,10 @@ impl App { let build = move || { let routes_started = std::time::Instant::now(); let routes = provider.model_routes(); + let routes = crate::provider_catalog::filter_model_routes_by_allowlist( + provider.name(), + routes, + ); let routes_ms = routes_started.elapsed().as_millis(); let _ = tx.send(Ok(ModelPickerRoutesResult { routes, routes_ms })); }; diff --git a/src/tui/app/state_ui_input_helpers.rs b/src/tui/app/state_ui_input_helpers.rs index 39f32de65..2a00b28d0 100644 --- a/src/tui/app/state_ui_input_helpers.rs +++ b/src/tui/app/state_ui_input_helpers.rs @@ -327,7 +327,11 @@ impl App { } } else { push_unique(&mut seen, &mut models, self.provider.model()); - for model in self.provider.available_models_display() { + let display = crate::provider_catalog::filter_models_by_allowlist( + self.provider.name(), + self.provider.available_models_display(), + ); + for model in display { push_unique(&mut seen, &mut models, model); } } From d4175d8b278bd6bbbd6b71cc75700e55241da7ae Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 13:58:53 -0230 Subject: [PATCH 07/34] feat: render askUserQuestion modal inline in the input area Match Claude Code's pattern: the modal takes over the chat input slot instead of floating as a centered popup. Cleaner focus and clearly communicates that the agent is blocked on the user. - new AskUserModal::render_inline(frame, area) draws into a host-supplied Rect - AskUserModal::desired_height() reports needed rows - ui.rs reserves the input chunk for the modal when visible and skips drawing the input box / inline UI / idle animation --- src/tui/ask_user_modal.rs | 345 ++++++++++++++++++++++++++++---------- src/tui/ui.rs | 63 ++++--- 2 files changed, 299 insertions(+), 109 deletions(-) diff --git a/src/tui/ask_user_modal.rs b/src/tui/ask_user_modal.rs index e622550a6..75bca0ff9 100644 --- a/src/tui/ask_user_modal.rs +++ b/src/tui/ask_user_modal.rs @@ -36,8 +36,11 @@ const MUTED_DARK: Color = Color::Rgb(100, 106, 122); const OPTION_FG: Color = Color::Rgb(220, 225, 240); const CUSTOM_HINT_FG: Color = Color::Rgb(190, 170, 240); -const OVERLAY_PERCENT_X: u16 = 78; +const OVERLAY_PERCENT_X: u16 = 70; +const OVERLAY_MAX_WIDTH: u16 = 84; +const OVERLAY_MIN_WIDTH: u16 = 44; const OVERLAY_MIN_HEIGHT: u16 = 14; +const CONTENT_PAD_X: u16 = 2; /// What the modal wants the host App to do after handling a key. pub enum AskUserModalOutcome { @@ -281,17 +284,76 @@ impl AskUserModal { } pub fn render(&self, frame: &mut Frame) { - let area = centered_rect(OVERLAY_PERCENT_X, frame.area()); + let area = centered_rect(frame.area()); + self.render_into(frame, area, true); + } - // Clear underlying widgets so the modal is fully opaque. - frame.render_widget(Clear, area); + /// Render the modal into a host-supplied rect without centering. The host + /// is responsible for laying out the area (typically the chat input slot) + /// so the modal can replace it inline, Claude-Code style. + pub fn render_inline(&self, frame: &mut Frame, area: Rect) { + self.render_into(frame, area, false); + } - let title = Line::from(vec![ - Span::styled( - format!(" {} ", self.title), - Style::default().fg(Color::White).bold(), - ), - ]); + /// Conservative estimate of the rows the modal wants. The host can use + /// this to reserve an input chunk tall enough to fit question + context + + /// divider + options (+ descriptions / recommendation reasons) + footer + /// hint + typing pane. + pub fn desired_height(&self) -> u16 { + // We don't know the exact width here; use OVERLAY_MAX_WIDTH minus + // padding/borders as a reasonable upper bound for wrap math. The host + // gets a slightly generous answer when terminals are narrow, which is + // fine since options/typing pane already have minimum heights. + let content_width = OVERLAY_MAX_WIDTH + .saturating_sub(2 + CONTENT_PAD_X * 2) // 2 borders + L/R padding + .max(1) as usize; + + let question_h = wrapped_height(&self.question, content_width).clamp(1, 4); + let context_h = self + .context + .as_deref() + .map(|s| wrapped_height(s, content_width).min(4)) + .unwrap_or(0); + // Options list: each option uses 1 row + optional description + optional + // recommendation reason + 1 blank spacer. Plus the synthetic "Other" + // row. Plus an optional footer hint with leading blank. + let mut options_h: usize = 0; + for opt in &self.options { + options_h += 1; + if opt.description.is_some() { + options_h += 1; + } + if opt.recommended && opt.recommendation_reason.is_some() { + options_h += 1; + } + options_h += 1; // visual spacer + } + options_h += 1; // Other row + if self.reply_instructions.is_some() { + options_h += 2; // blank + hint + } + let options_h = options_h.max(3) as u16; + let typing_h: u16 = if matches!(self.mode, Mode::Typing) { 5 } else { 0 }; + + // 2 border rows + 1 top inset pad + 1 divider + 1 blank above options. + let mut total: u16 = 2 + 1 + question_h + 1 + 1 + options_h + typing_h; + if context_h > 0 { + total = total.saturating_add(context_h + 1); // blank + context + } + total + } + + fn render_into(&self, frame: &mut Frame, area: Rect, clear_under: bool) { + if clear_under { + // Clear underlying widgets so the modal is fully opaque (only when + // the modal is drawn as a floating overlay). + frame.render_widget(Clear, area); + } + + let title = Line::from(Span::styled( + format!(" {} ", self.title), + Style::default().fg(Color::White).bold(), + )); let footer = self.footer_line(); let outer = Block::default() .title(title) @@ -300,111 +362,145 @@ impl AskUserModal { .border_style(Style::default().fg(PANEL_BORDER)) .style(Style::default().bg(PANEL_BG)); frame.render_widget(&outer, area); - let inner = outer.inner(area); + let outer_inner = outer.inner(area); + + // Inset content from the border so text doesn't hug the edges. + let inner = Rect { + x: outer_inner.x + CONTENT_PAD_X, + y: outer_inner.y + 1, + width: outer_inner.width.saturating_sub(CONTENT_PAD_X * 2), + height: outer_inner.height.saturating_sub(1), + }; + let content_width = inner.width.max(1) as usize; + let question_h = wrapped_height(&self.question, content_width).clamp(1, 4); let context_h = self .context .as_deref() - .map(|s| ((s.len() as u16 / inner.width.max(1)) + 1).min(4)) + .map(|s| wrapped_height(s, content_width).min(4)) .unwrap_or(0); - let question_h = ((self.question.len() as u16 / inner.width.max(1)) + 1).min(4); - - // Top-down layout: question, optional context divider, options list, - // bottom typing pane (only when active). let typing_h = if matches!(self.mode, Mode::Typing) { 5 } else { 0 }; + + // Vertical layout: + // question + // blank (only when context present) + // context (only when context present) + // divider + // blank + // options (fills) + // typing pane (only when active) + let mut constraints: Vec = Vec::with_capacity(7); + constraints.push(Constraint::Length(question_h)); + if context_h > 0 { + constraints.push(Constraint::Length(1)); // blank + constraints.push(Constraint::Length(context_h)); + } + constraints.push(Constraint::Length(1)); // divider + constraints.push(Constraint::Length(1)); // blank above options + constraints.push(Constraint::Min(3)); // options list + if typing_h > 0 { + constraints.push(Constraint::Length(typing_h)); + } + let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(question_h.max(1)), - Constraint::Length(context_h), - Constraint::Length(1), // divider - Constraint::Min(3), - Constraint::Length(typing_h), - ]) + .constraints(constraints) .split(inner); - // Question text. - let question_p = Paragraph::new(Line::from(Span::styled( - self.question.clone(), - Style::default().fg(Color::White).bold(), - ))) - .wrap(Wrap { trim: false }); - frame.render_widget(question_p, chunks[0]); - - // Optional context. - if let Some(ctx) = &self.context { - let context_p = Paragraph::new(Line::from(Span::styled( - ctx.clone(), - Style::default().fg(MUTED), - ))) + let mut slot = 0usize; + + // Question. + let question_para = Paragraph::new(self.question.clone()) + .style(Style::default().fg(Color::White).bold()) .wrap(Wrap { trim: false }); - frame.render_widget(context_p, chunks[1]); + frame.render_widget(question_para, chunks[slot]); + slot += 1; + + if context_h > 0 { + slot += 1; // skip blank + let context_para = + Paragraph::new(self.context.as_deref().unwrap_or("").to_string()) + .style(Style::default().fg(MUTED)) + .wrap(Wrap { trim: false }); + frame.render_widget(context_para, chunks[slot]); + slot += 1; } - // Divider. - let div = Paragraph::new(Line::from(Span::styled( - "─".repeat(inner.width as usize), - Style::default().fg(SECTION_BORDER), - ))); - frame.render_widget(div, chunks[2]); + // Divider that respects the content padding. + let divider_line = "─".repeat(inner.width as usize); + let divider = Paragraph::new(divider_line) + .style(Style::default().fg(SECTION_BORDER)); + frame.render_widget(divider, chunks[slot]); + slot += 1; + slot += 1; // blank above options - self.render_options(frame, chunks[3]); + let options_area = chunks[slot]; + slot += 1; + self.render_options(frame, options_area); - if matches!(self.mode, Mode::Typing) { - self.render_typing(frame, chunks[4]); + if typing_h > 0 { + self.render_typing(frame, chunks[slot]); } } fn render_options(&self, frame: &mut Frame, area: Rect) { - let mut lines: Vec> = Vec::with_capacity(self.rows()); + let content_width = area.width as usize; + let mut lines: Vec> = Vec::with_capacity(self.rows() * 3); + for (idx, opt) in self.options.iter().enumerate() { - lines.push(self.render_option_row(idx, opt)); + lines.push(self.render_option_row(idx, opt, content_width)); if let Some(desc) = opt.description.as_deref() { - lines.push(Line::from(Span::styled( - format!(" {}", desc), - Style::default().fg(MUTED), - ))); + lines.push(padded_secondary_line(desc, content_width, MUTED, false)); } if opt.recommended { if let Some(reason) = opt.recommendation_reason.as_deref() { - lines.push(Line::from(Span::styled( - format!(" why: {}", reason), - Style::default().fg(MUTED_DARK).italic(), - ))); + lines.push(padded_secondary_line( + &format!("recommended: {}", reason), + content_width, + MUTED_DARK, + true, + )); } } + // Visual breathing room between options. + lines.push(Line::from("")); } - lines.push(self.render_other_row()); + lines.push(self.render_other_row(content_width)); - // Optional reply hint just below the rows. if let Some(hint) = self.reply_instructions.as_deref() { lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - format!("hint: {}", hint), - Style::default().fg(MUTED_DARK).italic(), - ))); + lines.push(padded_secondary_line( + &format!("hint: {}", hint), + content_width, + MUTED_DARK, + true, + )); } let para = Paragraph::new(lines).wrap(Wrap { trim: false }); frame.render_widget(para, area); } - fn render_option_row(&self, idx: usize, opt: &AskUserOption) -> Line<'static> { + fn render_option_row( + &self, + idx: usize, + opt: &AskUserOption, + content_width: usize, + ) -> Line<'static> { let selected = self.cursor == idx; let picked = self.allow_multiple && self.picked.get(idx).copied().unwrap_or(false); - let arrow = if selected { "❯ " } else { " " }; + let arrow = if selected { "▌ " } else { " " }; let check = if self.allow_multiple { if picked { "[x] " } else { "[ ] " } } else { "" }; - let id_span = format!("{}.", opt.id); - let recommended_tag = if opt.recommended { " (recommended)" } else { "" }; + let recommended_tag = if opt.recommended { " ★" } else { "" }; let row_bg = if selected { if opt.recommended { @@ -422,39 +518,67 @@ impl AskUserModal { OPTION_FG }; - Line::from(vec![ + let mut spans = vec![ + Span::styled(arrow.to_string(), Style::default().fg(row_fg).bg(row_bg)), Span::styled( - format!("{arrow}{check}"), + check.to_string(), Style::default().fg(row_fg).bg(row_bg), ), Span::styled( - format!("{id_span} "), + format!("[{}] ", opt.id), Style::default().fg(row_fg).bg(row_bg).bold(), ), Span::styled(opt.label.clone(), Style::default().fg(row_fg).bg(row_bg)), - Span::styled( - recommended_tag, - Style::default().fg(RECOMMENDED_FG).bg(row_bg).italic(), - ), - ]) + ]; + if !recommended_tag.is_empty() { + spans.push(Span::styled( + recommended_tag.to_string(), + Style::default().fg(RECOMMENDED_FG).bg(row_bg), + )); + } + + // Pad to full content width so the background highlight extends to the + // right edge of the modal body. + let used: usize = spans.iter().map(|s| s.content.chars().count()).sum(); + if used < content_width { + spans.push(Span::styled( + " ".repeat(content_width - used), + Style::default().bg(row_bg), + )); + } + + Line::from(spans) } - fn render_other_row(&self) -> Line<'static> { + fn render_other_row(&self, content_width: usize) -> Line<'static> { let selected = self.cursor == self.other_row(); - let arrow = if selected { "❯ " } else { " " }; - let check = if self.allow_multiple { " " } else { "" }; + let arrow = if selected { "▌ " } else { " " }; + let check_pad = if self.allow_multiple { " " } else { "" }; let bg = if selected { SELECTED_BG } else { PANEL_BG }; - Line::from(vec![ + let mut spans = vec![ + Span::styled(arrow.to_string(), Style::default().fg(CUSTOM_HINT_FG).bg(bg)), Span::styled( - format!("{arrow}{check}"), + check_pad.to_string(), Style::default().fg(CUSTOM_HINT_FG).bg(bg), ), Span::styled( - "Other (type custom answer)", + "Other".to_string(), + Style::default().fg(CUSTOM_HINT_FG).bg(bg).bold(), + ), + Span::styled( + " type a custom answer".to_string(), Style::default().fg(CUSTOM_HINT_FG).bg(bg).italic(), ), - ]) + ]; + let used: usize = spans.iter().map(|s| s.content.chars().count()).sum(); + if used < content_width { + spans.push(Span::styled( + " ".repeat(content_width - used), + Style::default().bg(bg), + )); + } + Line::from(spans) } fn render_typing(&self, frame: &mut Frame, area: Rect) { @@ -519,15 +643,58 @@ fn hotkey(label: &str) -> Span<'static> { ) } -fn centered_rect(percent_x: u16, area: Rect) -> Rect { - let width = (area.width as u32 * percent_x as u32 / 100) as u16; - let width = width.clamp(40, area.width.saturating_sub(2).max(40)); - // Modal height grows with content but bounded to half the screen. +/// Number of visual rows `text` will occupy when wrapped to `width` columns. +/// Handles ASCII naively (good enough for the question + context strings the +/// agent is expected to emit; we cap the result in the caller). +fn wrapped_height(text: &str, width: usize) -> u16 { + if width == 0 { + return 1; + } + let len = text.chars().count(); + if len == 0 { + return 1; + } + let rows = len.div_ceil(width); + rows.max(1) as u16 +} + +/// Build a left-indented secondary line with a uniform style and pad it to +/// `content_width` so the modal feels grid-aligned (no ragged right edge). +fn padded_secondary_line( + text: &str, + content_width: usize, + fg: Color, + italic: bool, +) -> Line<'static> { + let body = format!(" {}", text); + let style = if italic { + Style::default().fg(fg).italic() + } else { + Style::default().fg(fg) + }; + let body_len = body.chars().count(); + let pad = content_width.saturating_sub(body_len); + Line::from(vec![ + Span::styled(body, style), + Span::styled(" ".repeat(pad), Style::default()), + ]) +} + +fn centered_rect(area: Rect) -> Rect { + // Width: percent of screen, clamped to [MIN, MAX], and never wider than the + // available area. + let width_pct = (area.width as u32 * OVERLAY_PERCENT_X as u32 / 100) as u16; + let width = width_pct + .clamp(OVERLAY_MIN_WIDTH, OVERLAY_MAX_WIDTH) + .min(area.width.saturating_sub(2).max(OVERLAY_MIN_WIDTH)); + // Height: grows with the screen but never less than the minimum and never + // more than two-thirds of the screen so the chat stays visible behind it. + let two_thirds = (area.height as u32 * 2 / 3) as u16; let height = OVERLAY_MIN_HEIGHT - .max(area.height / 2) + .max(two_thirds) .min(area.height.saturating_sub(2)); - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + (area.height.saturating_sub(height)) / 2; + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; Rect { x, y, diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 42829019b..69c0c78b6 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1831,9 +1831,21 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { input_ui::wrapped_input_line_count(app, chat_area.width, next_prompt).min(10) as u16; // Add 1 line for command suggestions, shell mode hints, or the Ctrl+Enter hint. let hint_line_height = input_ui::input_hint_line_height(app); - let inline_block_height: u16 = inline_ui_height(app); - let inline_ui_gap_height: u16 = if inline_block_height > 0 { 1 } else { 0 }; - let input_height = base_input_height + hint_line_height; + let mut inline_block_height: u16 = inline_ui_height(app); + let mut inline_ui_gap_height: u16 = if inline_block_height > 0 { 1 } else { 0 }; + let mut input_height = base_input_height + hint_line_height; + + // When the askUserQuestion modal is visible, take over the input chunk + // entirely (Claude-Code style): hide the regular input, inline UI, etc. + let ask_user_height: u16 = app + .ask_user_overlay() + .map(|c| c.borrow().desired_height()) + .unwrap_or(0); + if ask_user_height > 0 { + input_height = ask_user_height; + inline_block_height = 0; + inline_ui_gap_height = 0; + } if let Some(ref mut capture) = debug_capture { capture.render_order.push("prepare_messages".to_string()); @@ -1847,8 +1859,14 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { prepare::prepare_messages(app, wide_prepare_width, chat_area.height) }); let show_donut = super::idle_donut_active(app); - let donut_height: u16 = if show_donut { 14 } else { 0 }; - let notification_height: u16 = if app.has_notification() { 1 } else { 0 }; + let mut donut_height: u16 = if show_donut { 14 } else { 0 }; + let mut notification_height: u16 = if app.has_notification() { 1 } else { 0 }; + if ask_user_height > 0 { + // The modal absorbs the input slot; suppress sibling chunks so the + // chat above stays visible and stable. + notification_height = 0; + donut_height = 0; + } let fixed_height = 1 + queued_height + notification_height @@ -2118,16 +2136,24 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { draw_inline_ui(frame, app, chunks[4]); } - input_ui::draw_input( - frame, - app, - chunks[6], - user_count + pending_count + 1, - &mut debug_capture, - ); + if ask_user_height > 0 { + // The modal owns the input chunk: render it there and skip the + // regular input box / idle animation entirely. + if let Some(modal_cell) = app.ask_user_overlay() { + modal_cell.borrow().render_inline(frame, chunks[6]); + } + } else { + input_ui::draw_input( + frame, + app, + chunks[6], + user_count + pending_count + 1, + &mut debug_capture, + ); - if donut_height > 0 { - animations::draw_idle_animation(frame, app, chunks[7]); + if donut_height > 0 { + animations::draw_idle_animation(frame, app, chunks[7]); + } } // Draw info widget overlays (skip during idle animation - they look out of place) @@ -2220,12 +2246,9 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { visual_debug::record_frame(capture.build()); } - // Top-most overlay: askUserQuestion modal. Draw last so it sits on top - // of everything else (chat, side panel, status line). The modal uses - // ratatui's `Clear` internally to ensure full opacity. - if let Some(modal_cell) = app.ask_user_overlay() { - modal_cell.borrow().render(frame); - } + // Note: The askUserQuestion modal is rendered inline above as part of the + // input chunk (chunks[6]) so it cleanly replaces the input box and + // communicates that the agent is blocked on the user. finalize_frame_metrics( app, From fb131329b0abe8bd1f803cdb8bab45e7ef9e9795 Mon Sep 17 00:00:00 2001 From: jcode-agent Date: Wed, 13 May 2026 14:05:33 -0230 Subject: [PATCH 08/34] Resolve model_allowlist keys for openai-compatible profiles The openrouter provider impl backs every named openai-compatible profile (opencode-go, ollama-cloud, deepseek, ...) and always reports provider.name() == "openrouter". Extend provider_model_allowlist_patterns() so it also accepts the active profile id, which is the natural key users expect in `[provider.model_allowlist]` (e.g. `opencode-go = [\"deepseek-v4-flash\"]`). Adds active_openai_compatible_profile_id() helper alongside the existing active_openai_compatible_display_name(). The lookup tries the canonical provider name, the `claude`/`anthropic` alias pair, and finally the active openai-compatible profile id. --- src/provider_catalog.rs | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index 14d257916..c7313b07a 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -131,6 +131,47 @@ pub fn active_openai_compatible_display_name() -> Option { None } +/// Return the active openai-compatible profile id (e.g. `opencode-go`, +/// `ollama-cloud`, `deepseek`, ...) when the openrouter-style multi-profile +/// provider is in use. The profile id is the canonical key under which +/// users keep `[providers.]` blocks in `config.toml` and also the key +/// expected by the `[provider.model_allowlist]` map for non-OAuth profiles. +pub fn active_openai_compatible_profile_id() -> Option { + if let Ok(profile_name) = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") { + let trimmed = profile_name.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_ascii_lowercase()); + } + } + + if let Ok(namespace) = std::env::var("JCODE_OPENROUTER_CACHE_NAMESPACE") { + let trimmed = namespace.trim(); + if !trimmed.is_empty() + && openai_compatible_profiles() + .iter() + .any(|profile| profile.id == trimmed) + { + return Some(trimmed.to_ascii_lowercase()); + } + } + + let api_base = std::env::var("JCODE_OPENROUTER_API_BASE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| env_override("JCODE_OPENAI_COMPAT_API_BASE")); + + let api_base = api_base.and_then(|value| normalize_api_base(&value))?; + + for profile in openai_compatible_profiles().iter().copied() { + if normalize_api_base(profile.api_base).as_deref() == Some(api_base.as_str()) { + return Some(profile.id.to_string()); + } + } + + None +} + pub fn runtime_provider_display_name(provider_name: &str) -> String { if provider_name.eq_ignore_ascii_case("openrouter") { if let Ok(runtime_provider) = std::env::var("JCODE_RUNTIME_PROVIDER") @@ -166,6 +207,15 @@ pub fn provider_model_allowlist_patterns(provider_name: &str) -> Option Date: Wed, 13 May 2026 14:15:48 -0230 Subject: [PATCH 09/34] Filter cross-provider routes per-route Previously filter_model_routes_by_allowlist applied a single allowlist key to every route in the list. When the agent surfaces the aggregated MultiProvider routes, that meant only the current provider's allowlist was respected and other providers' routes were never filtered. Resolve the allowlist key from each route's (provider, api_method) pair so cross-provider model pickers honour every configured allowlist: - Anthropic / OpenAI / Gemini / Antigravity / Copilot / Cursor map to their respective allowlist keys. - openai-compatible: routes (opencode-go, ollama-cloud, deepseek, ...) map to the embedded profile id. Providers without a matching allowlist remain unrestricted. --- src/provider_catalog.rs | 58 +++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index c7313b07a..423a834af 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -261,24 +261,66 @@ pub fn filter_models_by_allowlist(provider_name: &str, models: Vec) -> V /// Filter `ModelRoute`s against the configured allowlist for `provider_name`. /// -/// Returns the original list when no allowlist is configured. +/// When the supplied list contains routes from multiple back-end providers +/// (as is the case for the aggregated `MultiProvider::model_routes()`), each +/// route is evaluated against the allowlist that matches its own +/// `route.provider` / `route.api_method` rather than the surrounding +/// `provider_name`. This keeps cross-provider model pickers honest: a +/// configured allowlist for `anthropic`, `openai`, `opencode-go`, ... only +/// hides models for the providers the user has explicitly restricted, and +/// leaves the others unrestricted. pub fn filter_model_routes_by_allowlist( provider_name: &str, routes: Vec, ) -> Vec { - let Some(patterns) = provider_model_allowlist_patterns(provider_name) else { + let cfg = crate::config::config(); + if cfg.provider.model_allowlist.is_empty() { return routes; - }; - let lower_patterns: Vec = patterns - .iter() - .map(|p| p.to_ascii_lowercase()) - .collect(); + } + routes .into_iter() - .filter(|route| model_matches_any(&route.model.to_ascii_lowercase(), &lower_patterns)) + .filter(|route| { + // Resolve which allowlist key applies to this specific route. + let route_key = allowlist_key_for_route(route).unwrap_or_else(|| { + canonical_allowlist_key(provider_name) + }); + match provider_model_allowlist_patterns(&route_key) { + Some(patterns) => { + let lower_patterns: Vec = + patterns.iter().map(|p| p.to_ascii_lowercase()).collect(); + model_matches_any(&route.model.to_ascii_lowercase(), &lower_patterns) + } + None => true, + } + }) .collect() } +/// Map a route's `(provider, api_method)` to the canonical allowlist key +/// used in `[provider.model_allowlist]`. Returns `None` when the route does +/// not map to a well-known key, in which case the caller falls back to the +/// surrounding provider context. +fn allowlist_key_for_route(route: &crate::provider::ModelRoute) -> Option { + let api_method = route.api_method.to_ascii_lowercase(); + let provider_display = route.provider.to_ascii_lowercase(); + + // openai-compatible profile routes embed their profile id in api_method. + if let Some(profile_id) = api_method.strip_prefix("openai-compatible:") { + return Some(profile_id.to_string()); + } + + match (provider_display.as_str(), api_method.as_str()) { + ("anthropic", _) => Some("anthropic".to_string()), + ("openai", _) => Some("openai".to_string()), + ("gemini", _) => Some("gemini".to_string()), + ("antigravity", _) => Some("antigravity".to_string()), + ("copilot", _) => Some("copilot".to_string()), + ("cursor", _) => Some("cursor".to_string()), + _ => None, + } +} + fn model_matches_any(model_lower: &str, lower_patterns: &[String]) -> bool { lower_patterns .iter() From 773698e55199b9edf3a616271c99d627887eb84b Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 17:11:50 -0230 Subject: [PATCH 10/34] Make provider model subsets persistent --- crates/jcode-config-types/src/lib.rs | 45 ++++++++- src/provider/mod.rs | 144 ++++++++++++++++++++++++++- src/provider/startup.rs | 38 +++++++ src/provider_catalog.rs | 105 ++++++++++++++----- src/provider_catalog_tests.rs | 64 ++++++++++++ 5 files changed, 369 insertions(+), 27 deletions(-) diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 548889e7b..eee35d0ec 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -612,6 +612,13 @@ pub struct ProviderConfig { /// Copilot premium request mode: "normal", "one", or "zero" /// "zero" means all requests are free (no premium requests consumed) pub copilot_premium: Option, + /// Provider/profile allowlist for model pickers and auto-selection. + /// + /// Empty means every configured provider is eligible. Non-empty means only + /// these built-in provider keys, OpenAI-compatible profile ids, or named + /// provider profile names should be displayed and auto-selected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enabled_providers: Vec, /// Per-provider model allowlist. Maps the built-in provider key /// (e.g. "anthropic", "openai", "gemini", "antigravity") to a list of /// allowed model identifiers. When a provider has a non-empty entry, @@ -635,6 +642,7 @@ impl Default for ProviderConfig { cross_provider_failover: CrossProviderFailoverMode::Countdown, same_provider_account_failover: true, copilot_premium: None, + enabled_providers: Vec::new(), model_allowlist: BTreeMap::new(), } } @@ -793,7 +801,10 @@ mod tests { let mut allow = BTreeMap::new(); allow.insert( "anthropic".to_string(), - vec!["claude-opus-4-7".to_string(), "claude-sonnet-4-6".to_string()], + vec![ + "claude-opus-4-7".to_string(), + "claude-sonnet-4-6".to_string(), + ], ); allow.insert("openai".to_string(), vec!["gpt-5.5".to_string()]); @@ -823,4 +834,36 @@ mod tests { "empty allowlist should be omitted from TOML output, got: {serialized}" ); } + + #[test] + fn provider_enabled_providers_round_trips_through_toml() { + let cfg = ProviderConfig { + enabled_providers: vec![ + "openai".to_string(), + "ollama-cloud".to_string(), + "opencode-go".to_string(), + ], + ..ProviderConfig::default() + }; + + let serialized = toml::to_string(&cfg).expect("serialize ProviderConfig"); + assert!( + serialized.contains("enabled_providers"), + "expected enabled_providers in serialized output, got: {serialized}" + ); + + let parsed: ProviderConfig = + toml::from_str(&serialized).expect("deserialize ProviderConfig"); + assert_eq!(parsed.enabled_providers, cfg.enabled_providers); + } + + #[test] + fn provider_enabled_providers_default_skipped_when_empty() { + let cfg = ProviderConfig::default(); + let serialized = toml::to_string(&cfg).expect("serialize ProviderConfig"); + assert!( + !serialized.contains("enabled_providers"), + "empty enabled providers should be omitted from TOML output, got: {serialized}" + ); + } } diff --git a/src/provider/mod.rs b/src/provider/mod.rs index c973e898f..7b83e44c4 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -332,6 +332,21 @@ impl MultiProvider { Some((profile, rest)) } + fn named_openai_compatible_model_prefix(model: &str) -> Option<(String, &str)> { + let (prefix, rest) = model.split_once(':')?; + let prefix = prefix.trim(); + let rest = rest.trim(); + if prefix.is_empty() || rest.is_empty() { + return None; + } + let cfg = crate::config::config(); + if cfg.providers.contains_key(prefix) { + Some((prefix.to_string(), rest)) + } else { + None + } + } + fn ensure_provider_lock_allows_model_target( &self, target: ActiveProvider, @@ -494,6 +509,38 @@ impl MultiProvider { Ok(()) } + fn set_model_on_named_openai_compatible_profile( + &self, + profile_name: &str, + model: &str, + ) -> Result<()> { + let model = model.trim(); + if model.is_empty() { + anyhow::bail!("Model cannot be empty"); + } + + crate::provider_catalog::apply_named_provider_profile_env(profile_name)?; + let cfg = crate::config::config(); + let profile = cfg.providers.get(profile_name).ok_or_else(|| { + anyhow::anyhow!( + "Unknown provider profile '{}'. Add [providers.{}] to config.toml.", + profile_name, + profile_name + ) + })?; + let provider = Arc::new(openrouter::OpenRouterProvider::new_named_openai_compatible( + profile_name, + profile, + )?); + provider.set_model(model)?; + *self + .openrouter + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(provider); + self.set_active_provider(ActiveProvider::OpenRouter); + Ok(()) + } + fn should_replace_openrouter_after_auth_change( existing: &openrouter::OpenRouterProvider, candidate: &openrouter::OpenRouterProvider, @@ -876,6 +923,13 @@ impl Provider for MultiProvider { return self.set_model_on_openai_compatible_profile(profile, target_model); } + if let Some((profile_name, target_model)) = + Self::named_openai_compatible_model_prefix(requested_model) + { + self.ensure_provider_lock_allows_openai_compatible_profile(requested_model)?; + return self.set_model_on_named_openai_compatible_profile(&profile_name, target_model); + } + // Provider-prefixed model names are explicit routing directives. They // must never silently fall through to another provider when the target // is unavailable or when --provider locks a different backend. @@ -1120,7 +1174,22 @@ impl Provider for MultiProvider { } let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile); let api_method = format!("openai-compatible:{}", resolved.id); - for model in crate::provider_catalog::openai_compatible_profile_static_models(profile) { + let mut profile_models = + crate::provider_catalog::openai_compatible_profile_static_models(profile); + if let Some(allowlist_models) = crate::config::config() + .provider + .model_allowlist + .get(&resolved.id) + { + for model in allowlist_models { + let model = model.trim(); + if !model.is_empty() && !profile_models.iter().any(|existing| existing == model) + { + profile_models.push(model.to_string()); + } + } + } + for model in profile_models { let already_present = routes.iter().any(|route| { route.model == model && route.provider == resolved.display_name @@ -1143,6 +1212,79 @@ impl Provider for MultiProvider { } } + let cfg = crate::config::config(); + for (profile_name, profile) in &cfg.providers { + if !crate::provider_catalog::provider_is_enabled(profile_name) { + continue; + } + let api_method = format!("openai-compatible:{}", profile_name); + let mut named_models = Vec::new(); + if let Some(default_model) = profile.default_model.as_deref().map(str::trim) + && !default_model.is_empty() + { + named_models.push(default_model.to_string()); + } + for model in &profile.models { + let id = model.id.trim(); + if !id.is_empty() && !named_models.iter().any(|existing| existing == id) { + named_models.push(id.to_string()); + } + } + if let Some(allowlist_models) = cfg.provider.model_allowlist.get(profile_name) { + for model in allowlist_models { + let model = model.trim(); + if !model.is_empty() && !named_models.iter().any(|existing| existing == model) { + named_models.push(model.to_string()); + } + } + } + let requires_key = profile.requires_api_key.unwrap_or_else(|| { + !crate::provider_catalog::api_base_uses_localhost(&profile.base_url) + }); + let has_credentials = match profile.auth { + crate::config::NamedProviderAuth::None => true, + crate::config::NamedProviderAuth::Bearer + | crate::config::NamedProviderAuth::Header => { + !requires_key + || profile + .api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + || profile + .api_key_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|env_key| { + if let Some(env_file) = profile.env_file.as_deref() { + crate::provider_catalog::load_env_value_from_env_or_config( + env_key, env_file, + ) + } else { + std::env::var(env_key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } + }) + .is_some() + } + }; + for model in named_models { + routes.push(ModelRoute { + model, + provider: profile_name.clone(), + api_method: api_method.clone(), + available: has_credentials, + detail: profile.base_url.clone(), + cheapness: None, + }); + added_direct_openai_compatible_routes = true; + } + } + // GitHub Copilot models { if let Some(copilot) = self.copilot_provider() { diff --git a/src/provider/startup.rs b/src/provider/startup.rs index 086ff6421..d59b6cf0f 100644 --- a/src/provider/startup.rs +++ b/src/provider/startup.rs @@ -236,6 +236,7 @@ impl MultiProvider { openrouter: openrouter.is_some(), copilot_premium_zero, }; + let availability = Self::filter_availability_by_enabled_providers(availability); let mut active = Self::auto_default_provider(availability); if copilot_premium_zero && matches!(active, ActiveProvider::Copilot) { @@ -433,6 +434,43 @@ impl MultiProvider { Self::new_with_auth_status(auth_status) } + fn filter_availability_by_enabled_providers( + availability: ProviderAvailability, + ) -> ProviderAvailability { + let cfg = crate::config::config(); + if cfg.provider.enabled_providers.is_empty() { + return availability; + } + + ProviderAvailability { + openai: availability.openai && crate::provider_catalog::provider_is_enabled("openai"), + claude: availability.claude + && crate::provider_catalog::provider_is_enabled("anthropic"), + copilot: availability.copilot + && crate::provider_catalog::provider_is_enabled("copilot"), + antigravity: availability.antigravity + && crate::provider_catalog::provider_is_enabled("antigravity"), + gemini: availability.gemini && crate::provider_catalog::provider_is_enabled("gemini"), + cursor: availability.cursor && crate::provider_catalog::provider_is_enabled("cursor"), + bedrock: availability.bedrock + && crate::provider_catalog::provider_is_enabled("bedrock"), + // OpenRouter backs both the raw OpenRouter provider and all + // OpenAI-compatible/named profiles. Keep it eligible when any + // enabled provider entry points at that shared transport; the route + // filter later hides non-enabled profiles/models. + openrouter: availability.openrouter + && (crate::provider_catalog::provider_is_enabled("openrouter") + || cfg.provider.enabled_providers.iter().any(|provider| { + crate::provider_catalog::resolve_openai_compatible_profile_selection( + provider, + ) + .is_some() + || cfg.providers.contains_key(provider.trim()) + })), + copilot_premium_zero: availability.copilot_premium_zero, + } + } + /// Create with explicit initial provider preference pub fn with_preference(prefer_openai: bool) -> Self { let provider = Self::new(); diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index 423a834af..3bdab0e21 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -200,22 +200,7 @@ pub fn provider_model_allowlist_patterns(provider_name: &str) -> Option = vec![canonical.clone()]; - if canonical == "anthropic" { - keys.push("claude".to_string()); - } else if canonical == "claude" { - keys.push("anthropic".to_string()); - } - // The openrouter provider impl backs every openai-compatible profile - // (opencode-go, ollama-cloud, deepseek, etc.). Also accept the active - // profile id as a lookup key so the user can write - // `[provider.model_allowlist] opencode-go = [...]`. - if canonical == "openrouter" { - if let Some(profile_id) = active_openai_compatible_profile_id() { - keys.push(profile_id); - } - } + let keys = provider_config_candidate_keys(provider_name); for key in &keys { if let Some(patterns) = cfg.provider.model_allowlist.get(key) { @@ -232,27 +217,90 @@ pub fn provider_model_allowlist_patterns(provider_name: &str) -> Option String { +fn canonical_provider_config_key(provider_name: &str) -> String { let trimmed = provider_name.trim().to_ascii_lowercase(); match trimmed.as_str() { "claude" => "anthropic".to_string(), + "github copilot" => "copilot".to_string(), + "aws bedrock" | "aws-bedrock" | "aws_bedrock" => "bedrock".to_string(), + "google gemini" => "gemini".to_string(), _ => trimmed, } } +fn push_unique(values: &mut Vec, value: impl Into) { + let value = value.into(); + if !value.is_empty() && !values.iter().any(|existing| existing == &value) { + values.push(value); + } +} + +fn provider_config_candidate_keys(provider_name: &str) -> Vec { + let canonical = canonical_provider_config_key(provider_name); + let mut keys = Vec::new(); + push_unique(&mut keys, canonical.clone()); + if canonical == "anthropic" { + push_unique(&mut keys, "claude"); + } else if canonical == "claude" { + push_unique(&mut keys, "anthropic"); + } + // The openrouter provider impl backs every openai-compatible profile + // (opencode-go, ollama-cloud, deepseek, etc.). Also accept the active + // profile id as a lookup key so the user can write + // `[provider.model_allowlist] opencode-go = [...]`. + if canonical == "openrouter" { + if let Some(profile_id) = active_openai_compatible_profile_id() { + push_unique(&mut keys, profile_id); + } + } + if let Some(profile_id) = openai_compatible_profile_id_for_display_name(provider_name) { + push_unique(&mut keys, profile_id); + } + keys +} + +/// Return whether a provider/profile should be visible and auto-selectable under +/// `[provider].enabled_providers`. An empty list means every configured provider +/// is enabled. Values may be built-in provider keys (`anthropic`, `openai`, +/// `copilot`, ...), OpenAI-compatible profile ids (`opencode-go`, `deepseek`), +/// display names, or named provider profile names. +pub fn provider_is_enabled(provider_name: &str) -> bool { + let cfg = crate::config::config(); + if cfg.provider.enabled_providers.is_empty() { + return true; + } + + let candidate_keys = provider_config_candidate_keys(provider_name); + cfg.provider + .enabled_providers + .iter() + .map(|value| canonical_provider_config_key(value)) + .any(|enabled| { + candidate_keys.iter().any(|candidate| candidate == &enabled) + || openai_compatible_profile_id_for_display_name(&enabled) + .map(|profile_id| { + candidate_keys + .iter() + .any(|candidate| candidate == profile_id) + }) + .unwrap_or(false) + }) +} + /// Filter a list of model names against the configured allowlist for `provider_name`. /// /// Matching is case-insensitive and accepts either an exact match or a /// substring match against any configured pattern. When no allowlist is /// configured for the provider, the input is returned unchanged. pub fn filter_models_by_allowlist(provider_name: &str, models: Vec) -> Vec { + if !provider_is_enabled(provider_name) { + return Vec::new(); + } + let Some(patterns) = provider_model_allowlist_patterns(provider_name) else { return models; }; - let lower_patterns: Vec = patterns - .iter() - .map(|p| p.to_ascii_lowercase()) - .collect(); + let lower_patterns: Vec = patterns.iter().map(|p| p.to_ascii_lowercase()).collect(); models .into_iter() .filter(|model| model_matches_any(&model.to_ascii_lowercase(), &lower_patterns)) @@ -274,7 +322,7 @@ pub fn filter_model_routes_by_allowlist( routes: Vec, ) -> Vec { let cfg = crate::config::config(); - if cfg.provider.model_allowlist.is_empty() { + if cfg.provider.model_allowlist.is_empty() && cfg.provider.enabled_providers.is_empty() { return routes; } @@ -282,9 +330,11 @@ pub fn filter_model_routes_by_allowlist( .into_iter() .filter(|route| { // Resolve which allowlist key applies to this specific route. - let route_key = allowlist_key_for_route(route).unwrap_or_else(|| { - canonical_allowlist_key(provider_name) - }); + let route_key = allowlist_key_for_route(route) + .unwrap_or_else(|| canonical_provider_config_key(provider_name)); + if !provider_is_enabled(&route_key) { + return false; + } match provider_model_allowlist_patterns(&route_key) { Some(patterns) => { let lower_patterns: Vec = @@ -310,6 +360,10 @@ fn allowlist_key_for_route(route: &crate::provider::ModelRoute) -> Option Some("anthropic".to_string()), ("openai", _) => Some("openai".to_string()), @@ -317,6 +371,7 @@ fn allowlist_key_for_route(route: &crate::provider::ModelRoute) -> Option Some("antigravity".to_string()), ("copilot", _) => Some("copilot".to_string()), ("cursor", _) => Some("cursor".to_string()), + ("aws bedrock", _) => Some("bedrock".to_string()), _ => None, } } diff --git a/src/provider_catalog_tests.rs b/src/provider_catalog_tests.rs index 4c4de00ed..f49f1a5b0 100644 --- a/src/provider_catalog_tests.rs +++ b/src/provider_catalog_tests.rs @@ -147,6 +147,70 @@ fn auth_issue_runtime_display_name_tracks_direct_compatible_profiles() { assert_eq!(runtime_provider_display_name("openrouter"), "Z.AI"); } +#[test] +fn provider_enabled_and_model_allowlists_filter_cross_provider_routes() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::save(&["JCODE_HOME"]); + crate::env::set_var("JCODE_HOME", temp.path()); + std::fs::write( + temp.path().join("config.toml"), + r#" +[provider] +enabled_providers = ["openai", "ollama-cloud"] + +[provider.model_allowlist] +openai = ["gpt-5.5"] +ollama-cloud = ["deepseek-v4-pro"] +"#, + ) + .expect("write config"); + crate::config::Config::invalidate_cache(); + + assert!(provider_is_enabled("openai")); + assert!(provider_is_enabled("ollama-cloud")); + assert!(!provider_is_enabled("anthropic")); + + let routes = vec![ + crate::provider::ModelRoute { + model: "claude-opus-4-7".to_string(), + provider: "Anthropic".to_string(), + api_method: "claude-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.5".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.4".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-pro".to_string(), + provider: "ollama-cloud".to_string(), + api_method: "openai-compatible:ollama-cloud".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + ]; + + let filtered = filter_model_routes_by_allowlist("Jcode", routes); + let models: Vec<_> = filtered.iter().map(|route| route.model.as_str()).collect(); + assert_eq!(models, vec!["gpt-5.5", "deepseek-v4-pro"]); +} + #[test] fn auth_profile_env_application_flushes_stale_openrouter_catalog_state() { let _lock = crate::storage::lock_test_env(); From 831d7253e84206dc49a7842f04d4a63ebc6f1b42 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 18:15:41 -0230 Subject: [PATCH 11/34] fix(config): disable OpenAI priority by default --- crates/jcode-config-types/src/lib.rs | 4 ++-- src/config/default_file.rs | 8 ++++---- src/config_tests.rs | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index eee35d0ec..6c4dfb75f 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -598,7 +598,7 @@ pub struct ProviderConfig { pub openai_reasoning_effort: Option, /// OpenAI transport mode (auto|websocket|https) pub openai_transport: Option, - /// OpenAI service tier override (priority|flex) + /// OpenAI service tier override (priority|flex|off) pub openai_service_tier: Option, /// OpenAI native compaction mode: "auto", "explicit", or "off". pub openai_native_compaction_mode: String, @@ -636,7 +636,7 @@ impl Default for ProviderConfig { default_provider: None, openai_reasoning_effort: Some("low".to_string()), openai_transport: None, - openai_service_tier: Some("priority".to_string()), + openai_service_tier: Some("off".to_string()), openai_native_compaction_mode: "auto".to_string(), openai_native_compaction_threshold_tokens: 200_000, cross_provider_failover: CrossProviderFailoverMode::Countdown, diff --git a/src/config/default_file.rs b/src/config/default_file.rs index 7d84e618c..ff8812c7a 100644 --- a/src/config/default_file.rs +++ b/src/config/default_file.rs @@ -159,10 +159,10 @@ update_channel = "stable" openai_reasoning_effort = "low" # OpenAI transport mode (auto|websocket|https) # openai_transport = "auto" -# OpenAI service tier override (priority|flex) -# Defaults to `priority` to match Codex /fast behavior for OpenAI OAuth -# (higher speed, higher usage). Set to "off" to disable. -openai_service_tier = "priority" +# OpenAI service tier override (priority|flex|off) +# Defaults to `off` so OpenAI Priority/Fast is never used unless explicitly enabled. +# Set to "priority" or use `/fast on` only when you intentionally want the higher-cost tier. +openai_service_tier = "off" # Cross-provider failover when the same prompt would be resent elsewhere. # countdown = 3-second countdown before retrying on another provider; press Esc to cancel (default) # manual = show a notice and let you switch yourself diff --git a/src/config_tests.rs b/src/config_tests.rs index c0ff4d748..c77e47970 100644 --- a/src/config_tests.rs +++ b/src/config_tests.rs @@ -22,10 +22,10 @@ fn test_openai_reasoning_effort_defaults_to_low() { } #[test] -fn test_openai_fast_mode_defaults_to_priority() { +fn test_openai_fast_mode_defaults_to_off() { assert_eq!( ProviderConfig::default().openai_service_tier.as_deref(), - Some("priority") + Some("off") ); } @@ -44,8 +44,8 @@ fn test_generated_default_config_uses_low_openai_reasoning_effort() { "generated default config should use low OpenAI reasoning effort" ); assert!( - content.contains("openai_service_tier = \"priority\""), - "generated default config should enable OpenAI fast mode" + content.contains("openai_service_tier = \"off\""), + "generated default config should disable OpenAI Priority/Fast mode" ); if let Some(prev) = prev_home { From 1a9bac0f4a630f6455d67186f4a0c2e420517339 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 19:16:00 -0230 Subject: [PATCH 12/34] Fix local jcode startup recovery --- crates/jcode-build-support/src/paths.rs | 27 +++++++++++++++++++- crates/jcode-embedding/src/lib.rs | 1 + crates/jcode-provider-core/src/lib.rs | 2 ++ src/cli/terminal.rs | 33 +++++++++++++++++++------ src/provider/gemini.rs | 1 + src/server.rs | 19 ++++++++++---- src/telemetry.rs | 1 + src/update.rs | 3 +++ 8 files changed, 73 insertions(+), 14 deletions(-) diff --git a/crates/jcode-build-support/src/paths.rs b/crates/jcode-build-support/src/paths.rs index 3d1a51050..c0085d5b9 100644 --- a/crates/jcode-build-support/src/paths.rs +++ b/crates/jcode-build-support/src/paths.rs @@ -79,6 +79,10 @@ pub fn selfdev_binary_path(repo_dir: &Path) -> PathBuf { profile_binary_path(repo_dir, SELFDEV_CARGO_PROFILE) } +pub fn debug_binary_path(repo_dir: &Path) -> PathBuf { + profile_binary_path(repo_dir, "debug") +} + fn binary_mtime(path: &Path) -> Option { std::fs::metadata(path) .ok() @@ -237,15 +241,32 @@ pub fn current_binary_build_time_string() -> Option { } /// Find the best development binary in the repo. -/// Prefers the newest local self-dev or release binary. +/// Prefers the newest local self-dev, release, or debug binary. pub fn find_dev_binary(repo_dir: &Path) -> Option { newest_existing_binary(vec![ (selfdev_binary_path(repo_dir), "repo-selfdev"), (release_binary_path(repo_dir), "repo-release"), + (debug_binary_path(repo_dir), "repo-debug"), ]) .map(|(path, _)| path) } +fn current_exe_is_repo_binary() -> Option { + let exe = std::env::current_exe().ok()?; + let name = exe.file_name()?.to_str()?; + if name != binary_name() { + return None; + } + + let profile = exe.parent()?.file_name()?.to_str()?; + if !matches!(profile, "debug" | "release" | SELFDEV_CARGO_PROFILE) { + return None; + } + + let repo = exe.parent()?.parent()?.parent()?; + if is_jcode_repo(repo) { Some(exe) } else { None } +} + fn home_dir() -> Result { std::env::var("HOME") .map(PathBuf::from) @@ -374,6 +395,10 @@ pub fn client_update_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'s pub fn shared_server_update_candidate( _is_selfdev_session: bool, ) -> Option<(PathBuf, &'static str)> { + if let Some(repo_exe) = current_exe_is_repo_binary() { + return Some((repo_exe, "repo-current")); + } + if let Some(shared_server) = existing_binary(shared_server_binary_path(), "shared-server") { return Some(shared_server); } diff --git a/crates/jcode-embedding/src/lib.rs b/crates/jcode-embedding/src/lib.rs index f1b83a39f..fc79fb578 100644 --- a/crates/jcode-embedding/src/lib.rs +++ b/crates/jcode-embedding/src/lib.rs @@ -276,6 +276,7 @@ fn download_model_blocking(model_dir: &Path) -> Result<()> { let client = reqwest::blocking::Client::builder() .user_agent(concat!("jcode-embedding/", env!("CARGO_PKG_VERSION"))) .timeout(std::time::Duration::from_secs(300)) + .no_proxy() .build()?; std::fs::create_dir_all(model_dir)?; diff --git a/crates/jcode-provider-core/src/lib.rs b/crates/jcode-provider-core/src/lib.rs index 2457f7ecc..aac00e835 100644 --- a/crates/jcode-provider-core/src/lib.rs +++ b/crates/jcode-provider-core/src/lib.rs @@ -388,6 +388,7 @@ pub fn shared_http_client() -> reqwest::Client { .get_or_init(|| { reqwest::Client::builder() .user_agent(JCODE_USER_AGENT) + .no_proxy() .connect_timeout(Duration::from_secs(15)) .tcp_keepalive(Some(Duration::from_secs(30))) .pool_idle_timeout(Duration::from_secs(90)) @@ -397,6 +398,7 @@ pub fn shared_http_client() -> reqwest::Client { eprintln!("jcode: failed to build shared provider HTTP client: {err}"); reqwest::Client::builder() .user_agent(JCODE_USER_AGENT) + .no_proxy() .build() .expect("fallback Jcode HTTP client should build") }) diff --git a/src/cli/terminal.rs b/src/cli/terminal.rs index c29f44f2c..855f580c9 100644 --- a/src/cli/terminal.rs +++ b/src/cli/terminal.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use std::io::{self, IsTerminal}; +use std::io::{self, IsTerminal, Write}; use std::panic; use crate::{id, session, telemetry, tui}; @@ -170,13 +170,15 @@ pub fn cleanup_tui_runtime_for_run_result( pub fn print_session_resume_hint(session_id: &str) { let session_name = id::extract_session_name(session_id).unwrap_or(session_id); - eprintln!(); - eprintln!( + let mut stderr = io::stderr().lock(); + let _ = writeln!(stderr); + let _ = writeln!( + stderr, "\x1b[33mSession \x1b[1m{}\x1b[0m\x1b[33m - to resume:\x1b[0m", session_name ); - eprintln!(" jcode --resume {}", session_id); - eprintln!(); + let _ = writeln!(stderr, " jcode --resume {}", session_id); + let _ = writeln!(stderr); } fn init_tui_terminal_resume() -> Result { @@ -229,18 +231,27 @@ fn signal_crash_reason(sig: i32) -> String { } } +#[cfg(unix)] +fn signal_should_print_resume_hint(sig: i32) -> bool { + !matches!(sig, libc::SIGHUP) && (io::stderr().is_terminal() || io::stdout().is_terminal()) +} + #[cfg(unix)] fn handle_termination_signal(sig: i32) -> ! { - mark_current_session_crashed(signal_crash_reason(sig)); + if matches!(sig, libc::SIGQUIT) { + mark_current_session_crashed(signal_crash_reason(sig)); + } let _ = crossterm::terminal::disable_raw_mode(); let _ = crossterm::execute!( - std::io::stderr(), + std::io::stdout(), crossterm::terminal::LeaveAlternateScreen, crossterm::cursor::Show ); - if let Some(session_id) = get_current_session() { + if signal_should_print_resume_hint(sig) + && let Some(session_id) = get_current_session() + { print_session_resume_hint(&session_id); } @@ -310,4 +321,10 @@ mod tests { panic!("Session ID should be set"); } } + + #[cfg(unix)] + #[test] + fn test_sighup_does_not_print_resume_hint() { + assert!(!signal_should_print_resume_hint(libc::SIGHUP)); + } } diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index 9fe39d31e..930dedc55 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -777,6 +777,7 @@ fn is_vpc_sc_error(err: &anyhow::Error) -> bool { fn gemini_http_client() -> reqwest::Client { reqwest::Client::builder() .user_agent("jcode/1.0 (gemini)") + .no_proxy() .http1_only() .connect_timeout(Duration::from_secs(20)) .timeout(Duration::from_secs(90)) diff --git a/src/server.rs b/src/server.rs index 714522807..6cef6547e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -81,7 +81,7 @@ use crate::runtime_memory_log::{ }; use crate::tool::selfdev::ReloadContext; use crate::transport::Listener; -use anyhow::Result; +use anyhow::{Context, Result}; use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource}; use jcode_swarm_core::{ append_swarm_completion_report_instructions, format_structured_completion_report, @@ -1695,11 +1695,13 @@ impl Server { pub async fn run(&self) -> Result<()> { // Ensure socket directory exists (for named sockets like /run/user/1000/jcode/) if let Some(parent) = self.socket_path.parent() { - std::fs::create_dir_all(parent)?; + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create socket directory {}", parent.display()) + })?; } #[cfg(unix)] - let _daemon_lock = acquire_daemon_lock()?; + let _daemon_lock = acquire_daemon_lock().context("failed to acquire daemon lock")?; if socket_has_live_listener(&self.socket_path).await { anyhow::bail!( @@ -1712,8 +1714,15 @@ impl Server { crate::transport::remove_socket(&self.socket_path); crate::transport::remove_socket(&self.debug_socket_path); - let main_listener = Listener::bind(&self.socket_path)?; - let debug_listener = Listener::bind(&self.debug_socket_path)?; + let main_listener = Listener::bind(&self.socket_path).with_context(|| { + format!("failed to bind main socket {}", self.socket_path.display()) + })?; + let debug_listener = Listener::bind(&self.debug_socket_path).with_context(|| { + format!( + "failed to bind debug socket {}", + self.debug_socket_path.display() + ) + })?; #[cfg(unix)] { diff --git a/src/telemetry.rs b/src/telemetry.rs index c439e9336..e862373e4 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -787,6 +787,7 @@ fn post_payload(payload: serde_json::Value, timeout: Duration) -> bool { let client = match reqwest::blocking::Client::builder() .user_agent(crate::provider::JCODE_USER_AGENT) .timeout(timeout) + .no_proxy() .build() { Ok(client) => client, diff --git a/src/update.rs b/src/update.rs index 2fbd9728b..8da746cea 100644 --- a/src/update.rs +++ b/src/update.rs @@ -204,6 +204,7 @@ pub fn fetch_latest_release_blocking() -> Result { let client = reqwest::blocking::Client::builder() .timeout(UPDATE_CHECK_TIMEOUT) .user_agent("jcode-updater") + .no_proxy() .build()?; let response = client @@ -229,6 +230,7 @@ fn latest_main_sha_blocking() -> Result { let client = reqwest::blocking::Client::builder() .timeout(UPDATE_CHECK_TIMEOUT) .user_agent("jcode-updater") + .no_proxy() .build()?; let response = client @@ -764,6 +766,7 @@ pub fn download_and_install_blocking_with_progress( let client = reqwest::blocking::Client::builder() .timeout(DOWNLOAD_TIMEOUT) .user_agent("jcode-updater") + .no_proxy() .build()?; let mut response = client From 16f759049f75e1cec907121b2304b418d9e96c18 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 21:42:35 -0230 Subject: [PATCH 13/34] docs: clarify background agent orchestration guidance --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index efd53c54b..3877c57d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,8 @@ - **Rebuild when done** - When you are done making changes, build the source. - **Bump version for releases** - Update version in `Cargo.toml` when making releases. When cutting a new release, look at all the changes that happened since the last release and determine what the version bump should be ie patch or minor, etc. - **Remote builds available** - Use `scripts/remote_build.sh` to offload heavy cargo work to another machine. If your build is terminated, likely is because there are not enough resources on this machine to build. use remote build in that case. Try checking the resource avaliablity on the machine before you run a build. +- **Delegate substantial independent work** - Prefer subagents/child sessions for parallelizable discovery, deep investigation, or long-running validation. +- **Background means non-UI** - Run background work in non-interactive, non-focus-stealing jobs. Do not spawn headed terminals or steal window focus unless the user explicitly asks. ## Logs - Logs are written to `~/.jcode/logs/` (daily files like `jcode-YYYY-MM-DD.log`). From 389978625343d978ec2eb5e136ae37513f36b122 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 21:42:35 -0230 Subject: [PATCH 14/34] feat(swarm): add configurable spawn mode --- crates/jcode-protocol/src/lib.rs | 21 +++- .../src/protocol_tests/comm_requests.rs | 7 ++ src/server/client_lifecycle.rs | 8 ++ src/server/comm_control.rs | 2 + .../assign_next_dependency.rs | 1 + .../assign_next_metadata.rs | 1 + src/server/comm_session.rs | 113 +++++++++++------- src/tool/communicate.rs | 18 ++- 8 files changed, 122 insertions(+), 49 deletions(-) diff --git a/crates/jcode-protocol/src/lib.rs b/crates/jcode-protocol/src/lib.rs index ed0c4dd8f..9cafec914 100644 --- a/crates/jcode-protocol/src/lib.rs +++ b/crates/jcode-protocol/src/lib.rs @@ -44,6 +44,18 @@ pub enum CommDeliveryMode { Wake, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum CommSpawnMode { + /// Launch a visible terminal session. This preserves the historical behavior. + Visible, + /// Create the agent session in-process/headlessly and skip terminal launch. + Headless, + /// Try a visible terminal first, then fall back to headless on launch failure. + #[default] + Auto, +} + /// A message in conversation history (for sync) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryMessage { @@ -270,7 +282,10 @@ pub enum Request { /// The `request_id` must match the value the server included in the /// preceding `ServerEvent::AskUserQuestionOpened` event. #[serde(rename = "submit_ask_user_answer")] - SubmitAskUserAnswer { id: u64, answer: AskUserAnswerPayload }, + SubmitAskUserAnswer { + id: u64, + answer: AskUserAnswerPayload, + }, /// Health check #[serde(rename = "ping")] @@ -612,6 +627,8 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] initial_message: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + spawn_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] request_nonce: Option, }, @@ -713,6 +730,8 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] spawn_if_needed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + spawn_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] message: Option, }, diff --git a/crates/jcode-protocol/src/protocol_tests/comm_requests.rs b/crates/jcode-protocol/src/protocol_tests/comm_requests.rs index fe528ce9c..da76a9c40 100644 --- a/crates/jcode-protocol/src/protocol_tests/comm_requests.rs +++ b/crates/jcode-protocol/src/protocol_tests/comm_requests.rs @@ -365,6 +365,7 @@ fn test_comm_assign_next_roundtrip() -> Result<()> { working_dir: Some("/tmp/project".to_string()), prefer_spawn: Some(true), spawn_if_needed: Some(true), + spawn_mode: Some(CommSpawnMode::Headless), message: Some("Take the next runnable task.".to_string()), }; let json = serde_json::to_string(&req)?; @@ -377,6 +378,7 @@ fn test_comm_assign_next_roundtrip() -> Result<()> { working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, .. } = decoded @@ -388,6 +390,7 @@ fn test_comm_assign_next_roundtrip() -> Result<()> { assert_eq!(working_dir.as_deref(), Some("/tmp/project")); assert_eq!(prefer_spawn, Some(true)); assert_eq!(spawn_if_needed, Some(true)); + assert_eq!(spawn_mode, Some(CommSpawnMode::Headless)); assert_eq!(message.as_deref(), Some("Take the next runnable task.")); Ok(()) } @@ -427,10 +430,12 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { session_id: "sess_coord".to_string(), working_dir: Some("/tmp/project".to_string()), initial_message: Some("Start here".to_string()), + spawn_mode: Some(CommSpawnMode::Headless), request_nonce: Some("planner-fresh-123".to_string()), }; let json = serde_json::to_string(&req)?; assert!(json.contains("\"type\":\"comm_spawn\"")); + assert!(json.contains("\"spawn_mode\":\"headless\"")); assert!(json.contains("\"request_nonce\":\"planner-fresh-123\"")); let decoded = parse_request_json(&json)?; assert_eq!(decoded.id(), 59); @@ -438,6 +443,7 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { session_id, working_dir, initial_message, + spawn_mode, request_nonce, .. } = decoded @@ -447,6 +453,7 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { assert_eq!(session_id, "sess_coord"); assert_eq!(working_dir.as_deref(), Some("/tmp/project")); assert_eq!(initial_message.as_deref(), Some("Start here")); + assert_eq!(spawn_mode, Some(CommSpawnMode::Headless)); assert_eq!(request_nonce.as_deref(), Some("planner-fresh-123")); Ok(()) } diff --git a/src/server/client_lifecycle.rs b/src/server/client_lifecycle.rs index e477bcb61..718237c3e 100644 --- a/src/server/client_lifecycle.rs +++ b/src/server/client_lifecycle.rs @@ -357,6 +357,7 @@ async fn handle_lightweight_control_request( session_id: req_session_id, working_dir, initial_message, + spawn_mode, request_nonce, } => { handle_comm_spawn( @@ -364,6 +365,7 @@ async fn handle_lightweight_control_request( req_session_id, working_dir, initial_message, + spawn_mode, request_nonce, &client_event_tx, sessions, @@ -586,6 +588,7 @@ async fn handle_lightweight_control_request( working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, } => { handle_comm_assign_next( @@ -595,6 +598,7 @@ async fn handle_lightweight_control_request( working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, &client_event_tx, sessions, @@ -2211,6 +2215,7 @@ pub(super) async fn handle_client( session_id: req_session_id, working_dir, initial_message, + spawn_mode, request_nonce, } => { handle_comm_spawn( @@ -2218,6 +2223,7 @@ pub(super) async fn handle_client( req_session_id, working_dir, initial_message, + spawn_mode, request_nonce, &client_event_tx, &sessions, @@ -2450,6 +2456,7 @@ pub(super) async fn handle_client( working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, } => { handle_comm_assign_next( @@ -2459,6 +2466,7 @@ pub(super) async fn handle_client( working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, &client_event_tx, &sessions, diff --git a/src/server/comm_control.rs b/src/server/comm_control.rs index 0319fcbdf..f254198dd 100644 --- a/src/server/comm_control.rs +++ b/src/server/comm_control.rs @@ -1156,6 +1156,7 @@ pub(super) async fn handle_comm_assign_next( working_dir: Option, prefer_spawn: Option, spawn_if_needed: Option, + spawn_mode: Option, message: Option, client_event_tx: &mpsc::UnboundedSender, sessions: &SessionAgents, @@ -1216,6 +1217,7 @@ pub(super) async fn handle_comm_assign_next( &swarm_id, working_dir.clone(), None, + spawn_mode, sessions, global_session_id, provider_template, diff --git a/src/server/comm_control_tests/assign_next_dependency.rs b/src/server/comm_control_tests/assign_next_dependency.rs index 977996f4a..a16e103ff 100644 --- a/src/server/comm_control_tests/assign_next_dependency.rs +++ b/src/server/comm_control_tests/assign_next_dependency.rs @@ -67,6 +67,7 @@ async fn assign_next_prefers_worker_with_dependency_context() { None, None, None, + None, &client_tx, &sessions, &global_session_id, diff --git a/src/server/comm_control_tests/assign_next_metadata.rs b/src/server/comm_control_tests/assign_next_metadata.rs index 6565666f1..3ad904292 100644 --- a/src/server/comm_control_tests/assign_next_metadata.rs +++ b/src/server/comm_control_tests/assign_next_metadata.rs @@ -72,6 +72,7 @@ async fn assign_next_prefers_worker_with_matching_subsystem_metadata() { None, None, None, + None, &client_tx, &sessions, &global_session_id, diff --git a/src/server/comm_session.rs b/src/server/comm_session.rs index 71c9dc11c..5701b5c17 100644 --- a/src/server/comm_session.rs +++ b/src/server/comm_session.rs @@ -12,7 +12,7 @@ use super::{ update_member_status, update_member_status_with_report, }; use crate::agent::Agent; -use crate::protocol::{NotificationType, ServerEvent}; +use crate::protocol::{CommSpawnMode, NotificationType, ServerEvent}; use crate::provider::Provider; use crate::session::Session; use std::collections::{HashMap, HashSet}; @@ -142,6 +142,14 @@ fn provider_key_for_spawn_model( crate::provider::provider_for_model(model).map(str::to_string) } +fn spawn_mode_key(spawn_mode: Option) -> &'static str { + match spawn_mode.unwrap_or_default() { + CommSpawnMode::Visible => "visible", + CommSpawnMode::Headless => "headless", + CommSpawnMode::Auto => "auto", + } +} + fn persist_headed_startup_message(session_id: &str, message: &str) { crate::tui::App::save_startup_submission_for_session( session_id, @@ -291,6 +299,7 @@ pub(super) async fn spawn_swarm_agent( swarm_id: &str, working_dir: Option, initial_message: Option, + spawn_mode: Option, sessions: &SessionAgents, global_session_id: &Arc>, provider_template: &Arc, @@ -330,52 +339,63 @@ pub(super) async fn spawn_swarm_agent( .as_deref() .map(append_swarm_completion_report_instructions); - let visible_spawn = prepare_visible_spawn_session( - resolved_working_dir.as_deref(), - spawn_model.as_deref(), - spawn_provider_key.as_deref(), - coordinator_is_canary, - startup_message.as_deref(), - spawn_visible_session_window, - ); + let spawn_mode = spawn_mode.unwrap_or_default(); + let spawn_headless = || async { + let cmd = if let Some(ref dir) = resolved_working_dir { + format!("create_session:{dir}") + } else { + "create_session".to_string() + }; + create_headless_session( + sessions, + global_session_id, + provider_template, + &cmd, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + soft_interrupt_queues, + coordinator_is_canary, + spawn_model.clone(), + spawn_provider_key.clone(), + Some(Arc::clone(mcp_pool)), + Some(req_session_id.to_string()), + ) + .await + .and_then(|result_json| { + serde_json::from_str::(&result_json) + .ok() + .and_then(|value| { + value + .get("session_id") + .and_then(|session_id| session_id.as_str()) + .map(|session_id| session_id.to_string()) + }) + .map(|session_id| (session_id, true)) + .ok_or_else(|| anyhow::anyhow!("Failed to parse spawned session id")) + }) + }; - let (new_session_id, is_headless_fallback) = match visible_spawn { - Ok((new_session_id, true)) => Ok((new_session_id, false)), - Ok((_, false)) | Err(_) => { - let cmd = if let Some(ref dir) = resolved_working_dir { - format!("create_session:{dir}") - } else { - "create_session".to_string() - }; - create_headless_session( - sessions, - global_session_id, - provider_template, - &cmd, - swarm_members, - swarms_by_id, - swarm_coordinators, - swarm_plans, - soft_interrupt_queues, + let (new_session_id, is_headless_fallback) = match spawn_mode { + CommSpawnMode::Headless => spawn_headless().await, + CommSpawnMode::Visible | CommSpawnMode::Auto => { + let visible_spawn = prepare_visible_spawn_session( + resolved_working_dir.as_deref(), + spawn_model.as_deref(), + spawn_provider_key.as_deref(), coordinator_is_canary, - spawn_model.clone(), - spawn_provider_key.clone(), - Some(Arc::clone(mcp_pool)), - Some(req_session_id.to_string()), - ) - .await - .and_then(|result_json| { - serde_json::from_str::(&result_json) - .ok() - .and_then(|value| { - value - .get("session_id") - .and_then(|session_id| session_id.as_str()) - .map(|session_id| session_id.to_string()) - }) - .map(|session_id| (session_id, true)) - .ok_or_else(|| anyhow::anyhow!("Failed to parse spawned session id")) - }) + startup_message.as_deref(), + spawn_visible_session_window, + ); + + match visible_spawn { + Ok((new_session_id, true)) => Ok((new_session_id, false)), + Ok((_, false)) if spawn_mode == CommSpawnMode::Auto => spawn_headless().await, + Ok((_, false)) => Err(anyhow::anyhow!("Visible session launch was not available")), + Err(_error) if spawn_mode == CommSpawnMode::Auto => spawn_headless().await, + Err(error) => Err(error), + } } }?; @@ -510,6 +530,7 @@ pub(super) async fn handle_comm_spawn( req_session_id: String, working_dir: Option, initial_message: Option, + spawn_mode: Option, request_nonce: Option, client_event_tx: &mpsc::UnboundedSender, sessions: &SessionAgents, @@ -551,6 +572,7 @@ pub(super) async fn handle_comm_spawn( swarm_id.clone(), working_dir.clone().unwrap_or_default(), initial_message.clone().unwrap_or_default(), + spawn_mode_key(spawn_mode).to_string(), request_nonce.clone().unwrap_or_default(), ], ); @@ -572,6 +594,7 @@ pub(super) async fn handle_comm_spawn( &swarm_id, working_dir, initial_message, + spawn_mode, sessions, global_session_id, provider_template, diff --git a/src/tool/communicate.rs b/src/tool/communicate.rs index 6e5b58ff0..5df33f013 100644 --- a/src/tool/communicate.rs +++ b/src/tool/communicate.rs @@ -3,9 +3,9 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::plan::PlanItem; use crate::protocol::{ - AgentInfo, AgentStatusSnapshot, AwaitedMemberStatus, CommDeliveryMode, ContextEntry, - HistoryMessage, PlanGraphStatus, Request, ServerEvent, SwarmChannelInfo, ToolCallSummary, - comm_cleanup_candidate_session_ids, default_comm_await_target_statuses, + AgentInfo, AgentStatusSnapshot, AwaitedMemberStatus, CommDeliveryMode, CommSpawnMode, + ContextEntry, HistoryMessage, PlanGraphStatus, Request, ServerEvent, SwarmChannelInfo, + ToolCallSummary, comm_cleanup_candidate_session_ids, default_comm_await_target_statuses, default_comm_cleanup_target_statuses, default_comm_run_await_statuses, format_comm_awaited_members_with_reports, format_comm_channels, format_comm_context_entries, format_comm_context_history, format_comm_members, format_comm_plan_followup, @@ -256,6 +256,7 @@ async fn run_swarm_plan_to_terminal( working_dir: params.working_dir.clone(), prefer_spawn: params.prefer_spawn, spawn_if_needed, + spawn_mode: params.spawn_mode, message: params.message.clone(), }; match send_request(request).await { @@ -305,6 +306,7 @@ async fn spawn_assignment_session(ctx: &ToolContext, params: &CommunicateInput) session_id: ctx.session_id.clone(), working_dir: params.working_dir.clone(), initial_message: None, + spawn_mode: params.spawn_mode, request_nonce: Some(fresh_spawn_request_nonce(ctx)), }; @@ -489,6 +491,8 @@ struct CommunicateInput { #[serde(default)] spawn_if_needed: Option, #[serde(default)] + spawn_mode: Option, + #[serde(default)] prefer_spawn: Option, #[serde(default)] plan_items: Option>, @@ -608,6 +612,11 @@ impl Tool for CommunicateTool { "type": "boolean", "description": "For assign_task without an explicit target_session: if no reusable agent is available, spawn a fresh agent and retry the assignment automatically." }, + "spawn_mode": { + "type": "string", + "enum": ["visible", "headless", "auto"], + "description": "For spawn/assign_next/fill_slots/run_plan: visible opens a terminal, headless runs in-process, auto tries visible then falls back to headless." + }, "prefer_spawn": { "type": "boolean", "description": "For assign_task without an explicit target_session: prefer a fresh spawned agent even if reusable workers are available." @@ -959,6 +968,7 @@ impl Tool for CommunicateTool { session_id: ctx.session_id.clone(), working_dir: params.working_dir.clone(), initial_message: params.spawn_initial_message(), + spawn_mode: params.spawn_mode, request_nonce: None, }; @@ -1234,6 +1244,7 @@ impl Tool for CommunicateTool { working_dir: params.working_dir.clone(), prefer_spawn: params.prefer_spawn, spawn_if_needed: params.spawn_if_needed, + spawn_mode: params.spawn_mode, message: params.message.clone(), }; @@ -1282,6 +1293,7 @@ impl Tool for CommunicateTool { working_dir: params.working_dir.clone(), prefer_spawn: params.prefer_spawn, spawn_if_needed: params.spawn_if_needed, + spawn_mode: params.spawn_mode, message: params.message.clone(), }; From b6a40f55237fe4e06fdd95431a0f04dec31a69bb Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 21:42:35 -0230 Subject: [PATCH 15/34] fix(desktop): activate macOS desktop window on launch --- crates/jcode-desktop/src/main.rs | 144 +++++++++++++++++++------------ 1 file changed, 88 insertions(+), 56 deletions(-) diff --git a/crates/jcode-desktop/src/main.rs b/crates/jcode-desktop/src/main.rs index ea413b115..423d77e07 100644 --- a/crates/jcode-desktop/src/main.rs +++ b/crates/jcode-desktop/src/main.rs @@ -227,9 +227,17 @@ async fn run() -> Result<()> { "pid": std::process::id(), }), ); - let event_loop = EventLoop::::with_user_event() - .build() - .context("failed to create event loop")?; + #[cfg(target_os = "macos")] + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + let event_loop = { + let mut builder = EventLoop::::with_user_event(); + #[cfg(target_os = "macos")] + { + builder.with_activation_policy(ActivationPolicy::Regular); + builder.with_activate_ignoring_other_apps(true); + } + builder.build().context("failed to create event loop")? + }; let event_loop_proxy = event_loop.create_proxy(); startup_trace.mark("event loop created"); @@ -362,12 +370,10 @@ impl ApplicationHandler for DesktopHandler { self.ensure_window_and_canvas(event_loop); } - fn new_events( - &mut self, - event_loop: &ActiveEventLoop, - _cause: winit::event::StartCause, - ) { - let Some(window) = self.window else { return; }; + fn new_events(&mut self, event_loop: &ActiveEventLoop, _cause: winit::event::StartCause) { + let Some(window) = self.window else { + return; + }; let event_loop_now = Instant::now(); let has_background_work = self.app.has_background_work(); self.power_inhibitor.set_active(has_background_work); @@ -420,11 +426,15 @@ impl ApplicationHandler for DesktopHandler { window_id: WindowId, event: WindowEvent, ) { - let Some(window) = self.window else { return; }; + let Some(window) = self.window else { + return; + }; if window_id != window.id() { return; } - let Some(canvas) = self.canvas.as_mut() else { return; }; + let Some(canvas) = self.canvas.as_mut() else { + return; + }; match event { WindowEvent::CloseRequested => { event_loop.exit(); @@ -455,9 +465,11 @@ impl ApplicationHandler for DesktopHandler { self.scroll_accumulator.reset(); self.scroll_metrics_cache.clear(); } else if let Some(lines) = self.scroll_accumulator.scroll_lines(delta, now) { - should_redraw |= self - .app - .scroll_single_session_body(lines, size, &mut self.scroll_metrics_cache); + should_redraw |= self.app.scroll_single_session_body( + lines, + size, + &mut self.scroll_metrics_cache, + ); } if matches!(phase, TouchPhase::Cancelled) { self.scroll_accumulator.reset(); @@ -558,9 +570,8 @@ impl ApplicationHandler for DesktopHandler { window.inner_size(), ); self.selecting_body = false; - let selected = self - .app - .selected_single_session_text(window.inner_size()); + let selected = + self.app.selected_single_session_text(window.inner_size()); if let Some(text) = selected { copy_text_to_clipboard(&text, "copied selection", &mut self.app); } @@ -572,9 +583,7 @@ impl ApplicationHandler for DesktopHandler { } } } - WindowEvent::KeyboardInput { event, .. } - if event.state == ElementState::Pressed => - { + WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => { let keyboard_started = Instant::now(); let size = window.inner_size(); let had_smooth_scroll = self @@ -592,7 +601,8 @@ impl ApplicationHandler for DesktopHandler { } let key_input = to_key_input(&event.logical_key, self.modifiers); let key_debug = format!("{key_input:?}"); - self.interaction_latency.mark("keyboard_input", keyboard_started); + self.interaction_latency + .mark("keyboard_input", keyboard_started); if key_input == KeyInput::RefreshSessions && self.app.is_workspace() { spawn_session_cards_load( DesktopSessionCardsPurpose::WorkspaceRefresh, @@ -798,8 +808,9 @@ impl ApplicationHandler for DesktopHandler { window.request_redraw(); } KeyOutcome::SendStdinResponse { request_id, input } => { - if let Err(error) = - self.app.send_single_session_stdin_response(request_id, input) + if let Err(error) = self + .app + .send_single_session_stdin_response(request_id, input) { apply_single_session_error(&mut self.app, error); } @@ -843,7 +854,8 @@ impl ApplicationHandler for DesktopHandler { smooth_scroll_lines, ) { Ok(frame) => { - self.no_paint_watchdog.observe_presented(Instant::now(), &frame); + self.no_paint_watchdog + .observe_presented(Instant::now(), &frame); self.interaction_latency.observe_presented(&frame); if !self.first_frame_presented { self.first_frame_presented = true; @@ -879,13 +891,16 @@ impl ApplicationHandler for DesktopHandler { } fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: DesktopUserEvent) { - let Some(window) = self.window else { return; }; + let Some(window) = self.window else { + return; + }; match event { DesktopUserEvent::RecoveryCount(recovery_count) => { if let DesktopApp::SingleSession(single_session) = &mut self.app { single_session.set_recovery_session_count(recovery_count); window.set_title(&self.app.status_title()); - self.interaction_latency.mark("recovery_count", Instant::now()); + self.interaction_latency + .mark("recovery_count", Instant::now()); window.request_redraw(); } } @@ -989,7 +1004,10 @@ impl ApplicationHandler for DesktopHandler { if apply_stats.session_card_refresh_requested && let Some(session_id) = self.app.single_session_live_id() { - spawn_single_session_card_refresh(session_id, self.event_loop_proxy.clone()); + spawn_single_session_card_refresh( + session_id, + self.event_loop_proxy.clone(), + ); session_card_refresh_spawned = true; } if let Some((message, images)) = @@ -1019,9 +1037,9 @@ impl ApplicationHandler for DesktopHandler { now.saturating_duration_since(last) >= BACKEND_REDRAW_FRAME_INTERVAL }); if redraw_due { - let first_pending = - self.pending_backend_redraw_since.take().unwrap_or(now); - self.interaction_latency.mark("backend_events", first_pending); + let first_pending = self.pending_backend_redraw_since.take().unwrap_or(now); + self.interaction_latency + .mark("backend_events", first_pending); self.last_backend_redraw_request = Some(now); window.request_redraw(); redraw_requested = true; @@ -1045,7 +1063,9 @@ impl ApplicationHandler for DesktopHandler { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(window) = self.window else { return; }; + let Some(window) = self.window else { + return; + }; if self.app.is_single_session() { let about_to_wait_started = Instant::now(); let size = window.inner_size(); @@ -2499,10 +2519,13 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { size, visible_whole_line_app.text_scale(), ); - body_buffer.set_scroll( - glyphon::cosmic_text::Scroll { line: initial_visible_viewport + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: initial_visible_viewport .start_line - .saturating_sub(visible_window_start), vertical: 0.0, horizontal: 0.0 }); + .saturating_sub(visible_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } let mut visible_viewport_ms = 0.0; let mut visible_window_ms = 0.0; @@ -2544,10 +2567,11 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { let phase_started = Instant::now(); if viewport.start_line != visible_whole_line_start { if let Some(body_buffer) = visible_whole_line_buffers.get_mut(1) { - body_buffer.set_scroll( - glyphon::cosmic_text::Scroll { line: viewport - .start_line - .saturating_sub(visible_window_start), vertical: 0.0, horizontal: 0.0 }); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: viewport.start_line.saturating_sub(visible_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } visible_whole_line_start = viewport.start_line; } @@ -2748,10 +2772,13 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { size, streaming_app.text_scale(), ); - body_buffer.set_scroll( - glyphon::cosmic_text::Scroll { line: streaming_initial_viewport + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: streaming_initial_viewport .start_line - .saturating_sub(streaming_window_start), vertical: 0.0, horizontal: 0.0 }); + .saturating_sub(streaming_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } let mut streaming_previous_key = Some(streaming_initial_key); let mut streaming_tail_text_key = None; @@ -2852,10 +2879,11 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { &mut streaming_font_system, ); if let Some(body_buffer) = streaming_buffers.get_mut(1) { - body_buffer.set_scroll( - glyphon::cosmic_text::Scroll { line: viewport - .start_line - .saturating_sub(streaming_window_start), vertical: 0.0, horizontal: 0.0 }); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: viewport.start_line.saturating_sub(streaming_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } let streaming_start_line = streaming_base_len.saturating_add(usize::from(!streaming_app.messages.is_empty())); @@ -3119,10 +3147,11 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { &mut hero_font_system, ); if let Some(body_buffer) = hero_buffers.get_mut(1) { - body_buffer.set_scroll( - glyphon::cosmic_text::Scroll { line: viewport - .start_line - .saturating_sub(hero_window_start), vertical: 0.0, horizontal: 0.0 }); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: viewport.start_line.saturating_sub(hero_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } let hero_visible = key.fresh_welcome_visible; hero_previous_key = Some(key); @@ -3261,10 +3290,11 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { &mut action_font_system, ); if let Some(body_buffer) = action_buffers.get_mut(1) { - body_buffer.set_scroll( - glyphon::cosmic_text::Scroll { line: viewport - .start_line - .saturating_sub(action_window_start), vertical: 0.0, horizontal: 0.0 }); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: viewport.start_line.saturating_sub(action_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } action_previous_key = Some(key); action_text_cache_ms += phase_started.elapsed().as_secs_f64() * 1000.0; @@ -6136,9 +6166,11 @@ impl<'window> Canvas<'window> { return; } if let Some(body_buffer) = self.single_session_text_buffers.get_mut(1) { - body_buffer.set_scroll( - glyphon::cosmic_text::Scroll { line: start_line - .saturating_sub(window_start), vertical: 0.0, horizontal: 0.0 }); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: start_line.saturating_sub(window_start), + vertical: 0.0, + horizontal: 0.0, + }); self.single_session_body_text_scroll_start = Some(start_line); self.text_needs_prepare = true; } From 0484d84db6635c16b7bb4a870fb07fb3bb18cf47 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 21:42:35 -0230 Subject: [PATCH 16/34] style: apply rustfmt cleanup --- .../src/single_session_render.rs | 7 +--- crates/jcode-provider-core/src/pricing.rs | 18 +++++----- crates/jcode-provider-metadata/src/lib.rs | 5 ++- src/agent/turn_execution.rs | 8 ++--- src/provider/openrouter_sse_stream.rs | 12 +++---- src/provider/openrouter_tests.rs | 3 +- src/tool/ask_user_question.rs | 4 +-- src/tool/mod.rs | 5 +-- src/tui/app.rs | 2 +- src/tui/app/inline_interactive.rs | 6 ++-- src/tui/app/tui_state.rs | 4 +-- src/tui/ask_user_modal.rs | 34 +++++++++++-------- src/tui/backend.rs | 5 +-- src/tui/mod.rs | 3 +- 14 files changed, 52 insertions(+), 64 deletions(-) diff --git a/crates/jcode-desktop/src/single_session_render.rs b/crates/jcode-desktop/src/single_session_render.rs index d60bfb55d..172b38c8c 100644 --- a/crates/jcode-desktop/src/single_session_render.rs +++ b/crates/jcode-desktop/src/single_session_render.rs @@ -5114,12 +5114,7 @@ fn single_session_styled_text_buffer( } else { Shaping::Basic }; - buffer.set_rich_text( - font_system, - segments.iter().copied(), - Attrs::new(), - shaping, - ); + buffer.set_rich_text(font_system, segments.iter().copied(), Attrs::new(), shaping); buffer.shape_until_scroll(font_system, false); buffer } diff --git a/crates/jcode-provider-core/src/pricing.rs b/crates/jcode-provider-core/src/pricing.rs index ca2ef7b8a..addb472b5 100644 --- a/crates/jcode-provider-core/src/pricing.rs +++ b/crates/jcode-provider-core/src/pricing.rs @@ -126,14 +126,16 @@ pub fn anthropic_oauth_pricing(model: &str, subscription: Option<&str>) -> Route pub fn openai_api_pricing(model: &str) -> Option { let base = model.strip_suffix("[1m]").unwrap_or(model); match base { - "gpt-5.5" | "gpt-5.4" | "gpt-5.4-pro" | "gpt-5.4-mini" => Some(RouteCheapnessEstimate::metered( - RouteCostSource::PublicApiPricing, - RouteCostConfidence::High, - usd_to_micros(2.5), - usd_to_micros(15.0), - Some(usd_to_micros(0.25)), - Some("OpenAI API pricing".to_string()), - )), + "gpt-5.5" | "gpt-5.4" | "gpt-5.4-pro" | "gpt-5.4-mini" => { + Some(RouteCheapnessEstimate::metered( + RouteCostSource::PublicApiPricing, + RouteCostConfidence::High, + usd_to_micros(2.5), + usd_to_micros(15.0), + Some(usd_to_micros(0.25)), + Some("OpenAI API pricing".to_string()), + )) + } "gpt-5.3-codex" | "gpt-5.2-codex" | "gpt-5.2" | "gpt-5.1" | "gpt-5.1-codex" => { Some(RouteCheapnessEstimate::metered( RouteCostSource::Heuristic, diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index 64d572b23..f72e50c59 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -1346,7 +1346,10 @@ mod tests { assert_eq!(OLLAMA_PROFILE.default_model, None); assert!(!OLLAMA_PROFILE.requires_api_key); - assert_eq!(OLLAMA_LOGIN_PROVIDER.auth_kind, LoginProviderAuthKind::Local); + assert_eq!( + OLLAMA_LOGIN_PROVIDER.auth_kind, + LoginProviderAuthKind::Local + ); assert_eq!(OLLAMA_LOGIN_PROVIDER.auth_status_method, "local endpoint"); assert!(matches!( OLLAMA_LOGIN_PROVIDER.target, diff --git a/src/agent/turn_execution.rs b/src/agent/turn_execution.rs index ff1deae86..fb76834b7 100644 --- a/src/agent/turn_execution.rs +++ b/src/agent/turn_execution.rs @@ -285,18 +285,14 @@ impl Agent { // Even when the base list is locked, append any tools the agent // unlocked via ToolSearch since the lock was taken. Without this, // tools unlocked mid-session would never reach the API. - let unlocked = - crate::tool::tool_search::unlocked_for_session(&self.session.id); + let unlocked = crate::tool::tool_search::unlocked_for_session(&self.session.id); if unlocked.is_empty() { return locked.clone(); } let already: std::collections::HashSet<&str> = locked.iter().map(|t| t.name.as_str()).collect(); let mut tools = locked.clone(); - let extra = self - .registry - .definitions_for_names(&unlocked) - .await; + let extra = self.registry.definitions_for_names(&unlocked).await; for def in extra { if !already.contains(def.name.as_str()) { tools.push(def); diff --git a/src/provider/openrouter_sse_stream.rs b/src/provider/openrouter_sse_stream.rs index bf374d3f9..a2e225430 100644 --- a/src/provider/openrouter_sse_stream.rs +++ b/src/provider/openrouter_sse_stream.rs @@ -363,12 +363,12 @@ impl OpenRouterStream { .and_then(|c| c.as_str()) && !reasoning_content.is_empty() { - let reasoning_delta = if reasoning_content.starts_with(&self.reasoning_buffer) - { - &reasoning_content[self.reasoning_buffer.len()..] - } else { - reasoning_content - }; + let reasoning_delta = + if reasoning_content.starts_with(&self.reasoning_buffer) { + &reasoning_content[self.reasoning_buffer.len()..] + } else { + reasoning_content + }; self.reasoning_buffer = reasoning_content.to_string(); if !reasoning_delta.is_empty() { self.pending diff --git a/src/provider/openrouter_tests.rs b/src/provider/openrouter_tests.rs index b44902e44..cdec5ee1c 100644 --- a/src/provider/openrouter_tests.rs +++ b/src/provider/openrouter_tests.rs @@ -1159,7 +1159,8 @@ fn test_parse_next_event_emits_only_incremental_reasoning_content() { } stream.buffer = - "data:{\"choices\":[{\"delta\":{\"reasoning_content\":\"Thinking more\"}}]}\n\n".to_string(); + "data:{\"choices\":[{\"delta\":{\"reasoning_content\":\"Thinking more\"}}]}\n\n" + .to_string(); match stream.parse_next_event() { Some(StreamEvent::ThinkingDelta(text)) => assert_eq!(text, " more"), other => panic!("expected incremental ThinkingDelta, got {:?}", other), diff --git a/src/tool/ask_user_question.rs b/src/tool/ask_user_question.rs index 8bf648416..c5f0e8753 100644 --- a/src/tool/ask_user_question.rs +++ b/src/tool/ask_user_question.rs @@ -1,7 +1,5 @@ use super::{Tool, ToolContext, ToolOutput}; -use crate::ask_user::{ - AskUserAnswerKind, AskUserOption, AskUserQuestion, register_pending, -}; +use crate::ask_user::{AskUserAnswerKind, AskUserOption, AskUserQuestion, register_pending}; use crate::bus::{Bus, BusEvent}; use anyhow::{Result, bail}; use async_trait::async_trait; diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 10e5b4162..e0522651b 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -311,10 +311,7 @@ impl Registry { /// Resolve a specific subset of tools by registry key. Unknown names are /// silently skipped. Used by ToolSearch to surface deferred tools. - pub async fn definitions_for_names( - &self, - names: &HashSet, - ) -> Vec { + pub async fn definitions_for_names(&self, names: &HashSet) -> Vec { let tools = self.tools.read().await; let mut defs: Vec = names .iter() diff --git a/src/tui/app.rs b/src/tui/app.rs index 10fc8c834..5ddfab8b5 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -49,9 +49,9 @@ pub enum AppRuntimeMode { TestHarness, } +mod ask_user_modal_app; mod auth; mod auth_account_picker_saved_accounts; -mod ask_user_modal_app; mod catchup; mod commands; mod commands_improve; diff --git a/src/tui/app/inline_interactive.rs b/src/tui/app/inline_interactive.rs index bb6467114..545d6cae6 100644 --- a/src/tui/app/inline_interactive.rs +++ b/src/tui/app/inline_interactive.rs @@ -388,10 +388,8 @@ impl App { let build = move || { let routes_started = std::time::Instant::now(); let routes = provider.model_routes(); - let routes = crate::provider_catalog::filter_model_routes_by_allowlist( - provider.name(), - routes, - ); + let routes = + crate::provider_catalog::filter_model_routes_by_allowlist(provider.name(), routes); let routes_ms = routes_started.elapsed().as_millis(); let _ = tx.send(Ok(ModelPickerRoutesResult { routes, routes_ms })); }; diff --git a/src/tui/app/tui_state.rs b/src/tui/app/tui_state.rs index a99547ba5..f4e78598c 100644 --- a/src/tui/app/tui_state.rs +++ b/src/tui/app/tui_state.rs @@ -1260,9 +1260,7 @@ impl crate::tui::TuiState for App { self.usage_overlay.as_ref() } - fn ask_user_overlay( - &self, - ) -> Option<&RefCell> { + fn ask_user_overlay(&self) -> Option<&RefCell> { self.ask_user_overlay.as_ref() } diff --git a/src/tui/ask_user_modal.rs b/src/tui/ask_user_modal.rs index 75bca0ff9..8bf212836 100644 --- a/src/tui/ask_user_modal.rs +++ b/src/tui/ask_user_modal.rs @@ -333,7 +333,11 @@ impl AskUserModal { options_h += 2; // blank + hint } let options_h = options_h.max(3) as u16; - let typing_h: u16 = if matches!(self.mode, Mode::Typing) { 5 } else { 0 }; + let typing_h: u16 = if matches!(self.mode, Mode::Typing) { + 5 + } else { + 0 + }; // 2 border rows + 1 top inset pad + 1 divider + 1 blank above options. let mut total: u16 = 2 + 1 + question_h + 1 + 1 + options_h + typing_h; @@ -422,18 +426,16 @@ impl AskUserModal { if context_h > 0 { slot += 1; // skip blank - let context_para = - Paragraph::new(self.context.as_deref().unwrap_or("").to_string()) - .style(Style::default().fg(MUTED)) - .wrap(Wrap { trim: false }); + let context_para = Paragraph::new(self.context.as_deref().unwrap_or("").to_string()) + .style(Style::default().fg(MUTED)) + .wrap(Wrap { trim: false }); frame.render_widget(context_para, chunks[slot]); slot += 1; } // Divider that respects the content padding. let divider_line = "─".repeat(inner.width as usize); - let divider = Paragraph::new(divider_line) - .style(Style::default().fg(SECTION_BORDER)); + let divider = Paragraph::new(divider_line).style(Style::default().fg(SECTION_BORDER)); frame.render_widget(divider, chunks[slot]); slot += 1; slot += 1; // blank above options @@ -520,10 +522,7 @@ impl AskUserModal { let mut spans = vec![ Span::styled(arrow.to_string(), Style::default().fg(row_fg).bg(row_bg)), - Span::styled( - check.to_string(), - Style::default().fg(row_fg).bg(row_bg), - ), + Span::styled(check.to_string(), Style::default().fg(row_fg).bg(row_bg)), Span::styled( format!("[{}] ", opt.id), Style::default().fg(row_fg).bg(row_bg).bold(), @@ -557,7 +556,10 @@ impl AskUserModal { let bg = if selected { SELECTED_BG } else { PANEL_BG }; let mut spans = vec![ - Span::styled(arrow.to_string(), Style::default().fg(CUSTOM_HINT_FG).bg(bg)), + Span::styled( + arrow.to_string(), + Style::default().fg(CUSTOM_HINT_FG).bg(bg), + ), Span::styled( check_pad.to_string(), Style::default().fg(CUSTOM_HINT_FG).bg(bg), @@ -595,9 +597,11 @@ impl AskUserModal { // Display typed text plus a blinking-style caret. let mut text = self.typed.clone(); text.push('▏'); - let para = - Paragraph::new(Line::from(Span::styled(text, Style::default().fg(Color::White)))) - .wrap(Wrap { trim: false }); + let para = Paragraph::new(Line::from(Span::styled( + text, + Style::default().fg(Color::White), + ))) + .wrap(Wrap { trim: false }); frame.render_widget(para, inner); } diff --git a/src/tui/backend.rs b/src/tui/backend.rs index d6f07d5e0..d706d9b38 100644 --- a/src/tui/backend.rs +++ b/src/tui/backend.rs @@ -702,10 +702,7 @@ impl RemoteConnection { /// Fire-and-forget: the server applies it to its pending registry which /// in turn wakes the tool task. We use the detached path so the modal /// dispatch from the synchronous key handler does not need an .await. - pub fn submit_ask_user_answer( - &mut self, - answer: jcode_protocol::AskUserAnswerPayload, - ) { + pub fn submit_ask_user_answer(&mut self, answer: jcode_protocol::AskUserAnswerPayload) { let id = self.next_request_id; self.next_request_id += 1; self.send_request_detached( diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e12897c4c..bcfc80c0a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -288,8 +288,7 @@ pub trait TuiState { /// Usage overlay for /usage command fn usage_overlay(&self) -> Option<&std::cell::RefCell>; /// `askUserQuestion` modal overlay (None = not visible). - fn ask_user_overlay(&self) - -> Option<&std::cell::RefCell>; + fn ask_user_overlay(&self) -> Option<&std::cell::RefCell>; /// Working directory for this session fn working_dir(&self) -> Option; /// Monotonic clock for viewport animations From d11e8af788848870d1187712772d8039ec2d84b3 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Wed, 13 May 2026 21:46:10 -0230 Subject: [PATCH 17/34] docs: require one-shot agents for mechanical tasks --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 3877c57d7..82a2b6036 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ - **Bump version for releases** - Update version in `Cargo.toml` when making releases. When cutting a new release, look at all the changes that happened since the last release and determine what the version bump should be ie patch or minor, etc. - **Remote builds available** - Use `scripts/remote_build.sh` to offload heavy cargo work to another machine. If your build is terminated, likely is because there are not enough resources on this machine to build. use remote build in that case. Try checking the resource avaliablity on the machine before you run a build. - **Delegate substantial independent work** - Prefer subagents/child sessions for parallelizable discovery, deep investigation, or long-running validation. +- **Use one-shot mechanical subagents** - For mechanical validation, status, and publishing work, such as long cargo validations, git status/diff summaries, PR/issue creation checks, and final repo audits, use no-context one-shot subagents and return only compact results to the main session. - **Background means non-UI** - Run background work in non-interactive, non-focus-stealing jobs. Do not spawn headed terminals or steal window focus unless the user explicitly asks. ## Logs From 6fde5acda5aa3849654858dc036ab9cbff6b2d2a Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 00:03:34 -0230 Subject: [PATCH 18/34] fix: stabilize session spawn and remote reconnects --- crates/jcode-desktop/src/session_launch.rs | 5 +- src/provider/mod.rs | 69 +++++++++------------- src/tui/backend.rs | 14 ++++- 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/crates/jcode-desktop/src/session_launch.rs b/crates/jcode-desktop/src/session_launch.rs index bac73c51f..44e3fac93 100644 --- a/crates/jcode-desktop/src/session_launch.rs +++ b/crates/jcode-desktop/src/session_launch.rs @@ -105,7 +105,10 @@ pub fn launch_resume_session(session_id: &str, title: &str) -> Result<()> { } pub fn launch_new_session() -> Result<()> { - let candidates = terminal_candidates("jcode · new session", &["--fresh-spawn"]); + // Fresh-spawn is signaled via JCODE_FRESH_SPAWN by jcode-terminal-launch. + // Do not pass --fresh-spawn as a CLI arg: release/test binaries do not + // accept it, which leaves orphan terminal windows and can trigger auth churn. + let candidates = terminal_candidates("jcode · new session", &[]); launch_first_available_terminal(candidates, "jcode") } diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 7b83e44c4..9fb764a0d 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -2008,25 +2008,26 @@ impl Provider for MultiProvider { let current_model = self.model(); let active = self.active_provider(); - let claude = if matches!(active, ActiveProvider::Claude) && self.claude_provider().is_some() - { - Some(Arc::new(claude::ClaudeProvider::new())) - } else { - None - }; - let anthropic = if self.anthropic_provider().is_some() { - Some(Arc::new(anthropic::AnthropicProvider::new())) - } else { - None - }; - let openai = if self.openai_provider().is_some() { - auth::codex::load_credentials() - .ok() - .map(openai::OpenAIProvider::new) - .map(Arc::new) - } else { - None - }; + // Shallow-clone all provider slots so no re-construction occurs. + // Each read is a cheap RwLock read + Option::clone (atomic increment). + // If a non-active slot is later needed by failover, + // reconcile_auth_if_provider_missing / handle_auth_changed will + // hot-initialize it on demand. + let claude = self + .claude + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let anthropic = self + .anthropic + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let openai = self + .openai + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); let copilot_api = self .copilot_api .read() @@ -2042,31 +2043,21 @@ impl Provider for MultiProvider { .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone(); - let cursor_provider = if self + let cursor_provider = self .cursor .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some() - { - Some(Arc::new(cursor::CursorCliProvider::new())) - } else { - None - }; - let bedrock_provider = if self.bedrock_provider().is_some() { - Some(Arc::new(bedrock::BedrockProvider::new())) - } else { - None - }; - let openrouter = if self + .clone(); + let bedrock_provider = self + .bedrock + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let openrouter = self .openrouter .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some() - { - openrouter::OpenRouterProvider::new().ok().map(Arc::new) - } else { - None - }; + .clone(); let provider = Self { claude: RwLock::new(claude), @@ -2084,8 +2075,6 @@ impl Provider for MultiProvider { forced_provider: self.forced_provider, }; - provider.spawn_anthropic_catalog_refresh_if_needed(); - provider.spawn_openai_catalog_refresh_if_needed(); if matches!(active, ActiveProvider::Copilot) { let _ = provider.set_model(&format!("copilot:{}", current_model)); } else if matches!(active, ActiveProvider::Antigravity) { diff --git a/src/tui/backend.rs b/src/tui/backend.rs index d706d9b38..808e9f99d 100644 --- a/src/tui/backend.rs +++ b/src/tui/backend.rs @@ -834,12 +834,22 @@ impl RemoteConnection { )); continue; } + let trimmed = self.line_buffer.trim_start(); + if !trimmed.starts_with('{') && !trimmed.starts_with('[') { + let preview: String = self.line_buffer.chars().take(240).collect(); + crate::logging::warn(&format!( + "RemoteConnection::next_event: skipping non-json stray line preview={:?} (session_id={:?}, client_instance_id={:?})", + preview, self.session_id, self.client_instance_id + )); + continue; + } match serde_json::from_str(&self.line_buffer) { Ok(event) => return RemoteRead::Event(event), Err(error) => { + let preview: String = self.line_buffer.chars().take(240).collect(); crate::logging::warn(&format!( - "RemoteConnection::next_event: protocol error={} line={:?} (session_id={:?}, client_instance_id={:?})", - error, self.line_buffer, self.session_id, self.client_instance_id + "RemoteConnection::next_event: protocol error={} line_preview={:?} (session_id={:?}, client_instance_id={:?})", + error, preview, self.session_id, self.client_instance_id )); return RemoteRead::Disconnected(RemoteDisconnectReason::Protocol( error.to_string(), From a27312e6c16e17e3bd813aeaadf3cc339b62d267 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 00:11:43 -0230 Subject: [PATCH 19/34] fix: stop passing fresh-spawn to child terminals --- src/cli/tui_launch.rs | 10 ++++++++-- src/tui/app/helpers.rs | 1 - src/tui/app/helpers_tests.rs | 4 ---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/cli/tui_launch.rs b/src/cli/tui_launch.rs index d4ad56c20..059189688 100644 --- a/src/cli/tui_launch.rs +++ b/src/cli/tui_launch.rs @@ -455,7 +455,10 @@ pub fn spawn_resume_in_new_terminal_with_provider( provider_key: Option<&str>, ) -> Result { let title = resumed_window_title(session_id); - let mut args = vec!["--fresh-spawn".to_string()]; + // Fresh-spawn is communicated via JCODE_FRESH_SPAWN by TerminalCommand. + // Do not pass --fresh-spawn as a CLI arg: spawned commands may resolve to + // release/test binaries that do not accept hidden dev-only CLI flags. + let mut args = Vec::new(); if let Some(provider_key) = provider_key.filter(|value| !value.trim().is_empty()) { args.push("--provider".to_string()); args.push(provider_key.to_string()); @@ -484,7 +487,10 @@ pub fn spawn_selfdev_in_new_terminal_with_provider( provider_key: Option<&str>, ) -> Result { let selfdev_title = format!("{} [self-dev]", resumed_window_title(session_id)); - let mut args = vec!["--fresh-spawn".to_string()]; + // Fresh-spawn is communicated via JCODE_FRESH_SPAWN by TerminalCommand. + // Do not pass --fresh-spawn as a CLI arg: spawned commands may resolve to + // release/test binaries that do not accept hidden dev-only CLI flags. + let mut args = Vec::new(); if let Some(provider_key) = provider_key.filter(|value| !value.trim().is_empty()) { args.push("--provider".to_string()); args.push(provider_key.to_string()); diff --git a/src/tui/app/helpers.rs b/src/tui/app/helpers.rs index 0e232656b..8830e6800 100644 --- a/src/tui/app/helpers.rs +++ b/src/tui/app/helpers.rs @@ -374,7 +374,6 @@ pub(super) fn mask_email(email: &str) -> String { /// Returns Ok(true) if a terminal was successfully launched, Ok(false) if no terminal found. fn resume_invocation_args(session_id: &str, socket: Option<&str>) -> Vec { let mut args = vec![ - "--fresh-spawn".to_string(), "--resume".to_string(), session_id.to_string(), ]; diff --git a/src/tui/app/helpers_tests.rs b/src/tui/app/helpers_tests.rs index af86e0506..22e570fa7 100644 --- a/src/tui/app/helpers_tests.rs +++ b/src/tui/app/helpers_tests.rs @@ -96,7 +96,6 @@ fn resume_invocation_args_includes_socket_when_present() { assert_eq!( args, vec![ - "--fresh-spawn".to_string(), "--resume".to_string(), "ses_123".to_string(), "--socket".to_string(), @@ -111,7 +110,6 @@ fn resume_invocation_args_omits_blank_socket() { assert_eq!( args, vec![ - "--fresh-spawn".to_string(), "--resume".to_string(), "ses_123".to_string() ] @@ -135,7 +133,6 @@ fn build_resume_command_uses_imported_jcode_session_for_claude_code() { assert_eq!( args, vec![ - "--fresh-spawn".to_string(), "--resume".to_string(), crate::import::imported_claude_code_session_id("claude-session-123") ] @@ -161,7 +158,6 @@ fn build_resume_command_uses_imported_jcode_session_for_codex() { assert_eq!( args, vec![ - "--fresh-spawn".to_string(), "--resume".to_string(), crate::import::imported_codex_session_id("codex-session-123") ] From e1b8f36ee005ae938341ecf9ee8ecc3b4fa6aef1 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 00:14:28 -0230 Subject: [PATCH 20/34] fix: avoid test binaries for spawned clients --- crates/jcode-build-support/src/paths.rs | 18 +++++++++++++++++- src/server/comm_session.rs | 4 +++- src/tui/app/helpers.rs | 4 +++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/jcode-build-support/src/paths.rs b/crates/jcode-build-support/src/paths.rs index c0085d5b9..1b56d2726 100644 --- a/crates/jcode-build-support/src/paths.rs +++ b/crates/jcode-build-support/src/paths.rs @@ -349,6 +349,18 @@ pub fn update_launcher_symlink_to_stable() -> Result { update_launcher_symlink(&stable) } +/// Check if a path looks like a Cargo test binary. +/// +/// Test binaries live in `target//deps/` and have the test harness as +/// their entry point, meaning they won't recognize jcode CLI flags like +/// `--fresh-spawn` or `--resume`. +fn is_test_binary_path(exe: &Path) -> bool { + exe.parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .is_some_and(|s| s == "deps") +} + /// Resolve which client binary should be considered for launches, updates, and reloads. /// /// Order matters: @@ -383,7 +395,11 @@ pub fn client_update_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'s return Some(stable); } - std::env::current_exe().ok().map(|exe| (exe, "current")) + // Avoid launching cargo test binaries whose entry point is the test harness. + std::env::current_exe() + .ok() + .filter(|exe| !is_test_binary_path(exe)) + .map(|exe| (exe, "current")) } /// Resolve the binary that the shared daemon should spawn or reload into. diff --git a/src/server/comm_session.rs b/src/server/comm_session.rs index 5701b5c17..e45ac03c6 100644 --- a/src/server/comm_session.rs +++ b/src/server/comm_session.rs @@ -91,9 +91,11 @@ fn spawn_visible_session_window( selfdev_requested: bool, provider_key: Option<&str>, ) -> anyhow::Result { + // client_update_candidate prefers published channels, then repo dev binaries, + // then current_exe() (filtering out cargo test binaries). If none found, + // fall through to PATH-resolved "jcode". let exe = crate::build::client_update_candidate(selfdev_requested) .map(|(path, _label)| path) - .or_else(|| std::env::current_exe().ok()) .unwrap_or_else(|| PathBuf::from("jcode")); if selfdev_requested { crate::cli::tui_launch::spawn_selfdev_in_new_terminal_with_provider( diff --git a/src/tui/app/helpers.rs b/src/tui/app/helpers.rs index 8830e6800..aca58a00a 100644 --- a/src/tui/app/helpers.rs +++ b/src/tui/app/helpers.rs @@ -36,9 +36,11 @@ pub(super) fn extract_bracketed_system_message(message: &str) -> Option } pub(super) fn launch_client_executable() -> PathBuf { + // client_update_candidate prefers published channels, then repo dev binaries, + // then current_exe() (filtering out cargo test binaries). If none found, + // fall through to PATH-resolved "jcode". crate::build::client_update_candidate(crate::cli::selfdev::client_selfdev_requested()) .map(|(path, _label)| path) - .or_else(|| std::env::current_exe().ok()) .unwrap_or_else(|| PathBuf::from("jcode")) } From ba95303ffcb2be1316339a47e827a24bcda04aa0 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 00:15:54 -0230 Subject: [PATCH 21/34] fix: avoid test binaries for server spawn --- crates/jcode-build-support/src/paths.rs | 6 ++++-- src/cli/dispatch.rs | 11 ++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/jcode-build-support/src/paths.rs b/crates/jcode-build-support/src/paths.rs index 1b56d2726..4cf1f5ee1 100644 --- a/crates/jcode-build-support/src/paths.rs +++ b/crates/jcode-build-support/src/paths.rs @@ -423,9 +423,11 @@ pub fn shared_server_update_candidate( return Some(stable); } - std::env::current_exe().ok().map(|exe| (exe, "current")) + std::env::current_exe() + .ok() + .filter(|exe| !is_test_binary_path(exe)) + .map(|exe| (exe, "current")) } - /// Resolve the best binary to use for `/reload`. /// /// This mostly follows `client_update_candidate`, but if a freshly built repo diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 8065f4fd6..9b2d8b5e5 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -750,7 +750,16 @@ pub(crate) async fn spawn_server( let client_requested_selfdev = selfdev::client_selfdev_requested(); let exe = build::shared_server_update_candidate(client_requested_selfdev) .map(|(path, _)| path) - .or_else(|| std::env::current_exe().ok()) + .or_else(|| { + // Fall back to current_exe(), but skip cargo test binaries + // (they have a test harness entry point, not jcode's main). + let exe = std::env::current_exe().ok()?; + if exe.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str()) == Some("deps") { + None + } else { + Some(exe) + } + }) .ok_or_else(|| anyhow::anyhow!("Could not determine executable path for server spawn"))?; let mut cmd = ProcessCommand::new(&exe); cmd.env_remove(selfdev::CLIENT_SELFDEV_ENV); From e6802bfdb3a682fc4f71ac453c432df96a1cd1dd Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 00:18:45 -0230 Subject: [PATCH 22/34] fix: keep slash autocomplete off disk hot path --- src/tui/app/state_ui_input_helpers.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/tui/app/state_ui_input_helpers.rs b/src/tui/app/state_ui_input_helpers.rs index 2a00b28d0..d557f92ec 100644 --- a/src/tui/app/state_ui_input_helpers.rs +++ b/src/tui/app/state_ui_input_helpers.rs @@ -277,19 +277,6 @@ impl App { let skills = self.current_skills_snapshot(); push_skill_commands(&mut commands, &mut seen, &skills); - // Remote/minimal TUI clients can start with an empty local skill registry, - // while direct slash invocation reloads on miss. Mirror that behavior for - // autocomplete so project-local skills like `/optimization` are suggested - // before the user has activated them once. - let working_dir = self - .session - .working_dir - .as_deref() - .map(std::path::Path::new); - if let Ok(reloaded) = crate::skill::SkillRegistry::load_for_working_dir(working_dir) { - push_skill_commands(&mut commands, &mut seen, &reloaded); - } - commands } From 0760417e642c213b909ff449014c57a61918d87f Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 00:19:48 -0230 Subject: [PATCH 23/34] fix: throttle static status notice redraws --- src/tui/mod.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index bcfc80c0a..b49f135f0 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1072,7 +1072,6 @@ pub(crate) fn redraw_interval_with_policy( if state.is_processing() || !state.streaming_text().is_empty() - || state.status_notice().is_some() || state.has_pending_mouse_scroll_animation() || state.has_notification() || state.rate_limit_remaining().is_some() @@ -1083,6 +1082,14 @@ pub(crate) fn redraw_interval_with_policy( }; } + // Status notices are static text with a short TTL. They need periodic + // redraws so they disappear, but they should not drive the high-frequency + // render loop over long transcripts while the user is just typing a slash + // command. + if state.status_notice().is_some() { + return REDRAW_PASSIVE_LIVENESS; + } + if state.remote_startup_phase_active() { return REDRAW_REMOTE_STARTUP; } From 9165fe2bb5d9f9a3f446f4748e8cb512feb9d5e1 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 01:08:46 -0230 Subject: [PATCH 24/34] Elide and cache large tool outputs --- src/agent.rs | 4 +- src/agent/tools.rs | 101 +++++++++++++++++++++++++- src/agent/turn_streaming_broadcast.rs | 7 +- src/agent/turn_streaming_mpsc.rs | 7 +- src/agent_tests.rs | 37 ++++++++++ 5 files changed, 150 insertions(+), 6 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index ce1c2e76e..e799c5dbd 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -19,7 +19,9 @@ mod utils; use self::streaming::{ send_stream_keepalive_broadcast, send_stream_keepalive_mpsc, stream_keepalive_ticker, }; -use self::tools::{print_tool_summary, tool_output_to_content_blocks}; +use self::tools::{ + maybe_elide_and_cache_tool_output, print_tool_summary, tool_output_to_content_blocks, +}; use self::utils::trace_enabled; use crate::build; use crate::bus::{Bus, BusEvent, SubagentStatus, ToolEvent, ToolStatus}; diff --git a/src/agent/tools.rs b/src/agent/tools.rs index de4baa60b..793287965 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -1,13 +1,21 @@ use crate::message::{ContentBlock, ToolCall}; use crate::tool::ToolOutput; +use chrono::Utc; +use std::fs; +use std::path::PathBuf; + +const TOOL_OUTPUT_ELIDE_THRESHOLD_TOKENS: usize = 500; +const TOOL_OUTPUT_ELIDE_HEAD_TOKENS: usize = 250; +const TOOL_OUTPUT_ELIDE_TAIL_TOKENS: usize = 250; pub(super) fn tool_output_to_content_blocks( tool_use_id: String, output: ToolOutput, ) -> Vec { + let content = maybe_elide_and_cache_tool_output(&tool_use_id, output.output); let mut blocks = vec![ContentBlock::ToolResult { tool_use_id, - content: output.output, + content, is_error: None, }]; for img in output.images { @@ -28,6 +36,97 @@ pub(super) fn tool_output_to_content_blocks( blocks } +pub(super) fn maybe_elide_and_cache_tool_output(tool_use_id: &str, output: String) -> String { + if output.contains("[... elided ") && output.contains("jcode-tool-output-cache") { + return output; + } + + let tokens: Vec<&str> = output.split_whitespace().collect(); + if tokens.len() <= TOOL_OUTPUT_ELIDE_THRESHOLD_TOKENS { + return output; + } + + match cache_full_tool_output(tool_use_id, &output) { + Ok(path) => { + let head = tokens + .iter() + .take(TOOL_OUTPUT_ELIDE_HEAD_TOKENS) + .copied() + .collect::>() + .join(" "); + let tail = tokens + .iter() + .rev() + .take(TOOL_OUTPUT_ELIDE_TAIL_TOKENS) + .copied() + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" "); + let omitted = tokens + .len() + .saturating_sub(TOOL_OUTPUT_ELIDE_HEAD_TOKENS + TOOL_OUTPUT_ELIDE_TAIL_TOKENS); + format!( + "{}\n\n[... elided {} middle tokens from tool output; full output cached at {} ...]\n\n{}", + head, + omitted, + path.display(), + tail + ) + } + Err(err) => { + let head = tokens + .iter() + .take(TOOL_OUTPUT_ELIDE_HEAD_TOKENS) + .copied() + .collect::>() + .join(" "); + let tail = tokens + .iter() + .rev() + .take(TOOL_OUTPUT_ELIDE_TAIL_TOKENS) + .copied() + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" "); + format!( + "{}\n\n[... elided middle of large tool output; failed to cache full output: {} ...]\n\n{}", + head, err, tail + ) + } + } +} + +fn cache_full_tool_output(tool_use_id: &str, output: &str) -> std::io::Result { + let now = Utc::now(); + let base = std::env::temp_dir() + .join("jcode-tool-output-cache") + .join(now.format("%Y-%m-%d").to_string()) + .join(now.format("%H").to_string()); + fs::create_dir_all(&base)?; + let safe_id: String = tool_use_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + let file = base.join(format!( + "{}-{}-{}.txt", + now.format("%Y%m%dT%H%M%S%.3fZ"), + std::process::id(), + safe_id + )); + fs::write(&file, output)?; + Ok(file) +} + pub(super) fn print_tool_summary(tool: &ToolCall) { match tool.name.as_str() { "bash" => { diff --git a/src/agent/turn_streaming_broadcast.rs b/src/agent/turn_streaming_broadcast.rs index a257cef8b..290d40b19 100644 --- a/src/agent/turn_streaming_broadcast.rs +++ b/src/agent/turn_streaming_broadcast.rs @@ -806,11 +806,14 @@ impl Agent { )); match result { - Ok(output) => { + Ok(mut output) => { + let output_text = + maybe_elide_and_cache_tool_output(&tc.id, output.output.clone()); + output.output = output_text.clone(); let _ = event_tx.send(ServerEvent::ToolDone { id: tc.id.clone(), name: tc.name.clone(), - output: output.output.clone(), + output: output_text, error: None, }); diff --git a/src/agent/turn_streaming_mpsc.rs b/src/agent/turn_streaming_mpsc.rs index c0841606e..2c98c97c6 100644 --- a/src/agent/turn_streaming_mpsc.rs +++ b/src/agent/turn_streaming_mpsc.rs @@ -888,11 +888,14 @@ impl Agent { )); match result { - Ok(output) => { + Ok(mut output) => { + let output_text = + maybe_elide_and_cache_tool_output(&tc.id, output.output.clone()); + output.output = output_text.clone(); let _ = event_tx.send(ServerEvent::ToolDone { id: tc.id.clone(), name: tc.name.clone(), - output: output.output.clone(), + output: output_text, error: None, }); diff --git a/src/agent_tests.rs b/src/agent_tests.rs index d8d333b50..a0b6f325e 100644 --- a/src/agent_tests.rs +++ b/src/agent_tests.rs @@ -134,6 +134,43 @@ fn tool_output_to_content_blocks_preserves_labeled_images() { } } +#[test] +fn tool_output_to_content_blocks_elides_and_caches_large_text_output() { + let full_output = (0..650) + .map(|i| format!("tok{i}")) + .collect::>() + .join(" "); + + let blocks = tool_output_to_content_blocks( + "call_large/output".to_string(), + ToolOutput::new(full_output.clone()), + ); + assert_eq!(blocks.len(), 1); + + let content = match &blocks[0] { + ContentBlock::ToolResult { content, .. } => content, + other => panic!("expected tool result, got {other:?}"), + }; + + assert!(content.contains("tok0")); + assert!(content.contains("tok249")); + assert!(content.contains("tok400")); + assert!(content.contains("tok649")); + assert!(content.contains("elided 150 middle tokens")); + assert!(content.contains("jcode-tool-output-cache")); + assert!(!content.contains("tok250 tok251 tok252")); + + let marker = "full output cached at "; + let path_start = content.find(marker).expect("cache marker") + marker.len(); + let path_end = content[path_start..] + .find(" ...]") + .expect("cache marker end") + + path_start; + let cached_path = &content[path_start..path_end]; + let cached = std::fs::read_to_string(cached_path).expect("read cached full tool output"); + assert_eq!(cached, full_output); +} + #[tokio::test] async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() { let _guard = crate::storage::lock_test_env(); From c94be7905c46e1a7177e752bb9e91df2f35f5074 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 01:10:52 -0230 Subject: [PATCH 25/34] Run scoped hygiene checks after code edits --- src/tool/apply_patch.rs | 8 +- src/tool/code_hygiene.rs | 211 +++++++++++++++++++++++++++++++++++++++ src/tool/edit.rs | 7 +- src/tool/mod.rs | 1 + src/tool/multiedit.rs | 2 + src/tool/write.rs | 12 ++- 6 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 src/tool/code_hygiene.rs diff --git a/src/tool/apply_patch.rs b/src/tool/apply_patch.rs index 1f89a9d11..9de8d528d 100644 --- a/src/tool/apply_patch.rs +++ b/src/tool/apply_patch.rs @@ -1,5 +1,6 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use crate::tool::code_hygiene::run_post_edit_hygiene_for_paths; use anyhow::Result; use async_trait::async_trait; use serde::Deserialize; @@ -221,7 +222,12 @@ impl Tool for ApplyPatchTool { if results.is_empty() { Ok(ToolOutput::new("No changes applied")) } else { - let output = ToolOutput::new(results.join("\n")); + let resolved_touched_paths = touched_paths + .iter() + .map(|path| ctx.resolve_path(Path::new(path))) + .collect::>(); + let hygiene = run_post_edit_hygiene_for_paths(&ctx, &resolved_touched_paths).await; + let output = ToolOutput::new(format!("{}{}", results.join("\n"), hygiene)); if touched_paths.len() == 1 { Ok(output.with_title(touched_paths[0].clone())) } else { diff --git a/src/tool/code_hygiene.rs b/src/tool/code_hygiene.rs new file mode 100644 index 000000000..7d06b5a8f --- /dev/null +++ b/src/tool/code_hygiene.rs @@ -0,0 +1,211 @@ +use crate::tool::ToolContext; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; +use tokio::time::timeout; + +const POST_EDIT_HOOK_TIMEOUT: Duration = Duration::from_secs(20); + +pub(crate) async fn run_post_edit_hygiene_for_paths( + ctx: &ToolContext, + paths: &[PathBuf], +) -> String { + if std::env::var("JCODE_POST_EDIT_HOOKS") + .ok() + .is_some_and(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" + ) + }) + { + return String::new(); + } + + let mut reports = Vec::new(); + for path in paths { + if !path.is_file() || !looks_like_code_file(path) { + continue; + } + if let Some(report) = run_post_edit_hygiene_for_path(ctx, path).await { + reports.push(report); + } + } + + if reports.is_empty() { + String::new() + } else { + format!("\n\nPost-edit hygiene:\n{}", reports.join("\n")) + } +} + +fn looks_like_code_file(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some( + "rs" | "ts" + | "tsx" + | "js" + | "jsx" + | "mjs" + | "cjs" + | "py" + | "go" + | "json" + | "css" + | "scss" + | "html" + | "md" + | "yaml" + | "yml" + ) + ) +} + +async fn run_post_edit_hygiene_for_path(ctx: &ToolContext, path: &Path) -> Option { + let cwd = ctx + .working_dir + .clone() + .or_else(|| path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from(".")); + let display = path + .strip_prefix(&cwd) + .unwrap_or(path) + .display() + .to_string(); + let ext = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default(); + + let mut steps: Vec<(&str, Vec)> = Vec::new(); + match ext { + "rs" => { + steps.push(("format", vec!["rustfmt".into(), path.display().to_string()])); + if nearest_named_file(path, "Cargo.toml").is_some() { + steps.push(( + "typecheck", + vec!["cargo".into(), "check".into(), "-q".into()], + )); + } + } + "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "json" | "css" | "scss" | "html" | "md" + | "yaml" | "yml" => { + if nearest_named_file(path, "package.json").is_some() { + steps.push(( + "format", + vec![ + "npx".into(), + "--yes".into(), + "prettier".into(), + "--write".into(), + path.display().to_string(), + ], + )); + steps.push(( + "lint", + vec![ + "npx".into(), + "--yes".into(), + "eslint".into(), + path.display().to_string(), + ], + )); + } + } + "py" => { + steps.push(( + "format", + vec![ + "python3".into(), + "-m".into(), + "black".into(), + path.display().to_string(), + ], + )); + steps.push(( + "lint", + vec![ + "python3".into(), + "-m".into(), + "ruff".into(), + "check".into(), + path.display().to_string(), + ], + )); + } + "go" => { + steps.push(( + "format", + vec!["gofmt".into(), "-w".into(), path.display().to_string()], + )); + if nearest_named_file(path, "go.mod").is_some() { + steps.push(( + "typecheck", + vec!["go".into(), "test".into(), "./...".into()], + )); + } + } + _ => {} + } + + if steps.is_empty() { + return None; + } + + let mut outcomes = Vec::new(); + for (label, command) in steps { + let outcome = run_command(&cwd, command).await; + outcomes.push(format!("{} {}", label, outcome)); + } + + Some(format!("- `{}`: {}", display, outcomes.join("; "))) +} + +async fn run_command(cwd: &Path, command: Vec) -> String { + let rendered = command.join(" "); + let Some((program, args)) = command.split_first() else { + return "skipped: empty command".to_string(); + }; + + let mut cmd = Command::new(program); + cmd.args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + match timeout(POST_EDIT_HOOK_TIMEOUT, cmd.output()).await { + Ok(Ok(output)) if output.status.success() => format!("✓ `{}`", rendered), + Ok(Ok(output)) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = first_nonempty_line(&stderr) + .or_else(|| first_nonempty_line(&stdout)) + .unwrap_or("no output") + .to_string(); + format!("✗ `{}` ({})", rendered, details) + } + Ok(Err(err)) => format!("skipped `{}` ({})", rendered, err), + Err(_) => format!( + "timed out `{}` after {}s", + rendered, + POST_EDIT_HOOK_TIMEOUT.as_secs() + ), + } +} + +fn first_nonempty_line(text: &str) -> Option<&str> { + text.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn nearest_named_file(path: &Path, name: &str) -> Option { + for ancestor in path.ancestors() { + let candidate = ancestor.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} diff --git a/src/tool/edit.rs b/src/tool/edit.rs index 17a235b13..3e9f269c1 100644 --- a/src/tool/edit.rs +++ b/src/tool/edit.rs @@ -1,5 +1,6 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use crate::tool::code_hygiene::run_post_edit_hygiene_for_paths; use anyhow::Result; use async_trait::async_trait; use serde::Deserialize; @@ -140,9 +141,11 @@ impl Tool for EditTool { let end_line = start_line + params.new_string.lines().count().saturating_sub(1); let context = extract_context(&new_content, start_line, end_line, 3); + let hygiene = run_post_edit_hygiene_for_paths(&ctx, &[path.to_path_buf()]).await; + Ok(ToolOutput::new(format!( - "Edited {}: replaced {} occurrence(s)\n{}\n\nContext after edit (lines {}-{}):\n{}", - params.file_path, occurrences, diff, context.0, context.1, context.2 + "Edited {}: replaced {} occurrence(s)\n{}\n\nContext after edit (lines {}-{}):\n{}{}", + params.file_path, occurrences, diff, context.0, context.1, context.2, hygiene )) .with_title(params.file_path.clone())) } diff --git a/src/tool/mod.rs b/src/tool/mod.rs index e0522651b..ff5f897f9 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -6,6 +6,7 @@ mod bash; mod batch; mod bg; mod browser; +mod code_hygiene; mod codesearch; mod communicate; mod conversation_search; diff --git a/src/tool/multiedit.rs b/src/tool/multiedit.rs index 7d856f988..c89ea9c44 100644 --- a/src/tool/multiedit.rs +++ b/src/tool/multiedit.rs @@ -1,4 +1,5 @@ use super::{Tool, ToolContext, ToolOutput}; +use crate::tool::code_hygiene::run_post_edit_hygiene_for_paths; use anyhow::Result; use async_trait::async_trait; use serde::Deserialize; @@ -155,6 +156,7 @@ impl Tool for MultiEditTool { if !applied.is_empty() { output.push_str("\nDiff:\n"); output.push_str(&generate_diff_summary(&original_content, &content)); + output.push_str(&run_post_edit_hygiene_for_paths(&ctx, &[path.to_path_buf()]).await); } Ok(ToolOutput::new(output).with_title(params.file_path.clone())) diff --git a/src/tool/write.rs b/src/tool/write.rs index 223b09868..1c71dfb0f 100644 --- a/src/tool/write.rs +++ b/src/tool/write.rs @@ -1,5 +1,6 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use crate::tool::code_hygiene::run_post_edit_hygiene_for_paths; use anyhow::Result; use async_trait::async_trait; use serde::Deserialize; @@ -103,21 +104,24 @@ impl Tool for WriteTool { detail, })); + let hygiene = run_post_edit_hygiene_for_paths(&ctx, &[path.to_path_buf()]).await; + if existed { Ok(ToolOutput::new(format!( - "Updated {} ({} lines){}\n{}", + "Updated {} ({} lines){}\n{}{}", params.file_path, line_count, if diff.is_empty() { "" } else { ":" }, - diff + diff, + hygiene )) .with_title(params.file_path.clone())) } else { // For new files, show all lines as additions let diff = generate_diff_summary("", ¶ms.content); Ok(ToolOutput::new(format!( - "Created {} ({} lines):\n{}", - params.file_path, line_count, diff + "Created {} ({} lines):\n{}{}", + params.file_path, line_count, diff, hygiene )) .with_title(params.file_path.clone())) } From af6aac82f13fc1031414421b662c4baf2779857f Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 02:13:21 -0230 Subject: [PATCH 26/34] =?UTF-8?q?feat:=20agent=20DB=20substrate=20first=20?= =?UTF-8?q?slice=20=E2=80=94=20docker-compose=20+=20db-execute=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.agent-db.yml: postgres:16-alpine on port 5432, well-known localhost creds - db-execute tool: agents execute scoped SQL via docker exec psql into their session schema - Schema-per-session isolation (agent_ convention) - Session-id sanitization: lowercase alphanumeric with leading-letter guarantee - Compiles clean, passes end-to-end smoke test with CRUD + cross-schema namespace isolation Part of goal: autonomous-local-neon-postgres-analytics-substrate-for-agents --- docker-compose.agent-db.yml | 26 +++++++ src/tool/db_execute.rs | 146 ++++++++++++++++++++++++++++++++++++ src/tool/mod.rs | 7 ++ 3 files changed, 179 insertions(+) create mode 100644 docker-compose.agent-db.yml create mode 100644 src/tool/db_execute.rs diff --git a/docker-compose.agent-db.yml b/docker-compose.agent-db.yml new file mode 100644 index 000000000..2436b2dde --- /dev/null +++ b/docker-compose.agent-db.yml @@ -0,0 +1,26 @@ +# Agent DB Substrate — local Postgres for agent analytics +# Well-known credentials; only accessible from localhost. +# Start: docker compose -f docker-compose.agent-db.yml up -d +# Stop: docker compose -f docker-compose.agent-db.yml down -v + +services: + postgres: + image: postgres:16-alpine + container_name: jcode-agent-db + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_USER: jcode_agent + POSTGRES_PASSWORD: jcode_agent_local + POSTGRES_DB: jcode_agent_workspace + volumes: + - jcode_agent_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U jcode_agent -d jcode_agent_workspace"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + jcode_agent_db_data: diff --git a/src/tool/db_execute.rs b/src/tool/db_execute.rs new file mode 100644 index 000000000..cc342b05b --- /dev/null +++ b/src/tool/db_execute.rs @@ -0,0 +1,146 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::process::Stdio; +use tokio::process::Command as TokioCommand; + +const DB_EXECUTE_DESCRIPTION: &str = "Execute a SQL statement against the agent's local Postgres database. The statement is scoped to the agent's own schema. Use for CREATE TABLE, INSERT, UPDATE, DELETE, SELECT, DROP TABLE, etc. For queries that may return large results, limit with SQL clauses."; + +pub struct DbExecuteTool; + +impl DbExecuteTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct DbExecuteInput { + sql: String, +} + +/// Build a db-execute tool that scopes SQL to the agent's session schema. +/// The container and credentials are well-known localhost defaults. +fn agent_schema_name(session_id: &str) -> String { + // Sanitize: schema names must start with a letter or underscore, + // contain only lowercase letters, digits, and underscores, and be <= 63 chars. + let sanitized: String = session_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect(); + // Ensure it starts with a letter + let prefixed = if sanitized.starts_with(|c: char| c.is_ascii_alphabetic()) { + sanitized + } else { + format!("a_{}", sanitized) + }; + // Truncate to 30 chars, then add "agent_" prefix (fits within 63-char limit) + let short: String = prefixed.chars().take(30).collect(); + format!("agent_{}", short) +} + +fn provision_schema_sql(schema: &str) -> String { + format!( + "CREATE SCHEMA IF NOT EXISTS {schema};\nSET search_path TO {schema};" + ) +} + +#[async_trait] +impl Tool for DbExecuteTool { + fn name(&self) -> &str { + "db-execute" + } + + fn description(&self) -> &str { + DB_EXECUTE_DESCRIPTION + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["sql"], + "properties": { + "intent": super::intent_schema_property(), + "sql": { + "type": "string", + "description": "SQL statement to execute. Scoped to agent's schema." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let params: DbExecuteInput = serde_json::from_value(input)?; + let schema = agent_schema_name(&ctx.session_id); + + let full_sql = format!( + "{}\n{}", + provision_schema_sql(&schema), + params.sql.trim() + ); + + let result = run_psql(&full_sql).await?; + Ok(ToolOutput::new(result)) + } +} + +async fn run_psql(sql: &str) -> Result { + let mut child = TokioCommand::new("docker") + .args([ + "exec", + "-i", + "jcode-agent-db", + "psql", + "-U", + "jcode_agent", + "-d", + "jcode_agent_workspace", + "-v", + "ON_ERROR_STOP=1", + "-A", // unaligned output + "-t", // tuples only (no headers) + "-q", // quiet + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + // Write SQL to stdin + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + stdin.write_all(sql.as_bytes()).await?; + stdin.write_all(b"\n").await?; + // stdin is dropped here, closing the pipe + } + + let output = child.wait_with_output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if output.status.success() { + if stdout.is_empty() && stderr.is_empty() { + Ok("OK".to_string()) + } else if stdout.is_empty() { + Ok(stderr) + } else { + Ok(stdout) + } + } else { + Err(anyhow::anyhow!( + "psql error (exit {}): {}\n{}", + output.status.code().unwrap_or(-1), + stderr, + stdout + )) + } +} diff --git a/src/tool/mod.rs b/src/tool/mod.rs index ff5f897f9..d345975fb 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -9,6 +9,7 @@ mod browser; mod code_hygiene; mod codesearch; mod communicate; +mod db_execute; mod conversation_search; mod debug_socket; mod edit; @@ -201,6 +202,12 @@ impl Registry { Self::insert_tool_timed(&mut m, &mut timings, "gmail", gmail::GmailTool::new); Self::insert_tool_timed(&mut m, &mut timings, "schedule", ambient::ScheduleTool::new); Self::insert_tool_timed(&mut m, &mut timings, "selfdev", selfdev::SelfDevTool::new); + Self::insert_tool_timed( + &mut m, + &mut timings, + "db-execute", + db_execute::DbExecuteTool::new, + ); let nonzero: Vec = timings .iter() .filter(|(_, ms)| *ms > 0) From 07431024e0a07ace10793f9e83cacc490d7211eb Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 02:23:03 -0230 Subject: [PATCH 27/34] chore: fmt sweep + AGENTS.md source-of-truth docs --- AGENTS.md | 3 ++- src/cli/dispatch.rs | 7 ++++++- src/tui/app/helpers.rs | 5 +---- src/tui/app/helpers_tests.rs | 8 +------- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 82a2b6036..98870e36a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## Development Workflow +- **GitHub is the durable source of truth** - For non-trivial repo work, use GitHub Issues, GitHub Projects, and repo docs as the canonical record of scope, acceptance criteria, blockers, decisions, and status. Keep issues/project fields/linked PRs updated religiously; do not rely on ephemeral chat or local notes as the final state. - **Commit as you go** - Make small, focused commits after completing each feature or fix - If the git state is not clean, or there are other agents working in the codebase in parallel, do your best to still commit your work. - **Push when done** - Push all commits to remote when finishing a task or session @@ -9,6 +10,7 @@ - **Rebuild when done** - When you are done making changes, build the source. - **Bump version for releases** - Update version in `Cargo.toml` when making releases. When cutting a new release, look at all the changes that happened since the last release and determine what the version bump should be ie patch or minor, etc. - **Remote builds available** - Use `scripts/remote_build.sh` to offload heavy cargo work to another machine. If your build is terminated, likely is because there are not enough resources on this machine to build. use remote build in that case. Try checking the resource avaliablity on the machine before you run a build. +- **Protect context aggressively** - Every token is gold. Keep main-thread output to checkpoint decisions and concise evidence; route noisy discovery/raw logs to background tasks, subagents, files, side panels, or cached tool outputs. - **Delegate substantial independent work** - Prefer subagents/child sessions for parallelizable discovery, deep investigation, or long-running validation. - **Use one-shot mechanical subagents** - For mechanical validation, status, and publishing work, such as long cargo validations, git status/diff summaries, PR/issue creation checks, and final repo audits, use no-context one-shot subagents and return only compact results to the main session. - **Background means non-UI** - Run background work in non-interactive, non-focus-stealing jobs. Do not spawn headed terminals or steal window focus unless the user explicitly asks. @@ -27,4 +29,3 @@ - `~/.jcode/builds/canary/jcode` still exists for canary/testing flows, but it is not the primary self-dev install path. - On Windows, the equivalents are `%LOCALAPPDATA%\\jcode\\bin\\jcode.exe` for the launcher, `%LOCALAPPDATA%\\jcode\\builds\\stable\\jcode.exe` for stable, and `%LOCALAPPDATA%\\jcode\\builds\\versions\\\\jcode.exe` for immutable installs; `scripts/install.ps1` currently installs the stable channel. - Ensure `~/.local/bin` is **before** `~/.cargo/bin` in `PATH`. - diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 9b2d8b5e5..1b03f9916 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -754,7 +754,12 @@ pub(crate) async fn spawn_server( // Fall back to current_exe(), but skip cargo test binaries // (they have a test harness entry point, not jcode's main). let exe = std::env::current_exe().ok()?; - if exe.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str()) == Some("deps") { + if exe + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + == Some("deps") + { None } else { Some(exe) diff --git a/src/tui/app/helpers.rs b/src/tui/app/helpers.rs index aca58a00a..a1d2aa7e0 100644 --- a/src/tui/app/helpers.rs +++ b/src/tui/app/helpers.rs @@ -375,10 +375,7 @@ pub(super) fn mask_email(email: &str) -> String { /// Spawn a new terminal window that resumes a jcode session. /// Returns Ok(true) if a terminal was successfully launched, Ok(false) if no terminal found. fn resume_invocation_args(session_id: &str, socket: Option<&str>) -> Vec { - let mut args = vec![ - "--resume".to_string(), - session_id.to_string(), - ]; + let mut args = vec!["--resume".to_string(), session_id.to_string()]; if let Some(socket) = socket.filter(|s| !s.trim().is_empty()) { args.push("--socket".to_string()); args.push(socket.to_string()); diff --git a/src/tui/app/helpers_tests.rs b/src/tui/app/helpers_tests.rs index 22e570fa7..22875fd3a 100644 --- a/src/tui/app/helpers_tests.rs +++ b/src/tui/app/helpers_tests.rs @@ -107,13 +107,7 @@ fn resume_invocation_args_includes_socket_when_present() { #[test] fn resume_invocation_args_omits_blank_socket() { let args = resume_invocation_args("ses_123", Some(" ")); - assert_eq!( - args, - vec![ - "--resume".to_string(), - "ses_123".to_string() - ] - ); + assert_eq!(args, vec!["--resume".to_string(), "ses_123".to_string()]); } #[test] From 7ac0593f68c482637306530ae22a1ee4bee7c54b Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 12:16:42 -0230 Subject: [PATCH 28/34] Respect enabled providers in model route picker --- src/provider/mod.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 9fb764a0d..eaa35583c 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -1169,10 +1169,13 @@ impl Provider for MultiProvider { .iter() .copied() { + let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile); + if !crate::provider_catalog::provider_is_enabled(&resolved.id) { + continue; + } if !crate::provider_catalog::openai_compatible_profile_is_configured(profile) { continue; } - let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile); let api_method = format!("openai-compatible:{}", resolved.id); let mut profile_models = crate::provider_catalog::openai_compatible_profile_static_models(profile); @@ -1286,7 +1289,7 @@ impl Provider for MultiProvider { } // GitHub Copilot models - { + if crate::provider_catalog::provider_is_enabled("copilot") { if let Some(copilot) = self.copilot_provider() { let copilot_models = copilot.available_models_display(); let detail = copilot.model_catalog_detail(); @@ -1523,7 +1526,10 @@ impl Provider for MultiProvider { )); } - dedupe_model_routes(routes) + crate::provider_catalog::filter_model_routes_by_allowlist( + "multi-provider", + dedupe_model_routes(routes), + ) } async fn prefetch_models(&self) -> Result<()> { From f8005c739b138a323e6ab4532e2783882cfcd82c Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 23:18:25 -0230 Subject: [PATCH 29/34] fix: restore remote model catalog routing --- crates/jcode-config-types/src/lib.rs | 5 +- crates/jcode-provider-metadata/src/lib.rs | 2 +- src/provider_catalog.rs | 21 ++- src/provider_catalog_tests.rs | 6 +- src/server.rs | 3 + src/server/client_state.rs | 8 +- src/server/config_reload.rs | 151 ++++++++++++++++ src/tui/app/inline_interactive.rs | 165 ++++++++++++++++-- .../tests/remote_startup_input_01/part_01.rs | 110 ++++++++++-- 9 files changed, 424 insertions(+), 47 deletions(-) create mode 100644 src/server/config_reload.rs diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 6c4dfb75f..570283bee 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -623,8 +623,9 @@ pub struct ProviderConfig { /// (e.g. "anthropic", "openai", "gemini", "antigravity") to a list of /// allowed model identifiers. When a provider has a non-empty entry, /// the model picker and `/model` command only expose the listed models - /// (substring match, case-insensitive). Providers absent from this map - /// or with an empty list are unrestricted. + /// (substring match, case-insensitive). Prefix a pattern with `=` for an + /// exact match. Providers absent from this map or with an empty list are + /// unrestricted. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub model_allowlist: BTreeMap>, } diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index f72e50c59..fd6e960ee 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -157,7 +157,7 @@ pub const OPENCODE_GO_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile api_key_env: "OPENCODE_GO_API_KEY", env_file: "opencode-go.env", setup_url: "https://opencode.ai/docs/providers#opencode-go", - default_model: Some("kimi-k2.5"), + default_model: Some("deepseek-v4-pro"), requires_api_key: true, }; diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index 3bdab0e21..d7011aca1 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -377,9 +377,13 @@ fn allowlist_key_for_route(route: &crate::provider::ModelRoute) -> Option bool { - lower_patterns - .iter() - .any(|pattern| model_lower == pattern || model_lower.contains(pattern)) + lower_patterns.iter().any(|pattern| { + if let Some(exact) = pattern.strip_prefix('=') { + model_lower == exact + } else { + model_lower == pattern || model_lower.contains(pattern) + } + }) } pub fn openai_compatible_profile_by_id(id: &str) -> Option { @@ -438,6 +442,7 @@ pub fn openai_compatible_profile_static_models(profile: OpenAiCompatibleProfile) push("kimi-k2.5"); push("glm-5"); push("glm-5.1"); + push("deepseek-v4-pro"); push("deepseek-v4-flash"); push("qwen3.5-plus"); } @@ -629,10 +634,12 @@ pub fn openai_compatible_profile_context_limit(profile_id: &str, model: &str) -> let model = model.trim().to_ascii_lowercase(); match profile_id.as_str() { - // DeepSeek V4 direct API models advertise a 1M token context window. The - // direct profile runs through the OpenRouter/OpenAI-compatible provider - // implementation, whose live catalog can be unavailable during startup. - "deepseek" if model.starts_with("deepseek-v4-") => Some(1_000_000), + // DeepSeek V4 models advertise a 1M token context window. These + // providers run through the OpenRouter/OpenAI-compatible implementation, + // whose live catalog can be unavailable during startup. + "deepseek" | "opencode-go" | "ollama-cloud" if model.starts_with("deepseek-v4-") => { + Some(1_000_000) + } _ => None, } } diff --git a/src/provider_catalog_tests.rs b/src/provider_catalog_tests.rs index f49f1a5b0..b76eb686e 100644 --- a/src/provider_catalog_tests.rs +++ b/src/provider_catalog_tests.rs @@ -160,8 +160,8 @@ fn provider_enabled_and_model_allowlists_filter_cross_provider_routes() { enabled_providers = ["openai", "ollama-cloud"] [provider.model_allowlist] -openai = ["gpt-5.5"] -ollama-cloud = ["deepseek-v4-pro"] +openai = ["=gpt-5.5"] +ollama-cloud = ["=deepseek-v4-pro"] "#, ) .expect("write config"); @@ -189,7 +189,7 @@ ollama-cloud = ["deepseek-v4-pro"] cheapness: None, }, crate::provider::ModelRoute { - model: "gpt-5.4".to_string(), + model: "gpt-5.5-mini".to_string(), provider: "OpenAI".to_string(), api_method: "openai-oauth".to_string(), available: true, diff --git a/src/server.rs b/src/server.rs index 6cef6547e..27ffefe84 100644 --- a/src/server.rs +++ b/src/server.rs @@ -15,6 +15,7 @@ mod comm_control; mod comm_plan; mod comm_session; mod comm_sync; +mod config_reload; mod debug; mod debug_ambient; mod debug_command_exec; @@ -913,6 +914,8 @@ impl Server { .await; }); + config_reload::spawn_config_reload_monitor(); + // Log when we receive SIGTERM for debugging #[cfg(unix)] { diff --git a/src/server/client_state.rs b/src/server/client_state.rs index f8c7ea4aa..d69732387 100644 --- a/src/server/client_state.rs +++ b/src/server/client_state.rs @@ -679,15 +679,17 @@ async fn write_event(writer: &Arc>, event: &ServerEvent) -> Res pub(super) fn spawn_model_prefetch_update(provider: Arc, agent: Arc>) { tokio::spawn(async move { - let (provider_name, initial_models) = { + let (provider_name, initial_models, initial_routes) = { let agent_guard = agent.lock().await; ( agent_guard.provider_name(), agent_guard.available_models_display(), + agent_guard.model_routes(), ) }; - if !initial_models.is_empty() { + if !initial_routes.is_empty() { + Bus::global().publish_models_updated(); return; } @@ -711,7 +713,7 @@ pub(super) fn spawn_model_prefetch_update(provider: Arc, agent: Ar ) }; - if refreshed.0 == initial_models && refreshed.1.is_empty() { + if refreshed.0 == initial_models && refreshed.1 == initial_routes { return; } diff --git a/src/server/config_reload.rs b/src/server/config_reload.rs new file mode 100644 index 000000000..459fddbfa --- /dev/null +++ b/src/server/config_reload.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +const CONFIG_RELOAD_POLL_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug, Eq, PartialEq)] +struct FileSignature { + modified: Option, + len: u64, +} + +type ConfigSnapshot = BTreeMap>; + +pub(super) fn spawn_config_reload_monitor() { + tokio::spawn(async move { + monitor_config_reload().await; + }); +} + +async fn monitor_config_reload() { + let mut previous = config_snapshot(); + let mut interval = tokio::time::interval(CONFIG_RELOAD_POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval.tick().await; + let current = config_snapshot(); + if current == previous { + continue; + } + + crate::logging::info("Config change detected; triggering server reload"); + let request_id = + crate::server::send_reload_signal(env!("JCODE_GIT_HASH").to_string(), None, false); + crate::logging::info(&format!( + "Config reload signal queued with request_id={request_id}" + )); + previous = current; + + // A real reload replaces this process. In test/no-exec modes, avoid + // enqueueing duplicate reloads while filesystem timestamps settle. + tokio::time::sleep(CONFIG_RELOAD_POLL_INTERVAL).await; + } +} + +fn config_snapshot() -> ConfigSnapshot { + config_watch_paths() + .into_iter() + .map(|path| { + let signature = std::fs::metadata(&path).ok().map(|metadata| FileSignature { + modified: metadata.modified().ok(), + len: metadata.len(), + }); + (path, signature) + }) + .collect() +} + +fn config_watch_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(path) = crate::config::Config::path() { + paths.push(path); + } + + if let Ok(jcode_dir) = crate::storage::jcode_dir() { + paths.push(jcode_dir.join("mcp.json")); + } + + if let Ok(config_dir) = crate::storage::app_config_dir() + && let Ok(entries) = std::fs::read_dir(config_dir) + { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) == Some("env") { + paths.push(path); + } + } + } + + paths.sort(); + paths.dedup(); + paths +} + +#[cfg(test)] +mod tests { + use super::*; + + struct EnvGuard { + key: &'static str, + old: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, old } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.old.take() { + crate::env::set_var(self.key, value); + } else { + crate::env::remove_var(self.key); + } + } + } + + #[test] + fn config_watch_paths_include_primary_config_mcp_and_env_files() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvGuard::set("JCODE_HOME", temp.path()); + let config_dir = crate::storage::app_config_dir().expect("config dir"); + std::fs::create_dir_all(&config_dir).expect("create config dir"); + std::fs::write( + config_dir.join("opencode-go.env"), + "OPENCODE_GO_API_KEY=test\n", + ) + .expect("write env"); + std::fs::write(config_dir.join("cache.json"), "{}\n").expect("write cache"); + + let paths = config_watch_paths(); + + assert!(paths.contains(&temp.path().join("config.toml"))); + assert!(paths.contains(&temp.path().join("mcp.json"))); + assert!(paths.contains(&config_dir.join("opencode-go.env"))); + assert!(!paths.contains(&config_dir.join("cache.json"))); + } + + #[test] + fn config_snapshot_changes_when_watched_file_changes() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvGuard::set("JCODE_HOME", temp.path()); + let config_path = temp.path().join("config.toml"); + std::fs::write(&config_path, "[provider]\n").expect("write config"); + + let before = config_snapshot(); + std::fs::write(&config_path, "[provider]\ndefault_model = \"gpt-5.5\"\n") + .expect("rewrite config"); + let after = config_snapshot(); + + assert_ne!(before, after); + } +} diff --git a/src/tui/app/inline_interactive.rs b/src/tui/app/inline_interactive.rs index 545d6cae6..b6316fca1 100644 --- a/src/tui/app/inline_interactive.rs +++ b/src/tui/app/inline_interactive.rs @@ -523,18 +523,8 @@ impl App { } }; - let routes = if routes.is_empty() && self.is_remote && current_model != "unknown" { - vec![crate::provider::ModelRoute { - model: current_model.clone(), - provider: self - .remote_provider_name - .clone() - .unwrap_or_else(|| "current".to_string()), - api_method: "current".to_string(), - available: true, - detail: "catalog still loading".to_string(), - cheapness: None, - }] + let routes = if routes.is_empty() && self.is_remote { + self.build_remote_config_model_routes_fallback(¤t_model) } else { routes }; @@ -908,9 +898,18 @@ impl App { } pub(super) fn build_remote_model_routes_fallback(&self) -> Vec { + let mut routes = + Self::build_remote_model_routes_for_entries(&self.remote_available_entries); + Self::append_remote_named_provider_routes(&mut routes, &self.remote_available_entries); + routes + } + + fn build_remote_model_routes_for_entries( + entries: &[String], + ) -> Vec { let auth = crate::auth::AuthStatus::check_fast(); let mut routes = Vec::new(); - for model in &self.remote_available_entries { + for model in entries { if !crate::provider::is_listable_model_name(model) { continue; } @@ -1087,6 +1086,146 @@ impl App { routes } + fn build_remote_config_model_routes_fallback( + &self, + current_model: &str, + ) -> Vec { + let mut models = Vec::new(); + let mut push_model = |model: &str| { + let model = model.trim(); + if model.is_empty() + || model == "unknown" + || model.contains('*') + || !crate::provider::is_listable_model_name(model) + || models.iter().any(|existing| existing == model) + { + return; + } + models.push(model.to_string()); + }; + + push_model(current_model); + + let cfg = crate::config::config(); + if let Some(default_model) = cfg.provider.default_model.as_deref() { + let default_model = default_model + .strip_prefix("copilot:") + .unwrap_or(default_model) + .strip_prefix("cursor:") + .unwrap_or(default_model) + .strip_prefix("antigravity:") + .unwrap_or(default_model) + .split('@') + .next() + .unwrap_or(default_model); + push_model(default_model); + } + + for patterns in cfg.provider.model_allowlist.values() { + for pattern in patterns { + let pattern = pattern.trim(); + let model = pattern.strip_prefix('=').unwrap_or(pattern); + push_model(model); + } + } + + let mut routes = Self::build_remote_model_routes_for_entries(&models); + Self::append_remote_named_provider_routes(&mut routes, &models); + routes + } + + fn append_remote_named_provider_routes( + routes: &mut Vec, + models: &[String], + ) { + let cfg = crate::config::config(); + for (profile_name, profile) in &cfg.providers { + if !crate::provider_catalog::provider_is_enabled(profile_name) { + continue; + } + + let mut profile_models = Vec::new(); + if let Some(default_model) = profile.default_model.as_deref() { + Self::push_unique_model(&mut profile_models, default_model); + } + for model in &profile.models { + Self::push_unique_model(&mut profile_models, &model.id); + } + if let Some(allowlist_models) = cfg.provider.model_allowlist.get(profile_name) { + for model in allowlist_models { + Self::push_unique_model(&mut profile_models, model); + } + } + + let Some(api_base) = crate::provider_catalog::normalize_api_base(&profile.base_url) + else { + continue; + }; + let requires_key = profile + .requires_api_key + .unwrap_or_else(|| !crate::provider_catalog::api_base_uses_localhost(&api_base)); + let has_credentials = match profile.auth { + crate::config::NamedProviderAuth::None => true, + crate::config::NamedProviderAuth::Bearer + | crate::config::NamedProviderAuth::Header => { + !requires_key + || profile + .api_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || profile + .api_key_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|env_key| { + if let Some(env_file) = profile.env_file.as_deref() { + crate::provider_catalog::load_env_value_from_env_or_config( + env_key, env_file, + ) + } else { + std::env::var(env_key).ok() + } + }) + .map(|value| value.trim().to_string()) + .is_some_and(|value| !value.is_empty()) + } + }; + let api_method = format!("openai-compatible:{}", profile_name); + for model in models { + if !profile_models.iter().any(|candidate| candidate == model) { + continue; + } + if routes.iter().any(|route| { + route.model == *model + && route.provider == *profile_name + && route.api_method == api_method + }) { + continue; + } + routes.push(crate::provider::ModelRoute { + model: model.clone(), + provider: profile_name.clone(), + api_method: api_method.clone(), + available: has_credentials, + detail: api_base.clone(), + cheapness: None, + }); + } + } + } + + fn push_unique_model(models: &mut Vec, model: &str) { + let model = model.trim().strip_prefix('=').unwrap_or(model.trim()); + if !model.is_empty() + && crate::provider::is_listable_model_name(model) + && !models.iter().any(|existing| existing == model) + { + models.push(model.to_string()); + } + } + pub(super) fn remote_model_should_offer_copilot_route(model: &str) -> bool { Self::remote_openai_compatible_route_for_model(model).is_none() && (Self::remote_model_is_server_copilot_only(model) diff --git a/src/tui/app/tests/remote_startup_input_01/part_01.rs b/src/tui/app/tests/remote_startup_input_01/part_01.rs index b3bf528dc..27c23995e 100644 --- a/src/tui/app/tests/remote_startup_input_01/part_01.rs +++ b/src/tui/app/tests/remote_startup_input_01/part_01.rs @@ -813,24 +813,98 @@ fn test_remote_model_switch_failure_restores_deferred_prompt() { #[test] fn test_model_picker_remote_falls_back_to_current_model_when_catalog_empty() { - let mut app = create_test_app(); - app.is_remote = true; - app.remote_provider_name = Some("openrouter".to_string()); - app.remote_provider_model = Some("anthropic/claude-sonnet-4".to_string()); - app.remote_available_entries.clear(); - app.remote_model_options.clear(); + with_temp_jcode_home(|| { + crate::config::invalidate_config_cache(); - app.open_model_picker(); + let mut app = create_test_app(); + app.is_remote = true; + app.remote_provider_name = Some("openrouter".to_string()); + app.remote_provider_model = Some("anthropic/claude-sonnet-4".to_string()); + app.remote_available_entries.clear(); + app.remote_model_options.clear(); + + app.open_model_picker(); + + let picker = app + .inline_interactive_state + .as_ref() + .expect("model picker should open with current-model fallback"); + + assert_eq!(picker.entries.len(), 1); + assert_eq!(picker.entries[0].name, "anthropic/claude-sonnet-4"); + assert_eq!(picker.entries[0].options.len(), 1); + assert_eq!(picker.entries[0].options[0].provider, "auto"); + assert_eq!(picker.entries[0].options[0].api_method, "openrouter"); + assert_ne!(picker.entries[0].options[0].detail, "catalog still loading"); + }); +} - let picker = app - .inline_interactive_state - .as_ref() - .expect("model picker should open with current-model fallback"); - - assert_eq!(picker.entries.len(), 1); - assert_eq!(picker.entries[0].name, "anthropic/claude-sonnet-4"); - assert_eq!(picker.entries[0].options.len(), 1); - assert_eq!(picker.entries[0].options[0].provider, "openrouter"); - assert_eq!(picker.entries[0].options[0].api_method, "current"); - assert!(picker.entries[0].options[0].available); +#[test] +fn test_model_picker_remote_falls_back_to_named_openai_compatible_profile() { + with_temp_jcode_home(|| { + let config_path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(config_path.parent().expect("config parent")) + .expect("create config dir"); + std::fs::write( + &config_path, + r#" +[provider.model_allowlist] +ollama-cloud = ["=deepseek-v4-pro", "=deepseek-v4-flash"] + +[providers.ollama-cloud] +type = "openai-compatible" +base_url = "http://localhost:11434/v1" +auth = "none" +default_model = "deepseek-v4-pro" +models = [ + { id = "deepseek-v4-pro", context_window = 1000000 }, + { id = "deepseek-v4-flash", context_window = 1000000 }, +] +"#, + ) + .expect("write config"); + crate::config::invalidate_config_cache(); + assert!( + crate::config::config() + .providers + .contains_key("ollama-cloud"), + "test config should include ollama-cloud profile" + ); + + let mut app = create_test_app(); + app.is_remote = true; + app.remote_provider_name = Some("ollama-cloud".to_string()); + app.remote_provider_model = Some("deepseek-v4-pro".to_string()); + app.remote_available_entries = vec![ + "deepseek-v4-pro".to_string(), + "deepseek-v4-flash".to_string(), + ]; + app.remote_model_options.clear(); + + app.open_model_picker(); + + let picker = app + .inline_interactive_state + .as_ref() + .expect("model picker should open with named provider fallback"); + assert!( + picker.entries.iter().any(|entry| { + entry.name == "deepseek-v4-pro" + && entry.options.iter().any(|option| { + option.provider == "ollama-cloud" + && option.api_method == "openai-compatible:ollama-cloud" + && option.available + }) + }), + "expected ollama-cloud route, got: {:?}", + picker.entries + ); + assert!( + picker + .entries + .iter() + .flat_map(|entry| entry.options.iter()) + .all(|option| option.detail != "catalog still loading") + ); + }); } From d277c03f427133ea9dde34c89522a5e16becddf2 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 23:18:43 -0230 Subject: [PATCH 30/34] fix: isolate agent db execute schemas --- src/tool/db_execute.rs | 29 ++++++++++++++++++++++------- src/tool/mod.rs | 2 +- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/tool/db_execute.rs b/src/tool/db_execute.rs index cc342b05b..f879b77bd 100644 --- a/src/tool/db_execute.rs +++ b/src/tool/db_execute.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use std::process::Stdio; use tokio::process::Command as TokioCommand; -const DB_EXECUTE_DESCRIPTION: &str = "Execute a SQL statement against the agent's local Postgres database. The statement is scoped to the agent's own schema. Use for CREATE TABLE, INSERT, UPDATE, DELETE, SELECT, DROP TABLE, etc. For queries that may return large results, limit with SQL clauses."; +const DB_EXECUTE_DESCRIPTION: &str = "Execute a SQL statement against the agent's local Postgres database. Statements run as a per-session role that owns the session's schema; agents cannot access other sessions' data. Use for CREATE TABLE, INSERT, UPDATE, DELETE, SELECT, DROP TABLE, etc. For queries that may return large results, limit with SQL clauses."; pub struct DbExecuteTool; @@ -47,9 +47,24 @@ fn agent_schema_name(session_id: &str) -> String { format!("agent_{}", short) } -fn provision_schema_sql(schema: &str) -> String { +fn provision_role_and_schema_sql(schema: &str) -> String { + // Creates a NOLOGIN role for the session (if missing), grants it to + // jcode_agent, creates/owns the schema, and sets the effective role + // + search_path. All SQL from the agent runs as this per-session role, + // which owns its schema but has no USAGE on any other agent's schema. format!( - "CREATE SCHEMA IF NOT EXISTS {schema};\nSET search_path TO {schema};" + "DO $$\n\ + BEGIN\n\ + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '{schema}') THEN\n\ + CREATE ROLE {schema} NOLOGIN;\n\ + END IF;\n\ + END\n\ + $$;\n\ + GRANT {schema} TO jcode_agent;\n\ + CREATE SCHEMA IF NOT EXISTS {schema} AUTHORIZATION {schema};\n\ + ALTER SCHEMA {schema} OWNER TO {schema};\n\ + SET ROLE {schema};\n\ + SET search_path TO {schema};" ) } @@ -83,7 +98,7 @@ impl Tool for DbExecuteTool { let full_sql = format!( "{}\n{}", - provision_schema_sql(&schema), + provision_role_and_schema_sql(&schema), params.sql.trim() ); @@ -105,9 +120,9 @@ async fn run_psql(sql: &str) -> Result { "jcode_agent_workspace", "-v", "ON_ERROR_STOP=1", - "-A", // unaligned output - "-t", // tuples only (no headers) - "-q", // quiet + "-A", // unaligned output + "-t", // tuples only (no headers) + "-q", // quiet ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/src/tool/mod.rs b/src/tool/mod.rs index d345975fb..b6686c0c5 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -9,8 +9,8 @@ mod browser; mod code_hygiene; mod codesearch; mod communicate; -mod db_execute; mod conversation_search; +mod db_execute; mod debug_socket; mod edit; mod glob; From 8742e025c3e8738b1cfe739da8c17bcd20d8c46e Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Thu, 14 May 2026 23:57:33 -0230 Subject: [PATCH 31/34] fix: preserve multi-provider model picker routes --- src/agent/provider.rs | 8 + src/tui/app/inline_interactive.rs | 12 +- src/tui/app/tests/state_model_poke_03.rs | 234 +++++++++++++++++++++++ 3 files changed, 253 insertions(+), 1 deletion(-) diff --git a/src/agent/provider.rs b/src/agent/provider.rs index 904cc5db6..ceb09cf95 100644 --- a/src/agent/provider.rs +++ b/src/agent/provider.rs @@ -22,11 +22,19 @@ impl Agent { } pub fn available_models_for_switching(&self) -> Vec { + let routes = self.model_routes(); + if !routes.is_empty() { + return crate::provider::listable_model_names_from_routes(&routes); + } let models = self.provider.available_models_for_switching(); crate::provider_catalog::filter_models_by_allowlist(self.provider.name(), models) } pub fn available_models_display(&self) -> Vec { + let routes = self.model_routes(); + if !routes.is_empty() { + return crate::provider::listable_model_names_from_routes(&routes); + } let models = self.provider.available_models_display(); crate::provider_catalog::filter_models_by_allowlist(self.provider.name(), models) } diff --git a/src/tui/app/inline_interactive.rs b/src/tui/app/inline_interactive.rs index b6316fca1..43e7fc5bb 100644 --- a/src/tui/app/inline_interactive.rs +++ b/src/tui/app/inline_interactive.rs @@ -132,6 +132,13 @@ impl App { let auth = crate::auth::AuthStatus::check_fast(); let mut routes = Vec::new(); + let route_catalog = crate::provider_catalog::filter_model_routes_by_allowlist( + self.provider.name(), + self.provider.model_routes(), + ); + if !route_catalog.is_empty() { + return route_catalog; + } let display = crate::provider_catalog::filter_models_by_allowlist( self.provider.name(), self.provider.available_models_display(), @@ -1050,7 +1057,10 @@ impl App { added_any = true; } - if Self::remote_model_should_offer_copilot_route(model) && !model.contains("[1m]") { + if crate::provider_catalog::provider_is_enabled("copilot") + && Self::remote_model_should_offer_copilot_route(model) + && !model.contains("[1m]") + { routes.push(crate::provider::build_copilot_route( model, auth.copilot == crate::auth::AuthState::Available diff --git a/src/tui/app/tests/state_model_poke_03.rs b/src/tui/app/tests/state_model_poke_03.rs index 90fd5ac03..e6b1369c5 100644 --- a/src/tui/app/tests/state_model_poke_03.rs +++ b/src/tui/app/tests/state_model_poke_03.rs @@ -70,6 +70,11 @@ struct MixedModelRoutesProvider { model: StdArc>, } +#[derive(Clone)] +struct SubscriptionModelRoutesProvider { + model: StdArc>, +} + #[derive(Clone)] struct AuthUxStateSpaceProvider { authed: StdArc, @@ -184,6 +189,93 @@ impl MixedModelRoutesProvider { } } +impl SubscriptionModelRoutesProvider { + fn routes() -> Vec { + vec![ + crate::provider::ModelRoute { + model: "copilot-gpt-5".to_string(), + provider: "Copilot".to_string(), + api_method: "copilot".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "claude-opus-4-7[1m]".to_string(), + provider: "Anthropic".to_string(), + api_method: "claude-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "claude-sonnet-4-6[1m]".to_string(), + provider: "Anthropic".to_string(), + api_method: "claude-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.5".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.4".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.3-codex-spark".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-pro".to_string(), + provider: "opencode-go".to_string(), + api_method: "openai-compatible:opencode-go".to_string(), + available: true, + detail: "https://opencode.ai/zen/go/v1".to_string(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-flash".to_string(), + provider: "opencode-go".to_string(), + api_method: "openai-compatible:opencode-go".to_string(), + available: true, + detail: "https://opencode.ai/zen/go/v1".to_string(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-pro".to_string(), + provider: "ollama-cloud".to_string(), + api_method: "openai-compatible:ollama-cloud".to_string(), + available: true, + detail: "https://ollama.com/v1".to_string(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-flash".to_string(), + provider: "ollama-cloud".to_string(), + api_method: "openai-compatible:ollama-cloud".to_string(), + available: true, + detail: "https://ollama.com/v1".to_string(), + cheapness: None, + }, + ] + } +} + #[async_trait::async_trait] impl Provider for AuthUxStateSpaceProvider { async fn complete( @@ -303,6 +395,48 @@ impl Provider for MixedModelRoutesProvider { } } +#[async_trait::async_trait] +impl Provider for SubscriptionModelRoutesProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[crate::message::ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + unimplemented!("SubscriptionModelRoutesProvider") + } + + fn name(&self) -> &str { + "OpenRouter" + } + + fn model(&self) -> String { + self.model.lock().unwrap().clone() + } + + fn available_models_display(&self) -> Vec { + crate::provider::listable_model_names_from_routes(&Self::routes()) + } + + fn model_routes(&self) -> Vec { + Self::routes() + } + + fn set_model(&self, model: &str) -> Result<()> { + let found = Self::routes().iter().any(|route| route.model == model); + if !found { + anyhow::bail!("model {model} is not available in the subscription catalog"); + } + *self.model.lock().unwrap() = model.to_string(); + Ok(()) + } + + fn fork(&self) -> Arc { + Arc::new(self.clone()) + } +} + #[async_trait::async_trait] impl Provider for EmptyPostLoginCatalogProvider { async fn complete( @@ -1071,6 +1205,106 @@ fn test_model_picker_state_space_preserves_provider_labels_after_route_hydration ); } +#[test] +fn test_model_picker_keeps_subscription_routes_under_openrouter_profile_allowlist() { + with_temp_jcode_home(|| { + let config_path = crate::storage::jcode_dir() + .expect("temp jcode home") + .join("config.toml"); + std::fs::write( + config_path, + r#" +[provider] +default_provider = "ollama-cloud" +default_model = "deepseek-v4-pro" +enabled_providers = ["anthropic", "openai", "opencode-go", "ollama-cloud"] + +[provider.model_allowlist] +anthropic = ["=claude-opus-4-7[1m]", "=claude-sonnet-4-6[1m]"] +openai = ["=gpt-5.5", "=gpt-5.4", "=gpt-5.3-codex-spark"] +opencode-go = ["=deepseek-v4-pro", "=deepseek-v4-flash"] +ollama-cloud = ["=deepseek-v4-pro", "=deepseek-v4-flash"] +"#, + ) + .expect("write temp config"); + crate::config::invalidate_config_cache(); + clear_persisted_test_ui_state(); + crate::tui::ui::clear_test_render_state_for_tests(); + + let provider: Arc = Arc::new(SubscriptionModelRoutesProvider { + model: StdArc::new(StdMutex::new("deepseek-v4-pro".to_string())), + }); + let rt = tokio::runtime::Runtime::new().unwrap(); + let registry = rt.block_on(crate::tool::Registry::new(provider.clone())); + let mut app = App::new_for_test_harness(provider, registry); + app.queue_mode = false; + app.diff_mode = crate::config::DiffDisplayMode::Inline; + + app.open_model_picker(); + wait_for_model_picker_load(&mut app); + + let picker = app + .inline_interactive_state + .as_ref() + .expect("subscription model picker should be open"); + let route_pairs: Vec<(String, String, String)> = picker + .entries + .iter() + .flat_map(|entry| { + entry.options.iter().map(|route| { + ( + entry.name.clone(), + route.provider.clone(), + route.api_method.clone(), + ) + }) + }) + .collect(); + + for expected in [ + "claude-opus-4-7[1m]", + "claude-sonnet-4-6[1m]", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex-spark", + "deepseek-v4-pro", + "deepseek-v4-flash", + ] { + assert!( + route_pairs.iter().any(|(model, _, _)| model == expected), + "missing {expected} from picker routes: {:?}", + route_pairs + ); + } + + assert!( + route_pairs.iter().any(|(model, provider, method)| { + model == "deepseek-v4-pro" + && provider == "ollama-cloud" + && method == "openai-compatible:ollama-cloud" + }), + "ollama-cloud Pro route should remain available: {:?}", + route_pairs + ); + assert!( + route_pairs.iter().any(|(model, provider, method)| { + model == "deepseek-v4-pro" + && provider == "opencode-go" + && method == "openai-compatible:opencode-go" + }), + "opencode-go Pro route should remain available: {:?}", + route_pairs + ); + assert!( + route_pairs.iter().all(|(_, provider, _)| provider != "Copilot"), + "Copilot routes should be hidden when Copilot is not enabled: {:?}", + route_pairs + ); + + crate::config::invalidate_config_cache(); + }); +} + #[test] fn test_model_picker_does_not_cache_single_model_fallback() { ensure_test_jcode_home_if_unset(); From 540a72a78eb6a1df40400483403ffd304edfc410 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Fri, 15 May 2026 00:10:13 -0230 Subject: [PATCH 32/34] fix: launch shared server from fork symlink --- crates/jcode-build-support/src/paths.rs | 1 + src/provider/mod.rs | 4 +- src/provider/tests/model_resolution.rs | 62 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/jcode-build-support/src/paths.rs b/crates/jcode-build-support/src/paths.rs index 4cf1f5ee1..bbee31f66 100644 --- a/crates/jcode-build-support/src/paths.rs +++ b/crates/jcode-build-support/src/paths.rs @@ -253,6 +253,7 @@ pub fn find_dev_binary(repo_dir: &Path) -> Option { fn current_exe_is_repo_binary() -> Option { let exe = std::env::current_exe().ok()?; + let exe = std::fs::canonicalize(&exe).unwrap_or(exe); let name = exe.file_name()?.to_str()?; if name != binary_name() { return None; diff --git a/src/provider/mod.rs b/src/provider/mod.rs index eaa35583c..1c8a6b997 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -1185,7 +1185,7 @@ impl Provider for MultiProvider { .get(&resolved.id) { for model in allowlist_models { - let model = model.trim(); + let model = model.trim().strip_prefix('=').unwrap_or(model.trim()); if !model.is_empty() && !profile_models.iter().any(|existing| existing == model) { profile_models.push(model.to_string()); @@ -1235,7 +1235,7 @@ impl Provider for MultiProvider { } if let Some(allowlist_models) = cfg.provider.model_allowlist.get(profile_name) { for model in allowlist_models { - let model = model.trim(); + let model = model.trim().strip_prefix('=').unwrap_or(model.trim()); if !model.is_empty() && !named_models.iter().any(|existing| existing == model) { named_models.push(model.to_string()); } diff --git a/src/provider/tests/model_resolution.rs b/src/provider/tests/model_resolution.rs index 0147d85f5..2d82a833b 100644 --- a/src/provider/tests/model_resolution.rs +++ b/src/provider/tests/model_resolution.rs @@ -627,6 +627,68 @@ fn test_configured_direct_compatible_profiles_are_listed_without_openrouter_key( }); } +#[test] +fn test_named_provider_allowlist_exact_markers_are_not_model_ids() { + with_clean_provider_test_env(|| { + let config_path = crate::storage::jcode_dir() + .expect("temp jcode home") + .join("config.toml"); + std::fs::write( + config_path, + r#" +[provider] +enabled_providers = ["ollama-cloud"] + +[provider.model_allowlist] +ollama-cloud = ["=deepseek-v4-pro", "=deepseek-v4-flash"] + +[providers.ollama-cloud] +type = "openai-compatible" +base_url = "http://localhost:11434/v1" +auth = "none" +default_model = "deepseek-v4-pro" +"#, + ) + .expect("write temp config"); + crate::config::invalidate_config_cache(); + + let provider = MultiProvider { + claude: RwLock::new(None), + anthropic: RwLock::new(None), + openai: RwLock::new(None), + copilot_api: RwLock::new(None), + antigravity: RwLock::new(None), + gemini: RwLock::new(None), + cursor: RwLock::new(None), + bedrock: RwLock::new(None), + openrouter: RwLock::new(None), + active: RwLock::new(ActiveProvider::OpenRouter), + use_claude_cli: false, + startup_notices: RwLock::new(Vec::new()), + forced_provider: Some(ActiveProvider::OpenRouter), + }; + + let routes = provider.model_routes(); + assert!(routes.iter().any(|route| { + route.model == "deepseek-v4-pro" + && route.provider == "ollama-cloud" + && route.api_method == "openai-compatible:ollama-cloud" + })); + assert!(routes.iter().any(|route| { + route.model == "deepseek-v4-flash" + && route.provider == "ollama-cloud" + && route.api_method == "openai-compatible:ollama-cloud" + })); + assert!( + !routes.iter().any(|route| route.model.starts_with('=')), + "allowlist exact-match markers must not leak into model ids: {:?}", + routes + ); + + crate::config::invalidate_config_cache(); + }); +} + #[test] fn test_profile_prefixed_model_switch_reinitializes_direct_compatible_runtime() { with_clean_provider_test_env(|| { From c72b2c284d4632c5e14dca0c721afd748969d213 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Fri, 15 May 2026 00:30:41 -0230 Subject: [PATCH 33/34] fix: normalize named provider picker labels --- src/provider/mod.rs | 9 ++++++++- src/tui/app/tests/state_model_poke_03.rs | 11 ++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 1c8a6b997..c3071d590 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -1221,6 +1221,13 @@ impl Provider for MultiProvider { continue; } let api_method = format!("openai-compatible:{}", profile_name); + let provider_label = + crate::provider_catalog::openai_compatible_profile_by_id(profile_name) + .map(|profile| { + crate::provider_catalog::resolve_openai_compatible_profile(profile) + .display_name + }) + .unwrap_or_else(|| profile_name.clone()); let mut named_models = Vec::new(); if let Some(default_model) = profile.default_model.as_deref().map(str::trim) && !default_model.is_empty() @@ -1278,7 +1285,7 @@ impl Provider for MultiProvider { for model in named_models { routes.push(ModelRoute { model, - provider: profile_name.clone(), + provider: provider_label.clone(), api_method: api_method.clone(), available: has_credentials, detail: profile.base_url.clone(), diff --git a/src/tui/app/tests/state_model_poke_03.rs b/src/tui/app/tests/state_model_poke_03.rs index e6b1369c5..3b3f2c8d7 100644 --- a/src/tui/app/tests/state_model_poke_03.rs +++ b/src/tui/app/tests/state_model_poke_03.rs @@ -242,7 +242,7 @@ impl SubscriptionModelRoutesProvider { }, crate::provider::ModelRoute { model: "deepseek-v4-pro".to_string(), - provider: "opencode-go".to_string(), + provider: "OpenCode Go".to_string(), api_method: "openai-compatible:opencode-go".to_string(), available: true, detail: "https://opencode.ai/zen/go/v1".to_string(), @@ -250,7 +250,7 @@ impl SubscriptionModelRoutesProvider { }, crate::provider::ModelRoute { model: "deepseek-v4-flash".to_string(), - provider: "opencode-go".to_string(), + provider: "OpenCode Go".to_string(), api_method: "openai-compatible:opencode-go".to_string(), available: true, detail: "https://opencode.ai/zen/go/v1".to_string(), @@ -1289,12 +1289,17 @@ ollama-cloud = ["=deepseek-v4-pro", "=deepseek-v4-flash"] assert!( route_pairs.iter().any(|(model, provider, method)| { model == "deepseek-v4-pro" - && provider == "opencode-go" + && provider == "OpenCode Go" && method == "openai-compatible:opencode-go" }), "opencode-go Pro route should remain available: {:?}", route_pairs ); + assert!( + route_pairs.iter().all(|(_, provider, _)| provider != "opencode-go"), + "known provider ids should render with display labels: {:?}", + route_pairs + ); assert!( route_pairs.iter().all(|(_, provider, _)| provider != "Copilot"), "Copilot routes should be hidden when Copilot is not enabled: {:?}", From ee392679fdd92de56ad59a5bba9cba5bd7582742 Mon Sep 17 00:00:00 2001 From: Jager Cooper <100608609+Zephyr709@users.noreply.github.com> Date: Fri, 15 May 2026 01:18:37 -0230 Subject: [PATCH 34/34] fix: clear stale named-provider env vars on startup When JCODE_PROVIDER_PROFILE_ACTIVE is not set (no CLI override), clear any lingering JCODE_NAMED_PROVIDER_PROFILE, JCODE_ACTIVE_PROVIDER, JCODE_RUNTIME_PROVIDER, and JCODE_OPENROUTER_CACHE_NAMESPACE env vars before the default_provider guard runs. Without this, stale env from a previous jcode session's shell environment blocks config.toml's default_provider from taking effect, causing the model picker and session provider key to resolve to the wrong provider. Regression tests cover both the stale-clear case and the explicit CLI override preservation case. --- src/provider/startup.rs | 15 ++++ src/provider/tests.rs | 1 + src/provider/tests/startup_stale_env.rs | 109 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 src/provider/tests/startup_stale_env.rs diff --git a/src/provider/startup.rs b/src/provider/startup.rs index d59b6cf0f..505a1a5cb 100644 --- a/src/provider/startup.rs +++ b/src/provider/startup.rs @@ -69,6 +69,21 @@ impl MultiProvider { let cfg = crate::config::config(); let provider_state = ProviderState::from_parts(cfg, &auth_status); let mut default_named_provider_profile: Option = None; + + // When no explicit CLI provider profile override is active, clear any + // stale named-provider env vars that may be lingering from a previous + // session's shell environment. Without this, stale + // JCODE_NAMED_PROVIDER_PROFILE / JCODE_ACTIVE_PROVIDER / etc. will + // block the config.toml default_provider from taking effect, causing + // the model picker and session provider key to resolve to the wrong + // provider. + if std::env::var_os("JCODE_PROVIDER_PROFILE_ACTIVE").is_none() { + crate::env::remove_var("JCODE_NAMED_PROVIDER_PROFILE"); + crate::env::remove_var("JCODE_OPENROUTER_CACHE_NAMESPACE"); + crate::env::remove_var("JCODE_ACTIVE_PROVIDER"); + crate::env::remove_var("JCODE_RUNTIME_PROVIDER"); + } + if std::env::var_os("JCODE_PROVIDER_PROFILE_ACTIVE").is_none() && std::env::var_os("JCODE_NAMED_PROVIDER_PROFILE").is_none() && let Some(pref) = provider_state.default_provider_key() diff --git a/src/provider/tests.rs b/src/provider/tests.rs index 68ef89adf..17939cad6 100644 --- a/src/provider/tests.rs +++ b/src/provider/tests.rs @@ -232,3 +232,4 @@ include!("tests/auth_refresh.rs"); include!("tests/model_resolution.rs"); include!("tests/fallback_failover.rs"); include!("tests/catalog_subscription.rs"); +include!("tests/startup_stale_env.rs"); diff --git a/src/provider/tests/startup_stale_env.rs b/src/provider/tests/startup_stale_env.rs new file mode 100644 index 000000000..c27221c27 --- /dev/null +++ b/src/provider/tests/startup_stale_env.rs @@ -0,0 +1,109 @@ +/// Regression test: when stale named-provider env vars leak into the +/// process from a previous jcode session's shell environment, +/// `new_with_auth_status` must clear them before the guard so +/// config.toml's `default_provider` can take effect. The key assertion +/// is that the stale profile is NOT used and config.toml wins. +#[test] +fn startup_clears_stale_named_provider_env_vars_when_no_cli_override() { + with_clean_provider_test_env(|| { + let cfg_path = crate::config::Config::path().expect("config path in test"); + std::fs::create_dir_all(cfg_path.parent().expect("config parent")) + .expect("create config dir"); + let toml = r#" +[providers.my-profile] +provider_type = "openai-compatible" +base_url = "https://llm.example.test/v1" +default_model = "my-gpt" + +[provider] +default_provider = "my-profile" +"#; + std::fs::write(&cfg_path, toml).expect("write config.toml"); + + // Stale env vars simulating a previous session's shell env. + // JCODE_PROVIDER_PROFILE_ACTIVE is NOT set, so no CLI override. + crate::env::set_var("JCODE_NAMED_PROVIDER_PROFILE", "stale-previous-profile"); + crate::env::set_var("JCODE_OPENROUTER_CACHE_NAMESPACE", "stale-namespace"); + crate::env::set_var("JCODE_ACTIVE_PROVIDER", "openrouter"); + crate::env::set_var("JCODE_RUNTIME_PROVIDER", "stale-runtime"); + + crate::config::Config::invalidate_cache(); + + let auth = crate::auth::AuthStatus::check(); + let provider = MultiProvider::new_with_auth_status(auth); + + // The config.toml default_provider="my-profile" should have + // taken effect (the stale vars were cleared first, then the + // guard applied the correct profile). + assert_eq!( + std::env::var("JCODE_PROVIDER_PROFILE_ACTIVE") + .ok() + .as_deref(), + Some("1"), + "profile should be active after default_provider is applied" + ); + assert_eq!( + std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .as_deref(), + Some("my-profile"), + "named profile should be config.toml's my-profile, not stale-previous-profile" + ); + + // The provider may resolve to Claude as a fallback when + // the named profile has no live credentials, but the env + // vars confirm the correct profile was applied. + let _ = provider.active_provider(); + }); +} + +/// When JCODE_PROVIDER_PROFILE_ACTIVE is explicitly set (CLI override), +/// stale env vars should NOT be cleared and the explicit profile should +/// be preserved. +#[test] +fn startup_preserves_explicit_cli_profile_override() { + with_clean_provider_test_env(|| { + let cfg_path = crate::config::Config::path().expect("config path in test"); + std::fs::create_dir_all(cfg_path.parent().expect("config parent")) + .expect("create config dir"); + let toml = r#" +[providers.cli-profile] +provider_type = "openai-compatible" +base_url = "https://cli.example.test/v1" +default_model = "cli-model" + +[provider] +default_provider = "my-profile" +"#; + std::fs::write(&cfg_path, toml).expect("write config.toml"); + + // Explicit CLI override: JCODE_PROVIDER_PROFILE_ACTIVE is set. + crate::env::set_var("JCODE_PROVIDER_PROFILE_ACTIVE", "1"); + crate::env::set_var("JCODE_NAMED_PROVIDER_PROFILE", "cli-profile"); + crate::env::set_var("JCODE_OPENROUTER_CACHE_NAMESPACE", "cli-profile"); + + crate::config::Config::invalidate_cache(); + + let auth = crate::auth::AuthStatus::check(); + let provider = MultiProvider::new_with_auth_status(auth); + + // With an explicit CLI override, the stale env vars should NOT + // be cleared. + assert_eq!( + std::env::var("JCODE_PROVIDER_PROFILE_ACTIVE") + .ok() + .as_deref(), + Some("1"), + "explicit CLI override should be preserved" + ); + assert_eq!( + std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .as_deref(), + Some("cli-profile"), + "explicit profile should be preserved" + ); + + let _ = provider; // just ensure construction succeeded + }); +}