merge(main<-merge/upstream-2025-08-29-124807): upstream sync; prefer ours; adopt compatible TUI fixes

This commit is contained in:
James Peter
2025-08-29 12:55:44 +10:00
22 changed files with 461 additions and 105 deletions
+6 -6
View File
@@ -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"
} }
} }
+5 -6
View File
@@ -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 {
+1 -1
View File
@@ -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;
} }
-1
View File
@@ -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,
+14 -3
View File
@@ -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)
+4 -5
View File
@@ -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,
+1 -1
View File
@@ -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,
} }
} }
+127
View File
@@ -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"]);
}
}
+1
View File
@@ -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;
+7 -5
View File
@@ -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),
}, },
+2 -1
View File
@@ -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)]
+3 -3
View File
@@ -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,
-1
View File
@@ -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);
+10
View File
@@ -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
View File
@@ -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;
+26
View File
@@ -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 {
+20
View File
@@ -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.
+55 -26
View File
@@ -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) {
+155 -39
View File
@@ -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"
);
}
} }
+2 -2
View File
@@ -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
+15
View File
@@ -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.