diff --git a/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml b/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml new file mode 100644 index 0000000000..f2ba251a1d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml @@ -0,0 +1,50 @@ +name: πŸ§‘β€πŸ’» VS Code Extension +description: Report an issue with the VS Code extension +labels: + - extension + - needs triage +body: + - type: markdown + attributes: + value: | + Before submitting a new issue, please search for existing issues to see if your issue has already been reported. + If it has, please add a πŸ‘ reaction (no need to leave a comment) to the existing issue instead of creating a new one. + + - type: input + id: version + attributes: + label: What version of the VS Code extension are you using? + - type: input + id: ide + attributes: + label: Which IDE are you using? + description: Like `VS Code`, `Cursor`, `Windsurf`, etc. + - type: input + id: platform + attributes: + label: What platform is your computer? + description: | + For MacOS and Linux: copy the output of `uname -mprs` + For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console + - type: textarea + id: steps + attributes: + label: What steps can reproduce the bug? + description: Explain the bug and provide a code snippet that can reproduce it. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What is the expected behavior? + description: If possible, please provide text instead of a screenshot. + - type: textarea + id: actual + attributes: + label: What do you see instead? + description: If possible, please provide text instead of a screenshot. + - type: textarea + id: notes + attributes: + label: Additional information + description: Is there anything else you think we should know? diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 641574074e..6eca119f3e 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -129,7 +129,9 @@ pub(crate) async fn stream_chat_completions( "content": output, })); } - ResponseItem::Reasoning { .. } | ResponseItem::Other => { + ResponseItem::Reasoning { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::Other => { // Omit these items from the conversation history. continue; } @@ -623,11 +625,8 @@ where Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => { continue; } - Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { .. }))) => { - return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { - call_id: String::new(), - query: None, - }))); + Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id }))) => { + return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id }))); } } } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index e0ab334fcf..b7bfeaa7ca 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -160,21 +160,7 @@ impl ModelClient { let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT); let full_instructions = prompt.get_full_instructions(&self.config.model_family); - let mut tools_json = create_tools_json_for_responses_api(&prompt.tools)?; - // ChatGPT backend expects the preview name for web search. - if auth_mode == Some(AuthMode::ChatGPT) { - for tool in &mut tools_json { - if let Some(map) = tool.as_object_mut() - && map.get("type").and_then(|v| v.as_str()) == Some("web_search") - { - map.insert( - "type".to_string(), - serde_json::Value::String("web_search_preview".to_string()), - ); - } - } - } - + let tools_json = create_tools_json_for_responses_api(&prompt.tools)?; let reasoning = create_reasoning_param_for_request( &self.config.model_family, self.effort, @@ -607,11 +593,9 @@ async fn process_sse( | "response.custom_tool_call_input.delta" | "response.custom_tool_call_input.done" // also emitted as response.output_item.done | "response.in_progress" - | "response.output_item.added" - | "response.output_text.done" => { - if event.kind == "response.output_item.added" - && let Some(item) = event.item.as_ref() - { + | "response.output_text.done" => {} + "response.output_item.added" => { + if let Some(item) = event.item.as_ref() { // Detect web_search_call begin and forward a synthetic event upstream. if let Some(ty) = item.get("type").and_then(|v| v.as_str()) && ty == "web_search_call" @@ -621,7 +605,7 @@ async fn process_sse( .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let ev = ResponseEvent::WebSearchCallBegin { call_id, query: None }; + let ev = ResponseEvent::WebSearchCallBegin { call_id }; if tx_event.send(Ok(ev)).await.is_err() { return; } diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e2c191f4a5..2740105100 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -95,7 +95,6 @@ pub enum ResponseEvent { ReasoningSummaryPartAdded, WebSearchCallBegin { call_id: String, - query: Option, }, } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 365969ac02..99c59d97a9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -89,6 +89,7 @@ use crate::protocol::ExecCommandBeginEvent; use crate::protocol::ExecCommandEndEvent; use crate::protocol::FileChange; use crate::protocol::InputItem; +use crate::protocol::ListCustomPromptsResponseEvent; use crate::protocol::Op; use crate::protocol::PatchApplyBeginEvent; use crate::protocol::PatchApplyEndEvent; @@ -100,6 +101,7 @@ use crate::protocol::Submission; use crate::protocol::TaskCompleteEvent; use crate::protocol::TurnDiffEvent; use crate::protocol::WebSearchBeginEvent; +use crate::protocol::WebSearchEndEvent; use crate::rollout::RolloutRecorder; use crate::safety::SafetyCheck; use crate::safety::assess_command_safety; @@ -110,6 +112,7 @@ use crate::user_notification::UserNotification; use crate::util::backoff; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::LocalShellAction; @@ -118,6 +121,7 @@ use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::models::ShellToolCallParams; +use codex_protocol::models::WebSearchAction; // A convenience extension trait for acquiring mutex locks where poisoning is // unrecoverable and should abort the program. This avoids scattered `.unwrap()` @@ -1286,6 +1290,27 @@ async fn submission_loop( warn!("failed to send McpListToolsResponse event: {e}"); } } + Op::ListCustomPrompts => { + let tx_event = sess.tx_event.clone(); + let sub_id = sub.id.clone(); + + let custom_prompts: Vec = + if let Some(dir) = crate::custom_prompts::default_prompts_dir() { + crate::custom_prompts::discover_prompts_in(&dir).await + } else { + Vec::new() + }; + + let event = Event { + id: sub_id, + msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent { + custom_prompts, + }), + }; + if let Err(e) = tx_event.send(event).await { + warn!("failed to send ListCustomPromptsResponse event: {e}"); + } + } Op::Compact => { // Create a summarization request as user input const SUMMARIZATION_PROMPT: &str = include_str!("prompt_for_compact_command.md"); @@ -1746,13 +1771,12 @@ async fn try_run_turn( .await?; output.push(ProcessedResponseItem { item, response }); } - ResponseEvent::WebSearchCallBegin { call_id, query } => { - let q = query.unwrap_or_else(|| "Searching Web...".to_string()); + ResponseEvent::WebSearchCallBegin { call_id } => { let _ = sess .tx_event .send(Event { id: sub_id.to_string(), - msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: q }), + msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id }), }) .await; } @@ -1884,6 +1908,12 @@ async fn run_compact_task( } sess.remove_task(&sub_id); + + { + let mut state = sess.state.lock_unchecked(); + state.history.keep_last_messages(1); + } + let event = Event { id: sub_id.clone(), msg: EventMsg::AgentMessage(AgentMessageEvent { @@ -1898,9 +1928,6 @@ async fn run_compact_task( }), }; sess.send_event(event).await; - - let mut state = sess.state.lock_unchecked(); - state.history.keep_last_messages(1); } async fn handle_response_item( @@ -2048,6 +2075,17 @@ async fn handle_response_item( debug!("unexpected CustomToolCallOutput from stream"); None } + ResponseItem::WebSearchCall { id, action, .. } => { + if let WebSearchAction::Search { query } = action { + let call_id = id.unwrap_or_else(|| "".to_string()); + let event = Event { + id: sub_id.to_string(), + msg: EventMsg::WebSearchEnd(WebSearchEndEvent { call_id, query }), + }; + sess.tx_event.send(event).await.ok(); + } + None + } ResponseItem::Other => None, }; Ok(output) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 4d623c3e5b..7845f5d4ce 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -181,6 +181,10 @@ pub struct Config { /// Include the `view_image` tool that lets the agent attach a local image path to context. pub include_view_image_tool: bool, + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + pub disable_paste_burst: bool, } impl Config { @@ -488,6 +492,11 @@ pub struct ConfigToml { /// Nested tools section for feature toggles pub tools: Option, + + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + pub disable_paste_burst: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] @@ -497,7 +506,6 @@ pub struct ProjectConfig { #[derive(Deserialize, Debug, Clone, Default)] pub struct ToolsToml { - // Renamed from `web_search_request`; keep alias for backwards compatibility. #[serde(default, alias = "web_search_request")] pub web_search: Option, @@ -663,7 +671,7 @@ impl Config { })? .clone(); - let shell_environment_policy = cfg.shell_environment_policy.clone().into(); + let shell_environment_policy = cfg.shell_environment_policy.into(); let resolved_cwd = { use std::env; @@ -684,7 +692,7 @@ impl Config { } }; - let history = cfg.history.clone().unwrap_or_default(); + let history = cfg.history.unwrap_or_default(); let tools_web_search_request = override_tools_web_search_request .or(cfg.tools.as_ref().and_then(|t| t.web_search)) @@ -766,7 +774,7 @@ impl Config { codex_home, history, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), - tui: cfg.tui.clone().unwrap_or_default(), + tui: cfg.tui.unwrap_or_default(), codex_linux_sandbox_exe, hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), @@ -785,7 +793,7 @@ impl Config { model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity), chatgpt_base_url: config_profile .chatgpt_base_url - .or(cfg.chatgpt_base_url.clone()) + .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), experimental_resume, @@ -798,6 +806,7 @@ impl Config { .experimental_use_exec_command_tool .unwrap_or(false), include_view_image_tool, + disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), }; Ok(config) } @@ -1167,6 +1176,7 @@ disable_response_storage = true preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, include_view_image_tool: true, + disable_paste_burst: false, }, o3_profile_config ); @@ -1224,6 +1234,7 @@ disable_response_storage = true preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, include_view_image_tool: true, + disable_paste_burst: false, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1296,6 +1307,7 @@ disable_response_storage = true preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, include_view_image_tool: true, + disable_paste_burst: false, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index a8995f57f7..ed15bd4155 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -72,7 +72,7 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::Reasoning { .. } => true, - ResponseItem::Other => false, + ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false, } } diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs new file mode 100644 index 0000000000..4974e7c52d --- /dev/null +++ b/codex-rs/core/src/custom_prompts.rs @@ -0,0 +1,127 @@ +use codex_protocol::custom_prompts::CustomPrompt; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use tokio::fs; + +/// Return the default prompts directory: `$CODEX_HOME/prompts`. +/// If `CODEX_HOME` cannot be resolved, returns `None`. +pub fn default_prompts_dir() -> Option { + crate::config::find_codex_home() + .ok() + .map(|home| home.join("prompts")) +} + +/// Discover prompt files in the given directory, returning entries sorted by name. +/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty. +pub async fn discover_prompts_in(dir: &Path) -> Vec { + discover_prompts_in_excluding(dir, &HashSet::new()).await +} + +/// Discover prompt files in the given directory, excluding any with names in `exclude`. +/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty. +pub async fn discover_prompts_in_excluding( + dir: &Path, + exclude: &HashSet, +) -> Vec { + let mut out: Vec = Vec::new(); + let mut entries = match fs::read_dir(dir).await { + Ok(entries) => entries, + Err(_) => return out, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let is_file = entry + .file_type() + .await + .map(|ft| ft.is_file()) + .unwrap_or(false); + if !is_file { + continue; + } + // Only include Markdown files with a .md extension. + let is_md = path + .extension() + .and_then(|s| s.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if !is_md { + continue; + } + let Some(name) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + else { + continue; + }; + if exclude.contains(&name) { + continue; + } + let content = match fs::read_to_string(&path).await { + Ok(s) => s, + Err(_) => continue, + }; + out.push(CustomPrompt { + name, + path, + content, + }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[tokio::test] + async fn empty_when_dir_missing() { + let tmp = tempdir().expect("create TempDir"); + let missing = tmp.path().join("nope"); + let found = discover_prompts_in(&missing).await; + assert!(found.is_empty()); + } + + #[tokio::test] + async fn discovers_and_sorts_files() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + fs::write(dir.join("b.md"), b"b").unwrap(); + fs::write(dir.join("a.md"), b"a").unwrap(); + fs::create_dir(dir.join("subdir")).unwrap(); + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["a", "b"]); + } + + #[tokio::test] + async fn excludes_builtins() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + fs::write(dir.join("init.md"), b"ignored").unwrap(); + fs::write(dir.join("foo.md"), b"ok").unwrap(); + let mut exclude = HashSet::new(); + exclude.insert("init".to_string()); + let found = discover_prompts_in_excluding(dir, &exclude).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["foo"]); + } + + #[tokio::test] + async fn skips_non_utf8_files() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + // Valid UTF-8 file + fs::write(dir.join("good.md"), b"hello").unwrap(); + // Invalid UTF-8 content in .md file (e.g., lone 0xFF byte) + fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap(); + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["good"]); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ae18332087..9f23420c7e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -17,6 +17,7 @@ pub mod config; pub mod config_profile; pub mod config_types; mod conversation_history; +pub mod custom_prompts; mod environment_context; pub mod error; pub mod exec; diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index f74188162c..59ca7a8361 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -47,7 +47,9 @@ pub(crate) enum OpenAiTool { Function(ResponsesApiTool), #[serde(rename = "local_shell")] LocalShell {}, - #[serde(rename = "web_search")] + // TODO: Understand why we get an error on web_search although the API docs say it's supported. + // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C + #[serde(rename = "web_search_preview")] WebSearch {}, #[serde(rename = "custom")] Freeform(FreeformTool), @@ -335,12 +337,12 @@ pub fn create_tools_json_for_responses_api( let mut tools_json = Vec::new(); for tool in tools { - tools_json.push(serde_json::to_value(tool)?); + let json = serde_json::to_value(tool)?; + tools_json.push(json); } Ok(tools_json) } - /// Returns JSON values that are compatible with Function Calling in the /// Chat Completions API: /// https://platform.openai.com/docs/guides/function-calling?api-mode=chat @@ -702,8 +704,8 @@ mod tests { "number_property": { "type": "number" }, }, "required": [ - "string_property".to_string(), - "number_property".to_string() + "string_property", + "number_property", ], "additionalProperties": Some(false), }, diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 46098c1637..3c7162e883 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -135,7 +135,7 @@ impl RolloutRecorder { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::Reasoning { .. } => filtered.push(item.clone()), - ResponseItem::Other => { + ResponseItem::WebSearchCall { .. } | ResponseItem::Other => { // These should never be serialized. continue; } @@ -199,7 +199,7 @@ impl RolloutRecorder { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::Reasoning { .. } => items.push(item), - ResponseItem::Other => {} + ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {} }, Err(e) => { warn!("failed to parse item: {v:?}, error: {e}"); @@ -326,7 +326,7 @@ async fn rollout_writer( | ResponseItem::Reasoning { .. } => { writer.write_line(&item).await?; } - ResponseItem::Other => {} + ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {} } } } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index a3558f7407..8403bbde28 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -25,6 +25,7 @@ use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::WebSearchBeginEvent; +use codex_core::protocol::WebSearchEndEvent; use owo_colors::OwoColorize; use owo_colors::Style; use shlex::try_join; @@ -362,8 +363,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { } } } - EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _, query }) => { - ts_println!(self, "🌐 {query}"); + EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _ }) => {} + EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => { + ts_println!(self, "🌐 Searched: {query}"); } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { call_id, @@ -533,6 +535,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::McpListToolsResponse(_) => { // Currently ignored in exec output. } + EventMsg::ListCustomPromptsResponse(_) => { + // Currently ignored in exec output. + } EventMsg::TurnAborted(abort_reason) => match abort_reason.reason { TurnAbortReason::Interrupted => { ts_println!(self, "task interrupted"); diff --git a/codex-rs/login/src/assets/success.html b/codex-rs/login/src/assets/success.html index eb2a0ee719..382f864c6a 100644 --- a/codex-rs/login/src/assets/success.html +++ b/codex-rs/login/src/assets/success.html @@ -2,7 +2,7 @@ - Sign into Codex CLI + Sign into Codex