mirror of
https://github.com/openai/codex.git
synced 2026-02-02 23:13:37 +00:00
Compare commits
11 Commits
bot/update
...
codex/vers
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9714d8dcaf | ||
|
|
108dddf46d | ||
|
|
0f15ed4325 | ||
|
|
f50c8b2f81 | ||
|
|
059d386f03 | ||
|
|
74327fa59c | ||
|
|
34c0534f6e | ||
|
|
0b460eda32 | ||
|
|
9d976962ec | ||
|
|
3392c5af24 | ||
|
|
d1e71cd202 |
7
.github/workflows/issue-labeler.yml
vendored
7
.github/workflows/issue-labeler.yml
vendored
@@ -38,9 +38,10 @@ jobs:
|
||||
- If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to.
|
||||
1. CLI — the Codex command line interface.
|
||||
2. extension — VS Code (or other IDE) extension-specific issues.
|
||||
3. codex-web — Issues targeting the Codex web UI/Cloud experience.
|
||||
4. github-action — Issues with the Codex GitHub action.
|
||||
5. iOS — Issues with the Codex iOS app.
|
||||
3. app - Issues related to the Codex desktop application.
|
||||
4. codex-web — Issues targeting the Codex web UI/Cloud experience.
|
||||
5. github-action — Issues with the Codex GitHub action.
|
||||
6. iOS — Issues with the Codex iOS app.
|
||||
|
||||
- Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones.
|
||||
1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
|
||||
|
||||
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -1901,6 +1901,7 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"dirs",
|
||||
"log",
|
||||
"owo-colors",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
|
||||
@@ -13,6 +13,10 @@ struct Args {
|
||||
/// Optional path to the Prettier executable to format generated TypeScript files.
|
||||
#[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
|
||||
prettier: Option<PathBuf>,
|
||||
|
||||
/// Include experimental API methods and fields in generated fixtures.
|
||||
#[arg(long = "experimental")]
|
||||
experimental: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -22,11 +26,17 @@ fn main() -> Result<()> {
|
||||
.schema_root
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schema"));
|
||||
|
||||
codex_app_server_protocol::write_schema_fixtures(&schema_root, args.prettier.as_deref())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to regenerate schema fixtures under {}",
|
||||
schema_root.display()
|
||||
)
|
||||
})
|
||||
codex_app_server_protocol::write_schema_fixtures_with_options(
|
||||
&schema_root,
|
||||
args.prettier.as_deref(),
|
||||
codex_app_server_protocol::SchemaFixtureOptions {
|
||||
experimental_api: args.experimental,
|
||||
},
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to regenerate schema fixtures under {}",
|
||||
schema_root.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,5 +16,7 @@ pub use protocol::common::*;
|
||||
pub use protocol::thread_history::*;
|
||||
pub use protocol::v1::*;
|
||||
pub use protocol::v2::*;
|
||||
pub use schema_fixtures::SchemaFixtureOptions;
|
||||
pub use schema_fixtures::read_schema_fixture_tree;
|
||||
pub use schema_fixtures::write_schema_fixtures;
|
||||
pub use schema_fixtures::write_schema_fixtures_with_options;
|
||||
|
||||
@@ -6,6 +6,11 @@ use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SchemaFixtureOptions {
|
||||
pub experimental_api: bool,
|
||||
}
|
||||
|
||||
pub fn read_schema_fixture_tree(schema_root: &Path) -> Result<BTreeMap<PathBuf, Vec<u8>>> {
|
||||
let typescript_root = schema_root.join("typescript");
|
||||
let json_root = schema_root.join("json");
|
||||
@@ -26,14 +31,30 @@ pub fn read_schema_fixture_tree(schema_root: &Path) -> Result<BTreeMap<PathBuf,
|
||||
/// This is intended to be used by tooling (e.g., `just write-app-server-schema`).
|
||||
/// It deletes any previously generated files so stale artifacts are removed.
|
||||
pub fn write_schema_fixtures(schema_root: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
write_schema_fixtures_with_options(schema_root, prettier, SchemaFixtureOptions::default())
|
||||
}
|
||||
|
||||
/// Regenerates schema fixtures with configurable options.
|
||||
pub fn write_schema_fixtures_with_options(
|
||||
schema_root: &Path,
|
||||
prettier: Option<&Path>,
|
||||
options: SchemaFixtureOptions,
|
||||
) -> Result<()> {
|
||||
let typescript_out_dir = schema_root.join("typescript");
|
||||
let json_out_dir = schema_root.join("json");
|
||||
|
||||
ensure_empty_dir(&typescript_out_dir)?;
|
||||
ensure_empty_dir(&json_out_dir)?;
|
||||
|
||||
crate::generate_ts(&typescript_out_dir, prettier)?;
|
||||
crate::generate_json(&json_out_dir)?;
|
||||
crate::generate_ts_with_options(
|
||||
&typescript_out_dir,
|
||||
prettier,
|
||||
crate::GenerateTsOptions {
|
||||
experimental_api: options.experimental_api,
|
||||
..crate::GenerateTsOptions::default()
|
||||
},
|
||||
)?;
|
||||
crate::generate_json_with_experimental(&json_out_dir, options.experimental_api)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -788,6 +788,8 @@ At runtime, clients must send `initialize` with `capabilities.experimentalApi =
|
||||
|
||||
```bash
|
||||
just write-app-server-schema
|
||||
# Include experimental API fields/methods in fixtures.
|
||||
just write-app-server-schema --experimental
|
||||
```
|
||||
|
||||
5. Verify the protocol crate:
|
||||
|
||||
@@ -75,9 +75,11 @@ pub fn write_models_cache_with_models(
|
||||
let cache_path = codex_home.join("models_cache.json");
|
||||
// DateTime<Utc> serializes to RFC3339 format by default with serde
|
||||
let fetched_at: DateTime<Utc> = Utc::now();
|
||||
let client_version = codex_core::models_manager::client_version_to_whole();
|
||||
let cache = json!({
|
||||
"fetched_at": fetched_at,
|
||||
"etag": null,
|
||||
"client_version": client_version,
|
||||
"models": models
|
||||
});
|
||||
std::fs::write(cache_path, serde_json::to_string_pretty(&cache)?)
|
||||
|
||||
@@ -27,12 +27,100 @@ pub fn command_might_be_dangerous(command: &[String]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_git_global_option_with_value(arg: &str) -> bool {
|
||||
matches!(
|
||||
arg,
|
||||
"-C" | "-c"
|
||||
| "--config-env"
|
||||
| "--exec-path"
|
||||
| "--git-dir"
|
||||
| "--namespace"
|
||||
| "--super-prefix"
|
||||
| "--work-tree"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_git_global_option_with_inline_value(arg: &str) -> bool {
|
||||
matches!(
|
||||
arg,
|
||||
s if s.starts_with("--config-env=")
|
||||
|| s.starts_with("--exec-path=")
|
||||
|| s.starts_with("--git-dir=")
|
||||
|| s.starts_with("--namespace=")
|
||||
|| s.starts_with("--super-prefix=")
|
||||
|| s.starts_with("--work-tree=")
|
||||
) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
|
||||
}
|
||||
|
||||
/// Find the first matching git subcommand, skipping known global options that
|
||||
/// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
|
||||
///
|
||||
/// Shared with `is_safe_command` to avoid git-global-option bypasses.
|
||||
pub(crate) fn find_git_subcommand<'a>(
|
||||
command: &'a [String],
|
||||
subcommands: &[&str],
|
||||
) -> Option<(usize, &'a str)> {
|
||||
let cmd0 = command.first().map(String::as_str)?;
|
||||
if !cmd0.ends_with("git") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut skip_next = false;
|
||||
for (idx, arg) in command.iter().enumerate().skip(1) {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let arg = arg.as_str();
|
||||
|
||||
if is_git_global_option_with_inline_value(arg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_git_global_option_with_value(arg) {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if arg == "--" || arg.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if subcommands.contains(&arg) {
|
||||
return Some((idx, arg));
|
||||
}
|
||||
|
||||
// In git, the first non-option token is the subcommand. If it isn't
|
||||
// one of the subcommands we're looking for, we must stop scanning to
|
||||
// avoid misclassifying later positional args (e.g., branch names).
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
|
||||
let cmd0 = command.first().map(String::as_str);
|
||||
|
||||
match cmd0 {
|
||||
Some(cmd) if cmd.ends_with("git") || cmd.ends_with("/git") => {
|
||||
matches!(command.get(1).map(String::as_str), Some("reset" | "rm"))
|
||||
Some(cmd) if cmd.ends_with("git") => {
|
||||
let Some((subcommand_idx, subcommand)) =
|
||||
find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match subcommand {
|
||||
"reset" | "rm" => true,
|
||||
"branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
|
||||
"push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
|
||||
"clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
|
||||
other => {
|
||||
debug_assert!(false, "unexpected git subcommand from matcher: {other}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some("rm") => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf")),
|
||||
@@ -45,6 +133,48 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn git_branch_is_delete(branch_args: &[String]) -> bool {
|
||||
// Git allows stacking short flags (for example, `-dv` or `-vd`). Treat any
|
||||
// short-flag group containing `d`/`D` as a delete flag.
|
||||
branch_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(arg, "-d" | "-D" | "--delete")
|
||||
|| arg.starts_with("--delete=")
|
||||
|| short_flag_group_contains(arg, 'd')
|
||||
|| short_flag_group_contains(arg, 'D')
|
||||
})
|
||||
}
|
||||
|
||||
fn short_flag_group_contains(arg: &str, target: char) -> bool {
|
||||
arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
|
||||
}
|
||||
|
||||
fn git_push_is_dangerous(push_args: &[String]) -> bool {
|
||||
push_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(
|
||||
arg,
|
||||
"--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d"
|
||||
) || arg.starts_with("--force-with-lease=")
|
||||
|| arg.starts_with("--force-if-includes=")
|
||||
|| arg.starts_with("--delete=")
|
||||
|| short_flag_group_contains(arg, 'f')
|
||||
|| short_flag_group_contains(arg, 'd')
|
||||
|| git_push_refspec_is_dangerous(arg)
|
||||
})
|
||||
}
|
||||
|
||||
fn git_push_refspec_is_dangerous(arg: &str) -> bool {
|
||||
// `+<refspec>` forces updates and `:<dst>` deletes remote refs.
|
||||
(arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
|
||||
}
|
||||
|
||||
fn git_clean_is_force(clean_args: &[String]) -> bool {
|
||||
clean_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(arg, "--force" | "-f")
|
||||
|| arg.starts_with("--force=")
|
||||
|| short_flag_group_contains(arg, 'f')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -63,7 +193,7 @@ mod tests {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git reset --hard"
|
||||
"git reset --hard",
|
||||
])));
|
||||
}
|
||||
|
||||
@@ -72,7 +202,7 @@ mod tests {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"zsh",
|
||||
"-lc",
|
||||
"git reset --hard"
|
||||
"git reset --hard",
|
||||
])));
|
||||
}
|
||||
|
||||
@@ -86,14 +216,14 @@ mod tests {
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git status"
|
||||
"git status",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sudo_git_reset_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"sudo", "git", "reset", "--hard"
|
||||
"sudo", "git", "reset", "--hard",
|
||||
])));
|
||||
}
|
||||
|
||||
@@ -102,7 +232,141 @@ mod tests {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"/usr/bin/git",
|
||||
"reset",
|
||||
"--hard"
|
||||
"--hard",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-d", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-D", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git branch --delete feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-dv", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-vd", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-vD", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-Dvv", "feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_with_global_options_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "-C", ".", "branch", "-d", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"-c",
|
||||
"color.ui=false",
|
||||
"branch",
|
||||
"-D",
|
||||
"feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git -C . branch -d feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_checkout_reset_is_not_dangerous() {
|
||||
// The first non-option token is "checkout", so later positional args
|
||||
// like branch names must not be treated as subcommands.
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"git", "checkout", "reset",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_force_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "--force", "origin", "main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "-f", "origin", "main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"-C",
|
||||
".",
|
||||
"push",
|
||||
"--force-with-lease",
|
||||
"origin",
|
||||
"main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_plus_refspec_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", "+main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"+refs/heads/main:refs/heads/main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_delete_flag_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "--delete", "origin", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "-d", "origin", "feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_delete_refspec_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", ":feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git push origin :feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_without_force_is_not_dangerous() {
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", "main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "-fdx",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "-xdf",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "--force",
|
||||
])));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use crate::bash::parse_shell_lc_plain_commands;
|
||||
// Find the first matching git subcommand, skipping known global options that
|
||||
// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
|
||||
// Implemented in `is_dangerous_command` and shared here.
|
||||
use crate::command_safety::is_dangerous_command::find_git_subcommand;
|
||||
use crate::command_safety::windows_safe_commands::is_safe_command_windows;
|
||||
|
||||
pub fn is_known_safe_command(command: &[String]) -> bool {
|
||||
@@ -131,13 +135,36 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool {
|
||||
}
|
||||
|
||||
// Git
|
||||
Some("git") => matches!(
|
||||
command.get(1).map(String::as_str),
|
||||
Some("branch" | "status" | "log" | "diff" | "show")
|
||||
),
|
||||
Some("git") => {
|
||||
// Global config overrides like `-c core.pager=...` can force git
|
||||
// to execute arbitrary external commands. With no sandboxing, we
|
||||
// should always prompt in those cases.
|
||||
if git_has_config_override_global_option(command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rust
|
||||
Some("cargo") if command.get(1).map(String::as_str) == Some("check") => true,
|
||||
let Some((subcommand_idx, subcommand)) =
|
||||
find_git_subcommand(command, &["status", "log", "diff", "show", "branch"])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let subcommand_args = &command[subcommand_idx + 1..];
|
||||
|
||||
match subcommand {
|
||||
"status" | "log" | "diff" | "show" => {
|
||||
git_subcommand_args_are_read_only(subcommand_args)
|
||||
}
|
||||
"branch" => {
|
||||
git_subcommand_args_are_read_only(subcommand_args)
|
||||
&& git_branch_is_read_only(subcommand_args)
|
||||
}
|
||||
other => {
|
||||
debug_assert!(false, "unexpected git subcommand from matcher: {other}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special-case `sed -n {N|M,N}p`
|
||||
Some("sed")
|
||||
@@ -155,6 +182,60 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Treat `git branch` as safe only when the arguments clearly indicate
|
||||
// a read-only query, not a branch mutation (create/rename/delete).
|
||||
fn git_branch_is_read_only(branch_args: &[String]) -> bool {
|
||||
if branch_args.is_empty() {
|
||||
// `git branch` with no additional args lists branches.
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut saw_read_only_flag = false;
|
||||
for arg in branch_args.iter().map(String::as_str) {
|
||||
match arg {
|
||||
"--list" | "-l" | "--show-current" | "-a" | "--all" | "-r" | "--remotes" | "-v"
|
||||
| "-vv" | "--verbose" => {
|
||||
saw_read_only_flag = true;
|
||||
}
|
||||
_ if arg.starts_with("--format=") => {
|
||||
saw_read_only_flag = true;
|
||||
}
|
||||
_ => {
|
||||
// Any other flag or positional argument may create, rename, or delete branches.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saw_read_only_flag
|
||||
}
|
||||
|
||||
fn git_has_config_override_global_option(command: &[String]) -> bool {
|
||||
command.iter().map(String::as_str).any(|arg| {
|
||||
matches!(arg, "-c" | "--config-env")
|
||||
|| (arg.starts_with("-c") && arg.len() > 2)
|
||||
|| arg.starts_with("--config-env=")
|
||||
})
|
||||
}
|
||||
|
||||
fn git_subcommand_args_are_read_only(args: &[String]) -> bool {
|
||||
// Flags that can write to disk or execute external tools should never be
|
||||
// auto-approved on an unsandboxed machine.
|
||||
const UNSAFE_GIT_FLAGS: &[&str] = &[
|
||||
"--output",
|
||||
"--ext-diff",
|
||||
"--textconv",
|
||||
"--exec",
|
||||
"--paginate",
|
||||
];
|
||||
|
||||
!args.iter().map(String::as_str).any(|arg| {
|
||||
UNSAFE_GIT_FLAGS.contains(&arg)
|
||||
|| arg.starts_with("--output=")
|
||||
|| arg.starts_with("--exec=")
|
||||
})
|
||||
}
|
||||
|
||||
// (bash parsing helpers implemented in crate::bash)
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
@@ -207,6 +288,12 @@ mod tests {
|
||||
fn known_safe_examples() {
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&["ls"])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&["git", "status"])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&["git", "branch"])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&[
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&["base64"])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&[
|
||||
"sed", "-n", "1,5p", "file.txt"
|
||||
@@ -231,6 +318,86 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_mutating_flags_are_not_safe() {
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git", "branch", "-d", "feature"
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"branch",
|
||||
"new-branch"
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_global_options_respect_safety_rules() {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
assert_eq!(
|
||||
is_known_safe_command(&vec_str(&["git", "-C", ".", "branch", "--show-current"])),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
is_known_safe_command(&vec_str(&["git", "-C", ".", "branch", "-d", "feature"])),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
is_known_safe_command(&vec_str(&["bash", "-lc", "git -C . branch -d feature",])),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_first_positional_is_the_subcommand() {
|
||||
// In git, the first non-option token is the subcommand. Later positional
|
||||
// args (like branch names) must not be treated as subcommands.
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git", "checkout", "status",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_output_and_config_override_flags_are_not_safe() {
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"log",
|
||||
"--output=/tmp/git-log-out-test",
|
||||
"-n",
|
||||
"1",
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"diff",
|
||||
"--output",
|
||||
"/tmp/git-diff-out-test",
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"show",
|
||||
"--output=/tmp/git-show-out-test",
|
||||
"HEAD",
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"-c",
|
||||
"core.pager=cat",
|
||||
"log",
|
||||
"-n",
|
||||
"1",
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"-ccore.pager=cat",
|
||||
"status",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_check_is_not_safe() {
|
||||
assert!(!is_known_safe_command(&vec_str(&["cargo", "check"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zsh_lc_safe_command_sequence() {
|
||||
assert!(is_known_safe_command(&vec_str(&["zsh", "-lc", "ls"])));
|
||||
|
||||
@@ -1280,6 +1280,30 @@ prefix_rule(
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangerous_git_push_requires_approval_in_danger_full_access() {
|
||||
let command = vec_str(&["git", "push", "origin", "+main"]);
|
||||
let manager = ExecPolicyManager::default();
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
features: &Features::with_defaults(),
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn vec_str(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ impl ModelsCacheManager {
|
||||
}
|
||||
|
||||
/// Attempt to load a fresh cache entry. Returns `None` if the cache doesn't exist or is stale.
|
||||
pub(crate) async fn load_fresh(&self) -> Option<ModelsCache> {
|
||||
pub(crate) async fn load_fresh(&self, expected_version: &str) -> Option<ModelsCache> {
|
||||
let cache = match self.load().await {
|
||||
Ok(cache) => cache?,
|
||||
Err(err) => {
|
||||
@@ -35,6 +35,9 @@ impl ModelsCacheManager {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if cache.client_version.as_deref() != Some(expected_version) {
|
||||
return None;
|
||||
}
|
||||
if !cache.is_fresh(self.cache_ttl) {
|
||||
return None;
|
||||
}
|
||||
@@ -42,10 +45,16 @@ impl ModelsCacheManager {
|
||||
}
|
||||
|
||||
/// Persist the cache to disk, creating parent directories as needed.
|
||||
pub(crate) async fn persist_cache(&self, models: &[ModelInfo], etag: Option<String>) {
|
||||
pub(crate) async fn persist_cache(
|
||||
&self,
|
||||
models: &[ModelInfo],
|
||||
etag: Option<String>,
|
||||
client_version: String,
|
||||
) {
|
||||
let cache = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
etag,
|
||||
client_version: Some(client_version),
|
||||
models: models.to_vec(),
|
||||
};
|
||||
if let Err(err) = self.save_internal(&cache).await {
|
||||
@@ -103,6 +112,20 @@ impl ModelsCacheManager {
|
||||
f(&mut cache.fetched_at);
|
||||
self.save_internal(&cache).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Mutate the full cache contents for testing.
|
||||
pub(crate) async fn mutate_cache_for_test<F>(&self, f: F) -> io::Result<()>
|
||||
where
|
||||
F: FnOnce(&mut ModelsCache),
|
||||
{
|
||||
let mut cache = match self.load().await? {
|
||||
Some(cache) => cache,
|
||||
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
|
||||
};
|
||||
f(&mut cache);
|
||||
self.save_internal(&cache).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized snapshot of models and metadata cached on disk.
|
||||
@@ -111,6 +134,8 @@ pub(crate) struct ModelsCache {
|
||||
pub(crate) fetched_at: DateTime<Utc>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) etag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) client_version: Option<String>,
|
||||
pub(crate) models: Vec<ModelInfo>,
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ impl ModelsManager {
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let client = ModelsClient::new(transport, api_provider, api_auth);
|
||||
|
||||
let client_version = format_client_version_to_whole();
|
||||
let client_version = crate::models_manager::client_version_to_whole();
|
||||
let (models, etag) = timeout(
|
||||
MODELS_REFRESH_TIMEOUT,
|
||||
client.list_models(&client_version, HeaderMap::new()),
|
||||
@@ -221,7 +221,9 @@ impl ModelsManager {
|
||||
|
||||
self.apply_remote_models(models.clone()).await;
|
||||
*self.etag.write().await = etag.clone();
|
||||
self.cache_manager.persist_cache(&models, etag).await;
|
||||
self.cache_manager
|
||||
.persist_cache(&models, etag, client_version)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -255,7 +257,8 @@ impl ModelsManager {
|
||||
async fn try_load_cache(&self) -> bool {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]);
|
||||
let cache = match self.cache_manager.load_fresh().await {
|
||||
let client_version = crate::models_manager::client_version_to_whole();
|
||||
let cache = match self.cache_manager.load_fresh(&client_version).await {
|
||||
Some(cache) => cache,
|
||||
None => return false,
|
||||
};
|
||||
@@ -350,16 +353,6 @@ impl ModelsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a client version string to a whole version string (e.g. "1.2.3-alpha.4" -> "1.2.3")
|
||||
fn format_client_version_to_whole() -> String {
|
||||
format!(
|
||||
"{}.{}.{}",
|
||||
env!("CARGO_PKG_VERSION_MAJOR"),
|
||||
env!("CARGO_PKG_VERSION_MINOR"),
|
||||
env!("CARGO_PKG_VERSION_PATCH")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -613,6 +606,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_refetches_when_version_mismatch() {
|
||||
let server = MockServer::start().await;
|
||||
let initial_models = vec![remote_model("old", "Old", 1)];
|
||||
let initial_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: initial_models.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
let auth_manager = Arc::new(AuthManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
));
|
||||
let provider = provider_for(server.uri());
|
||||
let manager =
|
||||
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
|
||||
|
||||
manager
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("initial refresh succeeds");
|
||||
|
||||
manager
|
||||
.cache_manager
|
||||
.mutate_cache_for_test(|cache| {
|
||||
let client_version = crate::models_manager::client_version_to_whole();
|
||||
cache.client_version = Some(format!("{client_version}-mismatch"));
|
||||
})
|
||||
.await
|
||||
.expect("cache mutation succeeds");
|
||||
|
||||
let updated_models = vec![remote_model("new", "New", 2)];
|
||||
server.reset().await;
|
||||
let refreshed_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: updated_models.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
manager
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("second refresh succeeds");
|
||||
assert_models_contain(&manager.get_remote_models(&config).await, &updated_models);
|
||||
assert_eq!(
|
||||
initial_mock.requests().len(),
|
||||
1,
|
||||
"initial refresh should only hit /models once"
|
||||
);
|
||||
assert_eq!(
|
||||
refreshed_mock.requests().len(),
|
||||
1,
|
||||
"version mismatch should fetch /models once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_drops_removed_remote_models() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -6,3 +6,13 @@ pub mod model_presets;
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use collaboration_mode_presets::test_builtin_collaboration_mode_presets;
|
||||
|
||||
/// Convert the client version string to a whole version string (e.g. "1.2.3-alpha.4" -> "1.2.3").
|
||||
pub fn client_version_to_whole() -> String {
|
||||
format!(
|
||||
"{}.{}.{}",
|
||||
env!("CARGO_PKG_VERSION_MAJOR"),
|
||||
env!("CARGO_PKG_VERSION_MINOR"),
|
||||
env!("CARGO_PKG_VERSION_PATCH")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ use uuid::Uuid;
|
||||
|
||||
use super::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use super::SESSIONS_SUBDIR;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::session_prefix::is_session_prefix_content;
|
||||
use crate::state_db;
|
||||
use codex_file_search as file_search;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -982,9 +984,12 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
||||
.created_at
|
||||
.clone()
|
||||
.or_else(|| Some(rollout_line.timestamp.clone()));
|
||||
if let codex_protocol::models::ResponseItem::Message { role, .. } = &item
|
||||
if let codex_protocol::models::ResponseItem::Message { role, content, .. } = &item
|
||||
&& role == "user"
|
||||
&& !UserInstructions::is_user_instructions(content.as_slice())
|
||||
&& !is_session_prefix_content(content.as_slice())
|
||||
{
|
||||
tracing::warn!("Item: {item:#?}");
|
||||
summary.saw_user_event = true;
|
||||
}
|
||||
if summary.head.len() < head_limit
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
|
||||
/// Helpers for identifying model-visible "session prefix" messages.
|
||||
///
|
||||
/// A session prefix is a user-role message that carries configuration or state needed by
|
||||
@@ -13,3 +15,12 @@ pub(crate) fn is_session_prefix(text: &str) -> bool {
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG)
|
||||
}
|
||||
|
||||
/// Returns true if `text` starts with a session prefix marker (case-insensitive).
|
||||
pub(crate) fn is_session_prefix_content(content: &[ContentItem]) -> bool {
|
||||
if let [ContentItem::InputText { text }] = content {
|
||||
is_session_prefix(text)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ use wiremock::MockServer;
|
||||
const ETAG: &str = "\"models-etag-ttl\"";
|
||||
const CACHE_FILE: &str = "models_cache.json";
|
||||
const REMOTE_MODEL: &str = "codex-test-ttl";
|
||||
const VERSIONED_MODEL: &str = "codex-test-versioned";
|
||||
const MISSING_VERSION_MODEL: &str = "codex-test-missing-version";
|
||||
const DIFFERENT_VERSION_MODEL: &str = "codex-test-different-version";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
|
||||
@@ -131,11 +134,157 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn uses_cache_when_version_matches() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
let cached_model = test_remote_model(VERSIONED_MODEL, 1);
|
||||
let models_mock = responses::mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![test_remote_model("remote", 2)],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
builder = builder
|
||||
.with_pre_build_hook(move |home| {
|
||||
let cache = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
etag: None,
|
||||
client_version: Some(codex_core::models_manager::client_version_to_whole()),
|
||||
models: vec![cached_model.clone()],
|
||||
};
|
||||
let cache_path = home.join(CACHE_FILE);
|
||||
write_cache_sync(&cache_path, &cache).expect("write cache");
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
});
|
||||
|
||||
let test = builder.build(&server).await?;
|
||||
let models_manager = test.thread_manager.get_models_manager();
|
||||
let models = models_manager
|
||||
.list_models(&test.config, RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
models.iter().any(|preset| preset.model == VERSIONED_MODEL),
|
||||
"expected cached model"
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
0,
|
||||
"/models should not be called when cache version matches"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn refreshes_when_cache_version_missing() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
let cached_model = test_remote_model(MISSING_VERSION_MODEL, 1);
|
||||
let models_mock = responses::mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![test_remote_model("remote-missing", 2)],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
builder = builder
|
||||
.with_pre_build_hook(move |home| {
|
||||
let cache = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
etag: None,
|
||||
client_version: None,
|
||||
models: vec![cached_model.clone()],
|
||||
};
|
||||
let cache_path = home.join(CACHE_FILE);
|
||||
write_cache_sync(&cache_path, &cache).expect("write cache");
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
});
|
||||
|
||||
let test = builder.build(&server).await?;
|
||||
let models_manager = test.thread_manager.get_models_manager();
|
||||
let models = models_manager
|
||||
.list_models(&test.config, RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
models.iter().any(|preset| preset.model == "remote-missing"),
|
||||
"expected refreshed models"
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
"/models should be called when cache version is missing"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn refreshes_when_cache_version_differs() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
let cached_model = test_remote_model(DIFFERENT_VERSION_MODEL, 1);
|
||||
let models_mock = responses::mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![test_remote_model("remote-different", 2)],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
builder = builder
|
||||
.with_pre_build_hook(move |home| {
|
||||
let client_version = codex_core::models_manager::client_version_to_whole();
|
||||
let cache = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
etag: None,
|
||||
client_version: Some(format!("{client_version}-diff")),
|
||||
models: vec![cached_model.clone()],
|
||||
};
|
||||
let cache_path = home.join(CACHE_FILE);
|
||||
write_cache_sync(&cache_path, &cache).expect("write cache");
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
});
|
||||
|
||||
let test = builder.build(&server).await?;
|
||||
let models_manager = test.thread_manager.get_models_manager();
|
||||
let models = models_manager
|
||||
.list_models(&test.config, RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
models
|
||||
.iter()
|
||||
.any(|preset| preset.model == "remote-different"),
|
||||
"expected refreshed models"
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
"/models should be called when cache version differs"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rewrite_cache_timestamp(path: &Path, fetched_at: DateTime<Utc>) -> Result<()> {
|
||||
let mut cache = read_cache(path).await?;
|
||||
cache.fetched_at = fetched_at;
|
||||
let contents = serde_json::to_vec_pretty(&cache)?;
|
||||
tokio::fs::write(path, contents).await?;
|
||||
write_cache(path, &cache).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -145,11 +294,25 @@ async fn read_cache(path: &Path) -> Result<ModelsCache> {
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
async fn write_cache(path: &Path, cache: &ModelsCache) -> Result<()> {
|
||||
let contents = serde_json::to_vec_pretty(cache)?;
|
||||
tokio::fs::write(path, contents).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_cache_sync(path: &Path, cache: &ModelsCache) -> Result<()> {
|
||||
let contents = serde_json::to_vec_pretty(cache)?;
|
||||
std::fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ModelsCache {
|
||||
fetched_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
etag: Option<String>,
|
||||
#[serde(default)]
|
||||
client_version: Option<String>,
|
||||
models: Vec<ModelInfo>,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod config_types;
|
||||
pub mod custom_prompts;
|
||||
pub mod dynamic_tools;
|
||||
pub mod items;
|
||||
pub mod mcp;
|
||||
pub mod message_history;
|
||||
pub mod models;
|
||||
pub mod num_format;
|
||||
|
||||
324
codex-rs/protocol/src/mcp.rs
Normal file
324
codex-rs/protocol/src/mcp.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Types used when representing Model Context Protocol (MCP) values inside the
|
||||
/// Codex protocol.
|
||||
///
|
||||
/// We intentionally keep these types TS/JSON-schema friendly (via `ts-rs` and
|
||||
/// `schemars`) so they can be embedded in Codex's own protocol structures.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
String(String),
|
||||
#[ts(type = "number")]
|
||||
Integer(i64),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RequestId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RequestId::String(s) => f.write_str(s),
|
||||
RequestId::Integer(i) => i.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: serde_json::Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub output_schema: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Resource {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mime_type: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
#[ts(type = "number")]
|
||||
pub size: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
pub uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceTemplate {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
pub uri_template: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallToolResult {
|
||||
pub content: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub structured_content: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub is_error: Option<bool>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// === Adapter helpers ===
|
||||
//
|
||||
// These types and conversions intentionally live in `codex-protocol` so other crates can convert
|
||||
// “wire-shaped” MCP JSON (typically coming from rmcp model structs serialized with serde) into our
|
||||
// TS/JsonSchema-friendly protocol types without depending on `mcp-types`.
|
||||
|
||||
fn deserialize_lossy_opt_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
match Option::<serde_json::Number>::deserialize(deserializer)? {
|
||||
Some(number) => {
|
||||
if let Some(v) = number.as_i64() {
|
||||
Ok(Some(v))
|
||||
} else if let Some(v) = number.as_u64() {
|
||||
Ok(i64::try_from(v).ok())
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ToolSerde {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default, rename = "inputSchema", alias = "input_schema")]
|
||||
input_schema: serde_json::Value,
|
||||
#[serde(default, rename = "outputSchema", alias = "output_schema")]
|
||||
output_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<ToolSerde> for Tool {
|
||||
fn from(value: ToolSerde) -> Self {
|
||||
let ToolSerde {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
input_schema,
|
||||
output_schema,
|
||||
annotations,
|
||||
icons,
|
||||
meta,
|
||||
} = value;
|
||||
Self {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
input_schema,
|
||||
output_schema,
|
||||
annotations,
|
||||
icons,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResourceSerde {
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "mimeType", alias = "mime_type", default)]
|
||||
mime_type: Option<String>,
|
||||
name: String,
|
||||
#[serde(default, deserialize_with = "deserialize_lossy_opt_i64")]
|
||||
size: Option<i64>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<ResourceSerde> for Resource {
|
||||
fn from(value: ResourceSerde) -> Self {
|
||||
let ResourceSerde {
|
||||
annotations,
|
||||
description,
|
||||
mime_type,
|
||||
name,
|
||||
size,
|
||||
title,
|
||||
uri,
|
||||
icons,
|
||||
meta,
|
||||
} = value;
|
||||
Self {
|
||||
annotations,
|
||||
description,
|
||||
mime_type,
|
||||
name,
|
||||
size,
|
||||
title,
|
||||
uri,
|
||||
icons,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResourceTemplateSerde {
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(rename = "uriTemplate", alias = "uri_template")]
|
||||
uri_template: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "mimeType", alias = "mime_type", default)]
|
||||
mime_type: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ResourceTemplateSerde> for ResourceTemplate {
|
||||
fn from(value: ResourceTemplateSerde) -> Self {
|
||||
let ResourceTemplateSerde {
|
||||
annotations,
|
||||
uri_template,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
mime_type,
|
||||
} = value;
|
||||
Self {
|
||||
annotations,
|
||||
uri_template,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
mime_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ToolSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Resource {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ResourceSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceTemplate {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ResourceTemplateSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_size_deserializes_without_narrowing() {
|
||||
let resource = serde_json::json!({
|
||||
"name": "big",
|
||||
"uri": "file:///tmp/big",
|
||||
"size": 5_000_000_000u64,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, Some(5_000_000_000));
|
||||
|
||||
let resource = serde_json::json!({
|
||||
"name": "negative",
|
||||
"uri": "file:///tmp/negative",
|
||||
"size": -1,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, Some(-1));
|
||||
|
||||
let resource = serde_json::json!({
|
||||
"name": "too_big_for_i64",
|
||||
"uri": "file:///tmp/too_big_for_i64",
|
||||
"size": 18446744073709551615u64,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, None);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ clap = { workspace = true, features = ["derive", "env"] }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
log = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -17,6 +17,8 @@ use chrono::Utc;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use log::LevelFilter;
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::QueryBuilder;
|
||||
use sqlx::Row;
|
||||
use sqlx::Sqlite;
|
||||
@@ -511,7 +513,8 @@ async fn open_sqlite(path: &Path) -> anyhow::Result<SqlitePool> {
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.log_statements(LevelFilter::Off);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
|
||||
@@ -2939,6 +2939,7 @@ mod tests {
|
||||
app.chat_widget.current_model(),
|
||||
event,
|
||||
is_first,
|
||||
None,
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//!
|
||||
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
|
||||
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
|
||||
//! - Promoting typed slash commands into atomic elements when the command name is completed.
|
||||
//! - Handling submit vs newline on Enter.
|
||||
//! - Turning raw key streams into explicit paste operations on platforms where terminals
|
||||
//! don't provide reliable bracketed paste (notably Windows).
|
||||
@@ -36,6 +37,8 @@
|
||||
//!
|
||||
//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion
|
||||
//! and attachment pruning, and clears pending paste state on success.
|
||||
//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so
|
||||
//! pasted content and text elements are preserved when extracting args.
|
||||
//!
|
||||
//! # Non-bracketed Paste Bursts
|
||||
//!
|
||||
@@ -164,6 +167,7 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -184,7 +188,7 @@ pub enum InputResult {
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
Command(SlashCommand),
|
||||
CommandWithArgs(SlashCommand, String),
|
||||
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -747,6 +751,7 @@ impl ChatComposer {
|
||||
/// Move the cursor to the end of the current text buffer.
|
||||
pub(crate) fn move_cursor_to_end(&mut self) {
|
||||
self.textarea.set_cursor(self.textarea.text().len());
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
|
||||
@@ -1235,6 +1240,7 @@ impl ChatComposer {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
self.textarea.input(input);
|
||||
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
@@ -1798,7 +1804,12 @@ impl ChatComposer {
|
||||
|
||||
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
|
||||
/// On success, clears pending paste payloads because placeholders have been expanded.
|
||||
fn prepare_submission_text(&mut self) -> Option<(String, Vec<TextElement>)> {
|
||||
///
|
||||
/// When `record_history` is true, the final submission is stored for ↑/↓ recall.
|
||||
fn prepare_submission_text(
|
||||
&mut self,
|
||||
record_history: bool,
|
||||
) -> Option<(String, Vec<TextElement>)> {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let original_text_elements = self.textarea.text_elements();
|
||||
@@ -1896,7 +1907,7 @@ impl ChatComposer {
|
||||
if text.is_empty() && self.attached_images.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !text.is_empty() || !self.attached_images.is_empty() {
|
||||
if record_history && (!text.is_empty() || !self.attached_images.is_empty()) {
|
||||
let local_image_paths = self
|
||||
.attached_images
|
||||
.iter()
|
||||
@@ -1978,7 +1989,7 @@ impl ChatComposer {
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
if let Some((text, text_elements)) = self.prepare_submission_text() {
|
||||
if let Some((text, text_elements)) = self.prepare_submission_text(true) {
|
||||
if should_queue {
|
||||
(
|
||||
InputResult::Queued {
|
||||
@@ -2026,6 +2037,9 @@ impl ChatComposer {
|
||||
self.windows_degraded_sandbox_active,
|
||||
)
|
||||
{
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
Some(InputResult::Command(cmd))
|
||||
} else {
|
||||
@@ -2039,28 +2053,104 @@ impl ChatComposer {
|
||||
if !self.slash_commands_enabled() {
|
||||
return None;
|
||||
}
|
||||
let original_input = self.textarea.text().to_string();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
|
||||
if !input_starts_with_space {
|
||||
let text = self.textarea.text().to_string();
|
||||
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some(cmd) = slash_commands::find_builtin_command(
|
||||
name,
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
self.personality_command_enabled,
|
||||
self.windows_degraded_sandbox_active,
|
||||
)
|
||||
&& matches!(cmd, SlashCommand::Review | SlashCommand::Rename)
|
||||
{
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
return Some(InputResult::CommandWithArgs(cmd, rest.to_string()));
|
||||
}
|
||||
let text = self.textarea.text().to_string();
|
||||
if text.starts_with(' ') {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
|
||||
let (name, rest, rest_offset) = parse_slash_name(&text)?;
|
||||
if rest.is_empty() || name.contains('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cmd = slash_commands::find_builtin_command(
|
||||
name,
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
self.personality_command_enabled,
|
||||
self.windows_degraded_sandbox_active,
|
||||
)?;
|
||||
|
||||
if !cmd.supports_inline_args() {
|
||||
return None;
|
||||
}
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
|
||||
let mut args_elements =
|
||||
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
|
||||
let trimmed_rest = rest.trim();
|
||||
args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements);
|
||||
Some(InputResult::CommandWithArgs(
|
||||
cmd,
|
||||
trimmed_rest.to_string(),
|
||||
args_elements,
|
||||
))
|
||||
}
|
||||
|
||||
/// Expand pending placeholders and extract normalized inline-command args.
|
||||
///
|
||||
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
|
||||
/// not consume user input. Once a command is accepted, this helper performs the usual
|
||||
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
|
||||
/// full-text offsets to command-arg offsets.
|
||||
pub(crate) fn prepare_inline_args_submission(
|
||||
&mut self,
|
||||
record_history: bool,
|
||||
) -> Option<(String, Vec<TextElement>)> {
|
||||
let (prepared_text, prepared_elements) = self.prepare_submission_text(record_history)?;
|
||||
let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(&prepared_text)?;
|
||||
let mut args_elements = Self::slash_command_args_elements(
|
||||
prepared_rest,
|
||||
prepared_rest_offset,
|
||||
&prepared_elements,
|
||||
);
|
||||
let trimmed_rest = prepared_rest.trim();
|
||||
args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements);
|
||||
Some((trimmed_rest.to_string(), args_elements))
|
||||
}
|
||||
|
||||
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
|
||||
if !self.is_task_running || cmd.available_during_task() {
|
||||
return false;
|
||||
}
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
cmd.command()
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(message),
|
||||
)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Translate full-text element ranges into command-argument ranges.
|
||||
///
|
||||
/// `rest_offset` is the byte offset where `rest` begins in the full text.
|
||||
fn slash_command_args_elements(
|
||||
rest: &str,
|
||||
rest_offset: usize,
|
||||
text_elements: &[TextElement],
|
||||
) -> Vec<TextElement> {
|
||||
if rest.is_empty() || text_elements.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
if elem.byte_range.end <= rest_offset {
|
||||
return None;
|
||||
}
|
||||
let start = elem.byte_range.start.saturating_sub(rest_offset);
|
||||
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
|
||||
if start >= rest.len() {
|
||||
return None;
|
||||
}
|
||||
end = end.min(rest.len());
|
||||
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
@@ -2441,6 +2531,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
fn sync_popups(&mut self) {
|
||||
self.sync_slash_command_elements();
|
||||
if !self.popups_enabled() {
|
||||
self.active_popup = ActivePopup::None;
|
||||
return;
|
||||
@@ -2507,6 +2598,88 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep slash command elements aligned with the current first line.
|
||||
fn sync_slash_command_elements(&mut self) {
|
||||
if !self.slash_commands_enabled() {
|
||||
return;
|
||||
}
|
||||
let text = self.textarea.text();
|
||||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||||
let first_line = &text[..first_line_end];
|
||||
let desired_range = self.slash_command_element_range(first_line);
|
||||
// Slash commands are only valid at byte 0 of the first line.
|
||||
// Any slash-shaped element not matching the current desired prefix is stale.
|
||||
let mut has_desired = false;
|
||||
let mut stale_ranges = Vec::new();
|
||||
for elem in self.textarea.text_elements() {
|
||||
let Some(payload) = elem.placeholder(text) else {
|
||||
continue;
|
||||
};
|
||||
if payload.strip_prefix('/').is_none() {
|
||||
continue;
|
||||
}
|
||||
let range = elem.byte_range.start..elem.byte_range.end;
|
||||
if desired_range.as_ref() == Some(&range) {
|
||||
has_desired = true;
|
||||
} else {
|
||||
stale_ranges.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
for range in stale_ranges {
|
||||
self.textarea.remove_element_range(range);
|
||||
}
|
||||
|
||||
if let Some(range) = desired_range
|
||||
&& !has_desired
|
||||
{
|
||||
self.textarea.add_element_range(range);
|
||||
}
|
||||
}
|
||||
|
||||
fn slash_command_element_range(&self, first_line: &str) -> Option<Range<usize>> {
|
||||
let (name, _rest, _rest_offset) = parse_slash_name(first_line)?;
|
||||
if name.contains('/') {
|
||||
return None;
|
||||
}
|
||||
let element_end = 1 + name.len();
|
||||
let has_space_after = first_line
|
||||
.get(element_end..)
|
||||
.and_then(|tail| tail.chars().next())
|
||||
.is_some_and(char::is_whitespace);
|
||||
if !has_space_after {
|
||||
return None;
|
||||
}
|
||||
if self.is_known_slash_name(name) {
|
||||
Some(0..element_end)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_known_slash_name(&self, name: &str) -> bool {
|
||||
let is_builtin = slash_commands::find_builtin_command(
|
||||
name,
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
self.personality_command_enabled,
|
||||
self.windows_degraded_sandbox_active,
|
||||
)
|
||||
.is_some();
|
||||
if is_builtin {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = name.strip_prefix(PROMPTS_CMD_PREFIX)
|
||||
&& let Some(prompt_name) = rest.strip_prefix(':')
|
||||
{
|
||||
return self
|
||||
.custom_prompts
|
||||
.iter()
|
||||
.any(|prompt| prompt.name == prompt_name);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// If the cursor is currently within a slash command on the first line,
|
||||
/// extract the command name and the rest of the line after it.
|
||||
/// Returns None if the cursor is outside a slash command.
|
||||
@@ -4582,7 +4755,7 @@ mod tests {
|
||||
InputResult::Command(cmd) => {
|
||||
assert_eq!(cmd.command(), "init");
|
||||
}
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
InputResult::CommandWithArgs(_, _, _) => {
|
||||
panic!("expected command dispatch without args for '/init'")
|
||||
}
|
||||
InputResult::Submitted { text, .. } => {
|
||||
@@ -4596,6 +4769,49 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_disabled_while_task_running_keeps_text() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
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_task_running(true);
|
||||
composer
|
||||
.textarea
|
||||
.set_text_clearing_elements("/review these changes");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("/review these changes", 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("disabled while a task is in progress"));
|
||||
found_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_error, "expected error history cell to be sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_args_supports_quoted_paths_single_arg() {
|
||||
let args = extract_positional_args_for_prompt_line(
|
||||
@@ -4683,7 +4899,7 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
InputResult::CommandWithArgs(_, _, _) => {
|
||||
panic!("expected command dispatch without args for '/diff'")
|
||||
}
|
||||
InputResult::Submitted { text, .. } => {
|
||||
@@ -4697,6 +4913,77 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_elementizes_on_space() {
|
||||
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_collaboration_modes_enabled(true);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
let elements = composer.textarea.text_elements();
|
||||
assert_eq!(text, "/plan ");
|
||||
assert_eq!(elements.len(), 1);
|
||||
assert_eq!(elements[0].placeholder(&text), Some("/plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_elementizes_only_known_commands() {
|
||||
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_collaboration_modes_enabled(true);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
let elements = composer.textarea.text_elements();
|
||||
assert_eq!(text, "/Users ");
|
||||
assert!(elements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_element_removed_when_not_at_start() {
|
||||
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,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
let elements = composer.textarea.text_elements();
|
||||
assert_eq!(text, "/review ");
|
||||
assert_eq!(elements.len(), 1);
|
||||
|
||||
composer.textarea.set_cursor(0);
|
||||
type_chars_humanlike(&mut composer, &['x']);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
let elements = composer.textarea.text_elements();
|
||||
assert_eq!(text, "x/review ");
|
||||
assert!(elements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_mention_dispatches_command_and_inserts_at() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -4722,7 +5009,7 @@ mod tests {
|
||||
InputResult::Command(cmd) => {
|
||||
assert_eq!(cmd.command(), "mention");
|
||||
}
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
InputResult::CommandWithArgs(_, _, _) => {
|
||||
panic!("expected command dispatch without args for '/mention'")
|
||||
}
|
||||
InputResult::Submitted { text, .. } => {
|
||||
@@ -4738,6 +5025,44 @@ mod tests {
|
||||
assert_eq!(composer.textarea.text(), "@");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_plan_args_preserve_text_elements() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
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_collaboration_modes_enabled(true);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
|
||||
let placeholder = local_image_label_text(1);
|
||||
composer.attach_image(PathBuf::from("/tmp/plan.png"));
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::CommandWithArgs(cmd, args, text_elements) => {
|
||||
assert_eq!(cmd.command(), "plan");
|
||||
assert_eq!(args, placeholder);
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(
|
||||
text_elements[0].placeholder(&args),
|
||||
Some(placeholder.as_str())
|
||||
);
|
||||
}
|
||||
_ => panic!("expected CommandWithArgs for /plan with args"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Behavior: multiple paste operations can coexist; placeholders should be expanded to their
|
||||
/// original content on submission.
|
||||
#[test]
|
||||
|
||||
@@ -175,11 +175,16 @@ impl BottomPaneView for ExperimentalFeaturesView {
|
||||
..
|
||||
} => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
code: KeyCode::Char(' '),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.toggle_selected(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.on_ctrl_c();
|
||||
@@ -287,9 +292,9 @@ impl Renderable for ExperimentalFeaturesView {
|
||||
fn experimental_popup_hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"Press ".into(),
|
||||
key_hint::plain(KeyCode::Char(' ')).into(),
|
||||
" to select or ".into(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to toggle or ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to save for next conversation".into(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -218,6 +218,12 @@ impl BottomPane {
|
||||
self.composer.take_mention_paths()
|
||||
}
|
||||
|
||||
/// Clear pending attachments and mention paths e.g. when a slash command doesn't submit text.
|
||||
pub(crate) fn drain_pending_submission_state(&mut self) {
|
||||
let _ = self.take_recent_submission_images_with_placeholders();
|
||||
let _ = self.take_mention_paths();
|
||||
}
|
||||
|
||||
pub fn set_steer_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_steer_enabled(enabled);
|
||||
}
|
||||
@@ -404,6 +410,7 @@ impl BottomPane {
|
||||
) {
|
||||
self.composer
|
||||
.set_text_content(text, text_elements, local_image_paths);
|
||||
self.composer.move_cursor_to_end();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -787,6 +794,13 @@ impl BottomPane {
|
||||
.take_recent_submission_images_with_placeholders()
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_inline_args_submission(
|
||||
&mut self,
|
||||
record_history: bool,
|
||||
) -> Option<(String, Vec<TextElement>)> {
|
||||
self.composer.prepare_inline_args_submission(record_history)
|
||||
}
|
||||
|
||||
fn as_renderable(&'_ self) -> RenderableItem<'_> {
|
||||
if let Some(view) = self.active_view() {
|
||||
RenderableItem::Borrowed(view)
|
||||
|
||||
@@ -844,6 +844,46 @@ impl TextArea {
|
||||
self.set_cursor(end);
|
||||
}
|
||||
|
||||
/// Mark an existing text range as an atomic element without changing the text.
|
||||
///
|
||||
/// This is used to convert already-typed tokens (like `/plan`) into elements
|
||||
/// so they render and edit atomically. Overlapping or duplicate ranges are ignored.
|
||||
pub fn add_element_range(&mut self, range: Range<usize>) {
|
||||
let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
|
||||
let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.elements
|
||||
.iter()
|
||||
.any(|e| e.range.start == start && e.range.end == end)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.elements
|
||||
.iter()
|
||||
.any(|e| start < e.range.end && end > e.range.start)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.elements.push(TextElement { range: start..end });
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
}
|
||||
|
||||
pub fn remove_element_range(&mut self, range: Range<usize>) -> bool {
|
||||
let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
|
||||
let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
|
||||
if start >= end {
|
||||
return false;
|
||||
}
|
||||
let len_before = self.elements.len();
|
||||
self.elements
|
||||
.retain(|elem| elem.range.start != start || elem.range.end != end);
|
||||
len_before != self.elements.len()
|
||||
}
|
||||
|
||||
fn add_element(&mut self, range: Range<usize>) {
|
||||
let elem = TextElement { range };
|
||||
self.elements.push(elem);
|
||||
|
||||
@@ -815,6 +815,9 @@ impl ChatWidget {
|
||||
&model_for_header,
|
||||
event,
|
||||
self.show_welcome_banner,
|
||||
self.auth_manager
|
||||
.auth_cached()
|
||||
.and_then(|auth| auth.account_plan_type()),
|
||||
);
|
||||
self.apply_session_info_cell(session_info_cell);
|
||||
|
||||
@@ -2732,8 +2735,8 @@ impl ChatWidget {
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
InputResult::CommandWithArgs(cmd, args, text_elements) => {
|
||||
self.dispatch_command_with_args(cmd, args, text_elements);
|
||||
}
|
||||
InputResult::None => {}
|
||||
},
|
||||
@@ -2783,6 +2786,7 @@ impl ChatWidget {
|
||||
cmd.command()
|
||||
);
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
@@ -3019,7 +3023,16 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) {
|
||||
fn dispatch_command_with_args(
|
||||
&mut self,
|
||||
cmd: SlashCommand,
|
||||
args: String,
|
||||
_text_elements: Vec<TextElement>,
|
||||
) {
|
||||
if !cmd.supports_inline_args() {
|
||||
self.dispatch_command(cmd);
|
||||
return;
|
||||
}
|
||||
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
@@ -3033,7 +3046,12 @@ impl ChatWidget {
|
||||
let trimmed = args.trim();
|
||||
match cmd {
|
||||
SlashCommand::Rename if !trimmed.is_empty() => {
|
||||
let Some(name) = codex_core::util::normalize_thread_name(trimmed) else {
|
||||
let Some((prepared_args, _prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else {
|
||||
self.add_error_message("Thread name cannot be empty.".to_string());
|
||||
return;
|
||||
};
|
||||
@@ -3042,20 +3060,50 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
SlashCommand::Collab | SlashCommand::Plan => {
|
||||
let _ = trimmed;
|
||||
SlashCommand::Plan if !trimmed.is_empty() => {
|
||||
self.dispatch_command(cmd);
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
return;
|
||||
}
|
||||
let Some((prepared_args, prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(true)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let user_message = UserMessage {
|
||||
text: prepared_args,
|
||||
local_images: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders(),
|
||||
text_elements: prepared_elements,
|
||||
mention_paths: self.bottom_pane.take_mention_paths(),
|
||||
};
|
||||
if self.is_session_configured() {
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
self.submit_user_message(user_message);
|
||||
} else {
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
}
|
||||
SlashCommand::Review if !trimmed.is_empty() => {
|
||||
let Some((prepared_args, _prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.submit_op(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: trimmed.to_string(),
|
||||
instructions: prepared_args,
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
});
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
_ => self.dispatch_command(cmd),
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ expression: popup
|
||||
› [ ] Ghost snapshots Capture undo snapshots each turn.
|
||||
[x] Shell tool Allow the model to run shell commands.
|
||||
|
||||
Press enter to toggle or esc to save for next conversation
|
||||
Press space to select or enter to save for next conversation
|
||||
|
||||
@@ -2316,6 +2316,50 @@ async fn plan_slash_command_switches_to_plan_mode() {
|
||||
assert_eq!(chat.current_collaboration_mode(), &initial);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: None,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "configured".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
let items = match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => items,
|
||||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||||
};
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(
|
||||
items[0],
|
||||
UserInput::Text {
|
||||
text: "build the plan".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collaboration_modes_defaults_to_code_on_startup() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
@@ -2992,14 +3036,14 @@ async fn experimental_features_toggle_saves_on_exit() {
|
||||
);
|
||||
chat.bottom_pane.show_view(Box::new(view));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected no updates until exiting the popup"
|
||||
"expected no updates until saving the popup"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let mut updates = None;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::web_search::web_search_detail;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
@@ -943,6 +944,7 @@ pub(crate) fn new_session_info(
|
||||
requested_model: &str,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
auth_plan: Option<PlanType>,
|
||||
) -> SessionInfoCell {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
@@ -995,7 +997,7 @@ pub(crate) fn new_session_info(
|
||||
parts.push(Box::new(PlainHistoryCell { lines: help_lines }));
|
||||
} else {
|
||||
if config.show_tooltips
|
||||
&& let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new)
|
||||
&& let Some(tooltips) = tooltips::get_tooltip(auth_plan).map(TooltipHistoryCell::new)
|
||||
{
|
||||
parts.push(Box::new(tooltips));
|
||||
}
|
||||
|
||||
@@ -87,6 +87,14 @@ impl SlashCommand {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// Whether this command supports inline args (for example `/review ...`).
|
||||
pub fn supports_inline_args(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this command can be run while a task is in progress.
|
||||
pub fn available_during_task(self) -> bool {
|
||||
match self {
|
||||
@@ -103,6 +111,7 @@ impl SlashCommand {
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Rename
|
||||
@@ -117,7 +126,6 @@ impl SlashCommand {
|
||||
| SlashCommand::Exit => true,
|
||||
SlashCommand::Rollout => true,
|
||||
SlashCommand::TestApproval => true,
|
||||
SlashCommand::Plan => true,
|
||||
SlashCommand::Collab => true,
|
||||
SlashCommand::Agent => true,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
use codex_core::features::FEATURES;
|
||||
use codex_protocol::account::PlanType;
|
||||
use lazy_static::lazy_static;
|
||||
use rand::Rng;
|
||||
|
||||
const ANNOUNCEMENT_TIP_URL: &str =
|
||||
"https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml";
|
||||
|
||||
const PAID_TOOLTIP: &str =
|
||||
"*New* Try the **Codex App** with 2x rate limits until *April 2nd*. https://chatgpt.com/codex";
|
||||
const OTHER_TOOLTIP: &str =
|
||||
"*New* Build faster with the **Codex App**. Try it now. https://chatgpt.com/codex";
|
||||
const FREE_GO_TOOLTIP: &str =
|
||||
"*New* Codex is included in your plan for free through *March 2nd* – let’s build together.";
|
||||
|
||||
const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt");
|
||||
|
||||
lazy_static! {
|
||||
@@ -28,11 +37,30 @@ fn experimental_tooltips() -> Vec<&'static str> {
|
||||
}
|
||||
|
||||
/// Pick a random tooltip to show to the user when starting Codex.
|
||||
pub(crate) fn random_tooltip() -> Option<String> {
|
||||
pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
|
||||
let mut rng = rand::rng();
|
||||
|
||||
// Leave small chance for a random tooltip to be shown.
|
||||
if rng.random_ratio(8, 10) {
|
||||
match plan {
|
||||
Some(PlanType::Plus)
|
||||
| Some(PlanType::Business)
|
||||
| Some(PlanType::Team)
|
||||
| Some(PlanType::Enterprise)
|
||||
| Some(PlanType::Pro) => {
|
||||
return Some(PAID_TOOLTIP.to_string());
|
||||
}
|
||||
Some(PlanType::Go) | Some(PlanType::Free) => {
|
||||
return Some(FREE_GO_TOOLTIP.to_string());
|
||||
}
|
||||
_ => return Some(OTHER_TOOLTIP.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(announcement) = announcement::fetch_announcement_tip() {
|
||||
return Some(announcement);
|
||||
}
|
||||
let mut rng = rand::rng();
|
||||
|
||||
pick_tooltip(&mut rng).map(str::to_string)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl
|
||||
history navigation, etc).
|
||||
- After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the
|
||||
latest text + cursor.
|
||||
- When a slash command name is completed and the user types a space, the `/command` token is
|
||||
promoted into a text element so it renders distinctly and edits atomically.
|
||||
|
||||
### History navigation (↑/↓)
|
||||
|
||||
@@ -105,6 +107,9 @@ There are multiple submission paths, but they share the same core rules:
|
||||
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
|
||||
are no attachments.
|
||||
|
||||
The same preparation path is reused for slash commands with arguments (for example `/plan` and
|
||||
`/review`) so pasted content and text elements are preserved when extracting args.
|
||||
|
||||
### Numeric auto-submit path
|
||||
|
||||
When the slash popup is open and the first line matches a numeric-only custom prompt with
|
||||
|
||||
4
justfile
4
justfile
@@ -69,8 +69,8 @@ write-config-schema:
|
||||
cargo run -p codex-core --bin codex-write-config-schema
|
||||
|
||||
# Regenerate vendored app-server protocol schema artifacts.
|
||||
write-app-server-schema:
|
||||
cargo run -p codex-app-server-protocol --bin write_schema_fixtures
|
||||
write-app-server-schema *args:
|
||||
cargo run -p codex-app-server-protocol --bin write_schema_fixtures -- "$@"
|
||||
|
||||
# Tail logs from the state SQLite database
|
||||
log *args:
|
||||
|
||||
Reference in New Issue
Block a user