mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
merge(main<-merge/upstream-2025-08-29-124807): upstream sync; prefer ours; adopt compatible TUI fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@just-every/code",
|
"name": "@just-every/code",
|
||||||
"version": "0.2.38",
|
"version": "0.2.39",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Lightweight coding agent that runs in your terminal - fork of OpenAI Codex",
|
"description": "Lightweight coding agent that runs in your terminal - fork of OpenAI Codex",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
"prettier": "^3.3.3"
|
"prettier": "^3.3.3"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@just-every/code-darwin-arm64": "0.2.38",
|
"@just-every/code-darwin-arm64": "0.2.39",
|
||||||
"@just-every/code-darwin-x64": "0.2.38",
|
"@just-every/code-darwin-x64": "0.2.39",
|
||||||
"@just-every/code-linux-x64-musl": "0.2.38",
|
"@just-every/code-linux-x64-musl": "0.2.39",
|
||||||
"@just-every/code-linux-arm64-musl": "0.2.38",
|
"@just-every/code-linux-arm64-musl": "0.2.39",
|
||||||
"@just-every/code-win32-x64": "0.2.38"
|
"@just-every/code-win32-x64": "0.2.39"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ pub(crate) async fn stream_chat_completions(
|
|||||||
"content": output,
|
"content": output,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
ResponseItem::Reasoning { .. } | ResponseItem::Other => {
|
ResponseItem::Reasoning { .. }
|
||||||
|
| ResponseItem::WebSearchCall { .. }
|
||||||
|
| ResponseItem::Other => {
|
||||||
// Omit these items from the conversation history.
|
// Omit these items from the conversation history.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -767,11 +769,8 @@ where
|
|||||||
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => {
|
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { .. }))) => {
|
Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id }))) => {
|
||||||
return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin {
|
return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id })));
|
||||||
call_id: String::new(),
|
|
||||||
query: None,
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallCompleted { call_id, query }))) => {
|
Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallCompleted { call_id, query }))) => {
|
||||||
return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallCompleted {
|
return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallCompleted {
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ async fn process_sse<S>(
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.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() {
|
if tx_event.send(Ok(ev)).await.is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ pub enum ResponseEvent {
|
|||||||
ReasoningSummaryPartAdded,
|
ReasoningSummaryPartAdded,
|
||||||
WebSearchCallBegin {
|
WebSearchCallBegin {
|
||||||
call_id: String,
|
call_id: String,
|
||||||
query: Option<String>,
|
|
||||||
},
|
},
|
||||||
WebSearchCallCompleted {
|
WebSearchCallCompleted {
|
||||||
call_id: String,
|
call_id: String,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ use uuid::Uuid;
|
|||||||
use codex_login::CodexAuth;
|
use codex_login::CodexAuth;
|
||||||
use crate::protocol::WebSearchBeginEvent;
|
use crate::protocol::WebSearchBeginEvent;
|
||||||
use crate::protocol::WebSearchCompleteEvent;
|
use crate::protocol::WebSearchCompleteEvent;
|
||||||
|
use codex_protocol::models::WebSearchAction;
|
||||||
|
|
||||||
/// Initial submission ID for session configuration
|
/// Initial submission ID for session configuration
|
||||||
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||||||
@@ -2311,13 +2312,12 @@ async fn try_run_turn(
|
|||||||
|
|
||||||
output.push(ProcessedResponseItem { item, response });
|
output.push(ProcessedResponseItem { item, response });
|
||||||
}
|
}
|
||||||
ResponseEvent::WebSearchCallBegin { call_id, query } => {
|
ResponseEvent::WebSearchCallBegin { call_id } => {
|
||||||
let q = query.unwrap_or_else(|| "Searching Web...".to_string());
|
|
||||||
let _ = sess
|
let _ = sess
|
||||||
.tx_event
|
.tx_event
|
||||||
.send(Event {
|
.send(Event {
|
||||||
id: sub_id.to_string(),
|
id: sub_id.to_string(),
|
||||||
msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: q }),
|
msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: None }),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -2642,6 +2642,17 @@ async fn handle_response_item(
|
|||||||
debug!("unexpected CustomToolCallOutput from stream");
|
debug!("unexpected CustomToolCallOutput from stream");
|
||||||
None
|
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::WebSearchComplete(WebSearchCompleteEvent { call_id, query: Some(query) }),
|
||||||
|
};
|
||||||
|
sess.tx_event.send(event).await.ok();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
ResponseItem::Other => None,
|
ResponseItem::Other => None,
|
||||||
};
|
};
|
||||||
Ok(output)
|
Ok(output)
|
||||||
|
|||||||
@@ -560,7 +560,6 @@ pub struct ProjectConfig {
|
|||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone, Default)]
|
#[derive(Deserialize, Debug, Clone, Default)]
|
||||||
pub struct ToolsToml {
|
pub struct ToolsToml {
|
||||||
// Renamed from `web_search_request`; keep alias for backwards compatibility.
|
|
||||||
#[serde(default, alias = "web_search_request")]
|
#[serde(default, alias = "web_search_request")]
|
||||||
pub web_search: Option<bool>,
|
pub web_search: Option<bool>,
|
||||||
|
|
||||||
@@ -724,7 +723,7 @@ impl Config {
|
|||||||
})?
|
})?
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
let shell_environment_policy = cfg.shell_environment_policy.clone().into();
|
let shell_environment_policy = cfg.shell_environment_policy.into();
|
||||||
|
|
||||||
let resolved_cwd = {
|
let resolved_cwd = {
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -745,7 +744,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
|
let tools_web_search_request = override_tools_web_search_request
|
||||||
.or(cfg.tools.as_ref().and_then(|t| t.web_search))
|
.or(cfg.tools.as_ref().and_then(|t| t.web_search))
|
||||||
@@ -833,7 +832,7 @@ impl Config {
|
|||||||
codex_home,
|
codex_home,
|
||||||
history,
|
history,
|
||||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
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,
|
codex_linux_sandbox_exe,
|
||||||
|
|
||||||
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
|
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
|
||||||
@@ -856,7 +855,7 @@ impl Config {
|
|||||||
|
|
||||||
chatgpt_base_url: config_profile
|
chatgpt_base_url: config_profile
|
||||||
.chatgpt_base_url
|
.chatgpt_base_url
|
||||||
.or(cfg.chatgpt_base_url.clone())
|
.or(cfg.chatgpt_base_url)
|
||||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||||
|
|
||||||
experimental_resume,
|
experimental_resume,
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
|||||||
| ResponseItem::CustomToolCallOutput { .. }
|
| ResponseItem::CustomToolCallOutput { .. }
|
||||||
| ResponseItem::LocalShellCall { .. }
|
| ResponseItem::LocalShellCall { .. }
|
||||||
| ResponseItem::Reasoning { .. } => true,
|
| ResponseItem::Reasoning { .. } => true,
|
||||||
ResponseItem::Other => false,
|
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<PathBuf> {
|
||||||
|
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<CustomPrompt> {
|
||||||
|
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<String>,
|
||||||
|
) -> Vec<CustomPrompt> {
|
||||||
|
let mut out: Vec<CustomPrompt> = 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<String> = 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<String> = 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<String> = found.into_iter().map(|e| e.name).collect();
|
||||||
|
assert_eq!(names, vec!["good"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub mod config;
|
|||||||
pub mod config_profile;
|
pub mod config_profile;
|
||||||
pub mod config_types;
|
pub mod config_types;
|
||||||
mod conversation_history;
|
mod conversation_history;
|
||||||
|
pub mod custom_prompts;
|
||||||
mod environment_context;
|
mod environment_context;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod exec;
|
pub mod exec;
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ pub(crate) enum OpenAiTool {
|
|||||||
Function(ResponsesApiTool),
|
Function(ResponsesApiTool),
|
||||||
#[serde(rename = "local_shell")]
|
#[serde(rename = "local_shell")]
|
||||||
LocalShell {},
|
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 {},
|
WebSearch {},
|
||||||
#[serde(rename = "custom")]
|
#[serde(rename = "custom")]
|
||||||
Freeform(FreeformTool),
|
Freeform(FreeformTool),
|
||||||
@@ -330,12 +332,12 @@ pub fn create_tools_json_for_responses_api(
|
|||||||
let mut tools_json = Vec::new();
|
let mut tools_json = Vec::new();
|
||||||
|
|
||||||
for tool in tools {
|
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)
|
Ok(tools_json)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns JSON values that are compatible with Function Calling in the
|
/// Returns JSON values that are compatible with Function Calling in the
|
||||||
/// Chat Completions API:
|
/// Chat Completions API:
|
||||||
/// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
|
/// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
|
||||||
@@ -742,8 +744,8 @@ mod tests {
|
|||||||
"number_property": { "type": "number" },
|
"number_property": { "type": "number" },
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"string_property".to_string(),
|
"string_property",
|
||||||
"number_property".to_string()
|
"number_property",
|
||||||
],
|
],
|
||||||
"additionalProperties": Some(false),
|
"additionalProperties": Some(false),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -541,7 +541,8 @@ impl From<TokenUsage> for FinalOutput {
|
|||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct WebSearchBeginEvent {
|
pub struct WebSearchBeginEvent {
|
||||||
pub call_id: String,
|
pub call_id: String,
|
||||||
pub query: String,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub query: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ impl RolloutRecorder {
|
|||||||
| ResponseItem::CustomToolCall { .. }
|
| ResponseItem::CustomToolCall { .. }
|
||||||
| ResponseItem::CustomToolCallOutput { .. }
|
| ResponseItem::CustomToolCallOutput { .. }
|
||||||
| ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
|
| ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
|
||||||
ResponseItem::Other => {
|
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {
|
||||||
// These should never be serialized.
|
// These should never be serialized.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ impl RolloutRecorder {
|
|||||||
| ResponseItem::CustomToolCall { .. }
|
| ResponseItem::CustomToolCall { .. }
|
||||||
| ResponseItem::CustomToolCallOutput { .. }
|
| ResponseItem::CustomToolCallOutput { .. }
|
||||||
| ResponseItem::Reasoning { .. } => items.push(item),
|
| ResponseItem::Reasoning { .. } => items.push(item),
|
||||||
ResponseItem::Other => {}
|
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("failed to parse item: {v:?}, error: {e}");
|
warn!("failed to parse item: {v:?}, error: {e}");
|
||||||
@@ -387,7 +387,7 @@ async fn rollout_writer(
|
|||||||
msg_count_delta = msg_count_delta.saturating_add(1);
|
msg_count_delta = msg_count_delta.saturating_add(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResponseItem::Other => {}
|
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use codex_core::protocol::SessionConfiguredEvent;
|
|||||||
use codex_core::protocol::TaskCompleteEvent;
|
use codex_core::protocol::TaskCompleteEvent;
|
||||||
use codex_core::protocol::TurnDiffEvent;
|
use codex_core::protocol::TurnDiffEvent;
|
||||||
use codex_core::protocol::WebSearchBeginEvent;
|
use codex_core::protocol::WebSearchBeginEvent;
|
||||||
|
use codex_core::protocol::WebSearchCompleteEvent;
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
use owo_colors::Style;
|
use owo_colors::Style;
|
||||||
use shlex::try_join;
|
use shlex::try_join;
|
||||||
@@ -359,11 +360,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _, query }) => {
|
EventMsg::WebSearchBegin(WebSearchBeginEvent { .. }) => {}
|
||||||
ts_println!(self, "🌐 {query}");
|
EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id: _, query }) => {
|
||||||
}
|
if let Some(query) = query {
|
||||||
EventMsg::WebSearchComplete(_) => {
|
ts_println!(self, "🌐 Searched: {query}");
|
||||||
// No-op for human output; completion is reflected in UI.
|
}
|
||||||
}
|
}
|
||||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||||
call_id,
|
call_id,
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ pub async fn run_main(
|
|||||||
|
|
||||||
// Task: read from stdin, push to `incoming_tx`.
|
// Task: read from stdin, push to `incoming_tx`.
|
||||||
let stdin_reader_handle = tokio::spawn({
|
let stdin_reader_handle = tokio::spawn({
|
||||||
let incoming_tx = incoming_tx.clone();
|
|
||||||
async move {
|
async move {
|
||||||
let stdin = io::stdin();
|
let stdin = io::stdin();
|
||||||
let reader = BufReader::new(stdin);
|
let reader = BufReader::new(stdin);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct CustomPrompt {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod config_types;
|
pub mod config_types;
|
||||||
|
pub mod custom_prompts;
|
||||||
pub mod mcp_protocol;
|
pub mod mcp_protocol;
|
||||||
pub mod message_history;
|
pub mod message_history;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|||||||
@@ -102,6 +102,22 @@ pub enum ResponseItem {
|
|||||||
call_id: String,
|
call_id: String,
|
||||||
output: String,
|
output: String,
|
||||||
},
|
},
|
||||||
|
// Emitted by the Responses API when the agent triggers a web search.
|
||||||
|
// Example payload (from SSE `response.output_item.done`):
|
||||||
|
// {
|
||||||
|
// "id":"ws_...",
|
||||||
|
// "type":"web_search_call",
|
||||||
|
// "status":"completed",
|
||||||
|
// "action": {"type":"search","query":"weather: San Francisco, CA"}
|
||||||
|
// }
|
||||||
|
WebSearchCall {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
status: Option<String>,
|
||||||
|
action: WebSearchAction,
|
||||||
|
},
|
||||||
|
|
||||||
#[serde(other)]
|
#[serde(other)]
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
@@ -169,6 +185,16 @@ pub struct LocalShellExecAction {
|
|||||||
pub user: Option<String>,
|
pub user: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum WebSearchAction {
|
||||||
|
Search {
|
||||||
|
query: String,
|
||||||
|
},
|
||||||
|
#[serde(other)]
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ReasoningItemReasoningSummary {
|
pub enum ReasoningItemReasoningSummary {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::path::PathBuf;
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::custom_prompts::CustomPrompt;
|
||||||
use mcp_types::CallToolResult;
|
use mcp_types::CallToolResult;
|
||||||
use mcp_types::Tool as McpTool;
|
use mcp_types::Tool as McpTool;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -146,6 +147,9 @@ pub enum Op {
|
|||||||
/// Reply is delivered via `EventMsg::McpListToolsResponse`.
|
/// Reply is delivered via `EventMsg::McpListToolsResponse`.
|
||||||
ListMcpTools,
|
ListMcpTools,
|
||||||
|
|
||||||
|
/// Request the list of available custom prompts.
|
||||||
|
ListCustomPrompts,
|
||||||
|
|
||||||
/// Request the agent to summarize the current conversation context.
|
/// Request the agent to summarize the current conversation context.
|
||||||
/// The agent will use its existing context (either conversation history or previous response id)
|
/// The agent will use its existing context (either conversation history or previous response id)
|
||||||
/// to generate a summary which will be returned as an AgentMessage event.
|
/// to generate a summary which will be returned as an AgentMessage event.
|
||||||
@@ -440,6 +444,8 @@ pub enum EventMsg {
|
|||||||
|
|
||||||
WebSearchBegin(WebSearchBeginEvent),
|
WebSearchBegin(WebSearchBeginEvent),
|
||||||
|
|
||||||
|
WebSearchEnd(WebSearchEndEvent),
|
||||||
|
|
||||||
/// Notification that the server is about to execute a command.
|
/// Notification that the server is about to execute a command.
|
||||||
ExecCommandBegin(ExecCommandBeginEvent),
|
ExecCommandBegin(ExecCommandBeginEvent),
|
||||||
|
|
||||||
@@ -473,6 +479,9 @@ pub enum EventMsg {
|
|||||||
/// List of MCP tools available to the agent.
|
/// List of MCP tools available to the agent.
|
||||||
McpListToolsResponse(McpListToolsResponseEvent),
|
McpListToolsResponse(McpListToolsResponseEvent),
|
||||||
|
|
||||||
|
/// List of custom prompts available to the agent.
|
||||||
|
ListCustomPromptsResponse(ListCustomPromptsResponseEvent),
|
||||||
|
|
||||||
PlanUpdate(UpdatePlanArgs),
|
PlanUpdate(UpdatePlanArgs),
|
||||||
|
|
||||||
TurnAborted(TurnAbortedEvent),
|
TurnAborted(TurnAbortedEvent),
|
||||||
@@ -669,6 +678,11 @@ impl McpToolCallEndEvent {
|
|||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct WebSearchBeginEvent {
|
pub struct WebSearchBeginEvent {
|
||||||
pub call_id: String,
|
pub call_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct WebSearchEndEvent {
|
||||||
|
pub call_id: String,
|
||||||
pub query: String,
|
pub query: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,6 +821,12 @@ pub struct McpListToolsResponseEvent {
|
|||||||
pub tools: std::collections::HashMap<String, McpTool>,
|
pub tools: std::collections::HashMap<String, McpTool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response payload for `Op::ListCustomPrompts`.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ListCustomPromptsResponseEvent {
|
||||||
|
pub custom_prompts: Vec<CustomPrompt>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||||
pub struct SessionConfiguredEvent {
|
pub struct SessionConfiguredEvent {
|
||||||
/// Unique id for this session.
|
/// Unique id for this session.
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ use ratatui::widgets::StatefulWidgetRef;
|
|||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
|
||||||
use super::chat_composer_history::ChatComposerHistory;
|
use super::chat_composer_history::ChatComposerHistory;
|
||||||
|
use super::command_popup::CommandItem;
|
||||||
use super::command_popup::CommandPopup;
|
use super::command_popup::CommandPopup;
|
||||||
use super::file_search_popup::FileSearchPopup;
|
use super::file_search_popup::FileSearchPopup;
|
||||||
use super::paste_burst::PasteBurst;
|
use super::paste_burst::PasteBurst;
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
|
use codex_protocol::custom_prompts::CustomPrompt;
|
||||||
|
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
@@ -45,6 +47,7 @@ const BASE_PLACEHOLDER_TEXT: &str = "Welcome to Code — What are we coding toda
|
|||||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||||
|
|
||||||
/// Result returned when the user interacts with the text area.
|
/// Result returned when the user interacts with the text area.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
pub enum InputResult {
|
pub enum InputResult {
|
||||||
Submitted(String),
|
Submitted(String),
|
||||||
Command(SlashCommand),
|
Command(SlashCommand),
|
||||||
@@ -601,16 +604,27 @@ impl ChatComposer {
|
|||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Tab, ..
|
code: KeyCode::Tab, ..
|
||||||
} => {
|
} => {
|
||||||
if let Some(cmd) = popup.selected_command() {
|
if let Some(sel) = popup.selected_item() {
|
||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
|
|
||||||
let starts_with_cmd = first_line
|
match sel {
|
||||||
.trim_start()
|
CommandItem::Builtin(cmd) => {
|
||||||
.starts_with(&format!("/{}", cmd.command()));
|
let starts_with_cmd = first_line
|
||||||
|
.trim_start()
|
||||||
if !starts_with_cmd {
|
.starts_with(&format!("/{}", cmd.command()));
|
||||||
self.textarea.set_text(&format!("/{} ", cmd.command()));
|
if !starts_with_cmd {
|
||||||
self.textarea.set_cursor(self.textarea.text().len());
|
self.textarea.set_text(&format!("/{} ", cmd.command()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandItem::UserPrompt(idx) => {
|
||||||
|
if let Some(name) = popup.prompt_name(idx) {
|
||||||
|
let starts_with_cmd =
|
||||||
|
first_line.trim_start().starts_with(&format!("/{name}"));
|
||||||
|
if !starts_with_cmd {
|
||||||
|
self.textarea.set_text(&format!("/{name} "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// After completing, place the cursor at the end of the
|
// After completing, place the cursor at the end of the
|
||||||
// slash command so the user can immediately type args.
|
// slash command so the user can immediately type args.
|
||||||
@@ -624,31 +638,39 @@ impl ChatComposer {
|
|||||||
modifiers: KeyModifiers::NONE,
|
modifiers: KeyModifiers::NONE,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if let Some(cmd) = popup.selected_command() {
|
if let Some(sel) = popup.selected_item() {
|
||||||
// Get the full command text before clearing
|
// Get the full command text before clearing
|
||||||
let command_text = self.textarea.text().to_string();
|
let command_text = self.textarea.text().to_string();
|
||||||
|
|
||||||
// Record the exact slash command that was typed
|
// Record the exact slash command that was typed
|
||||||
self.history.record_local_submission(&command_text);
|
self.history.record_local_submission(&command_text);
|
||||||
|
|
||||||
// Check if this is a prompt-expanding command that will trigger agents
|
match sel {
|
||||||
if cmd.is_prompt_expanding() {
|
CommandItem::Builtin(cmd) => {
|
||||||
self.app_event_tx.send(AppEvent::PrepareAgents);
|
// Check if this is a prompt-expanding command that will trigger agents
|
||||||
|
if cmd.is_prompt_expanding() {
|
||||||
|
self.app_event_tx.send(AppEvent::PrepareAgents);
|
||||||
|
}
|
||||||
|
// Send command to the app layer with full text.
|
||||||
|
self.app_event_tx
|
||||||
|
.send(AppEvent::DispatchCommand(cmd, command_text.clone()));
|
||||||
|
// Clear textarea and dismiss popup
|
||||||
|
self.textarea.set_text("");
|
||||||
|
self.active_popup = ActivePopup::None;
|
||||||
|
return (InputResult::Command(cmd), true);
|
||||||
|
}
|
||||||
|
CommandItem::UserPrompt(idx) => {
|
||||||
|
let prompt_content = popup
|
||||||
|
.prompt_content(idx)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
self.textarea.set_text("");
|
||||||
|
self.active_popup = ActivePopup::None;
|
||||||
|
if let Some(contents) = prompt_content {
|
||||||
|
return (InputResult::Submitted(contents), true);
|
||||||
|
}
|
||||||
|
return (InputResult::None, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send command to the app layer with full text.
|
|
||||||
self.app_event_tx
|
|
||||||
.send(AppEvent::DispatchCommand(*cmd, command_text.clone()));
|
|
||||||
|
|
||||||
// Clear textarea so no residual text remains.
|
|
||||||
self.textarea.set_text("");
|
|
||||||
|
|
||||||
let result = (InputResult::Command(*cmd), true);
|
|
||||||
|
|
||||||
// Hide popup since the command has been dispatched.
|
|
||||||
self.active_popup = ActivePopup::None;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
// Fallback to default newline handling if no command selected.
|
// Fallback to default newline handling if no command selected.
|
||||||
self.handle_key_event_without_popup(key_event)
|
self.handle_key_event_without_popup(key_event)
|
||||||
@@ -1227,6 +1249,13 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
|
||||||
|
if let ActivePopup::Command(popup) = &mut self.active_popup {
|
||||||
|
popup.set_prompts(prompts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
||||||
/// Note this is only called when self.active_popup is NOT Command.
|
/// Note this is only called when self.active_popup is NOT Command.
|
||||||
fn sync_file_search_popup(&mut self) {
|
fn sync_file_search_popup(&mut self) {
|
||||||
|
|||||||
@@ -10,10 +10,21 @@ use super::selection_popup_common::render_rows;
|
|||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
use crate::slash_command::built_in_slash_commands;
|
use crate::slash_command::built_in_slash_commands;
|
||||||
use codex_common::fuzzy_match::fuzzy_match;
|
use codex_common::fuzzy_match::fuzzy_match;
|
||||||
|
use codex_protocol::custom_prompts::CustomPrompt;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CommandItem {
|
||||||
|
Builtin(SlashCommand),
|
||||||
|
// Index into `prompts`
|
||||||
|
UserPrompt(usize),
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct CommandPopup {
|
pub(crate) struct CommandPopup {
|
||||||
command_filter: String,
|
command_filter: String,
|
||||||
all_commands: Vec<(&'static str, SlashCommand)>,
|
builtins: Vec<(&'static str, SlashCommand)>,
|
||||||
|
prompts: Vec<CustomPrompt>,
|
||||||
state: ScrollState,
|
state: ScrollState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +42,32 @@ impl CommandPopup {
|
|||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
command_filter: String::new(),
|
command_filter: String::new(),
|
||||||
all_commands: commands,
|
builtins: commands,
|
||||||
|
prompts: Vec::new(),
|
||||||
state: ScrollState::new(),
|
state: ScrollState::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
|
||||||
|
let exclude: HashSet<String> = self
|
||||||
|
.builtins
|
||||||
|
.iter()
|
||||||
|
.map(|(n, _)| (*n).to_string())
|
||||||
|
.collect();
|
||||||
|
prompts.retain(|p| !exclude.contains(&p.name));
|
||||||
|
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
self.prompts = prompts;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_name(&self, idx: usize) -> Option<&str> {
|
||||||
|
self.prompts.get(idx).map(|p| p.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_content(&self, idx: usize) -> Option<&str> {
|
||||||
|
self.prompts.get(idx).map(|p| p.content.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the filter string based on the current composer text. The text
|
/// Update the filter string based on the current composer text. The text
|
||||||
/// passed in is expected to start with a leading '/'. Everything after the
|
/// passed in is expected to start with a leading '/'. Everything after the
|
||||||
/// *first* '/" on the *first* line becomes the active filter that is used
|
/// *first* '/" on the *first* line becomes the active filter that is used
|
||||||
@@ -61,7 +93,7 @@ impl CommandPopup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset or clamp selected index based on new filtered list.
|
// Reset or clamp selected index based on new filtered list.
|
||||||
let matches_len = self.filtered_commands().len();
|
let matches_len = self.filtered_items().len();
|
||||||
self.state.clamp_selection(matches_len);
|
self.state.clamp_selection(matches_len);
|
||||||
self.state
|
self.state
|
||||||
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||||
@@ -70,61 +102,81 @@ impl CommandPopup {
|
|||||||
/// Determine the preferred height of the popup. This is the number of
|
/// Determine the preferred height of the popup. This is the number of
|
||||||
/// rows required to show at most MAX_POPUP_ROWS commands.
|
/// rows required to show at most MAX_POPUP_ROWS commands.
|
||||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||||
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
|
self.filtered_items().len().clamp(1, MAX_POPUP_ROWS) as u16
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute fuzzy-filtered matches paired with optional highlight indices and score.
|
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
||||||
/// Sorted by ascending score, then by command name for stability.
|
/// paired with optional highlight indices and score. Sorted by ascending
|
||||||
fn filtered(&self) -> Vec<(&SlashCommand, Option<Vec<usize>>, i32)> {
|
/// score, then by name for stability.
|
||||||
|
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>, i32)> {
|
||||||
let filter = self.command_filter.trim();
|
let filter = self.command_filter.trim();
|
||||||
let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
|
let mut out: Vec<(CommandItem, Option<Vec<usize>>, i32)> = Vec::new();
|
||||||
if filter.is_empty() {
|
if filter.is_empty() {
|
||||||
for (_, cmd) in self.all_commands.iter() {
|
// Built-ins first, in presentation order.
|
||||||
out.push((cmd, None, 0));
|
for (_, cmd) in self.builtins.iter() {
|
||||||
|
out.push((CommandItem::Builtin(*cmd), None, 0));
|
||||||
|
}
|
||||||
|
// Then prompts, already sorted by name.
|
||||||
|
for idx in 0..self.prompts.len() {
|
||||||
|
out.push((CommandItem::UserPrompt(idx), None, 0));
|
||||||
}
|
}
|
||||||
// Keep the original presentation order when no filter is applied.
|
|
||||||
return out;
|
return out;
|
||||||
} else {
|
}
|
||||||
for (_, cmd) in self.all_commands.iter() {
|
|
||||||
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
|
for (_, cmd) in self.builtins.iter() {
|
||||||
out.push((cmd, Some(indices), score));
|
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
|
||||||
}
|
out.push((CommandItem::Builtin(*cmd), Some(indices), score));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// When filtering, sort by ascending score and then by command for stability.
|
for (idx, p) in self.prompts.iter().enumerate() {
|
||||||
out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command())));
|
if let Some((indices, score)) = fuzzy_match(&p.name, filter) {
|
||||||
|
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// When filtering, sort by ascending score and then by name for stability.
|
||||||
|
out.sort_by(|a, b| {
|
||||||
|
a.2.cmp(&b.2).then_with(|| {
|
||||||
|
let an = match a.0 {
|
||||||
|
CommandItem::Builtin(c) => c.command(),
|
||||||
|
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||||
|
};
|
||||||
|
let bn = match b.0 {
|
||||||
|
CommandItem::Builtin(c) => c.command(),
|
||||||
|
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||||
|
};
|
||||||
|
an.cmp(bn)
|
||||||
|
})
|
||||||
|
});
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filtered_commands(&self) -> Vec<&SlashCommand> {
|
fn filtered_items(&self) -> Vec<CommandItem> {
|
||||||
self.filtered().into_iter().map(|(c, _, _)| c).collect()
|
self.filtered().into_iter().map(|(c, _, _)| c).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the current number of selectable commands under the active filter.
|
/// Return the current number of selectable commands under the active filter.
|
||||||
pub(crate) fn match_count(&self) -> usize {
|
pub(crate) fn match_count(&self) -> usize {
|
||||||
self.filtered_commands().len()
|
self.filtered_items().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the selection cursor one step up.
|
/// Move the selection cursor one step up.
|
||||||
pub(crate) fn move_up(&mut self) {
|
pub(crate) fn move_up(&mut self) {
|
||||||
let matches = self.filtered_commands();
|
let len = self.filtered_items().len();
|
||||||
let len = matches.len();
|
|
||||||
self.state.move_up_wrap(len);
|
self.state.move_up_wrap(len);
|
||||||
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the selection cursor one step down.
|
/// Move the selection cursor one step down.
|
||||||
pub(crate) fn move_down(&mut self) {
|
pub(crate) fn move_down(&mut self) {
|
||||||
let matches = self.filtered_commands();
|
let matches_len = self.filtered_items().len();
|
||||||
let matches_len = matches.len();
|
|
||||||
self.state.move_down_wrap(matches_len);
|
self.state.move_down_wrap(matches_len);
|
||||||
self.state
|
self.state
|
||||||
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return currently selected command, if any.
|
/// Return currently selected command, if any.
|
||||||
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
|
pub(crate) fn selected_item(&self) -> Option<CommandItem> {
|
||||||
let matches = self.filtered_commands();
|
let matches = self.filtered_items();
|
||||||
self.state
|
self.state
|
||||||
.selected_idx
|
.selected_idx
|
||||||
.and_then(|idx| matches.get(idx).copied())
|
.and_then(|idx| matches.get(idx).copied())
|
||||||
@@ -144,13 +196,26 @@ impl WidgetRef for CommandPopup {
|
|||||||
} else {
|
} else {
|
||||||
matches
|
matches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(cmd, indices, _)| GenericDisplayRow {
|
.map(|(cmd_item, indices, _)| {
|
||||||
name: format!("/{}", cmd.command()),
|
let (name, desc) = match cmd_item {
|
||||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
CommandItem::Builtin(cmd) => (
|
||||||
is_current: false,
|
format!("/{}", cmd.command()),
|
||||||
description: Some(cmd.description().to_string()),
|
Some(cmd.description().to_string()),
|
||||||
// Slash command names should use theme primary color
|
),
|
||||||
name_color: Some(crate::colors::primary()),
|
CommandItem::UserPrompt(i) => (
|
||||||
|
format!("/{}", self.prompts[i].name),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
GenericDisplayRow {
|
||||||
|
name,
|
||||||
|
match_indices: indices
|
||||||
|
.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||||
|
is_current: false,
|
||||||
|
description: desc,
|
||||||
|
// Slash command names should use theme primary color
|
||||||
|
name_color: Some(crate::colors::primary()),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
@@ -171,31 +236,82 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_includes_init_when_typing_prefix() {
|
fn filter_includes_init_when_typing_prefix() {
|
||||||
let mut popup = CommandPopup::new();
|
let mut popup = CommandPopup::new(Vec::new());
|
||||||
// Simulate the composer line starting with '/in' so the popup filters
|
// Simulate the composer line starting with '/in' so the popup filters
|
||||||
// matching commands by prefix.
|
// matching commands by prefix.
|
||||||
popup.on_composer_text_change("/in".to_string());
|
popup.on_composer_text_change("/in".to_string());
|
||||||
|
|
||||||
// Access the filtered list via the selected command and ensure that
|
// Access the filtered list via the selected command and ensure that
|
||||||
// one of the matches is the new "init" command.
|
// one of the matches is the new "init" command.
|
||||||
let matches = popup.filtered_commands();
|
let matches = popup.filtered_items();
|
||||||
|
let has_init = matches.iter().any(|item| match item {
|
||||||
|
CommandItem::Builtin(cmd) => cmd.command() == "init",
|
||||||
|
CommandItem::UserPrompt(_) => false,
|
||||||
|
});
|
||||||
assert!(
|
assert!(
|
||||||
matches.iter().any(|cmd| cmd.command() == "init"),
|
has_init,
|
||||||
"expected '/init' to appear among filtered commands"
|
"expected '/init' to appear among filtered commands"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selecting_init_by_exact_match() {
|
fn selecting_init_by_exact_match() {
|
||||||
let mut popup = CommandPopup::new();
|
let mut popup = CommandPopup::new(Vec::new());
|
||||||
popup.on_composer_text_change("/init".to_string());
|
popup.on_composer_text_change("/init".to_string());
|
||||||
|
|
||||||
// When an exact match exists, the selected command should be that
|
// When an exact match exists, the selected command should be that
|
||||||
// command by default.
|
// command by default.
|
||||||
let selected = popup.selected_command();
|
let selected = popup.selected_item();
|
||||||
match selected {
|
match selected {
|
||||||
Some(cmd) => assert_eq!(cmd.command(), "init"),
|
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
|
||||||
|
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
|
||||||
None => panic!("expected a selected command for exact match"),
|
None => panic!("expected a selected command for exact match"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_discovery_lists_custom_prompts() {
|
||||||
|
let prompts = vec![
|
||||||
|
CustomPrompt {
|
||||||
|
name: "foo".to_string(),
|
||||||
|
path: "/tmp/foo.md".to_string().into(),
|
||||||
|
content: "hello from foo".to_string(),
|
||||||
|
},
|
||||||
|
CustomPrompt {
|
||||||
|
name: "bar".to_string(),
|
||||||
|
path: "/tmp/bar.md".to_string().into(),
|
||||||
|
content: "hello from bar".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let popup = CommandPopup::new(prompts);
|
||||||
|
let items = popup.filtered_items();
|
||||||
|
let mut prompt_names: Vec<String> = items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|it| match it {
|
||||||
|
CommandItem::UserPrompt(i) => popup.prompt_name(i).map(|s| s.to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
prompt_names.sort();
|
||||||
|
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_name_collision_with_builtin_is_ignored() {
|
||||||
|
// Create a prompt named like a builtin (e.g. "init").
|
||||||
|
let popup = CommandPopup::new(vec![CustomPrompt {
|
||||||
|
name: "init".to_string(),
|
||||||
|
path: "/tmp/init.md".to_string().into(),
|
||||||
|
content: "should be ignored".to_string(),
|
||||||
|
}]);
|
||||||
|
let items = popup.filtered_items();
|
||||||
|
let has_collision_prompt = items.into_iter().any(|it| match it {
|
||||||
|
CommandItem::UserPrompt(i) => popup.prompt_name(i) == Some("init"),
|
||||||
|
_ => false,
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!has_collision_prompt,
|
||||||
|
"prompt with builtin name should be ignored"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2551,12 +2551,12 @@ impl ChatWidget<'_> {
|
|||||||
self.flush_interrupt_queue();
|
self.flush_interrupt_queue();
|
||||||
|
|
||||||
// Show an active Web Search entry with live timer and query (if present)
|
// Show an active Web Search entry with live timer and query (if present)
|
||||||
let cell = history_cell::new_running_web_search(Some(ev.query.clone()));
|
let cell = history_cell::new_running_web_search(ev.query.clone());
|
||||||
self.add_to_history(cell);
|
self.add_to_history(cell);
|
||||||
|
|
||||||
// Track by call_id so we can mark complete later
|
// Track by call_id so we can mark complete later
|
||||||
if let Some(last_idx) = self.history_cells.len().checked_sub(1) {
|
if let Some(last_idx) = self.history_cells.len().checked_sub(1) {
|
||||||
self.running_web_search.insert(ev.call_id, (last_idx, Some(ev.query)));
|
self.running_web_search.insert(ev.call_id, (last_idx, ev.query));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reflect status in the input border
|
// Reflect status in the input border
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Custom Prompts
|
||||||
|
|
||||||
|
Save frequently used prompts as Markdown files and reuse them quickly from the slash menu.
|
||||||
|
|
||||||
|
- Location: Put files in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`).
|
||||||
|
- File type: Only Markdown files with the `.md` extension are recognized.
|
||||||
|
- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`.
|
||||||
|
- Content: The file contents are sent as your message when you select the item in the slash popup and press Enter.
|
||||||
|
- How to use:
|
||||||
|
- Start a new session (Codex loads custom prompts on session start).
|
||||||
|
- In the composer, type `/` to open the slash popup and begin typing your prompt name.
|
||||||
|
- Use Up/Down to select it. Press Enter to submit its contents, or Tab to autocomplete the name.
|
||||||
|
- Notes:
|
||||||
|
- Files with names that collide with built‑in commands (e.g. `/init`) are ignored and won’t appear.
|
||||||
|
- New or changed files are discovered on session start. If you add a new prompt while Codex is running, start a new session to pick it up.
|
||||||
Reference in New Issue
Block a user