mirror of
https://github.com/openai/codex.git
synced 2026-02-05 00:13:42 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ecb4522dc | ||
|
|
b8e1fe60c5 | ||
|
|
ddfb7eb548 | ||
|
|
6910be3224 | ||
|
|
a534356fe1 | ||
|
|
c89b0e1235 | ||
|
|
f6a152848a | ||
|
|
3592ecb23c |
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -773,7 +773,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1126,7 +1125,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-protocol",
|
||||
"mcp-types",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
|
||||
@@ -55,14 +55,6 @@ In the transcript preview, the footer shows an `Esc edit prev` hint while editin
|
||||
|
||||
Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session.
|
||||
|
||||
### Resuming sessions
|
||||
|
||||
When you use `codex resume`, provide any follow-up prompt *before* an optional session id. This keeps combinations like `codex resume --last "fix the tests"` working while still letting you resume a specific session when needed:
|
||||
|
||||
- `codex resume --last "kick off linting"` — resume the most recent session and immediately send a new prompt.
|
||||
- `codex resume "draft release notes" d9b7b8b8-3a1f-4a4d-b0a2-4f04bb8d58df` — resume a specific session and send a follow-up prompt.
|
||||
- `codex resume d9b7b8b8-3a1f-4a4d-b0a2-4f04bb8d58df` — resume a session without sending a prompt (the CLI treats lone UUIDs as session ids).
|
||||
|
||||
### Shell completions
|
||||
|
||||
Generate shell completion scripts via:
|
||||
|
||||
@@ -193,28 +193,43 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::LoginApiKey { request_id, params } => {
|
||||
self.login_api_key(request_id, params).await;
|
||||
}
|
||||
ClientRequest::LoginChatGpt { request_id } => {
|
||||
ClientRequest::LoginChatGpt {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.login_chatgpt(request_id).await;
|
||||
}
|
||||
ClientRequest::CancelLoginChatGpt { request_id, params } => {
|
||||
self.cancel_login_chatgpt(request_id, params.login_id).await;
|
||||
}
|
||||
ClientRequest::LogoutChatGpt { request_id } => {
|
||||
ClientRequest::LogoutChatGpt {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.logout_chatgpt(request_id).await;
|
||||
}
|
||||
ClientRequest::GetAuthStatus { request_id, params } => {
|
||||
self.get_auth_status(request_id, params).await;
|
||||
}
|
||||
ClientRequest::GetUserSavedConfig { request_id } => {
|
||||
ClientRequest::GetUserSavedConfig {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.get_user_saved_config(request_id).await;
|
||||
}
|
||||
ClientRequest::SetDefaultModel { request_id, params } => {
|
||||
self.set_default_model(request_id, params).await;
|
||||
}
|
||||
ClientRequest::GetUserAgent { request_id } => {
|
||||
ClientRequest::GetUserAgent {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.get_user_agent(request_id).await;
|
||||
}
|
||||
ClientRequest::UserInfo { request_id } => {
|
||||
ClientRequest::UserInfo {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.get_user_info(request_id).await;
|
||||
}
|
||||
ClientRequest::FuzzyFileSearch { request_id, params } => {
|
||||
|
||||
@@ -36,7 +36,6 @@ ctor = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use clap::CommandFactory;
|
||||
use clap::Parser;
|
||||
use clap::error::Error as ClapError;
|
||||
use clap::error::ErrorKind as ClapErrorKind;
|
||||
use clap_complete::Shell;
|
||||
use clap_complete::generate;
|
||||
use codex_arg0::arg0_dispatch_or_else;
|
||||
@@ -24,7 +22,6 @@ use codex_tui::Cli as TuiCli;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::path::PathBuf;
|
||||
use supports_color::Stream;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod mcp_cmd;
|
||||
|
||||
@@ -115,17 +112,17 @@ struct CompletionCommand {
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ResumeCommand {
|
||||
#[clap(flatten)]
|
||||
config_overrides: TuiCli,
|
||||
|
||||
/// Continue the most recent session without showing the picker.
|
||||
#[arg(long = "last", default_value_t = false)]
|
||||
last: bool,
|
||||
|
||||
/// Conversation/session id (UUID). When provided, resumes this session.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
#[arg(value_name = "SESSION_ID", index = 2)]
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
session_id: Option<String>,
|
||||
|
||||
/// Continue the most recent session without showing the picker.
|
||||
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
|
||||
last: bool,
|
||||
|
||||
#[clap(flatten)]
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -227,25 +224,12 @@ fn print_exit_messages(exit_info: AppExitInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE";
|
||||
|
||||
/// As early as possible in the process lifecycle, apply hardening measures
|
||||
/// if the CODEX_SECURE_MODE environment variable is set to "1".
|
||||
/// As early as possible in the process lifecycle, apply hardening measures. We
|
||||
/// skip this in debug builds to avoid interfering with debugging.
|
||||
#[ctor::ctor]
|
||||
#[cfg(not(debug_assertions))]
|
||||
fn pre_main_hardening() {
|
||||
let secure_mode = match std::env::var(CODEX_SECURE_MODE_ENV_VAR) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if secure_mode == "1" {
|
||||
codex_process_hardening::pre_main_hardening();
|
||||
}
|
||||
|
||||
// Always clear this env var so child processes don't inherit it.
|
||||
unsafe {
|
||||
std::env::remove_var(CODEX_SECURE_MODE_ENV_VAR);
|
||||
}
|
||||
codex_process_hardening::pre_main_hardening();
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
@@ -289,15 +273,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
Some(Subcommand::AppServer) => {
|
||||
codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
|
||||
}
|
||||
Some(Subcommand::Resume(mut resume_cmd)) => {
|
||||
if let Err(err) = resume_cmd.normalize() {
|
||||
err.exit();
|
||||
}
|
||||
let ResumeCommand {
|
||||
config_overrides,
|
||||
last,
|
||||
session_id,
|
||||
} = resume_cmd;
|
||||
Some(Subcommand::Resume(ResumeCommand {
|
||||
session_id,
|
||||
last,
|
||||
config_overrides,
|
||||
})) => {
|
||||
interactive = finalize_resume_interactive(
|
||||
interactive,
|
||||
root_config_overrides.clone(),
|
||||
@@ -498,16 +478,14 @@ mod tests {
|
||||
subcommand,
|
||||
} = cli;
|
||||
|
||||
let mut resume_cmd = match subcommand.expect("resume present") {
|
||||
Subcommand::Resume(cmd) => cmd,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
resume_cmd.normalize().expect("normalize resume args");
|
||||
let ResumeCommand {
|
||||
config_overrides: resume_cli,
|
||||
last,
|
||||
let Subcommand::Resume(ResumeCommand {
|
||||
session_id,
|
||||
} = resume_cmd;
|
||||
last,
|
||||
config_overrides: resume_cli,
|
||||
}) = subcommand.expect("resume present")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_resume_interactive(interactive, root_overrides, session_id, last, resume_cli)
|
||||
}
|
||||
@@ -584,45 +562,12 @@ mod tests {
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_last_accepts_follow_up_prompt() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "--last", "hi there"].as_ref());
|
||||
assert!(interactive.resume_last);
|
||||
assert_eq!(interactive.prompt.as_deref(), Some("hi there"));
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_prompt_before_session_id() {
|
||||
let interactive = finalize_from_args(
|
||||
[
|
||||
"codex",
|
||||
"resume",
|
||||
"summarize progress",
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
]
|
||||
.as_ref(),
|
||||
);
|
||||
assert_eq!(interactive.prompt.as_deref(), Some("summarize progress"));
|
||||
assert_eq!(
|
||||
interactive.resume_session_id.as_deref(),
|
||||
Some("123e4567-e89b-12d3-a456-426614174000"),
|
||||
);
|
||||
assert!(!interactive.resume_last);
|
||||
assert!(!interactive.resume_picker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_picker_logic_with_session_id() {
|
||||
let interactive = finalize_from_args(
|
||||
["codex", "resume", "123e4567-e89b-12d3-a456-426614174000"].as_ref(),
|
||||
);
|
||||
let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref());
|
||||
assert!(!interactive.resume_picker);
|
||||
assert!(!interactive.resume_last);
|
||||
assert_eq!(
|
||||
interactive.resume_session_id.as_deref(),
|
||||
Some("123e4567-e89b-12d3-a456-426614174000")
|
||||
);
|
||||
assert_eq!(interactive.resume_session_id.as_deref(), Some("1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -631,7 +576,7 @@ mod tests {
|
||||
[
|
||||
"codex",
|
||||
"resume",
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
"sid",
|
||||
"--oss",
|
||||
"--full-auto",
|
||||
"--search",
|
||||
@@ -679,10 +624,7 @@ mod tests {
|
||||
assert!(has_a && has_b);
|
||||
assert!(!interactive.resume_picker);
|
||||
assert!(!interactive.resume_last);
|
||||
assert_eq!(
|
||||
interactive.resume_session_id.as_deref(),
|
||||
Some("123e4567-e89b-12d3-a456-426614174000")
|
||||
);
|
||||
assert_eq!(interactive.resume_session_id.as_deref(), Some("sid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -701,45 +643,3 @@ mod tests {
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
}
|
||||
}
|
||||
|
||||
impl ResumeCommand {
|
||||
fn normalize(&mut self) -> Result<(), ClapError> {
|
||||
if self.last {
|
||||
if let Some(value) = self.session_id.take() {
|
||||
if Self::looks_like_session_id(&value) {
|
||||
return Err(ClapError::raw(
|
||||
ClapErrorKind::ArgumentConflict,
|
||||
"The argument '--last' cannot be used with '[SESSION_ID]'",
|
||||
));
|
||||
}
|
||||
if let Some(existing) = &mut self.config_overrides.prompt {
|
||||
if !existing.is_empty() {
|
||||
existing.push(' ');
|
||||
}
|
||||
existing.push_str(&value);
|
||||
} else {
|
||||
self.config_overrides.prompt = Some(value);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.session_id.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(prompt) = self.config_overrides.prompt.take() {
|
||||
if Self::looks_like_session_id(&prompt) {
|
||||
self.session_id = Some(prompt);
|
||||
} else {
|
||||
self.config_overrides.prompt = Some(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn looks_like_session_id(value: &str) -> bool {
|
||||
Uuid::parse_str(value).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
|
||||
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
|
||||
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.
|
||||
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
|
||||
- When editing or creating files, you MUST use apply_patch as a standalone tool without going through ["bash", "-lc"], `Python`, `cat`, `sed`, ... Example: functions.shell({"command":["apply_patch","*** Begin Patch\nAdd File: hello.txt\n+Hello, world!\n*** End Patch"]}).
|
||||
|
||||
## Editing constraints
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ codex-protocol = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use clap::Parser;
|
||||
use clap::ValueEnum;
|
||||
use clap::error::Error as ClapError;
|
||||
use clap::error::ErrorKind as ClapErrorKind;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version)]
|
||||
@@ -103,59 +100,18 @@ pub enum Command {
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ResumeArgs {
|
||||
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
|
||||
#[arg(value_name = "PROMPT", index = 1)]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Resume the most recent recorded session (newest) without specifying an id.
|
||||
#[arg(long = "last", default_value_t = false)]
|
||||
pub last: bool,
|
||||
|
||||
/// Conversation/session id (UUID). When provided, resumes this session.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
#[arg(value_name = "SESSION_ID", index = 2)]
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ResumeArgs {
|
||||
pub fn normalize(&mut self) -> Result<(), ClapError> {
|
||||
if self.last {
|
||||
if let Some(value) = self.session_id.take() {
|
||||
if Self::looks_like_session_id(&value) {
|
||||
return Err(ClapError::raw(
|
||||
ClapErrorKind::ArgumentConflict,
|
||||
"The argument '--last' cannot be used with '[SESSION_ID]'",
|
||||
));
|
||||
}
|
||||
if let Some(existing) = &mut self.prompt {
|
||||
if !existing.is_empty() {
|
||||
existing.push(' ');
|
||||
}
|
||||
existing.push_str(&value);
|
||||
} else {
|
||||
self.prompt = Some(value);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
/// Resume the most recent recorded session (newest) without specifying an id.
|
||||
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
|
||||
pub last: bool,
|
||||
|
||||
if self.session_id.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(value) = self.prompt.take() {
|
||||
if Self::looks_like_session_id(&value) {
|
||||
self.session_id = Some(value);
|
||||
} else {
|
||||
self.prompt = Some(value);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn looks_like_session_id(value: &str) -> bool {
|
||||
Uuid::parse_str(value).is_ok()
|
||||
}
|
||||
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
|
||||
#[arg(value_name = "PROMPT")]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||
|
||||
@@ -29,6 +29,7 @@ use crate::exec_events::TurnCompletedEvent;
|
||||
use crate::exec_events::TurnFailedEvent;
|
||||
use crate::exec_events::TurnStartedEvent;
|
||||
use crate::exec_events::Usage;
|
||||
use crate::exec_events::WebSearchItem;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::plan_tool::StepStatus;
|
||||
use codex_core::plan_tool::UpdatePlanArgs;
|
||||
@@ -46,6 +47,7 @@ use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TaskStartedEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use tracing::error;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -106,6 +108,8 @@ impl ExperimentalEventProcessorWithJsonOutput {
|
||||
EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev),
|
||||
EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev),
|
||||
EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev),
|
||||
EventMsg::WebSearchBegin(_) => Vec::new(),
|
||||
EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev),
|
||||
EventMsg::TokenCount(ev) => {
|
||||
if let Some(info) = &ev.info {
|
||||
self.last_total_token_usage = Some(info.total_token_usage.clone());
|
||||
@@ -143,6 +147,17 @@ impl ExperimentalEventProcessorWithJsonOutput {
|
||||
})]
|
||||
}
|
||||
|
||||
fn handle_web_search_end(&self, ev: &WebSearchEndEvent) -> Vec<ThreadEvent> {
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
details: ThreadItemDetails::WebSearch(WebSearchItem {
|
||||
query: ev.query.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec<ThreadEvent> {
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
|
||||
@@ -61,24 +61,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
json: json_mode,
|
||||
experimental_json,
|
||||
sandbox_mode: sandbox_mode_cli_arg,
|
||||
prompt: parent_prompt,
|
||||
prompt,
|
||||
output_schema: output_schema_path,
|
||||
include_plan_tool,
|
||||
config_overrides,
|
||||
} = cli;
|
||||
|
||||
// Determine the prompt source (parent or subcommand) and read from stdin if needed.
|
||||
let mut command = command;
|
||||
let prompt_arg = match &mut command {
|
||||
let prompt_arg = match &command {
|
||||
// Allow prompt before the subcommand by falling back to the parent-level prompt
|
||||
// when the Resume subcommand did not provide its own prompt.
|
||||
Some(ExecCommand::Resume(args)) => {
|
||||
if let Err(err) = args.normalize() {
|
||||
err.exit();
|
||||
}
|
||||
args.prompt.clone().or_else(|| parent_prompt.clone())
|
||||
}
|
||||
None => parent_prompt,
|
||||
Some(ExecCommand::Resume(args)) => args.prompt.clone().or(prompt),
|
||||
None => prompt,
|
||||
};
|
||||
|
||||
let prompt = match prompt_arg {
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_exec::exec_events::AssistantMessageItem;
|
||||
use codex_exec::exec_events::CommandExecutionItem;
|
||||
use codex_exec::exec_events::CommandExecutionStatus;
|
||||
@@ -34,6 +35,7 @@ use codex_exec::exec_events::TurnCompletedEvent;
|
||||
use codex_exec::exec_events::TurnFailedEvent;
|
||||
use codex_exec::exec_events::TurnStartedEvent;
|
||||
use codex_exec::exec_events::Usage;
|
||||
use codex_exec::exec_events::WebSearchItem;
|
||||
use codex_exec::experimental_event_processor_with_json_output::ExperimentalEventProcessorWithJsonOutput;
|
||||
use mcp_types::CallToolResult;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -89,6 +91,29 @@ fn task_started_produces_turn_started_event() {
|
||||
assert_eq!(out, vec![ThreadEvent::TurnStarted(TurnStartedEvent {})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_end_emits_item_completed() {
|
||||
let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None);
|
||||
let query = "rust async await".to_string();
|
||||
let out = ep.collect_thread_events(&event(
|
||||
"w1",
|
||||
EventMsg::WebSearchEnd(WebSearchEndEvent {
|
||||
call_id: "call-123".to_string(),
|
||||
query: query.clone(),
|
||||
}),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
||||
item: ThreadItem {
|
||||
id: "item_0".to_string(),
|
||||
details: ThreadItemDetails::WebSearch(WebSearchItem { query }),
|
||||
},
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_update_emits_todo_list_started_updated_and_completed() {
|
||||
use codex_core::plan_tool::PlanItemArg;
|
||||
|
||||
@@ -130,62 +130,6 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_resume_last_accepts_prompt_after_flag() -> anyhow::Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/cli_responses_fixture.sse");
|
||||
|
||||
let marker = format!("resume-last-flag-{}", Uuid::new_v4());
|
||||
let prompt = format!("echo {marker}");
|
||||
|
||||
Command::cargo_bin("codex-exec")
|
||||
.context("should find binary for codex-exec")?
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg(&prompt)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let sessions_dir = home.path().join("sessions");
|
||||
let path = find_session_file_containing_marker(&sessions_dir, &marker)
|
||||
.expect("no session file found after first run");
|
||||
|
||||
let marker2 = format!("resume-last-flag-2-{}", Uuid::new_v4());
|
||||
let prompt2 = format!("echo {marker2}");
|
||||
|
||||
Command::cargo_bin("codex-exec")
|
||||
.context("should find binary for codex-exec")?
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg("resume")
|
||||
.arg("--last")
|
||||
.arg(&prompt2)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
|
||||
.expect("no resumed session file containing marker2");
|
||||
assert_eq!(
|
||||
resumed_path, path,
|
||||
"resume --last should reuse the existing file",
|
||||
);
|
||||
let content = std::fs::read_to_string(&resumed_path)?;
|
||||
assert!(content.contains(&marker));
|
||||
assert!(content.contains(&marker2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
|
||||
@@ -16,7 +16,6 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
mcp-types = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-protocol = { workspace = true }
|
||||
ts-rs = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -15,43 +15,43 @@ const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
|
||||
pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
ensure_dir(out_dir)?;
|
||||
|
||||
// Generate TS bindings
|
||||
mcp_types::InitializeResult::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ConversationId::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::InitializeResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ListConversationsResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ResumeConversationResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ArchiveConversationResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::SendUserMessageResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LoginApiKeyParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LoginApiKeyResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::FuzzyFileSearchParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::FuzzyFileSearchResult::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::FuzzyFileSearchResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GetUserSavedConfigResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::SetDefaultModelResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GetUserAgentResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::UserInfoResponse::export_all_to(out_dir)?;
|
||||
use codex_protocol::mcp_protocol::*;
|
||||
|
||||
// All notification types reachable from this enum will be generated by
|
||||
// induction, so they do not need to be listed individually.
|
||||
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ClientNotification::export_all_to(out_dir)?;
|
||||
// Generating the TS bindings for these top-level enums ensures all types
|
||||
// reachable from them will be generated by induction, so they do not need
|
||||
// to be listed individually.
|
||||
ClientRequest::export_all_to(out_dir)?;
|
||||
ClientNotification::export_all_to(out_dir)?;
|
||||
ServerRequest::export_all_to(out_dir)?;
|
||||
ServerNotification::export_all_to(out_dir)?;
|
||||
|
||||
// Response types for ClientRequest (mirror enum order).
|
||||
InitializeResponse::export_all_to(out_dir)?;
|
||||
NewConversationResponse::export_all_to(out_dir)?;
|
||||
ListConversationsResponse::export_all_to(out_dir)?;
|
||||
ResumeConversationResponse::export_all_to(out_dir)?;
|
||||
ArchiveConversationResponse::export_all_to(out_dir)?;
|
||||
SendUserMessageResponse::export_all_to(out_dir)?;
|
||||
SendUserTurnResponse::export_all_to(out_dir)?;
|
||||
InterruptConversationResponse::export_all_to(out_dir)?;
|
||||
AddConversationSubscriptionResponse::export_all_to(out_dir)?;
|
||||
RemoveConversationSubscriptionResponse::export_all_to(out_dir)?;
|
||||
GitDiffToRemoteResponse::export_all_to(out_dir)?;
|
||||
LoginApiKeyResponse::export_all_to(out_dir)?;
|
||||
LoginChatGptResponse::export_all_to(out_dir)?;
|
||||
CancelLoginChatGptResponse::export_all_to(out_dir)?;
|
||||
LogoutChatGptResponse::export_all_to(out_dir)?;
|
||||
GetAuthStatusResponse::export_all_to(out_dir)?;
|
||||
GetUserSavedConfigResponse::export_all_to(out_dir)?;
|
||||
SetDefaultModelResponse::export_all_to(out_dir)?;
|
||||
GetUserAgentResponse::export_all_to(out_dir)?;
|
||||
UserInfoResponse::export_all_to(out_dir)?;
|
||||
FuzzyFileSearchResponse::export_all_to(out_dir)?;
|
||||
ExecArbitraryCommandResponse::export_all_to(out_dir)?;
|
||||
|
||||
// Response types for ServerRequest (mirror enum order).
|
||||
ApplyPatchApprovalResponse::export_all_to(out_dir)?;
|
||||
ExecCommandApprovalResponse::export_all_to(out_dir)?;
|
||||
|
||||
generate_index_ts(out_dir)?;
|
||||
|
||||
|
||||
@@ -158,6 +158,10 @@ pub enum ClientRequest {
|
||||
LoginChatGpt {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
|
||||
#[ts(type = "undefined")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<()>,
|
||||
},
|
||||
CancelLoginChatGpt {
|
||||
#[serde(rename = "id")]
|
||||
@@ -167,6 +171,10 @@ pub enum ClientRequest {
|
||||
LogoutChatGpt {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
|
||||
#[ts(type = "undefined")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<()>,
|
||||
},
|
||||
GetAuthStatus {
|
||||
#[serde(rename = "id")]
|
||||
@@ -176,6 +184,10 @@ pub enum ClientRequest {
|
||||
GetUserSavedConfig {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
|
||||
#[ts(type = "undefined")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<()>,
|
||||
},
|
||||
SetDefaultModel {
|
||||
#[serde(rename = "id")]
|
||||
@@ -185,10 +197,18 @@ pub enum ClientRequest {
|
||||
GetUserAgent {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
|
||||
#[ts(type = "undefined")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<()>,
|
||||
},
|
||||
UserInfo {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
|
||||
#[ts(type = "undefined")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<()>,
|
||||
},
|
||||
FuzzyFileSearch {
|
||||
#[serde(rename = "id")]
|
||||
|
||||
@@ -94,8 +94,14 @@ impl ApprovalOverlay {
|
||||
);
|
||||
};
|
||||
let (options, title) = match &state.variant {
|
||||
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
|
||||
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
|
||||
ApprovalVariant::Exec { .. } => (
|
||||
exec_options(),
|
||||
"Would you like to run the following command?".to_string(),
|
||||
),
|
||||
ApprovalVariant::ApplyPatch { .. } => (
|
||||
patch_options(),
|
||||
"Would you like to apply these changes?".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let items = options
|
||||
@@ -112,7 +118,7 @@ impl ApprovalOverlay {
|
||||
|
||||
let params = SelectionViewParams {
|
||||
title,
|
||||
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
|
||||
footer_hint: Some("Press Enter to continue".to_string()),
|
||||
items,
|
||||
header: state.header.clone(),
|
||||
..Default::default()
|
||||
@@ -281,9 +287,8 @@ impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
}
|
||||
let command_snippet = exec_snippet(&command);
|
||||
if !command_snippet.is_empty() {
|
||||
header.push(HeaderLine::Text {
|
||||
text: format!("Command: {command_snippet}"),
|
||||
italic: false,
|
||||
header.push(HeaderLine::Command {
|
||||
command: command_snippet,
|
||||
});
|
||||
header.push(HeaderLine::Spacer);
|
||||
}
|
||||
@@ -529,7 +534,7 @@ mod tests {
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Command: echo hello world")),
|
||||
.any(|line| line.contains("$ echo hello world")),
|
||||
"expected header to include command snippet, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ use crate::bottom_pane::paste_burst::FlushResult;
|
||||
use crate::bottom_pane::prompt_args::expand_custom_prompt;
|
||||
use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args;
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::bottom_pane::prompt_args::prompt_argument_names;
|
||||
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
|
||||
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::style::user_message_style;
|
||||
@@ -45,6 +47,7 @@ use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
use crate::clipboard_paste::normalize_pasted_path;
|
||||
use crate::clipboard_paste::pasted_image_format;
|
||||
use crate::history_cell;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
@@ -72,6 +75,16 @@ struct AttachedImage {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
enum PromptSelectionMode {
|
||||
Completion,
|
||||
Submit,
|
||||
}
|
||||
|
||||
enum PromptSelectionAction {
|
||||
Insert { text: String, cursor: Option<usize> },
|
||||
Submit { text: String },
|
||||
}
|
||||
|
||||
pub(crate) struct ChatComposer {
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
@@ -449,17 +462,17 @@ impl ChatComposer {
|
||||
}
|
||||
CommandItem::UserPrompt(idx) => {
|
||||
if let Some(prompt) = popup.prompt(idx) {
|
||||
let name = prompt.name.clone();
|
||||
let starts_with_cmd = first_line
|
||||
.trim_start()
|
||||
.starts_with(format!("/{PROMPTS_CMD_PREFIX}:{name}").as_str());
|
||||
if !starts_with_cmd {
|
||||
self.textarea.set_text(
|
||||
format!("/{PROMPTS_CMD_PREFIX}:{name} ").as_str(),
|
||||
);
|
||||
}
|
||||
if !self.textarea.text().is_empty() {
|
||||
cursor_target = Some(self.textarea.text().len());
|
||||
match prompt_selection_action(
|
||||
prompt,
|
||||
first_line,
|
||||
PromptSelectionMode::Completion,
|
||||
) {
|
||||
PromptSelectionAction::Insert { text, cursor } => {
|
||||
let target = cursor.unwrap_or(text.len());
|
||||
self.textarea.set_text(&text);
|
||||
cursor_target = Some(target);
|
||||
}
|
||||
PromptSelectionAction::Submit { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,28 +510,21 @@ impl ChatComposer {
|
||||
}
|
||||
CommandItem::UserPrompt(idx) => {
|
||||
if let Some(prompt) = popup.prompt(idx) {
|
||||
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
|
||||
|
||||
if !has_numeric {
|
||||
// No placeholders at all: auto-submit the literal content
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(prompt.content.clone()), true);
|
||||
}
|
||||
// Numeric placeholders present.
|
||||
// If the user already typed positional args on the first line,
|
||||
// expand immediately and submit; otherwise insert "/name " so
|
||||
// they can type args.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some(expanded) =
|
||||
expand_if_numeric_with_positional_args(prompt, first_line)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(expanded), true);
|
||||
} else {
|
||||
let name = prompt.name.clone();
|
||||
let text = format!("/{PROMPTS_CMD_PREFIX}:{name} ");
|
||||
self.textarea.set_text(&text);
|
||||
self.textarea.set_cursor(self.textarea.text().len());
|
||||
match prompt_selection_action(
|
||||
prompt,
|
||||
first_line,
|
||||
PromptSelectionMode::Submit,
|
||||
) {
|
||||
PromptSelectionAction::Submit { text } => {
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(text), true);
|
||||
}
|
||||
PromptSelectionAction::Insert { text, cursor } => {
|
||||
let target = cursor.unwrap_or(text.len());
|
||||
self.textarea.set_text(&text);
|
||||
self.textarea.set_cursor(target);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
@@ -932,6 +938,7 @@ impl ChatComposer {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
self.textarea.set_text("");
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
@@ -945,13 +952,20 @@ impl ChatComposer {
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
|
||||
if let Some(expanded) =
|
||||
expand_custom_prompt(&text, &self.custom_prompts).unwrap_or_default()
|
||||
{
|
||||
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
|
||||
Ok(expanded) => expanded,
|
||||
Err(err) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
}
|
||||
|
||||
if text.is_empty() && !has_attachments {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
@@ -1513,6 +1527,54 @@ impl WidgetRef for ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_selection_action(
|
||||
prompt: &CustomPrompt,
|
||||
first_line: &str,
|
||||
mode: PromptSelectionMode,
|
||||
) -> PromptSelectionAction {
|
||||
let named_args = prompt_argument_names(&prompt.content);
|
||||
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
|
||||
|
||||
match mode {
|
||||
PromptSelectionMode::Completion => {
|
||||
if !named_args.is_empty() {
|
||||
let (text, cursor) =
|
||||
prompt_command_with_arg_placeholders(&prompt.name, &named_args);
|
||||
return PromptSelectionAction::Insert {
|
||||
text,
|
||||
cursor: Some(cursor),
|
||||
};
|
||||
}
|
||||
if has_numeric {
|
||||
let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name);
|
||||
return PromptSelectionAction::Insert { text, cursor: None };
|
||||
}
|
||||
let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name);
|
||||
PromptSelectionAction::Insert { text, cursor: None }
|
||||
}
|
||||
PromptSelectionMode::Submit => {
|
||||
if !named_args.is_empty() {
|
||||
let (text, cursor) =
|
||||
prompt_command_with_arg_placeholders(&prompt.name, &named_args);
|
||||
return PromptSelectionAction::Insert {
|
||||
text,
|
||||
cursor: Some(cursor),
|
||||
};
|
||||
}
|
||||
if has_numeric {
|
||||
if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) {
|
||||
return PromptSelectionAction::Submit { text: expanded };
|
||||
}
|
||||
let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name);
|
||||
return PromptSelectionAction::Insert { text, cursor: None };
|
||||
}
|
||||
PromptSelectionAction::Submit {
|
||||
text: prompt.content.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1528,7 +1590,6 @@ mod tests {
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::chat_composer::AttachedImage;
|
||||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use crate::bottom_pane::footer::footer_height;
|
||||
use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -2666,6 +2727,174 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prompt_submission_expands_arguments() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
composer
|
||||
.textarea
|
||||
.set_text("/prompts:my-prompt USER=Alice BRANCH=main");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Review Alice changes on main".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prompt_submission_accepts_quoted_values() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Pair $USER with $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
composer
|
||||
.textarea
|
||||
.set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Pair Alice Smith with dev-main".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prompt_invalid_args_reports_error() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
composer
|
||||
.textarea
|
||||
.set_text("/prompts:my-prompt USER=Alice stray");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!(
|
||||
"/prompts:my-prompt USER=Alice stray",
|
||||
composer.textarea.text()
|
||||
);
|
||||
|
||||
let mut found_error = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
let message = cell
|
||||
.display_lines(80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(message.contains("expected key=value"));
|
||||
found_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_error, "expected error history cell to be sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prompt_missing_required_args_reports_error() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
// Provide only one of the required args
|
||||
composer.textarea.set_text("/prompts:my-prompt USER=Alice");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("/prompts:my-prompt USER=Alice", composer.textarea.text());
|
||||
|
||||
let mut found_error = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
let message = cell
|
||||
.display_lines(80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(message.to_lowercase().contains("missing required args"));
|
||||
assert!(message.contains("BRANCH"));
|
||||
found_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_error,
|
||||
"expected missing args error history cell to be sent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_custom_prompt_with_args_expands_placeholders() {
|
||||
// Support $1..$9 and $ARGUMENTS in prompt content.
|
||||
@@ -2704,6 +2933,37 @@ mod tests {
|
||||
assert_eq!(InputResult::Submitted(expected), result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_prompt_positional_args_does_not_error() {
|
||||
// Ensure that a prompt with only numeric placeholders does not trigger
|
||||
// key=value parsing errors when given positional arguments.
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "elegant".to_string(),
|
||||
path: "/tmp/elegant.md".to_string().into(),
|
||||
content: "Echo: $ARGUMENTS".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
// Type positional args; should submit with numeric expansion, no errors.
|
||||
composer.textarea.set_text("/prompts:elegant hi");
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result);
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_custom_prompt_with_no_args_inserts_template() {
|
||||
let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]";
|
||||
|
||||
@@ -11,6 +11,7 @@ use ratatui::widgets::Widget;
|
||||
use textwrap::wrap;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::render::border::draw_history_border;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
@@ -26,6 +27,7 @@ pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum HeaderLine {
|
||||
Text { text: String, italic: bool },
|
||||
Command { command: String },
|
||||
Spacer,
|
||||
}
|
||||
|
||||
@@ -66,15 +68,6 @@ pub(crate) struct ListSelectionView {
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
fn dim_prefix_span() -> Span<'static> {
|
||||
"▌ ".dim()
|
||||
}
|
||||
|
||||
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
|
||||
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
|
||||
para.render(area, buf);
|
||||
}
|
||||
|
||||
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
let mut s = Self {
|
||||
title: params.title,
|
||||
@@ -171,7 +164,7 @@ impl ListSelectionView {
|
||||
.filter_map(|(visible_idx, actual_idx)| {
|
||||
self.items.get(*actual_idx).map(|item| {
|
||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let prefix = if is_selected { '›' } else { ' ' };
|
||||
let name = item.name.as_str();
|
||||
let name_with_marker = if item.is_current {
|
||||
format!("{name} (current)")
|
||||
@@ -232,19 +225,18 @@ impl ListSelectionView {
|
||||
self.last_selected_actual_idx.take()
|
||||
}
|
||||
|
||||
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
|
||||
fn header_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if self.header.is_empty() || width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let prefix_width = Self::dim_prefix_span().width() as u16;
|
||||
let available = width.saturating_sub(prefix_width).max(1) as usize;
|
||||
let available = width.max(1) as usize;
|
||||
let mut lines = Vec::new();
|
||||
for entry in &self.header {
|
||||
match entry {
|
||||
HeaderLine::Spacer => lines.push(Vec::new()),
|
||||
HeaderLine::Spacer => lines.push(Line::from(String::new())),
|
||||
HeaderLine::Text { text, italic } => {
|
||||
if text.is_empty() {
|
||||
lines.push(Vec::new());
|
||||
lines.push(Line::from(String::new()));
|
||||
continue;
|
||||
}
|
||||
for part in wrap(text, available) {
|
||||
@@ -253,7 +245,21 @@ impl ListSelectionView {
|
||||
} else {
|
||||
Span::from(part.into_owned())
|
||||
};
|
||||
lines.push(vec![span]);
|
||||
lines.push(Line::from(vec![span]));
|
||||
}
|
||||
}
|
||||
HeaderLine::Command { command } => {
|
||||
if command.is_empty() {
|
||||
lines.push(Line::from(String::new()));
|
||||
continue;
|
||||
}
|
||||
let command_width = available.saturating_sub(2).max(1);
|
||||
for (idx, part) in wrap(command, command_width).into_iter().enumerate() {
|
||||
let mut spans = Vec::new();
|
||||
let prefix = if idx == 0 { "$ " } else { " " };
|
||||
spans.push(Span::from(prefix).dim());
|
||||
spans.push(Span::from(part.into_owned()));
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,7 +268,7 @@ impl ListSelectionView {
|
||||
}
|
||||
|
||||
fn header_height(&self, width: u16) -> u16 {
|
||||
self.header_spans_for_width(width).len() as u16
|
||||
self.header_lines(width).len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,80 +324,83 @@ impl BottomPaneView for ListSelectionView {
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
||||
// Build the same display rows used by the renderer so wrapping math matches.
|
||||
let rows = self.build_rows();
|
||||
|
||||
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
||||
|
||||
// +1 for the title row, +1 for a spacer line beneath the header,
|
||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
||||
let mut height = self.header_height(width);
|
||||
height = height.saturating_add(rows_height + 2);
|
||||
if width < 4 {
|
||||
return 3;
|
||||
}
|
||||
let inner_width = width.saturating_sub(4).max(1);
|
||||
let mut height: u16 = 2; // border rows
|
||||
height = height.saturating_add(self.header_height(inner_width));
|
||||
height = height.saturating_add(1); // title
|
||||
if self.is_searchable {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
if self.subtitle.is_some() {
|
||||
// +1 for subtitle (the spacer is accounted for above)
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
|
||||
let rows = self.build_rows();
|
||||
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, inner_width);
|
||||
if !rows.is_empty() {
|
||||
height = height.saturating_add(1); // spacer before rows
|
||||
}
|
||||
height = height.saturating_add(rows_height);
|
||||
|
||||
if self.footer_hint.is_some() {
|
||||
height = height.saturating_add(2);
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
height
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
if area.width < 4 || area.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut next_y = area.y;
|
||||
let header_spans = self.header_spans_for_width(area.width);
|
||||
for spans in header_spans.into_iter() {
|
||||
if next_y >= area.y + area.height {
|
||||
let Some(inner) = draw_history_border(buf, area) else {
|
||||
return;
|
||||
};
|
||||
if inner.width == 0 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cursor_y = inner.y;
|
||||
let inner_bottom = inner.y.saturating_add(inner.height);
|
||||
|
||||
for line in self.header_lines(inner.width) {
|
||||
if cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
let row = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
|
||||
if spans.is_empty() {
|
||||
prefixed.push(String::new().into());
|
||||
} else {
|
||||
prefixed.extend(spans);
|
||||
}
|
||||
Paragraph::new(Line::from(prefixed)).render(row, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
Paragraph::new(line).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: cursor_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if next_y >= area.y + area.height {
|
||||
if cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(Line::from(vec![
|
||||
Self::dim_prefix_span(),
|
||||
self.title.clone().bold(),
|
||||
]))
|
||||
.render(title_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
|
||||
if self.is_searchable && next_y < area.y + area.height {
|
||||
let search_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
Paragraph::new(Line::from(self.title.clone().bold())).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: cursor_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
|
||||
if self.is_searchable {
|
||||
if cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
let query_span: Span<'static> = if self.search_query.is_empty() {
|
||||
self.search_placeholder
|
||||
.as_ref()
|
||||
@@ -400,54 +409,63 @@ impl BottomPaneView for ListSelectionView {
|
||||
} else {
|
||||
self.search_query.clone().into()
|
||||
};
|
||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
|
||||
.render(search_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
Paragraph::new(Line::from(vec![query_span])).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: cursor_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if let Some(sub) = &self.subtitle {
|
||||
if next_y >= area.y + area.height {
|
||||
if cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
let subtitle_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
|
||||
.render(subtitle_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
Paragraph::new(Line::from(sub.clone().dim())).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: cursor_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let spacer_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Self::render_dim_prefix_line(spacer_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
|
||||
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let rows_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: area
|
||||
.height
|
||||
.saturating_sub(next_y.saturating_sub(area.y))
|
||||
.saturating_sub(footer_reserved),
|
||||
};
|
||||
|
||||
let rows = self.build_rows();
|
||||
if rows_area.height > 0 {
|
||||
let footer_reserved = self.footer_hint.is_some() as usize;
|
||||
let available_rows = inner_bottom.saturating_sub(cursor_y) as usize;
|
||||
|
||||
let mut row_space = available_rows.saturating_sub(footer_reserved);
|
||||
if !rows.is_empty() && row_space > 0 {
|
||||
if cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
Paragraph::new(Line::from(String::new())).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: cursor_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
row_space = row_space.saturating_sub(1);
|
||||
}
|
||||
|
||||
if row_space > 0 {
|
||||
let rows_area = Rect {
|
||||
x: inner.x,
|
||||
y: cursor_y,
|
||||
width: inner.width,
|
||||
height: row_space as u16,
|
||||
};
|
||||
render_rows(
|
||||
rows_area,
|
||||
buf,
|
||||
@@ -455,18 +473,22 @@ impl BottomPaneView for ListSelectionView {
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
"no matches",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(hint) = &self.footer_hint {
|
||||
let footer_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + area.height - 1,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
|
||||
if inner.height > 0 {
|
||||
Paragraph::new(hint.clone().dim()).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + inner.height - 1,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,6 +601,9 @@ mod tests {
|
||||
view.set_search_query("filters".to_string());
|
||||
|
||||
let lines = render_lines(&view);
|
||||
assert!(lines.contains("▌ filters"));
|
||||
assert!(
|
||||
lines.lines().any(|line| line.contains("filters")),
|
||||
"expected search query to render, got {lines}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use lazy_static::lazy_static;
|
||||
use regex_lite::Regex;
|
||||
use shlex::Shlex;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
lazy_static! {
|
||||
static ref PROMPT_ARG_REGEX: Regex =
|
||||
Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort());
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptArgsError {
|
||||
MissingAssignment { token: String },
|
||||
MissingKey { token: String },
|
||||
}
|
||||
|
||||
impl PromptArgsError {
|
||||
fn describe(&self, command: &str) -> String {
|
||||
match self {
|
||||
PromptArgsError::MissingAssignment { token } => format!(
|
||||
"Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces."
|
||||
),
|
||||
PromptArgsError::MissingKey { token } => {
|
||||
format!("Could not parse {command}: expected a name before '=' in '{token}'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptExpansionError {
|
||||
Args {
|
||||
command: String,
|
||||
error: PromptArgsError,
|
||||
},
|
||||
MissingArgs {
|
||||
command: String,
|
||||
missing: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PromptExpansionError {
|
||||
pub fn user_message(&self) -> String {
|
||||
match self {
|
||||
PromptExpansionError::Args { command, error } => error.describe(command),
|
||||
PromptExpansionError::MissingArgs { command, missing } => {
|
||||
let list = missing.join(", ");
|
||||
format!(
|
||||
"Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a first-line slash command of the form `/name <rest>`.
|
||||
/// Returns `(name, rest_after_name)` if the line begins with `/` and contains
|
||||
@@ -27,6 +81,54 @@ pub fn parse_positional_args(rest: &str) -> Vec<String> {
|
||||
Shlex::new(rest).collect()
|
||||
}
|
||||
|
||||
/// Extracts the unique placeholder variable names from a prompt template.
|
||||
///
|
||||
/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*`
|
||||
/// (for example `$USER`). The function returns the variable names without
|
||||
/// the leading `$`, de-duplicated and in the order of first appearance.
|
||||
pub fn prompt_argument_names(content: &str) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut names = Vec::new();
|
||||
for m in PROMPT_ARG_REGEX.find_iter(content) {
|
||||
if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' {
|
||||
continue;
|
||||
}
|
||||
let name = &content[m.start() + 1..m.end()];
|
||||
// Exclude special positional aggregate token from named args.
|
||||
if name == "ARGUMENTS" {
|
||||
continue;
|
||||
}
|
||||
let name = name.to_string();
|
||||
if seen.insert(name.clone()) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Parses the `key=value` pairs that follow a custom prompt name.
|
||||
///
|
||||
/// The input is split using shlex rules, so quoted values are supported
|
||||
/// (for example `USER="Alice Smith"`). The function returns a map of parsed
|
||||
/// arguments, or an error if a token is missing `=` or if the key is empty.
|
||||
pub fn parse_prompt_inputs(rest: &str) -> Result<HashMap<String, String>, PromptArgsError> {
|
||||
let mut map = HashMap::new();
|
||||
if rest.trim().is_empty() {
|
||||
return Ok(map);
|
||||
}
|
||||
|
||||
for token in Shlex::new(rest) {
|
||||
let Some((key, value)) = token.split_once('=') else {
|
||||
return Err(PromptArgsError::MissingAssignment { token });
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(PromptArgsError::MissingKey { token });
|
||||
}
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
|
||||
///
|
||||
/// If the text does not start with `/prompts:`, or if no prompt named `name` exists,
|
||||
@@ -35,7 +137,7 @@ pub fn parse_positional_args(rest: &str) -> Vec<String> {
|
||||
pub fn expand_custom_prompt(
|
||||
text: &str,
|
||||
custom_prompts: &[CustomPrompt],
|
||||
) -> Result<Option<String>, ()> {
|
||||
) -> Result<Option<String>, PromptExpansionError> {
|
||||
let Some((name, rest)) = parse_slash_name(text) else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -49,14 +151,45 @@ pub fn expand_custom_prompt(
|
||||
Some(prompt) => prompt,
|
||||
None => return Ok(None),
|
||||
};
|
||||
// Only support numeric placeholders ($1..$9) and $ARGUMENTS.
|
||||
if prompt_has_numeric_placeholders(&prompt.content) {
|
||||
let pos_args: Vec<String> = Shlex::new(rest).collect();
|
||||
let expanded = expand_numeric_placeholders(&prompt.content, &pos_args);
|
||||
return Ok(Some(expanded));
|
||||
// If there are named placeholders, expect key=value inputs.
|
||||
let required = prompt_argument_names(&prompt.content);
|
||||
if !required.is_empty() {
|
||||
let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args {
|
||||
command: format!("/{name}"),
|
||||
error,
|
||||
})?;
|
||||
let missing: Vec<String> = required
|
||||
.into_iter()
|
||||
.filter(|k| !inputs.contains_key(k))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(PromptExpansionError::MissingArgs {
|
||||
command: format!("/{name}"),
|
||||
missing,
|
||||
});
|
||||
}
|
||||
let content = &prompt.content;
|
||||
let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| {
|
||||
if let Some(matched) = caps.get(0)
|
||||
&& matched.start() > 0
|
||||
&& content.as_bytes()[matched.start() - 1] == b'$'
|
||||
{
|
||||
return matched.as_str().to_string();
|
||||
}
|
||||
let whole = &caps[0];
|
||||
let key = &whole[1..];
|
||||
inputs
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| whole.to_string())
|
||||
});
|
||||
return Ok(Some(replaced.into_owned()));
|
||||
}
|
||||
// No recognized placeholders: return the literal content.
|
||||
Ok(Some(prompt.content.clone()))
|
||||
|
||||
// Otherwise, treat it as numeric/positional placeholder prompt (or none).
|
||||
let pos_args: Vec<String> = Shlex::new(rest).collect();
|
||||
let expanded = expand_numeric_placeholders(&prompt.content, &pos_args);
|
||||
Ok(Some(expanded))
|
||||
}
|
||||
|
||||
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
|
||||
@@ -107,6 +240,9 @@ pub fn expand_if_numeric_with_positional_args(
|
||||
prompt: &CustomPrompt,
|
||||
first_line: &str,
|
||||
) -> Option<String> {
|
||||
if !prompt_argument_names(&prompt.content).is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !prompt_has_numeric_placeholders(&prompt.content) {
|
||||
return None;
|
||||
}
|
||||
@@ -159,3 +295,112 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String {
|
||||
out.push_str(&content[i..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Constructs a command text for a custom prompt with arguments.
|
||||
/// Returns the text and the cursor position (inside the first double quote).
|
||||
pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) {
|
||||
let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}");
|
||||
let mut cursor: usize = text.len();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
text.push_str(format!(" {arg}=\"\"").as_str());
|
||||
if i == 0 {
|
||||
cursor = text.len() - 1; // inside first ""
|
||||
}
|
||||
}
|
||||
(text, cursor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_arguments_basic() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out =
|
||||
expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap();
|
||||
assert_eq!(out, Some("Review Alice changes on main".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_values_ok() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Pair $USER with $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt(
|
||||
"/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main",
|
||||
&prompts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_arg_token_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.contains("expected key=value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_args_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.to_lowercase().contains("missing required args"));
|
||||
assert!(err.contains("BRANCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_placeholder_is_ignored() {
|
||||
assert_eq!(
|
||||
prompt_argument_names("literal $$USER"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
prompt_argument_names("literal $$USER and $REAL"),
|
||||
vec!["REAL".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_placeholder_remains_literal() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "literal $$USER".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap();
|
||||
assert_eq!(out, Some("literal $$USER".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,18 @@ use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
|
||||
use super::scroll_state::ScrollState;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
|
||||
/// A generic representation of a display row for selection popups.
|
||||
pub(crate) struct GenericDisplayRow {
|
||||
pub name: String,
|
||||
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
|
||||
#[allow(dead_code)]
|
||||
pub is_current: bool,
|
||||
pub description: Option<String>, // optional grey text after the name
|
||||
}
|
||||
@@ -108,6 +113,25 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||
Line::from(full_spans)
|
||||
}
|
||||
|
||||
fn wrap_options(desc_col: usize, width: u16) -> RtOptions<'static> {
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(String::new()))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)))
|
||||
}
|
||||
|
||||
fn wrap_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
|
||||
let full_line = build_full_line(row, desc_col);
|
||||
let wrapped = word_wrap_line(&full_line, wrap_options(desc_col, width));
|
||||
let mut owned = Vec::with_capacity(wrapped.len());
|
||||
push_owned_lines(&wrapped, &mut owned);
|
||||
owned
|
||||
}
|
||||
|
||||
fn wrapped_line_count(row: &GenericDisplayRow, desc_col: usize, width: u16) -> usize {
|
||||
let full_line = build_full_line(row, desc_col);
|
||||
word_wrap_line(&full_line, wrap_options(desc_col, width)).len()
|
||||
}
|
||||
|
||||
/// Render a list of rows using the provided ScrollState, with shared styling
|
||||
/// and behavior for selection popups.
|
||||
pub(crate) fn render_rows(
|
||||
@@ -161,9 +185,8 @@ pub(crate) fn render_rows(
|
||||
}
|
||||
|
||||
if rows_all.is_empty() {
|
||||
if content_area.height > 0 {
|
||||
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
|
||||
para.render(
|
||||
if content_area.height > 0 && content_area.width > 0 {
|
||||
Paragraph::new(Line::from(empty_message.dim().italic())).render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: content_area.y,
|
||||
@@ -176,78 +199,134 @@ pub(crate) fn render_rows(
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which logical rows (items) are visible given the selection and
|
||||
// the max_results clamp. Scrolling is still item-based for simplicity.
|
||||
let max_rows_from_area = content_area.height as usize;
|
||||
let visible_items = max_results
|
||||
.min(rows_all.len())
|
||||
.min(max_rows_from_area.max(1));
|
||||
if content_area.width == 0 || content_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
let total_items = rows_all.len();
|
||||
let height_limit = content_area.height as usize;
|
||||
let max_visible_items = max_results.min(total_items).min(height_limit.max(1));
|
||||
if max_visible_items == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut start_idx = state.scroll_top.min(total_items.saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
if start_idx > sel {
|
||||
start_idx = sel;
|
||||
} else if visible_items > 0 {
|
||||
let bottom = start_idx + visible_items - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
|
||||
let mut attempts = 0usize;
|
||||
let mut chosen_start = start_idx;
|
||||
let mut chosen_visible = max_visible_items;
|
||||
let mut chosen_desc_col =
|
||||
compute_desc_col(rows_all, start_idx, chosen_visible, content_area.width);
|
||||
|
||||
// Render items, wrapping descriptions and aligning wrapped lines under the
|
||||
// shared description column. Stop when we run out of vertical space.
|
||||
loop {
|
||||
attempts = attempts.saturating_add(1);
|
||||
if attempts > total_items {
|
||||
break;
|
||||
}
|
||||
|
||||
let remaining = total_items - start_idx;
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let window_len = max_visible_items.min(remaining);
|
||||
if window_len == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut desc_col = compute_desc_col(rows_all, start_idx, window_len, content_area.width);
|
||||
let mut used_height = 0usize;
|
||||
let mut actual_count = 0usize;
|
||||
for row in rows_all.iter().skip(start_idx).take(window_len) {
|
||||
let line_count = wrapped_line_count(row, desc_col, content_area.width);
|
||||
if line_count == 0 {
|
||||
continue;
|
||||
}
|
||||
if used_height + line_count > height_limit {
|
||||
break;
|
||||
}
|
||||
used_height += line_count;
|
||||
actual_count += 1;
|
||||
}
|
||||
|
||||
if actual_count == 0 {
|
||||
actual_count = 1.min(window_len);
|
||||
}
|
||||
|
||||
desc_col = compute_desc_col(rows_all, start_idx, actual_count, content_area.width);
|
||||
let mut refined_height = 0usize;
|
||||
let mut refined_count = 0usize;
|
||||
for row in rows_all.iter().skip(start_idx).take(actual_count) {
|
||||
let line_count = wrapped_line_count(row, desc_col, content_area.width);
|
||||
if line_count == 0 {
|
||||
continue;
|
||||
}
|
||||
if refined_height + line_count > height_limit {
|
||||
break;
|
||||
}
|
||||
refined_height += line_count;
|
||||
refined_count += 1;
|
||||
}
|
||||
|
||||
if refined_count == 0 {
|
||||
refined_count = 1.min(window_len);
|
||||
}
|
||||
|
||||
chosen_start = start_idx;
|
||||
chosen_visible = refined_count;
|
||||
chosen_desc_col = desc_col;
|
||||
|
||||
let selection_visible = state.selected_idx.map_or(true, |sel| {
|
||||
sel >= start_idx && sel < start_idx + refined_count
|
||||
});
|
||||
|
||||
if selection_visible {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel >= start_idx + refined_count {
|
||||
if start_idx + 1 >= total_items {
|
||||
break;
|
||||
}
|
||||
start_idx += 1;
|
||||
continue;
|
||||
}
|
||||
if sel < start_idx {
|
||||
if start_idx == 0 {
|
||||
break;
|
||||
}
|
||||
start_idx -= 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
let content_bottom = content_area.y.saturating_add(content_area.height);
|
||||
let mut cur_y = content_area.y;
|
||||
for (i, row) in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
.skip(chosen_start)
|
||||
.take(chosen_visible)
|
||||
{
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let GenericDisplayRow {
|
||||
name,
|
||||
match_indices,
|
||||
is_current: _is_current,
|
||||
description,
|
||||
} = row;
|
||||
|
||||
let full_line = build_full_line(
|
||||
&GenericDisplayRow {
|
||||
name: name.clone(),
|
||||
match_indices: match_indices.clone(),
|
||||
is_current: *_is_current,
|
||||
description: description.clone(),
|
||||
},
|
||||
desc_col,
|
||||
);
|
||||
|
||||
// Wrap with subsequent indent aligned to the description column.
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let options = RtOptions::new(content_area.width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
let wrapped = word_wrap_line(&full_line, options);
|
||||
|
||||
// Render the wrapped lines.
|
||||
for mut line in wrapped {
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
break;
|
||||
for mut line in wrap_row(row, chosen_desc_col, content_area.width) {
|
||||
if cur_y >= content_bottom {
|
||||
return;
|
||||
}
|
||||
if Some(i) == state.selected_idx {
|
||||
// Match previous behavior: cyan + bold for the selected row.
|
||||
line.style = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
let para = Paragraph::new(line);
|
||||
para.render(
|
||||
Paragraph::new(line).render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cur_y,
|
||||
@@ -292,21 +371,10 @@ pub(crate) fn measure_rows_height(
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width);
|
||||
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let mut total: u16 = 0;
|
||||
for row in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
.map(|(_, r)| r)
|
||||
{
|
||||
let full_line = build_full_line(row, desc_col);
|
||||
let opts = RtOptions::new(content_width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16);
|
||||
for row in rows_all.iter().skip(start_idx).take(visible_items) {
|
||||
let lines = wrapped_line_count(row, desc_col, content_width) as u16;
|
||||
total = total.saturating_add(lines.max(1));
|
||||
}
|
||||
total.max(1)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
assertion_line: 575
|
||||
expression: render_lines(&view)
|
||||
---
|
||||
▌ Select Approval Mode
|
||||
▌ Switch between Codex approval presets
|
||||
▌
|
||||
▌ > 1. Read Only (current) Codex can read files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
|
||||
Press Enter to confirm or Esc to go back
|
||||
╭──────────────────────────────────────────────╮
|
||||
│ Select Approval Mode │
|
||||
│ Switch between Codex approval presets │
|
||||
│ │
|
||||
│ › 1. Read Only (current) Codex can read │
|
||||
│ files │
|
||||
│ 2. Full Access Codex can edit │
|
||||
│ files │
|
||||
│ Press Enter to confirm or Esc to go back │
|
||||
╰──────────────────────────────────────────────╯
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
assertion_line: 566
|
||||
expression: render_lines(&view)
|
||||
---
|
||||
▌ Select Approval Mode
|
||||
▌
|
||||
▌ > 1. Read Only (current) Codex can read files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
|
||||
Press Enter to confirm or Esc to go back
|
||||
╭──────────────────────────────────────────────╮
|
||||
│ Select Approval Mode │
|
||||
│ │
|
||||
│ › 1. Read Only (current) Codex can read │
|
||||
│ files │
|
||||
│ 2. Full Access Codex can edit │
|
||||
│ files │
|
||||
│ Press Enter to confirm or Esc to go back │
|
||||
╰──────────────────────────────────────────────╯
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1208
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"▌ this is a test reason such as one that would be produced by the model "
|
||||
"▌ "
|
||||
"▌ Command: echo hello world "
|
||||
"▌ "
|
||||
"▌ Allow command? "
|
||||
"▌ "
|
||||
"▌ > 1. Approve and run now (Y) Run this command one time "
|
||||
"▌ 2. Always approve this session (A) Automatically approve this command for "
|
||||
"▌ the rest of the session "
|
||||
"▌ 3. Cancel (N) Do not run the command "
|
||||
" "
|
||||
"Press Enter to confirm or Esc to cancel "
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ this is a test reason such as one that would be produced by the model │"
|
||||
"│ │"
|
||||
"│ $ echo hello world │"
|
||||
"│ │"
|
||||
"│ Would you like to run the following command? │"
|
||||
"│ │"
|
||||
"│ › 1. Approve and run now (Y) Run this command one time │"
|
||||
"│ 2. Always approve this session (A) Automatically approve this command │"
|
||||
"│ for the rest of the session │"
|
||||
"│ 3. Cancel (N) Do not run the command │"
|
||||
"│ Press Enter to continue │"
|
||||
"╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
" "
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1235
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"▌ Command: echo hello world "
|
||||
"▌ "
|
||||
"▌ Allow command? "
|
||||
"▌ "
|
||||
"▌ > 1. Approve and run now (Y) Run this command one time "
|
||||
"▌ 2. Always approve this session (A) Automatically approve this command for "
|
||||
"▌ the rest of the session "
|
||||
"▌ 3. Cancel (N) Do not run the command "
|
||||
" "
|
||||
"Press Enter to confirm or Esc to cancel "
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ $ echo hello world │"
|
||||
"│ │"
|
||||
"│ Would you like to run the following command? │"
|
||||
"│ │"
|
||||
"│ › 1. Approve and run now (Y) Run this command one time │"
|
||||
"│ 2. Always approve this session (A) Automatically approve this command │"
|
||||
"│ for the rest of the session │"
|
||||
"│ 3. Cancel (N) Do not run the command │"
|
||||
"│ Press Enter to continue │"
|
||||
"╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
" "
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1270
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"▌ The model wants to apply changes "
|
||||
"▌ "
|
||||
"▌ Grant write access to /tmp for the remainder of this session. "
|
||||
"▌ "
|
||||
"▌ Apply changes? "
|
||||
"▌ "
|
||||
"▌ > 1. Approve (Y) Apply the proposed changes "
|
||||
"▌ 2. Cancel (N) Do not apply the changes "
|
||||
" "
|
||||
"Press Enter to confirm or Esc to cancel "
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ The model wants to apply changes │"
|
||||
"│ │"
|
||||
"│ Grant write access to /tmp for the remainder of this session. │"
|
||||
"│ │"
|
||||
"│ Would you like to apply these changes? │"
|
||||
"│ │"
|
||||
"│ › 1. Approve (Y) Apply the proposed changes │"
|
||||
"│ 2. Cancel (N) Do not apply the changes │"
|
||||
"│ Press Enter to continue │"
|
||||
"╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
" "
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1437
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"▌ this is a test reason such as one that would be produced by the model "
|
||||
"▌ "
|
||||
"▌ Command: echo 'hello world' "
|
||||
"▌ "
|
||||
"▌ Allow command? "
|
||||
"▌ "
|
||||
"▌ > 1. Approve and run now (Y) Run this command one time "
|
||||
"▌ 2. Always approve this session (A) Automatically approve this command for "
|
||||
"▌ the rest of the session "
|
||||
"▌ 3. Cancel (N) Do not run the command "
|
||||
" "
|
||||
"Press Enter to confirm or Esc to cancel "
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ this is a test reason such as one that would be produced by the model │"
|
||||
"│ │"
|
||||
"│ $ echo 'hello world' │"
|
||||
"│ │"
|
||||
"│ Would you like to run the following command? │"
|
||||
"│ │"
|
||||
"│ › 1. Approve and run now (Y) Run this command one time │"
|
||||
"│ 2. Always approve this session (A) Automatically approve this command │"
|
||||
"│ for the rest of the session │"
|
||||
"│ 3. Cancel (N) Do not run the command │"
|
||||
"│ Press Enter to continue │"
|
||||
"╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
" "
|
||||
|
||||
@@ -933,18 +933,42 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(chat).render_ref(area, &mut buf);
|
||||
let mut row = String::new();
|
||||
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
|
||||
let y = 1u16.min(height.saturating_sub(1));
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
row.push(' ');
|
||||
} else {
|
||||
row.push_str(s);
|
||||
for y in 0..area.height {
|
||||
let mut row = String::new();
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
row.push(' ');
|
||||
} else {
|
||||
row.push_str(s);
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(start), Some(end)) = (row.find('│'), row.rfind('│')) {
|
||||
let left = start + '│'.len_utf8();
|
||||
if end > left {
|
||||
if let Some(content) = row.get(left..end) {
|
||||
let trimmed = content.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let trimmed = row.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let is_border = trimmed
|
||||
.chars()
|
||||
.all(|c| matches!(c, '╭' | '╮' | '╰' | '╯' | '─'));
|
||||
if !is_border {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
row
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1764,14 +1788,14 @@ fn apply_patch_untrusted_shows_approval_modal() {
|
||||
for x in 0..area.width {
|
||||
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
if row.contains("Apply changes?") {
|
||||
if row.contains("Would you like to apply these changes?") {
|
||||
contains_title = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
contains_title,
|
||||
"expected approval modal to be visible with title 'Apply changes?'"
|
||||
"expected approval modal to be visible with title 'Would you like to apply these changes?'"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::path::PathBuf;
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Optional user prompt to start the session.
|
||||
#[arg(value_name = "PROMPT", index = 1)]
|
||||
#[arg(value_name = "PROMPT")]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Optional image(s) to attach to the initial prompt.
|
||||
|
||||
57
codex-rs/tui/src/render/border.rs
Normal file
57
codex-rs/tui/src/render/border.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
|
||||
/// Draw the standard Codex rounded border into `buf` and return the interior
|
||||
/// rectangle available for content. When the area is too small to hold the
|
||||
/// border (width < 4 or height < 3) this returns `None` and leaves the buffer
|
||||
/// untouched.
|
||||
pub(crate) fn draw_history_border(buf: &mut Buffer, area: Rect) -> Option<Rect> {
|
||||
if area.width < 4 || area.height < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let style = Style::default().add_modifier(Modifier::DIM);
|
||||
let left = area.x;
|
||||
let right = area.x + area.width - 1;
|
||||
let top = area.y;
|
||||
let bottom = area.y + area.height - 1;
|
||||
|
||||
// Top border
|
||||
buf[(left, top)].set_symbol("╭").set_style(style);
|
||||
for x in left + 1..right {
|
||||
buf[(x, top)].set_symbol("─").set_style(style);
|
||||
}
|
||||
buf[(right, top)].set_symbol("╮").set_style(style);
|
||||
|
||||
// Bottom border
|
||||
buf[(left, bottom)].set_symbol("╰").set_style(style);
|
||||
for x in left + 1..right {
|
||||
buf[(x, bottom)].set_symbol("─").set_style(style);
|
||||
}
|
||||
buf[(right, bottom)].set_symbol("╯").set_style(style);
|
||||
|
||||
// Sides + clear interior padding columns
|
||||
for y in top + 1..bottom {
|
||||
buf[(left, y)].set_symbol("│").set_style(style);
|
||||
buf[(right, y)].set_symbol("│").set_style(style);
|
||||
|
||||
// Left padding column
|
||||
buf[(left + 1, y)].set_symbol(" ").set_style(style);
|
||||
// Right padding column
|
||||
buf[(right - 1, y)].set_symbol(" ").set_style(style);
|
||||
|
||||
// Interior content area reset to spaces
|
||||
for x in left + 2..right - 1 {
|
||||
buf[(x, y)].set_symbol(" ").set_style(Style::default());
|
||||
}
|
||||
}
|
||||
|
||||
Some(Rect {
|
||||
x: area.x + 2,
|
||||
y: area.y + 1,
|
||||
width: area.width - 4,
|
||||
height: area.height - 2,
|
||||
})
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod border;
|
||||
pub mod highlight;
|
||||
pub mod line_utils;
|
||||
|
||||
@@ -25,6 +25,7 @@ export type ResponsesProxy = {
|
||||
};
|
||||
|
||||
export type ResponsesApiRequest = {
|
||||
model?: string;
|
||||
input: Array<{
|
||||
role: string;
|
||||
content?: Array<{ type: string; text: string }>;
|
||||
|
||||
@@ -85,6 +85,52 @@ describe("Codex", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("continues the thread when run is called twice with options", async () => {
|
||||
const { url, close, requests } = await startResponsesTestProxy({
|
||||
statusCode: 200,
|
||||
responseBodies: [
|
||||
sse(
|
||||
responseStarted("response_1"),
|
||||
assistantMessage("First response", "item_1"),
|
||||
responseCompleted("response_1"),
|
||||
),
|
||||
sse(
|
||||
responseStarted("response_2"),
|
||||
assistantMessage("Second response", "item_2"),
|
||||
responseCompleted("response_2"),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
|
||||
|
||||
const thread = client.startThread();
|
||||
await thread.run("first input");
|
||||
await thread.run("second input", {
|
||||
model: "gpt-test-1",
|
||||
});
|
||||
|
||||
// Check second request continues the same thread
|
||||
expect(requests.length).toBeGreaterThanOrEqual(2);
|
||||
const secondRequest = requests[1];
|
||||
expect(secondRequest).toBeDefined();
|
||||
const payload = secondRequest!.json;
|
||||
|
||||
expect(payload.model).toBe("gpt-test-1");
|
||||
const assistantEntry = payload.input.find(
|
||||
(entry: { role: string }) => entry.role === "assistant",
|
||||
);
|
||||
expect(assistantEntry).toBeDefined();
|
||||
const assistantText = assistantEntry?.content?.find(
|
||||
(item: { type: string; text: string }) => item.type === "output_text",
|
||||
)?.text;
|
||||
expect(assistantText).toBe("First response");
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("resumes thread by id", async () => {
|
||||
const { url, close, requests } = await startResponsesTestProxy({
|
||||
statusCode: 200,
|
||||
|
||||
Reference in New Issue
Block a user