rework message styling (#2877)
https://github.com/user-attachments/assets/cf07f62b-1895-44bb-b9c3-7a12032eb371
This commit is contained in:
8
codex-rs/Cargo.lock
generated
8
codex-rs/Cargo.lock
generated
@@ -973,11 +973,13 @@ dependencies = [
|
|||||||
"diffy",
|
"diffy",
|
||||||
"image",
|
"image",
|
||||||
"insta",
|
"insta",
|
||||||
|
"itertools 0.14.0",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"libc",
|
"libc",
|
||||||
"mcp-types",
|
"mcp-types",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"path-clean",
|
"path-clean",
|
||||||
|
"pathdiff",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -3377,6 +3379,12 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathdiff"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ pub enum ApplyPatchFileChange {
|
|||||||
Add {
|
Add {
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
Delete,
|
Delete {
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
Update {
|
Update {
|
||||||
unified_diff: String,
|
unified_diff: String,
|
||||||
move_path: Option<PathBuf>,
|
move_path: Option<PathBuf>,
|
||||||
@@ -210,7 +212,18 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
|
|||||||
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
||||||
}
|
}
|
||||||
Hunk::DeleteFile { .. } => {
|
Hunk::DeleteFile { .. } => {
|
||||||
changes.insert(path, ApplyPatchFileChange::Delete);
|
let content = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(e) => {
|
||||||
|
return MaybeApplyPatchVerified::CorrectnessError(
|
||||||
|
ApplyPatchError::IoError(IoError {
|
||||||
|
context: format!("Failed to read {}", path.display()),
|
||||||
|
source: e,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
changes.insert(path, ApplyPatchFileChange::Delete { content });
|
||||||
}
|
}
|
||||||
Hunk::UpdateFile {
|
Hunk::UpdateFile {
|
||||||
move_path, chunks, ..
|
move_path, chunks, ..
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ pub(crate) fn convert_apply_patch_to_protocol(
|
|||||||
ApplyPatchFileChange::Add { content } => FileChange::Add {
|
ApplyPatchFileChange::Add { content } => FileChange::Add {
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
},
|
},
|
||||||
ApplyPatchFileChange::Delete => FileChange::Delete,
|
ApplyPatchFileChange::Delete { content } => FileChange::Delete {
|
||||||
|
content: content.clone(),
|
||||||
|
},
|
||||||
ApplyPatchFileChange::Update {
|
ApplyPatchFileChange::Update {
|
||||||
unified_diff,
|
unified_diff,
|
||||||
move_path,
|
move_path,
|
||||||
|
|||||||
@@ -10,7 +10,35 @@ use tokio::process::Command;
|
|||||||
use tokio::time::Duration as TokioDuration;
|
use tokio::time::Duration as TokioDuration;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use crate::util::is_inside_git_repo;
|
/// Return `true` if the project folder specified by the `Config` is inside a
|
||||||
|
/// Git repository.
|
||||||
|
///
|
||||||
|
/// The check walks up the directory hierarchy looking for a `.git` file or
|
||||||
|
/// directory (note `.git` can be a file that contains a `gitdir` entry). This
|
||||||
|
/// approach does **not** require the `git` binary or the `git2` crate and is
|
||||||
|
/// therefore fairly lightweight.
|
||||||
|
///
|
||||||
|
/// Note that this does **not** detect *work‑trees* created with
|
||||||
|
/// `git worktree add` where the checkout lives outside the main repository
|
||||||
|
/// directory. If you need Codex to work from such a checkout simply pass the
|
||||||
|
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
|
||||||
|
pub fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
|
||||||
|
let mut dir = base_dir.to_path_buf();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if dir.join(".git").exists() {
|
||||||
|
return Some(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop one component (go up one directory). `pop` returns false when
|
||||||
|
// we have reached the filesystem root.
|
||||||
|
if !dir.pop() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Timeout for git commands to prevent freezing on large repositories
|
/// Timeout for git commands to prevent freezing on large repositories
|
||||||
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
||||||
@@ -94,9 +122,7 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
|||||||
|
|
||||||
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
||||||
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
||||||
if !is_inside_git_repo(cwd) {
|
get_git_repo_root(cwd)?;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let remotes = get_git_remotes(cwd).await?;
|
let remotes = get_git_remotes(cwd).await?;
|
||||||
let branches = branch_ancestry(cwd).await?;
|
let branches = branch_ancestry(cwd).await?;
|
||||||
@@ -440,7 +466,7 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the path that should be used for trust checks. Similar to
|
/// Resolve the path that should be used for trust checks. Similar to
|
||||||
/// `[utils::is_inside_git_repo]`, but resolves to the root of the main
|
/// `[get_git_repo_root]`, but resolves to the root of the main
|
||||||
/// repository. Handles worktrees.
|
/// repository. Handles worktrees.
|
||||||
pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
|
pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
|
||||||
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
|
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
|
||||||
|
|||||||
@@ -20,22 +20,6 @@ pub enum ParsedCommand {
|
|||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
},
|
},
|
||||||
Format {
|
|
||||||
cmd: String,
|
|
||||||
tool: Option<String>,
|
|
||||||
targets: Option<Vec<String>>,
|
|
||||||
},
|
|
||||||
Test {
|
|
||||||
cmd: String,
|
|
||||||
},
|
|
||||||
Lint {
|
|
||||||
cmd: String,
|
|
||||||
tool: Option<String>,
|
|
||||||
targets: Option<Vec<String>>,
|
|
||||||
},
|
|
||||||
Noop {
|
|
||||||
cmd: String,
|
|
||||||
},
|
|
||||||
Unknown {
|
Unknown {
|
||||||
cmd: String,
|
cmd: String,
|
||||||
},
|
},
|
||||||
@@ -50,10 +34,6 @@ impl From<ParsedCommand> for codex_protocol::parse_command::ParsedCommand {
|
|||||||
ParsedCommand::Read { cmd, name } => P::Read { cmd, name },
|
ParsedCommand::Read { cmd, name } => P::Read { cmd, name },
|
||||||
ParsedCommand::ListFiles { cmd, path } => P::ListFiles { cmd, path },
|
ParsedCommand::ListFiles { cmd, path } => P::ListFiles { cmd, path },
|
||||||
ParsedCommand::Search { cmd, query, path } => P::Search { cmd, query, path },
|
ParsedCommand::Search { cmd, query, path } => P::Search { cmd, query, path },
|
||||||
ParsedCommand::Format { cmd, tool, targets } => P::Format { cmd, tool, targets },
|
|
||||||
ParsedCommand::Test { cmd } => P::Test { cmd },
|
|
||||||
ParsedCommand::Lint { cmd, tool, targets } => P::Lint { cmd, tool, targets },
|
|
||||||
ParsedCommand::Noop { cmd } => P::Noop { cmd },
|
|
||||||
ParsedCommand::Unknown { cmd } => P::Unknown { cmd },
|
ParsedCommand::Unknown { cmd } => P::Unknown { cmd },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +102,7 @@ mod tests {
|
|||||||
assert_parsed(
|
assert_parsed(
|
||||||
&vec_str(&["bash", "-lc", inner]),
|
&vec_str(&["bash", "-lc", inner]),
|
||||||
vec![ParsedCommand::Unknown {
|
vec![ParsedCommand::Unknown {
|
||||||
cmd: "git status | wc -l".to_string(),
|
cmd: "git status".to_string(),
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -244,6 +224,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cd_then_cat_is_single_read() {
|
||||||
|
assert_parsed(
|
||||||
|
&shlex_split_safe("cd foo && cat foo.txt"),
|
||||||
|
vec![ParsedCommand::Read {
|
||||||
|
cmd: "cat foo.txt".to_string(),
|
||||||
|
name: "foo.txt".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supports_ls_with_pipe() {
|
fn supports_ls_with_pipe() {
|
||||||
let inner = "ls -la | sed -n '1,120p'";
|
let inner = "ls -la | sed -n '1,120p'";
|
||||||
@@ -315,27 +306,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn supports_npm_run_with_forwarded_args() {
|
|
||||||
assert_parsed(
|
|
||||||
&vec_str(&[
|
|
||||||
"npm",
|
|
||||||
"run",
|
|
||||||
"lint",
|
|
||||||
"--",
|
|
||||||
"--max-warnings",
|
|
||||||
"0",
|
|
||||||
"--format",
|
|
||||||
"json",
|
|
||||||
]),
|
|
||||||
vec![ParsedCommand::Lint {
|
|
||||||
cmd: "npm run lint -- --max-warnings 0 --format json".to_string(),
|
|
||||||
tool: Some("npm-script:lint".to_string()),
|
|
||||||
targets: None,
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supports_grep_recursive_current_dir() {
|
fn supports_grep_recursive_current_dir() {
|
||||||
assert_parsed(
|
assert_parsed(
|
||||||
@@ -396,173 +366,10 @@ mod tests {
|
|||||||
fn supports_cd_and_rg_files() {
|
fn supports_cd_and_rg_files() {
|
||||||
assert_parsed(
|
assert_parsed(
|
||||||
&shlex_split_safe("cd codex-rs && rg --files"),
|
&shlex_split_safe("cd codex-rs && rg --files"),
|
||||||
vec![
|
vec![ParsedCommand::Search {
|
||||||
ParsedCommand::Unknown {
|
cmd: "rg --files".to_string(),
|
||||||
cmd: "cd codex-rs".to_string(),
|
query: None,
|
||||||
},
|
path: None,
|
||||||
ParsedCommand::Search {
|
|
||||||
cmd: "rg --files".to_string(),
|
|
||||||
query: None,
|
|
||||||
path: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_then_cargo_test_sequence() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("echo Running tests... && cargo test --all-features --quiet"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "cargo test --all-features --quiet".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn supports_cargo_fmt_and_test_with_config() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe(
|
|
||||||
"cargo fmt -- --config imports_granularity=Item && cargo test -p core --all-features",
|
|
||||||
),
|
|
||||||
vec![
|
|
||||||
ParsedCommand::Format {
|
|
||||||
cmd: "cargo fmt -- --config 'imports_granularity=Item'".to_string(),
|
|
||||||
tool: Some("cargo fmt".to_string()),
|
|
||||||
targets: None,
|
|
||||||
},
|
|
||||||
ParsedCommand::Test {
|
|
||||||
cmd: "cargo test -p core --all-features".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recognizes_rustfmt_and_clippy() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("rustfmt src/main.rs"),
|
|
||||||
vec![ParsedCommand::Format {
|
|
||||||
cmd: "rustfmt src/main.rs".to_string(),
|
|
||||||
tool: Some("rustfmt".to_string()),
|
|
||||||
targets: Some(vec!["src/main.rs".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("cargo clippy -p core --all-features -- -D warnings"),
|
|
||||||
vec![ParsedCommand::Lint {
|
|
||||||
cmd: "cargo clippy -p core --all-features -- -D warnings".to_string(),
|
|
||||||
tool: Some("cargo clippy".to_string()),
|
|
||||||
targets: None,
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recognizes_pytest_go_and_tools() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe(
|
|
||||||
"pytest -k 'Login and not slow' tests/test_login.py::TestLogin::test_ok",
|
|
||||||
),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "pytest -k 'Login and not slow' tests/test_login.py::TestLogin::test_ok"
|
|
||||||
.to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("go fmt ./..."),
|
|
||||||
vec![ParsedCommand::Format {
|
|
||||||
cmd: "go fmt ./...".to_string(),
|
|
||||||
tool: Some("go fmt".to_string()),
|
|
||||||
targets: Some(vec!["./...".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("go test ./pkg -run TestThing"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "go test ./pkg -run TestThing".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("eslint . --max-warnings 0"),
|
|
||||||
vec![ParsedCommand::Lint {
|
|
||||||
cmd: "eslint . --max-warnings 0".to_string(),
|
|
||||||
tool: Some("eslint".to_string()),
|
|
||||||
targets: Some(vec![".".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("prettier -w ."),
|
|
||||||
vec![ParsedCommand::Format {
|
|
||||||
cmd: "prettier -w .".to_string(),
|
|
||||||
tool: Some("prettier".to_string()),
|
|
||||||
targets: Some(vec![".".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recognizes_jest_and_vitest_filters() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("jest -t 'should work' src/foo.test.ts"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "jest -t 'should work' src/foo.test.ts".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("vitest -t 'runs' src/foo.test.tsx"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "vitest -t runs src/foo.test.tsx".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recognizes_npx_and_scripts() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("npx eslint src"),
|
|
||||||
vec![ParsedCommand::Lint {
|
|
||||||
cmd: "npx eslint src".to_string(),
|
|
||||||
tool: Some("eslint".to_string()),
|
|
||||||
targets: Some(vec!["src".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("npx prettier -c ."),
|
|
||||||
vec![ParsedCommand::Format {
|
|
||||||
cmd: "npx prettier -c .".to_string(),
|
|
||||||
tool: Some("prettier".to_string()),
|
|
||||||
targets: Some(vec![".".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("pnpm run lint -- --max-warnings 0"),
|
|
||||||
vec![ParsedCommand::Lint {
|
|
||||||
cmd: "pnpm run lint -- --max-warnings 0".to_string(),
|
|
||||||
tool: Some("pnpm-script:lint".to_string()),
|
|
||||||
targets: None,
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("npm test"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "npm test".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("yarn test"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "yarn test".to_string(),
|
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -770,6 +577,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_mixed_sequence_with_pipes_semicolons_and_or() {
|
||||||
|
// Provided long command sequence combining sequencing, pipelines, and ORs.
|
||||||
|
let inner = "pwd; ls -la; rg --files -g '!target' | wc -l; rg -n '^\\[workspace\\]' -n Cargo.toml || true; rg -n '^\\[package\\]' -n */Cargo.toml || true; cargo --version; rustc --version; cargo clippy --workspace --all-targets --all-features -q";
|
||||||
|
let args = vec_str(&["bash", "-lc", inner]);
|
||||||
|
|
||||||
|
let expected = vec![
|
||||||
|
ParsedCommand::Unknown {
|
||||||
|
cmd: "pwd".to_string(),
|
||||||
|
},
|
||||||
|
ParsedCommand::ListFiles {
|
||||||
|
cmd: shlex_join(&shlex_split_safe("ls -la")),
|
||||||
|
path: None,
|
||||||
|
},
|
||||||
|
ParsedCommand::Search {
|
||||||
|
cmd: shlex_join(&shlex_split_safe("rg --files -g '!target'")),
|
||||||
|
query: None,
|
||||||
|
path: Some("!target".to_string()),
|
||||||
|
},
|
||||||
|
ParsedCommand::Search {
|
||||||
|
cmd: shlex_join(&shlex_split_safe("rg -n '^\\[workspace\\]' -n Cargo.toml")),
|
||||||
|
query: Some("^\\[workspace\\]".to_string()),
|
||||||
|
path: Some("Cargo.toml".to_string()),
|
||||||
|
},
|
||||||
|
ParsedCommand::Search {
|
||||||
|
cmd: shlex_join(&shlex_split_safe("rg -n '^\\[package\\]' -n */Cargo.toml")),
|
||||||
|
query: Some("^\\[package\\]".to_string()),
|
||||||
|
path: Some("Cargo.toml".to_string()),
|
||||||
|
},
|
||||||
|
ParsedCommand::Unknown {
|
||||||
|
cmd: shlex_join(&shlex_split_safe("cargo --version")),
|
||||||
|
},
|
||||||
|
ParsedCommand::Unknown {
|
||||||
|
cmd: shlex_join(&shlex_split_safe("rustc --version")),
|
||||||
|
},
|
||||||
|
ParsedCommand::Unknown {
|
||||||
|
cmd: shlex_join(&shlex_split_safe(
|
||||||
|
"cargo clippy --workspace --all-targets --all-features -q",
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_parsed(&args, expected);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strips_true_in_sequence() {
|
fn strips_true_in_sequence() {
|
||||||
// `true` should be dropped from parsed sequences
|
// `true` should be dropped from parsed sequences
|
||||||
@@ -867,159 +719,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pnpm_test_is_parsed_as_test() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("pnpm test"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "pnpm test".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pnpm_exec_vitest_is_unknown() {
|
|
||||||
// From commands_combined: cd codex-cli && pnpm exec vitest run tests/... --threads=false --passWithNoTests
|
|
||||||
let inner = "cd codex-cli && pnpm exec vitest run tests/file-tag-utils.test.ts --threads=false --passWithNoTests";
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe(inner),
|
|
||||||
vec![
|
|
||||||
ParsedCommand::Unknown {
|
|
||||||
cmd: "cd codex-cli".to_string(),
|
|
||||||
},
|
|
||||||
ParsedCommand::Unknown {
|
|
||||||
cmd: "pnpm exec vitest run tests/file-tag-utils.test.ts '--threads=false' --passWithNoTests".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cargo_test_with_crate() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("cargo test -p codex-core parse_command::"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "cargo test -p codex-core parse_command::".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cargo_test_with_crate_2() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe(
|
|
||||||
"cd core && cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants",
|
|
||||||
),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cargo_test_with_crate_3() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("cd core && cargo test -q parse_command::tests"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "cargo test -q parse_command::tests".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cargo_test_with_crate_4() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("cd core && cargo test --all-features parse_command -- --nocapture"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "cargo test --all-features parse_command -- --nocapture".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional coverage for other common tools/frameworks
|
|
||||||
#[test]
|
|
||||||
fn recognizes_black_and_ruff() {
|
|
||||||
// black formats Python code
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("black src"),
|
|
||||||
vec![ParsedCommand::Format {
|
|
||||||
cmd: "black src".to_string(),
|
|
||||||
tool: Some("black".to_string()),
|
|
||||||
targets: Some(vec!["src".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ruff check is a linter; ensure we collect targets
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("ruff check ."),
|
|
||||||
vec![ParsedCommand::Lint {
|
|
||||||
cmd: "ruff check .".to_string(),
|
|
||||||
tool: Some("ruff".to_string()),
|
|
||||||
targets: Some(vec![".".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ruff format is a formatter
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("ruff format pkg/"),
|
|
||||||
vec![ParsedCommand::Format {
|
|
||||||
cmd: "ruff format pkg/".to_string(),
|
|
||||||
tool: Some("ruff".to_string()),
|
|
||||||
targets: Some(vec!["pkg/".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recognizes_pnpm_monorepo_test_and_npm_format_script() {
|
|
||||||
// pnpm -r test in a monorepo should still parse as a test action
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("pnpm -r test"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "pnpm -r test".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
// npm run format should be recognized as a format action
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("npm run format -- -w ."),
|
|
||||||
vec![ParsedCommand::Format {
|
|
||||||
cmd: "npm run format -- -w .".to_string(),
|
|
||||||
tool: Some("npm-script:format".to_string()),
|
|
||||||
targets: None,
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn yarn_test_is_parsed_as_test() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("yarn test"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "yarn test".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pytest_file_only_and_go_run_regex() {
|
|
||||||
// pytest invoked with a file path should be captured as a filter
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("pytest tests/test_example.py"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "pytest tests/test_example.py".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
// go test with -run regex should capture the filter
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("go test ./... -run '^TestFoo$'"),
|
|
||||||
vec![ParsedCommand::Test {
|
|
||||||
cmd: "go test ./... -run '^TestFoo$'".to_string(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn grep_with_query_and_path() {
|
fn grep_with_query_and_path() {
|
||||||
assert_parsed(
|
assert_parsed(
|
||||||
@@ -1090,30 +789,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eslint_with_config_path_and_target() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("eslint -c .eslintrc.json src"),
|
|
||||||
vec![ParsedCommand::Lint {
|
|
||||||
cmd: "eslint -c .eslintrc.json src".to_string(),
|
|
||||||
tool: Some("eslint".to_string()),
|
|
||||||
targets: Some(vec!["src".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn npx_eslint_with_config_path_and_target() {
|
|
||||||
assert_parsed(
|
|
||||||
&shlex_split_safe("npx eslint -c .eslintrc src"),
|
|
||||||
vec![ParsedCommand::Lint {
|
|
||||||
cmd: "npx eslint -c .eslintrc src".to_string(),
|
|
||||||
tool: Some("eslint".to_string()),
|
|
||||||
targets: Some(vec!["src".to_string()]),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fd_file_finder_variants() {
|
fn fd_file_finder_variants() {
|
||||||
assert_parsed(
|
assert_parsed(
|
||||||
@@ -1202,16 +877,13 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option<Vec<ParsedCommand>> {
|
|||||||
return Some(commands[1..].to_vec());
|
return Some(commands[1..].to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
// cd foo && [any Test command] => [any Test command]
|
// cd foo && [any command] => [any command] (keep non-cd when a cd is followed by something)
|
||||||
if let Some(idx) = commands.iter().position(|pc| match pc {
|
if let Some(idx) = commands.iter().position(|pc| match pc {
|
||||||
ParsedCommand::Unknown { cmd } => {
|
ParsedCommand::Unknown { cmd } => {
|
||||||
shlex_split(cmd).is_some_and(|t| t.first().map(|s| s.as_str()) == Some("cd"))
|
shlex_split(cmd).is_some_and(|t| t.first().map(|s| s.as_str()) == Some("cd"))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}) && commands
|
}) && commands.len() > idx + 1
|
||||||
.iter()
|
|
||||||
.skip(idx + 1)
|
|
||||||
.any(|pc| matches!(pc, ParsedCommand::Test { .. }))
|
|
||||||
{
|
{
|
||||||
let mut out = Vec::with_capacity(commands.len() - 1);
|
let mut out = Vec::with_capacity(commands.len() - 1);
|
||||||
out.extend_from_slice(&commands[..idx]);
|
out.extend_from_slice(&commands[..idx]);
|
||||||
@@ -1220,10 +892,10 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option<Vec<ParsedCommand>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cmd || true => cmd
|
// cmd || true => cmd
|
||||||
if let Some(idx) = commands.iter().position(|pc| match pc {
|
if let Some(idx) = commands
|
||||||
ParsedCommand::Noop { cmd } => cmd == "true",
|
.iter()
|
||||||
_ => false,
|
.position(|pc| matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"))
|
||||||
}) {
|
{
|
||||||
let mut out = Vec::with_capacity(commands.len() - 1);
|
let mut out = Vec::with_capacity(commands.len() - 1);
|
||||||
out.extend_from_slice(&commands[..idx]);
|
out.extend_from_slice(&commands[..idx]);
|
||||||
out.extend_from_slice(&commands[idx + 1..]);
|
out.extend_from_slice(&commands[idx + 1..]);
|
||||||
@@ -1377,75 +1049,6 @@ fn skip_flag_values<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common flags for ESLint that take a following value and should not be
|
|
||||||
/// considered positional targets.
|
|
||||||
const ESLINT_FLAGS_WITH_VALUES: &[&str] = &[
|
|
||||||
"-c",
|
|
||||||
"--config",
|
|
||||||
"--parser",
|
|
||||||
"--parser-options",
|
|
||||||
"--rulesdir",
|
|
||||||
"--plugin",
|
|
||||||
"--max-warnings",
|
|
||||||
"--format",
|
|
||||||
];
|
|
||||||
|
|
||||||
fn collect_non_flag_targets(args: &[String]) -> Option<Vec<String>> {
|
|
||||||
let mut targets = Vec::new();
|
|
||||||
let mut skip_next = false;
|
|
||||||
for (i, a) in args.iter().enumerate() {
|
|
||||||
if a == "--" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if skip_next {
|
|
||||||
skip_next = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if a == "-p"
|
|
||||||
|| a == "--package"
|
|
||||||
|| a == "--features"
|
|
||||||
|| a == "-C"
|
|
||||||
|| a == "--config"
|
|
||||||
|| a == "--config-path"
|
|
||||||
|| a == "--out-dir"
|
|
||||||
|| a == "-o"
|
|
||||||
|| a == "--run"
|
|
||||||
|| a == "--max-warnings"
|
|
||||||
|| a == "--format"
|
|
||||||
{
|
|
||||||
if i + 1 < args.len() {
|
|
||||||
skip_next = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if a.starts_with('-') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
targets.push(a.clone());
|
|
||||||
}
|
|
||||||
if targets.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(targets)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_non_flag_targets_with_flags(
|
|
||||||
args: &[String],
|
|
||||||
flags_with_vals: &[&str],
|
|
||||||
) -> Option<Vec<String>> {
|
|
||||||
let targets: Vec<String> = skip_flag_values(args, flags_with_vals)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !a.starts_with('-'))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
if targets.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(targets)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_pathish(s: &str) -> bool {
|
fn is_pathish(s: &str) -> bool {
|
||||||
s == "."
|
s == "."
|
||||||
|| s == ".."
|
|| s == ".."
|
||||||
@@ -1514,47 +1117,6 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>
|
|||||||
(query, path)
|
(query, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_npm_like(tool: &str, tail: &[String], full_cmd: &[String]) -> Option<ParsedCommand> {
|
|
||||||
let mut r = tail;
|
|
||||||
if tool == "pnpm" && r.first().map(|s| s.as_str()) == Some("-r") {
|
|
||||||
r = &r[1..];
|
|
||||||
}
|
|
||||||
let mut script_name: Option<String> = None;
|
|
||||||
if r.first().map(|s| s.as_str()) == Some("run") {
|
|
||||||
script_name = r.get(1).cloned();
|
|
||||||
} else {
|
|
||||||
let is_test_cmd = (tool == "npm" && r.first().map(|s| s.as_str()) == Some("t"))
|
|
||||||
|| ((tool == "npm" || tool == "pnpm" || tool == "yarn")
|
|
||||||
&& r.first().map(|s| s.as_str()) == Some("test"));
|
|
||||||
if is_test_cmd {
|
|
||||||
script_name = Some("test".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(name) = script_name {
|
|
||||||
let lname = name.to_lowercase();
|
|
||||||
if lname == "test" || lname == "unit" || lname == "jest" || lname == "vitest" {
|
|
||||||
return Some(ParsedCommand::Test {
|
|
||||||
cmd: shlex_join(full_cmd),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if lname == "lint" || lname == "eslint" {
|
|
||||||
return Some(ParsedCommand::Lint {
|
|
||||||
cmd: shlex_join(full_cmd),
|
|
||||||
tool: Some(format!("{tool}-script:{name}")),
|
|
||||||
targets: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if lname == "format" || lname == "fmt" || lname == "prettier" {
|
|
||||||
return Some(ParsedCommand::Format {
|
|
||||||
cmd: shlex_join(full_cmd),
|
|
||||||
tool: Some(format!("{tool}-script:{name}")),
|
|
||||||
targets: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
||||||
let [bash, flag, script] = original else {
|
let [bash, flag, script] = original else {
|
||||||
return None;
|
return None;
|
||||||
@@ -1586,7 +1148,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
|||||||
.map(|tokens| summarize_main_tokens(&tokens))
|
.map(|tokens| summarize_main_tokens(&tokens))
|
||||||
.collect();
|
.collect();
|
||||||
if commands.len() > 1 {
|
if commands.len() > 1 {
|
||||||
commands.retain(|pc| !matches!(pc, ParsedCommand::Noop { .. }));
|
commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
|
||||||
}
|
}
|
||||||
if commands.len() == 1 {
|
if commands.len() == 1 {
|
||||||
// If we reduced to a single command, attribute the full original script
|
// If we reduced to a single command, attribute the full original script
|
||||||
@@ -1655,27 +1217,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ParsedCommand::Format {
|
other => other,
|
||||||
tool, targets, cmd, ..
|
|
||||||
} => ParsedCommand::Format {
|
|
||||||
cmd: cmd.clone(),
|
|
||||||
tool,
|
|
||||||
targets,
|
|
||||||
},
|
|
||||||
ParsedCommand::Test { cmd, .. } => ParsedCommand::Test { cmd: cmd.clone() },
|
|
||||||
ParsedCommand::Lint {
|
|
||||||
tool, targets, cmd, ..
|
|
||||||
} => ParsedCommand::Lint {
|
|
||||||
cmd: cmd.clone(),
|
|
||||||
tool,
|
|
||||||
targets,
|
|
||||||
},
|
|
||||||
ParsedCommand::Unknown { .. } => ParsedCommand::Unknown {
|
|
||||||
cmd: script.clone(),
|
|
||||||
},
|
|
||||||
ParsedCommand::Noop { .. } => ParsedCommand::Noop {
|
|
||||||
cmd: script.clone(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
@@ -1728,124 +1270,6 @@ fn drop_small_formatting_commands(mut commands: Vec<Vec<String>>) -> Vec<Vec<Str
|
|||||||
|
|
||||||
fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
|
fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
|
||||||
match main_cmd.split_first() {
|
match main_cmd.split_first() {
|
||||||
Some((head, tail)) if head == "true" && tail.is_empty() => ParsedCommand::Noop {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
},
|
|
||||||
// (sed-specific logic handled below in dedicated arm returning Read)
|
|
||||||
Some((head, tail))
|
|
||||||
if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("fmt") =>
|
|
||||||
{
|
|
||||||
ParsedCommand::Format {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("cargo fmt".to_string()),
|
|
||||||
targets: collect_non_flag_targets(&tail[1..]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, tail))
|
|
||||||
if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("clippy") =>
|
|
||||||
{
|
|
||||||
ParsedCommand::Lint {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("cargo clippy".to_string()),
|
|
||||||
targets: collect_non_flag_targets(&tail[1..]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, tail))
|
|
||||||
if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("test") =>
|
|
||||||
{
|
|
||||||
ParsedCommand::Test {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, tail)) if head == "rustfmt" => ParsedCommand::Format {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("rustfmt".to_string()),
|
|
||||||
targets: collect_non_flag_targets(tail),
|
|
||||||
},
|
|
||||||
Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("fmt") => {
|
|
||||||
ParsedCommand::Format {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("go fmt".to_string()),
|
|
||||||
targets: collect_non_flag_targets(&tail[1..]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("test") => {
|
|
||||||
ParsedCommand::Test {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, _)) if head == "pytest" => ParsedCommand::Test {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
},
|
|
||||||
Some((head, tail)) if head == "eslint" => {
|
|
||||||
// Treat configuration flags with values (e.g. `-c .eslintrc`) as non-targets.
|
|
||||||
let targets = collect_non_flag_targets_with_flags(tail, ESLINT_FLAGS_WITH_VALUES);
|
|
||||||
ParsedCommand::Lint {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("eslint".to_string()),
|
|
||||||
targets,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, tail)) if head == "prettier" => ParsedCommand::Format {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("prettier".to_string()),
|
|
||||||
targets: collect_non_flag_targets(tail),
|
|
||||||
},
|
|
||||||
Some((head, tail)) if head == "black" => ParsedCommand::Format {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("black".to_string()),
|
|
||||||
targets: collect_non_flag_targets(tail),
|
|
||||||
},
|
|
||||||
Some((head, tail))
|
|
||||||
if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("check") =>
|
|
||||||
{
|
|
||||||
ParsedCommand::Lint {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("ruff".to_string()),
|
|
||||||
targets: collect_non_flag_targets(&tail[1..]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, tail))
|
|
||||||
if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("format") =>
|
|
||||||
{
|
|
||||||
ParsedCommand::Format {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("ruff".to_string()),
|
|
||||||
targets: collect_non_flag_targets(&tail[1..]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, _)) if (head == "jest" || head == "vitest") => ParsedCommand::Test {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
},
|
|
||||||
Some((head, tail))
|
|
||||||
if head == "npx" && tail.first().map(|s| s.as_str()) == Some("eslint") =>
|
|
||||||
{
|
|
||||||
let targets = collect_non_flag_targets_with_flags(&tail[1..], ESLINT_FLAGS_WITH_VALUES);
|
|
||||||
ParsedCommand::Lint {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("eslint".to_string()),
|
|
||||||
targets,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, tail))
|
|
||||||
if head == "npx" && tail.first().map(|s| s.as_str()) == Some("prettier") =>
|
|
||||||
{
|
|
||||||
ParsedCommand::Format {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
tool: Some("prettier".to_string()),
|
|
||||||
targets: collect_non_flag_targets(&tail[1..]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// NPM-like scripts including yarn
|
|
||||||
Some((tool, tail)) if (tool == "pnpm" || tool == "npm" || tool == "yarn") => {
|
|
||||||
if let Some(cmd) = classify_npm_like(tool, tail, main_cmd) {
|
|
||||||
cmd
|
|
||||||
} else {
|
|
||||||
ParsedCommand::Unknown {
|
|
||||||
cmd: shlex_join(main_cmd),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((head, tail)) if head == "ls" => {
|
Some((head, tail)) if head == "ls" => {
|
||||||
// Avoid treating option values as paths (e.g., ls -I "*.test.js").
|
// Avoid treating option values as paths (e.g., ls -I "*.test.js").
|
||||||
let candidates = skip_flag_values(
|
let candidates = skip_flag_values(
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ fn is_write_patch_constrained_to_writable_paths(
|
|||||||
|
|
||||||
for (path, change) in action.changes() {
|
for (path, change) in action.changes() {
|
||||||
match change {
|
match change {
|
||||||
ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Delete => {
|
ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Delete { .. } => {
|
||||||
if !is_path_writable(path) {
|
if !is_path_writable(path) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -578,7 +578,12 @@ index {ZERO_OID}..{right_oid}
|
|||||||
fs::write(&file, "x\n").unwrap();
|
fs::write(&file, "x\n").unwrap();
|
||||||
|
|
||||||
let mut acc = TurnDiffTracker::new();
|
let mut acc = TurnDiffTracker::new();
|
||||||
let del_changes = HashMap::from([(file.clone(), FileChange::Delete)]);
|
let del_changes = HashMap::from([(
|
||||||
|
file.clone(),
|
||||||
|
FileChange::Delete {
|
||||||
|
content: "x\n".to_string(),
|
||||||
|
},
|
||||||
|
)]);
|
||||||
acc.on_patch_begin(&del_changes);
|
acc.on_patch_begin(&del_changes);
|
||||||
|
|
||||||
// Simulate apply: delete the file from disk.
|
// Simulate apply: delete the file from disk.
|
||||||
@@ -741,7 +746,12 @@ index {left_oid}..{right_oid}
|
|||||||
assert_eq!(first, expected_first);
|
assert_eq!(first, expected_first);
|
||||||
|
|
||||||
// Next: introduce a brand-new path b.txt into baseline snapshots via a delete change.
|
// Next: introduce a brand-new path b.txt into baseline snapshots via a delete change.
|
||||||
let del_b = HashMap::from([(b.clone(), FileChange::Delete)]);
|
let del_b = HashMap::from([(
|
||||||
|
b.clone(),
|
||||||
|
FileChange::Delete {
|
||||||
|
content: "z\n".to_string(),
|
||||||
|
},
|
||||||
|
)]);
|
||||||
acc.on_patch_begin(&del_b);
|
acc.on_patch_begin(&del_b);
|
||||||
// Simulate apply: delete b.txt.
|
// Simulate apply: delete b.txt.
|
||||||
let baseline_mode = file_mode_for_path(&b).unwrap_or(FileMode::Regular);
|
let baseline_mode = file_mode_for_path(&b).unwrap_or(FileMode::Regular);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use std::path::Path;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
@@ -12,33 +11,3 @@ pub(crate) fn backoff(attempt: u64) -> Duration {
|
|||||||
let jitter = rand::rng().random_range(0.9..1.1);
|
let jitter = rand::rng().random_range(0.9..1.1);
|
||||||
Duration::from_millis((base as f64 * jitter) as u64)
|
Duration::from_millis((base as f64 * jitter) as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `true` if the project folder specified by the `Config` is inside a
|
|
||||||
/// Git repository.
|
|
||||||
///
|
|
||||||
/// The check walks up the directory hierarchy looking for a `.git` file or
|
|
||||||
/// directory (note `.git` can be a file that contains a `gitdir` entry). This
|
|
||||||
/// approach does **not** require the `git` binary or the `git2` crate and is
|
|
||||||
/// therefore fairly lightweight.
|
|
||||||
///
|
|
||||||
/// Note that this does **not** detect *work‑trees* created with
|
|
||||||
/// `git worktree add` where the checkout lives outside the main repository
|
|
||||||
/// directory. If you need Codex to work from such a checkout simply pass the
|
|
||||||
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
|
|
||||||
pub fn is_inside_git_repo(base_dir: &Path) -> bool {
|
|
||||||
let mut dir = base_dir.to_path_buf();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if dir.join(".git").exists() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pop one component (go up one directory). `pop` returns false when
|
|
||||||
// we have reached the filesystem root.
|
|
||||||
if !dir.pop() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -404,13 +404,16 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
|||||||
println!("{}", line.style(self.green));
|
println!("{}", line.style(self.green));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FileChange::Delete => {
|
FileChange::Delete { content } => {
|
||||||
let header = format!(
|
let header = format!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
format_file_change(change),
|
format_file_change(change),
|
||||||
path.to_string_lossy()
|
path.to_string_lossy()
|
||||||
);
|
);
|
||||||
println!("{}", header.style(self.magenta));
|
println!("{}", header.style(self.magenta));
|
||||||
|
for line in content.lines() {
|
||||||
|
println!("{}", line.style(self.red));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
FileChange::Update {
|
FileChange::Update {
|
||||||
unified_diff,
|
unified_diff,
|
||||||
@@ -560,7 +563,7 @@ fn escape_command(command: &[String]) -> String {
|
|||||||
fn format_file_change(change: &FileChange) -> &'static str {
|
fn format_file_change(change: &FileChange) -> &'static str {
|
||||||
match change {
|
match change {
|
||||||
FileChange::Add { .. } => "A",
|
FileChange::Add { .. } => "A",
|
||||||
FileChange::Delete => "D",
|
FileChange::Delete { .. } => "D",
|
||||||
FileChange::Update {
|
FileChange::Update {
|
||||||
move_path: Some(_), ..
|
move_path: Some(_), ..
|
||||||
} => "R",
|
} => "R",
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ use codex_core::ConversationManager;
|
|||||||
use codex_core::NewConversation;
|
use codex_core::NewConversation;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config::ConfigOverrides;
|
use codex_core::config::ConfigOverrides;
|
||||||
|
use codex_core::git_info::get_git_repo_root;
|
||||||
use codex_core::protocol::AskForApproval;
|
use codex_core::protocol::AskForApproval;
|
||||||
use codex_core::protocol::Event;
|
use codex_core::protocol::Event;
|
||||||
use codex_core::protocol::EventMsg;
|
use codex_core::protocol::EventMsg;
|
||||||
use codex_core::protocol::InputItem;
|
use codex_core::protocol::InputItem;
|
||||||
use codex_core::protocol::Op;
|
use codex_core::protocol::Op;
|
||||||
use codex_core::protocol::TaskCompleteEvent;
|
use codex_core::protocol::TaskCompleteEvent;
|
||||||
use codex_core::util::is_inside_git_repo;
|
|
||||||
use codex_login::AuthManager;
|
use codex_login::AuthManager;
|
||||||
use codex_ollama::DEFAULT_OSS_MODEL;
|
use codex_ollama::DEFAULT_OSS_MODEL;
|
||||||
use codex_protocol::config_types::SandboxMode;
|
use codex_protocol::config_types::SandboxMode;
|
||||||
@@ -183,7 +183,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
|||||||
// is using.
|
// is using.
|
||||||
event_processor.print_config_summary(&config, &prompt);
|
event_processor.print_config_summary(&config, &prompt);
|
||||||
|
|
||||||
if !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf()) {
|
if !skip_git_repo_check && get_git_repo_root(&config.cwd.to_path_buf()).is_none() {
|
||||||
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
|
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,22 +17,6 @@ pub enum ParsedCommand {
|
|||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
},
|
},
|
||||||
Format {
|
|
||||||
cmd: String,
|
|
||||||
tool: Option<String>,
|
|
||||||
targets: Option<Vec<String>>,
|
|
||||||
},
|
|
||||||
Test {
|
|
||||||
cmd: String,
|
|
||||||
},
|
|
||||||
Lint {
|
|
||||||
cmd: String,
|
|
||||||
tool: Option<String>,
|
|
||||||
targets: Option<Vec<String>>,
|
|
||||||
},
|
|
||||||
Noop {
|
|
||||||
cmd: String,
|
|
||||||
},
|
|
||||||
Unknown {
|
Unknown {
|
||||||
cmd: String,
|
cmd: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -869,7 +869,9 @@ pub enum FileChange {
|
|||||||
Add {
|
Add {
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
Delete,
|
Delete {
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
Update {
|
Update {
|
||||||
unified_diff: String,
|
unified_diff: String,
|
||||||
move_path: Option<PathBuf>,
|
move_path: Option<PathBuf>,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ image = { version = "^0.25.6", default-features = false, features = [
|
|||||||
"jpeg",
|
"jpeg",
|
||||||
"png",
|
"png",
|
||||||
] }
|
] }
|
||||||
|
itertools = "0.14.0"
|
||||||
lazy_static = "1"
|
lazy_static = "1"
|
||||||
mcp-types = { path = "../mcp-types" }
|
mcp-types = { path = "../mcp-types" }
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
@@ -87,6 +88,7 @@ unicode-segmentation = "1.12.0"
|
|||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
url = "2"
|
url = "2"
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
|
pathdiff = "0.2"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub(crate) struct App {
|
|||||||
// Pager overlay state (Transcript or Static like Diff)
|
// Pager overlay state (Transcript or Static like Diff)
|
||||||
pub(crate) overlay: Option<Overlay>,
|
pub(crate) overlay: Option<Overlay>,
|
||||||
pub(crate) deferred_history_lines: Vec<Line<'static>>,
|
pub(crate) deferred_history_lines: Vec<Line<'static>>,
|
||||||
|
has_emitted_history_lines: bool,
|
||||||
|
|
||||||
pub(crate) enhanced_keys_supported: bool,
|
pub(crate) enhanced_keys_supported: bool,
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ impl App {
|
|||||||
transcript_lines: Vec::new(),
|
transcript_lines: Vec::new(),
|
||||||
overlay: None,
|
overlay: None,
|
||||||
deferred_history_lines: Vec::new(),
|
deferred_history_lines: Vec::new(),
|
||||||
|
has_emitted_history_lines: false,
|
||||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||||
backtrack: BacktrackState::default(),
|
backtrack: BacktrackState::default(),
|
||||||
};
|
};
|
||||||
@@ -177,27 +179,23 @@ impl App {
|
|||||||
);
|
);
|
||||||
tui.frame_requester().schedule_frame();
|
tui.frame_requester().schedule_frame();
|
||||||
}
|
}
|
||||||
AppEvent::InsertHistoryLines(lines) => {
|
|
||||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
|
||||||
t.insert_lines(lines.clone());
|
|
||||||
tui.frame_requester().schedule_frame();
|
|
||||||
}
|
|
||||||
self.transcript_lines.extend(lines.clone());
|
|
||||||
if self.overlay.is_some() {
|
|
||||||
self.deferred_history_lines.extend(lines);
|
|
||||||
} else {
|
|
||||||
tui.insert_history_lines(lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppEvent::InsertHistoryCell(cell) => {
|
AppEvent::InsertHistoryCell(cell) => {
|
||||||
let cell_transcript = cell.transcript_lines();
|
let mut cell_transcript = cell.transcript_lines();
|
||||||
|
if !cell.is_stream_continuation() && !self.transcript_lines.is_empty() {
|
||||||
|
cell_transcript.insert(0, Line::from(""));
|
||||||
|
}
|
||||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||||
t.insert_lines(cell_transcript.clone());
|
t.insert_lines(cell_transcript.clone());
|
||||||
tui.frame_requester().schedule_frame();
|
tui.frame_requester().schedule_frame();
|
||||||
}
|
}
|
||||||
self.transcript_lines.extend(cell_transcript.clone());
|
self.transcript_lines.extend(cell_transcript.clone());
|
||||||
let display = cell.display_lines();
|
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
||||||
if !display.is_empty() {
|
if !display.is_empty() {
|
||||||
|
if self.has_emitted_history_lines {
|
||||||
|
display.insert(0, Line::from(""));
|
||||||
|
} else {
|
||||||
|
self.has_emitted_history_lines = true;
|
||||||
|
}
|
||||||
if self.overlay.is_some() {
|
if self.overlay.is_some() {
|
||||||
self.deferred_history_lines.extend(display);
|
self.deferred_history_lines.extend(display);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use codex_core::protocol::ConversationHistoryResponseEvent;
|
use codex_core::protocol::ConversationHistoryResponseEvent;
|
||||||
use codex_core::protocol::Event;
|
use codex_core::protocol::Event;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
use ratatui::text::Line;
|
|
||||||
|
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ pub(crate) enum AppEvent {
|
|||||||
/// Result of computing a `/diff` command.
|
/// Result of computing a `/diff` command.
|
||||||
DiffResult(String),
|
DiffResult(String),
|
||||||
|
|
||||||
InsertHistoryLines(Vec<Line<'static>>),
|
|
||||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||||
|
|
||||||
StartCommitAnimation,
|
StartCommitAnimation,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use super::command_popup::CommandPopup;
|
|||||||
use super::file_search_popup::FileSearchPopup;
|
use super::file_search_popup::FileSearchPopup;
|
||||||
use super::paste_burst::CharDecision;
|
use super::paste_burst::CharDecision;
|
||||||
use super::paste_burst::PasteBurst;
|
use super::paste_burst::PasteBurst;
|
||||||
|
use crate::bottom_pane::paste_burst::FlushResult;
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
use codex_protocol::custom_prompts::CustomPrompt;
|
use codex_protocol::custom_prompts::CustomPrompt;
|
||||||
|
|
||||||
@@ -223,7 +224,7 @@ impl ChatComposer {
|
|||||||
let placeholder = format!("[Pasted Content {char_count} chars]");
|
let placeholder = format!("[Pasted Content {char_count} chars]");
|
||||||
self.textarea.insert_element(&placeholder);
|
self.textarea.insert_element(&placeholder);
|
||||||
self.pending_pastes.push((placeholder, pasted));
|
self.pending_pastes.push((placeholder, pasted));
|
||||||
} else if self.handle_paste_image_path(pasted.clone()) {
|
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
|
||||||
self.textarea.insert_str(" ");
|
self.textarea.insert_str(" ");
|
||||||
} else {
|
} else {
|
||||||
self.textarea.insert_str(&pasted);
|
self.textarea.insert_str(&pasted);
|
||||||
@@ -298,12 +299,7 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||||
let now = Instant::now();
|
self.handle_paste_burst_flush(Instant::now())
|
||||||
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
|
|
||||||
let _ = self.handle_paste(pasted);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||||
@@ -396,9 +392,11 @@ impl ChatComposer {
|
|||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Tab, ..
|
code: KeyCode::Tab, ..
|
||||||
} => {
|
} => {
|
||||||
|
// Ensure popup filtering/selection reflects the latest composer text
|
||||||
|
// before applying completion.
|
||||||
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
|
popup.on_composer_text_change(first_line.to_string());
|
||||||
if let Some(sel) = popup.selected_item() {
|
if let Some(sel) = popup.selected_item() {
|
||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
|
||||||
|
|
||||||
match sel {
|
match sel {
|
||||||
CommandItem::Builtin(cmd) => {
|
CommandItem::Builtin(cmd) => {
|
||||||
let starts_with_cmd = first_line
|
let starts_with_cmd = first_line
|
||||||
@@ -853,15 +851,36 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
|
||||||
|
match self.paste_burst.flush_if_due(now) {
|
||||||
|
FlushResult::Paste(pasted) => {
|
||||||
|
self.handle_paste(pasted);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
FlushResult::Typed(ch) => {
|
||||||
|
// Mirror insert_str() behavior so popups stay in sync when a
|
||||||
|
// pending fast char flushes as normal typed input.
|
||||||
|
self.textarea.insert_str(ch.to_string().as_str());
|
||||||
|
// Keep popup sync consistent with key handling: prefer slash popup; only
|
||||||
|
// sync file popup when slash popup is NOT active.
|
||||||
|
self.sync_command_popup();
|
||||||
|
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||||||
|
self.dismissed_file_popup_token = None;
|
||||||
|
} else {
|
||||||
|
self.sync_file_search_popup();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
FlushResult::None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle generic Input events that modify the textarea content.
|
/// Handle generic Input events that modify the textarea content.
|
||||||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||||
// If we have a buffered non-bracketed paste burst and enough time has
|
// If we have a buffered non-bracketed paste burst and enough time has
|
||||||
// elapsed since the last char, flush it before handling a new input.
|
// elapsed since the last char, flush it before handling a new input.
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
|
self.handle_paste_burst_flush(now);
|
||||||
// Reuse normal paste path (handles large-paste placeholders).
|
|
||||||
self.handle_paste(pasted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
||||||
if matches!(input.code, KeyCode::Enter)
|
if matches!(input.code, KeyCode::Enter)
|
||||||
@@ -1672,6 +1691,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_popup_model_first_for_mo_ui() {
|
||||||
|
use insta::assert_snapshot;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
|
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 "/mo" humanlike so paste-burst doesn’t interfere.
|
||||||
|
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
||||||
|
|
||||||
|
let mut terminal = match Terminal::new(TestBackend::new(60, 4)) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => panic!("Failed to create terminal: {e}"),
|
||||||
|
};
|
||||||
|
terminal
|
||||||
|
.draw(|f| f.render_widget_ref(composer, f.area()))
|
||||||
|
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
|
||||||
|
|
||||||
|
// Visual snapshot should show the slash popup with /model as the first entry.
|
||||||
|
assert_snapshot!("slash_popup_mo", terminal.backend());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_popup_model_first_for_mo_logic() {
|
||||||
|
use super::super::command_popup::CommandItem;
|
||||||
|
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, &['/', 'm', 'o']);
|
||||||
|
|
||||||
|
match &composer.active_popup {
|
||||||
|
ActivePopup::Command(popup) => match popup.selected_item() {
|
||||||
|
Some(CommandItem::Builtin(cmd)) => {
|
||||||
|
assert_eq!(cmd.command(), "model")
|
||||||
|
}
|
||||||
|
Some(CommandItem::UserPrompt(_)) => {
|
||||||
|
panic!("unexpected prompt selected for '/mo'")
|
||||||
|
}
|
||||||
|
None => panic!("no selected command for '/mo'"),
|
||||||
|
},
|
||||||
|
_ => panic!("slash popup not active after typing '/mo'"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
|
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
|
||||||
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
|
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
|
|||||||
@@ -238,6 +238,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_is_first_suggestion_for_mo() {
|
||||||
|
let mut popup = CommandPopup::new(Vec::new());
|
||||||
|
popup.on_composer_text_change("/mo".to_string());
|
||||||
|
let matches = popup.filtered_items();
|
||||||
|
match matches.first() {
|
||||||
|
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
|
||||||
|
Some(CommandItem::UserPrompt(_)) => {
|
||||||
|
panic!("unexpected prompt ranked before '/model' for '/mo'")
|
||||||
|
}
|
||||||
|
None => panic!("expected at least one match for '/mo'"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_discovery_lists_custom_prompts() {
|
fn prompt_discovery_lists_custom_prompts() {
|
||||||
let prompts = vec![
|
let prompts = vec![
|
||||||
|
|||||||
@@ -100,53 +100,47 @@ impl BottomPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn desired_height(&self, width: u16) -> u16 {
|
pub fn desired_height(&self, width: u16) -> u16 {
|
||||||
let top_margin = if self.active_view.is_some() { 0 } else { 1 };
|
// Always reserve one blank row above the pane for visual spacing.
|
||||||
|
let top_margin = 1;
|
||||||
|
|
||||||
// Base height depends on whether a modal/overlay is active.
|
// Base height depends on whether a modal/overlay is active.
|
||||||
let mut base = if let Some(view) = self.active_view.as_ref() {
|
let base = match self.active_view.as_ref() {
|
||||||
view.desired_height(width)
|
Some(view) => view.desired_height(width),
|
||||||
} else {
|
None => self.composer.desired_height(width).saturating_add(
|
||||||
self.composer.desired_height(width)
|
self.status
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |status| status.desired_height(width)),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
// If a status indicator is active and no modal is covering the composer,
|
|
||||||
// include its height above the composer.
|
|
||||||
if self.active_view.is_none()
|
|
||||||
&& let Some(status) = self.status.as_ref()
|
|
||||||
{
|
|
||||||
base = base.saturating_add(status.desired_height(width));
|
|
||||||
}
|
|
||||||
// Account for bottom padding rows. Top spacing is handled in layout().
|
// Account for bottom padding rows. Top spacing is handled in layout().
|
||||||
base.saturating_add(Self::BOTTOM_PAD_LINES)
|
base.saturating_add(Self::BOTTOM_PAD_LINES)
|
||||||
.saturating_add(top_margin)
|
.saturating_add(top_margin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn layout(&self, area: Rect) -> [Rect; 2] {
|
fn layout(&self, area: Rect) -> [Rect; 2] {
|
||||||
// Prefer showing the status header when space is extremely tight.
|
// At small heights, bottom pane takes the entire height.
|
||||||
// Drop the top spacer if there is only one row available.
|
let (top_margin, bottom_margin) = if area.height <= BottomPane::BOTTOM_PAD_LINES + 1 {
|
||||||
let mut top_margin = if self.active_view.is_some() { 0 } else { 1 };
|
(0, 0)
|
||||||
if area.height <= 1 {
|
|
||||||
top_margin = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let status_height = if self.active_view.is_none() {
|
|
||||||
if let Some(status) = self.status.as_ref() {
|
|
||||||
status.desired_height(area.width)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
0
|
(1, BottomPane::BOTTOM_PAD_LINES)
|
||||||
};
|
};
|
||||||
|
|
||||||
let [_, status, content, _] = Layout::vertical([
|
let area = Rect {
|
||||||
Constraint::Max(top_margin),
|
x: area.x,
|
||||||
Constraint::Max(status_height),
|
y: area.y + top_margin,
|
||||||
Constraint::Min(1),
|
width: area.width,
|
||||||
Constraint::Max(BottomPane::BOTTOM_PAD_LINES),
|
height: area.height - top_margin - bottom_margin,
|
||||||
])
|
};
|
||||||
.areas(area);
|
match self.active_view.as_ref() {
|
||||||
|
Some(_) => [Rect::ZERO, area],
|
||||||
[status, content]
|
None => {
|
||||||
|
let status_height = self
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |status| status.desired_height(area.width));
|
||||||
|
Layout::vertical([Constraint::Max(status_height), Constraint::Min(1)]).areas(area)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||||
@@ -705,7 +699,7 @@ mod tests {
|
|||||||
|
|
||||||
pane.set_task_running(true);
|
pane.set_task_running(true);
|
||||||
|
|
||||||
// Height=2 → composer visible; status is hidden to preserve composer. Spacer may collapse.
|
// Height=2 → status on one row, composer on the other.
|
||||||
let area2 = Rect::new(0, 0, 20, 2);
|
let area2 = Rect::new(0, 0, 20, 2);
|
||||||
let mut buf2 = Buffer::empty(area2);
|
let mut buf2 = Buffer::empty(area2);
|
||||||
(&pane).render_ref(area2, &mut buf2);
|
(&pane).render_ref(area2, &mut buf2);
|
||||||
@@ -721,8 +715,8 @@ mod tests {
|
|||||||
"expected composer to be visible on one of the rows: row0={row0:?}, row1={row1:?}"
|
"expected composer to be visible on one of the rows: row0={row0:?}, row1={row1:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!row0.contains("Working") && !row1.contains("Working"),
|
row0.contains("Working") || row1.contains("Working"),
|
||||||
"status header should be hidden when height=2"
|
"expected status header to be visible at height=2: row0={row0:?}, row1={row1:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Height=1 → no padding; single row is the composer (status hidden).
|
// Height=1 → no padding; single row is the composer (status hidden).
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ pub(crate) struct RetroGrab {
|
|||||||
pub grabbed: String,
|
pub grabbed: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) enum FlushResult {
|
||||||
|
Paste(String),
|
||||||
|
Typed(char),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
impl PasteBurst {
|
impl PasteBurst {
|
||||||
/// Recommended delay to wait between simulated keypresses (or before
|
/// Recommended delay to wait between simulated keypresses (or before
|
||||||
/// scheduling a UI tick) so that a pending fast keystroke is flushed
|
/// scheduling a UI tick) so that a pending fast keystroke is flushed
|
||||||
@@ -95,24 +101,24 @@ impl PasteBurst {
|
|||||||
/// now emit that char as normal typed input.
|
/// now emit that char as normal typed input.
|
||||||
///
|
///
|
||||||
/// Returns None if the timeout has not elapsed or there is nothing to flush.
|
/// Returns None if the timeout has not elapsed or there is nothing to flush.
|
||||||
pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
|
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
|
||||||
let timed_out = self
|
let timed_out = self
|
||||||
.last_plain_char_time
|
.last_plain_char_time
|
||||||
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
||||||
if timed_out && self.is_active_internal() {
|
if timed_out && self.is_active_internal() {
|
||||||
self.active = false;
|
self.active = false;
|
||||||
let out = std::mem::take(&mut self.buffer);
|
let out = std::mem::take(&mut self.buffer);
|
||||||
Some(out)
|
FlushResult::Paste(out)
|
||||||
} else if timed_out {
|
} else if timed_out {
|
||||||
// If we were saving a single fast char and no burst followed,
|
// If we were saving a single fast char and no burst followed,
|
||||||
// flush it as normal typed input.
|
// flush it as normal typed input.
|
||||||
if let Some((ch, _at)) = self.pending_first_char.take() {
|
if let Some((ch, _at)) = self.pending_first_char.take() {
|
||||||
Some(ch.to_string())
|
FlushResult::Typed(ch)
|
||||||
} else {
|
} else {
|
||||||
None
|
FlushResult::None
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
FlushResult::None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/bottom_pane/chat_composer.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"▌/mo "
|
||||||
|
"▌ "
|
||||||
|
"▌/model choose what model and reasoning effort to use "
|
||||||
|
"▌/mention mention a file "
|
||||||
@@ -245,7 +245,6 @@ impl TextArea {
|
|||||||
} => self.delete_backward_word(),
|
} => self.delete_backward_word(),
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Backspace,
|
code: KeyCode::Backspace,
|
||||||
modifiers: KeyModifiers::NONE,
|
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
| KeyEvent {
|
| KeyEvent {
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ pub(crate) struct ChatWidget {
|
|||||||
// Stream lifecycle controller
|
// Stream lifecycle controller
|
||||||
stream: StreamController,
|
stream: StreamController,
|
||||||
running_commands: HashMap<String, RunningCommand>,
|
running_commands: HashMap<String, RunningCommand>,
|
||||||
pending_exec_completions: Vec<(Vec<String>, Vec<ParsedCommand>, CommandOutput)>,
|
|
||||||
task_complete_pending: bool,
|
task_complete_pending: bool,
|
||||||
// Queue of interruptive UI events deferred during an active write cycle
|
// Queue of interruptive UI events deferred during an active write cycle
|
||||||
interrupts: InterruptManager,
|
interrupts: InterruptManager,
|
||||||
@@ -113,7 +112,6 @@ pub(crate) struct ChatWidget {
|
|||||||
frame_requester: FrameRequester,
|
frame_requester: FrameRequester,
|
||||||
// Whether to include the initial welcome banner on session configured
|
// Whether to include the initial welcome banner on session configured
|
||||||
show_welcome_banner: bool,
|
show_welcome_banner: bool,
|
||||||
last_history_was_exec: bool,
|
|
||||||
// User messages queued while a turn is in progress
|
// User messages queued while a turn is in progress
|
||||||
queued_user_messages: VecDeque<UserMessage>,
|
queued_user_messages: VecDeque<UserMessage>,
|
||||||
}
|
}
|
||||||
@@ -333,6 +331,7 @@ impl ChatWidget {
|
|||||||
auto_approved: event.auto_approved,
|
auto_approved: event.auto_approved,
|
||||||
},
|
},
|
||||||
event.changes,
|
event.changes,
|
||||||
|
&self.config.cwd,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,14 +441,14 @@ impl ChatWidget {
|
|||||||
self.task_complete_pending = false;
|
self.task_complete_pending = false;
|
||||||
}
|
}
|
||||||
// A completed stream indicates non-exec content was just inserted.
|
// A completed stream indicates non-exec content was just inserted.
|
||||||
// Reset the exec header grouping so the next exec shows its header.
|
|
||||||
self.last_history_was_exec = false;
|
|
||||||
self.flush_interrupt_queue();
|
self.flush_interrupt_queue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn handle_streaming_delta(&mut self, delta: String) {
|
fn handle_streaming_delta(&mut self, delta: String) {
|
||||||
|
// Before streaming agent content, flush any active exec cell group.
|
||||||
|
self.flush_active_exec_cell();
|
||||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||||
self.stream.begin(&sink);
|
self.stream.begin(&sink);
|
||||||
self.stream.push_and_maybe_commit(&delta, &sink);
|
self.stream.push_and_maybe_commit(&delta, &sink);
|
||||||
@@ -462,31 +461,29 @@ impl ChatWidget {
|
|||||||
Some(rc) => (rc.command, rc.parsed_cmd),
|
Some(rc) => (rc.command, rc.parsed_cmd),
|
||||||
None => (vec![ev.call_id.clone()], Vec::new()),
|
None => (vec![ev.call_id.clone()], Vec::new()),
|
||||||
};
|
};
|
||||||
self.pending_exec_completions.push((
|
|
||||||
command,
|
|
||||||
parsed,
|
|
||||||
CommandOutput {
|
|
||||||
exit_code: ev.exit_code,
|
|
||||||
stdout: ev.stdout.clone(),
|
|
||||||
stderr: ev.stderr.clone(),
|
|
||||||
formatted_output: ev.formatted_output.clone(),
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
if self.running_commands.is_empty() {
|
if self.active_exec_cell.is_none() {
|
||||||
self.active_exec_cell = None;
|
// This should have been created by handle_exec_begin_now, but in case it wasn't,
|
||||||
let pending = std::mem::take(&mut self.pending_exec_completions);
|
// create it now.
|
||||||
for (command, parsed, output) in pending {
|
self.active_exec_cell = Some(history_cell::new_active_exec_command(
|
||||||
let include_header = !self.last_history_was_exec;
|
ev.call_id.clone(),
|
||||||
let cell = history_cell::new_completed_exec_command(
|
command,
|
||||||
command,
|
parsed,
|
||||||
parsed,
|
));
|
||||||
output,
|
}
|
||||||
include_header,
|
if let Some(cell) = self.active_exec_cell.as_mut() {
|
||||||
ev.duration,
|
cell.complete_call(
|
||||||
);
|
&ev.call_id,
|
||||||
self.add_to_history(cell);
|
CommandOutput {
|
||||||
self.last_history_was_exec = true;
|
exit_code: ev.exit_code,
|
||||||
|
stdout: ev.stdout.clone(),
|
||||||
|
stderr: ev.stderr.clone(),
|
||||||
|
formatted_output: ev.formatted_output.clone(),
|
||||||
|
},
|
||||||
|
ev.duration,
|
||||||
|
);
|
||||||
|
if cell.should_flush() {
|
||||||
|
self.flush_active_exec_cell();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,9 +492,9 @@ impl ChatWidget {
|
|||||||
&mut self,
|
&mut self,
|
||||||
event: codex_core::protocol::PatchApplyEndEvent,
|
event: codex_core::protocol::PatchApplyEndEvent,
|
||||||
) {
|
) {
|
||||||
if event.success {
|
// If the patch was successful, just let the "Edited" block stand.
|
||||||
self.add_to_history(history_cell::new_patch_apply_success(event.stdout));
|
// Otherwise, add a failure block.
|
||||||
} else {
|
if !event.success {
|
||||||
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
|
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,6 +520,7 @@ impl ChatWidget {
|
|||||||
self.add_to_history(history_cell::new_patch_event(
|
self.add_to_history(history_cell::new_patch_event(
|
||||||
PatchEventType::ApprovalRequest,
|
PatchEventType::ApprovalRequest,
|
||||||
ev.changes.clone(),
|
ev.changes.clone(),
|
||||||
|
&self.config.cwd,
|
||||||
));
|
));
|
||||||
|
|
||||||
let request = ApprovalRequest::ApplyPatch {
|
let request = ApprovalRequest::ApplyPatch {
|
||||||
@@ -543,19 +541,28 @@ impl ChatWidget {
|
|||||||
parsed_cmd: ev.parsed_cmd.clone(),
|
parsed_cmd: ev.parsed_cmd.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Accumulate parsed commands into a single active Exec cell so they stack
|
if let Some(exec) = &self.active_exec_cell {
|
||||||
match self.active_exec_cell.as_mut() {
|
if let Some(new_exec) = exec.with_added_call(
|
||||||
Some(exec) => {
|
ev.call_id.clone(),
|
||||||
exec.parsed.extend(ev.parsed_cmd);
|
ev.command.clone(),
|
||||||
}
|
ev.parsed_cmd.clone(),
|
||||||
_ => {
|
) {
|
||||||
let include_header = !self.last_history_was_exec;
|
self.active_exec_cell = Some(new_exec);
|
||||||
|
} else {
|
||||||
|
// Make a new cell.
|
||||||
|
self.flush_active_exec_cell();
|
||||||
self.active_exec_cell = Some(history_cell::new_active_exec_command(
|
self.active_exec_cell = Some(history_cell::new_active_exec_command(
|
||||||
ev.command,
|
ev.call_id.clone(),
|
||||||
ev.parsed_cmd,
|
ev.command.clone(),
|
||||||
include_header,
|
ev.parsed_cmd.clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self.active_exec_cell = Some(history_cell::new_active_exec_command(
|
||||||
|
ev.call_id.clone(),
|
||||||
|
ev.command.clone(),
|
||||||
|
ev.parsed_cmd.clone(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request a redraw so the working header and command list are visible immediately.
|
// Request a redraw so the working header and command list are visible immediately.
|
||||||
@@ -585,7 +592,7 @@ impl ChatWidget {
|
|||||||
Constraint::Max(
|
Constraint::Max(
|
||||||
self.active_exec_cell
|
self.active_exec_cell
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(0, |c| c.desired_height(area.width)),
|
.map_or(0, |c| c.desired_height(area.width) + 1),
|
||||||
),
|
),
|
||||||
Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||||||
])
|
])
|
||||||
@@ -627,13 +634,11 @@ impl ChatWidget {
|
|||||||
last_token_usage: TokenUsage::default(),
|
last_token_usage: TokenUsage::default(),
|
||||||
stream: StreamController::new(config),
|
stream: StreamController::new(config),
|
||||||
running_commands: HashMap::new(),
|
running_commands: HashMap::new(),
|
||||||
pending_exec_completions: Vec::new(),
|
|
||||||
task_complete_pending: false,
|
task_complete_pending: false,
|
||||||
interrupts: InterruptManager::new(),
|
interrupts: InterruptManager::new(),
|
||||||
reasoning_buffer: String::new(),
|
reasoning_buffer: String::new(),
|
||||||
full_reasoning_buffer: String::new(),
|
full_reasoning_buffer: String::new(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
last_history_was_exec: false,
|
|
||||||
queued_user_messages: VecDeque::new(),
|
queued_user_messages: VecDeque::new(),
|
||||||
show_welcome_banner: true,
|
show_welcome_banner: true,
|
||||||
}
|
}
|
||||||
@@ -673,13 +678,11 @@ impl ChatWidget {
|
|||||||
last_token_usage: TokenUsage::default(),
|
last_token_usage: TokenUsage::default(),
|
||||||
stream: StreamController::new(config),
|
stream: StreamController::new(config),
|
||||||
running_commands: HashMap::new(),
|
running_commands: HashMap::new(),
|
||||||
pending_exec_completions: Vec::new(),
|
|
||||||
task_complete_pending: false,
|
task_complete_pending: false,
|
||||||
interrupts: InterruptManager::new(),
|
interrupts: InterruptManager::new(),
|
||||||
reasoning_buffer: String::new(),
|
reasoning_buffer: String::new(),
|
||||||
full_reasoning_buffer: String::new(),
|
full_reasoning_buffer: String::new(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
last_history_was_exec: false,
|
|
||||||
queued_user_messages: VecDeque::new(),
|
queued_user_messages: VecDeque::new(),
|
||||||
show_welcome_banner: false,
|
show_welcome_banner: false,
|
||||||
}
|
}
|
||||||
@@ -690,7 +693,7 @@ impl ChatWidget {
|
|||||||
+ self
|
+ self
|
||||||
.active_exec_cell
|
.active_exec_cell
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(0, |c| c.desired_height(width))
|
.map_or(0, |c| c.desired_height(width) + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||||
@@ -891,18 +894,15 @@ impl ChatWidget {
|
|||||||
|
|
||||||
fn flush_active_exec_cell(&mut self) {
|
fn flush_active_exec_cell(&mut self) {
|
||||||
if let Some(active) = self.active_exec_cell.take() {
|
if let Some(active) = self.active_exec_cell.take() {
|
||||||
self.last_history_was_exec = true;
|
|
||||||
self.app_event_tx
|
self.app_event_tx
|
||||||
.send(AppEvent::InsertHistoryCell(Box::new(active)));
|
.send(AppEvent::InsertHistoryCell(Box::new(active)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
|
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
|
||||||
// Only break exec grouping if the cell renders visible lines.
|
if !cell.display_lines(u16::MAX).is_empty() {
|
||||||
let has_display_lines = !cell.display_lines().is_empty();
|
// Only break exec grouping if the cell renders visible lines.
|
||||||
self.flush_active_exec_cell();
|
self.flush_active_exec_cell();
|
||||||
if has_display_lines {
|
|
||||||
self.last_history_was_exec = false;
|
|
||||||
}
|
}
|
||||||
self.app_event_tx
|
self.app_event_tx
|
||||||
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||||||
@@ -1028,7 +1028,6 @@ impl ChatWidget {
|
|||||||
let cell = cell.into_failed();
|
let cell = cell.into_failed();
|
||||||
// Insert finalized exec into history and keep grouping consistent.
|
// Insert finalized exec into history and keep grouping consistent.
|
||||||
self.add_to_history(cell);
|
self.add_to_history(cell);
|
||||||
self.last_history_was_exec = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,6 +1283,9 @@ impl WidgetRef for &ChatWidget {
|
|||||||
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||||||
(&self.bottom_pane).render(bottom_pane_area, buf);
|
(&self.bottom_pane).render(bottom_pane_area, buf);
|
||||||
if let Some(cell) = &self.active_exec_cell {
|
if let Some(cell) = &self.active_exec_cell {
|
||||||
|
let mut active_cell_area = active_cell_area;
|
||||||
|
active_cell_area.y += 1;
|
||||||
|
active_cell_area.height -= 1;
|
||||||
cell.render_ref(active_cell_area, buf);
|
cell.render_ref(active_cell_area, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: lines_to_single_string(&approved_lines)
|
||||||
|
---
|
||||||
|
• Change Approved foo.txt (+1 -0)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: lines_to_single_string(&proposed_lines)
|
||||||
|
---
|
||||||
|
• Proposed Change foo.txt (+1 -0)
|
||||||
|
1 +hello
|
||||||
@@ -3,6 +3,7 @@ source: tui/src/chatwidget/tests.rs
|
|||||||
assertion_line: 728
|
assertion_line: 728
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
|
" "
|
||||||
"? Codex wants to run echo hello world "
|
"? Codex wants to run echo hello world "
|
||||||
" "
|
" "
|
||||||
"Model wants to run a command "
|
"Model wants to run a command "
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
" "
|
||||||
|
"? Codex wants to run echo hello world "
|
||||||
|
" "
|
||||||
|
"▌Allow command? "
|
||||||
|
"▌ Yes Always No, provide feedback "
|
||||||
|
"▌ Approve and run the command "
|
||||||
|
" "
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 763
|
assertion_line: 794
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
|
" "
|
||||||
"The model wants to apply changes "
|
"The model wants to apply changes "
|
||||||
" "
|
" "
|
||||||
"This will grant write access to /tmp for the remainder of this session. "
|
"This will grant write access to /tmp for the remainder of this session. "
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: visible_after
|
||||||
|
---
|
||||||
|
> I’m going to scan the workspace and Cargo manifests to see build profiles and
|
||||||
|
dependencies that impact binary size. Then I’ll summarize the main causes.
|
||||||
|
|
||||||
|
• Explored
|
||||||
|
└ List ls -la
|
||||||
|
Read Cargo.toml
|
||||||
|
|
||||||
|
• Ran
|
||||||
|
└ for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
|
||||||
|
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama
|
||||||
|
tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
|
||||||
|
done
|
||||||
|
|
||||||
|
• Explored
|
||||||
|
└ Read Cargo.toml
|
||||||
|
|
||||||
|
> Here’s what’s driving size in this workspace’s binaries.
|
||||||
|
|
||||||
|
Main Causes
|
||||||
|
|
||||||
|
- Static linking style: Each bin (codex, codex-tui, codex-exec,
|
||||||
|
codex-mcp-server, etc.) statically links its full dependency graph, so common
|
||||||
|
code isn’t shared at runtime across executables.
|
||||||
|
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors,
|
||||||
|
and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui,
|
||||||
|
and ollama you enable reqwest with json/stream, which still pulls a large
|
||||||
|
transitive set.
|
||||||
|
- Image/terminal stack: codex-tui includes image (with jpeg), ratatui,
|
||||||
|
crossterm, and ratatui-image, which together contribute significant code size
|
||||||
|
(decoders, pixel ops, terminal backends).
|
||||||
|
- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and
|
||||||
|
starlark (in execpolicy) include sizeable parser/VM tables and runtimes.
|
||||||
|
- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process,
|
||||||
|
signal) across many crates inflate code size even if only subsets are used
|
||||||
|
per bin.
|
||||||
|
- Panic + backtraces: Default panic = unwind and backtrace support keep
|
||||||
|
unwinding tables and symbols that add weight.
|
||||||
|
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables
|
||||||
|
openssl-sys with vendored, compiling OpenSSL into the binary—this adds
|
||||||
|
multiple megabytes per executable.
|
||||||
|
|
||||||
|
Build-Mode Notes
|
||||||
|
|
||||||
|
- Release settings: You use lto = "fat" and codegen-units = 1 (good for size),
|
||||||
|
but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single
|
||||||
|
contributor; if you build in release with that setting, binaries can still
|
||||||
|
be large.
|
||||||
|
- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and
|
||||||
|
assertions—outputs are much larger than cargo build --release.
|
||||||
|
|
||||||
|
If you want, I can outline targeted trims (e.g., strip = "debuginfo",
|
||||||
|
opt-level = "z", panic abort, tighter tokio/reqwest features) and estimate
|
||||||
|
impact per binary.
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 779
|
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"▌ Ask Codex to do anything "
|
"▌ Ask Codex to do anything "
|
||||||
" "
|
" ⏎ send Ctrl+J newline Ctrl+T transc"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 779
|
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 807
|
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
|
" Thinking (0s • Esc to interrupt) "
|
||||||
"▌ Ask Codex to do anything "
|
"▌ Ask Codex to do anything "
|
||||||
" "
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 807
|
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: visual
|
||||||
|
---
|
||||||
|
> I’m going to search the repo for where “Change Approved” is rendered to update
|
||||||
|
that view.
|
||||||
|
|
||||||
|
• Explored
|
||||||
|
└ Search Change Approved
|
||||||
|
Read diff_render.rs
|
||||||
|
|
||||||
|
Investigating rendering code (0s • Esc to interrupt)
|
||||||
|
|
||||||
|
▌Summarize recent commits
|
||||||
|
⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
expression: combined
|
expression: combined
|
||||||
---
|
---
|
||||||
codex
|
> Here is the result.
|
||||||
Here is the result.
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: blob1
|
||||||
|
---
|
||||||
|
⠋ Exploring
|
||||||
|
└ List ls -la
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: blob2
|
||||||
|
---
|
||||||
|
• Explored
|
||||||
|
└ List ls -la
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: blob3
|
||||||
|
---
|
||||||
|
⠋ Exploring
|
||||||
|
└ List ls -la
|
||||||
|
Read foo.txt
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: blob4
|
||||||
|
---
|
||||||
|
• Explored
|
||||||
|
└ List ls -la
|
||||||
|
Read foo.txt
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: blob5
|
||||||
|
---
|
||||||
|
• Explored
|
||||||
|
└ List ls -la
|
||||||
|
Read foo.txt
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests.rs
|
||||||
|
expression: blob6
|
||||||
|
---
|
||||||
|
• Explored
|
||||||
|
└ List ls -la
|
||||||
|
Read foo.txt, bar.txt
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
expression: combined
|
expression: combined
|
||||||
---
|
---
|
||||||
codex
|
> Here is the result.
|
||||||
Here is the result.
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@
|
|||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
expression: exec_blob
|
expression: exec_blob
|
||||||
---
|
---
|
||||||
>_
|
• Ran sleep 1
|
||||||
✗ ⌨️ sleep 1
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 878
|
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 851
|
assertion_line: 921
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
|
" "
|
||||||
"? Codex wants to run echo 'hello world' "
|
"? Codex wants to run echo 'hello world' "
|
||||||
" "
|
" "
|
||||||
"Codex wants to run a command "
|
"Codex wants to run a command "
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
pub(crate) const DEFAULT_WRAP_COLS: u16 = 80;
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
use crossterm::terminal;
|
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use ratatui::style::Modifier;
|
use ratatui::style::Modifier;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
|
use ratatui::style::Stylize;
|
||||||
use ratatui::text::Line as RtLine;
|
use ratatui::text::Line as RtLine;
|
||||||
use ratatui::text::Span as RtSpan;
|
use ratatui::text::Span as RtSpan;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::common::DEFAULT_WRAP_COLS;
|
use crate::exec_command::relativize_to_home;
|
||||||
use codex_core::protocol::FileChange;
|
|
||||||
|
|
||||||
use crate::history_cell::PatchEventType;
|
use crate::history_cell::PatchEventType;
|
||||||
|
use codex_core::git_info::get_git_repo_root;
|
||||||
|
use codex_core::protocol::FileChange;
|
||||||
|
|
||||||
const SPACES_AFTER_LINE_NUMBER: usize = 6;
|
const SPACES_AFTER_LINE_NUMBER: usize = 6;
|
||||||
|
|
||||||
@@ -22,205 +23,199 @@ enum DiffLineType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn create_diff_summary(
|
pub(crate) fn create_diff_summary(
|
||||||
title: &str,
|
|
||||||
changes: &HashMap<PathBuf, FileChange>,
|
changes: &HashMap<PathBuf, FileChange>,
|
||||||
event_type: PatchEventType,
|
event_type: PatchEventType,
|
||||||
|
cwd: &Path,
|
||||||
|
wrap_cols: usize,
|
||||||
) -> Vec<RtLine<'static>> {
|
) -> Vec<RtLine<'static>> {
|
||||||
struct FileSummary {
|
let rows = collect_rows(changes);
|
||||||
display_path: String,
|
let header_kind = match event_type {
|
||||||
added: usize,
|
PatchEventType::ApplyBegin { auto_approved } => {
|
||||||
removed: usize,
|
if auto_approved {
|
||||||
}
|
HeaderKind::Edited
|
||||||
|
} else {
|
||||||
let count_from_unified = |diff: &str| -> (usize, usize) {
|
HeaderKind::ChangeApproved
|
||||||
if let Ok(patch) = diffy::Patch::from_str(diff) {
|
|
||||||
patch
|
|
||||||
.hunks()
|
|
||||||
.iter()
|
|
||||||
.flat_map(|h| h.lines())
|
|
||||||
.fold((0, 0), |(a, d), l| match l {
|
|
||||||
diffy::Line::Insert(_) => (a + 1, d),
|
|
||||||
diffy::Line::Delete(_) => (a, d + 1),
|
|
||||||
_ => (a, d),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Fallback: manual scan to preserve counts even for unparsable diffs
|
|
||||||
let mut adds = 0usize;
|
|
||||||
let mut dels = 0usize;
|
|
||||||
for l in diff.lines() {
|
|
||||||
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match l.as_bytes().first() {
|
|
||||||
Some(b'+') => adds += 1,
|
|
||||||
Some(b'-') => dels += 1,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(adds, dels)
|
|
||||||
}
|
}
|
||||||
|
PatchEventType::ApprovalRequest => HeaderKind::ProposedChange,
|
||||||
};
|
};
|
||||||
|
render_changes_block(rows, wrap_cols, header_kind, cwd)
|
||||||
let mut files: Vec<FileSummary> = Vec::new();
|
|
||||||
for (path, change) in changes.iter() {
|
|
||||||
match change {
|
|
||||||
FileChange::Add { content } => files.push(FileSummary {
|
|
||||||
display_path: path.display().to_string(),
|
|
||||||
added: content.lines().count(),
|
|
||||||
removed: 0,
|
|
||||||
}),
|
|
||||||
FileChange::Delete => files.push(FileSummary {
|
|
||||||
display_path: path.display().to_string(),
|
|
||||||
added: 0,
|
|
||||||
removed: std::fs::read_to_string(path)
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.lines().count())
|
|
||||||
.unwrap_or(0),
|
|
||||||
}),
|
|
||||||
FileChange::Update {
|
|
||||||
unified_diff,
|
|
||||||
move_path,
|
|
||||||
} => {
|
|
||||||
let (added, removed) = count_from_unified(unified_diff);
|
|
||||||
let display_path = if let Some(new_path) = move_path {
|
|
||||||
format!("{} → {}", path.display(), new_path.display())
|
|
||||||
} else {
|
|
||||||
path.display().to_string()
|
|
||||||
};
|
|
||||||
files.push(FileSummary {
|
|
||||||
display_path,
|
|
||||||
added,
|
|
||||||
removed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_count = files.len();
|
|
||||||
let total_added: usize = files.iter().map(|f| f.added).sum();
|
|
||||||
let total_removed: usize = files.iter().map(|f| f.removed).sum();
|
|
||||||
let noun = if file_count == 1 { "file" } else { "files" };
|
|
||||||
|
|
||||||
let mut out: Vec<RtLine<'static>> = Vec::new();
|
|
||||||
|
|
||||||
// Header
|
|
||||||
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
|
|
||||||
header_spans.push(RtSpan::styled(
|
|
||||||
title.to_owned(),
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Magenta)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
));
|
|
||||||
header_spans.push(RtSpan::raw(" to "));
|
|
||||||
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
|
|
||||||
header_spans.push(RtSpan::raw("("));
|
|
||||||
header_spans.push(RtSpan::styled(
|
|
||||||
format!("+{total_added}"),
|
|
||||||
Style::default().fg(Color::Green),
|
|
||||||
));
|
|
||||||
header_spans.push(RtSpan::raw(" "));
|
|
||||||
header_spans.push(RtSpan::styled(
|
|
||||||
format!("-{total_removed}"),
|
|
||||||
Style::default().fg(Color::Red),
|
|
||||||
));
|
|
||||||
header_spans.push(RtSpan::raw(")"));
|
|
||||||
out.push(RtLine::from(header_spans));
|
|
||||||
|
|
||||||
// Dimmed per-file lines with prefix
|
|
||||||
for (idx, f) in files.iter().enumerate() {
|
|
||||||
let mut spans: Vec<RtSpan<'static>> = Vec::new();
|
|
||||||
spans.push(RtSpan::raw(f.display_path.clone()));
|
|
||||||
// Show per-file +/- counts only when there are multiple files
|
|
||||||
if file_count > 1 {
|
|
||||||
spans.push(RtSpan::raw(" ("));
|
|
||||||
spans.push(RtSpan::styled(
|
|
||||||
format!("+{}", f.added),
|
|
||||||
Style::default().fg(Color::Green),
|
|
||||||
));
|
|
||||||
spans.push(RtSpan::raw(" "));
|
|
||||||
spans.push(RtSpan::styled(
|
|
||||||
format!("-{}", f.removed),
|
|
||||||
Style::default().fg(Color::Red),
|
|
||||||
));
|
|
||||||
spans.push(RtSpan::raw(")"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut line = RtLine::from(spans);
|
|
||||||
let prefix = if idx == 0 { " └ " } else { " " };
|
|
||||||
line.spans.insert(0, prefix.into());
|
|
||||||
line.spans
|
|
||||||
.iter_mut()
|
|
||||||
.for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
|
|
||||||
out.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
let show_details = matches!(
|
|
||||||
event_type,
|
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: true
|
|
||||||
} | PatchEventType::ApprovalRequest
|
|
||||||
);
|
|
||||||
|
|
||||||
if show_details {
|
|
||||||
out.extend(render_patch_details(changes));
|
|
||||||
}
|
|
||||||
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
|
// Shared row for per-file presentation
|
||||||
let mut out: Vec<RtLine<'static>> = Vec::new();
|
#[derive(Clone)]
|
||||||
let term_cols: usize = terminal::size()
|
struct Row {
|
||||||
.map(|(w, _)| w as usize)
|
#[allow(dead_code)]
|
||||||
.unwrap_or(DEFAULT_WRAP_COLS.into());
|
path: PathBuf,
|
||||||
|
move_path: Option<PathBuf>,
|
||||||
|
added: usize,
|
||||||
|
removed: usize,
|
||||||
|
change: FileChange,
|
||||||
|
}
|
||||||
|
|
||||||
for (index, (path, change)) in changes.iter().enumerate() {
|
fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
|
||||||
let is_first_file = index == 0;
|
let mut rows: Vec<Row> = Vec::new();
|
||||||
// Add separator only between files (not at the very start)
|
for (path, change) in changes.iter() {
|
||||||
if !is_first_file {
|
let (added, removed) = match change {
|
||||||
out.push(RtLine::from(vec![
|
FileChange::Add { content } => (content.lines().count(), 0),
|
||||||
RtSpan::raw(" "),
|
FileChange::Delete { content } => (0, content.lines().count()),
|
||||||
RtSpan::styled("...", style_dim()),
|
FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff),
|
||||||
]));
|
};
|
||||||
|
let move_path = match change {
|
||||||
|
FileChange::Update {
|
||||||
|
move_path: Some(new),
|
||||||
|
..
|
||||||
|
} => Some(new.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
rows.push(Row {
|
||||||
|
path: path.clone(),
|
||||||
|
move_path,
|
||||||
|
added,
|
||||||
|
removed,
|
||||||
|
change: change.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.sort_by_key(|r| r.path.clone());
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HeaderKind {
|
||||||
|
ProposedChange,
|
||||||
|
Edited,
|
||||||
|
ChangeApproved,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_changes_block(
|
||||||
|
rows: Vec<Row>,
|
||||||
|
wrap_cols: usize,
|
||||||
|
header_kind: HeaderKind,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> Vec<RtLine<'static>> {
|
||||||
|
let mut out: Vec<RtLine<'static>> = Vec::new();
|
||||||
|
let term_cols = wrap_cols;
|
||||||
|
|
||||||
|
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
spans.push("(".into());
|
||||||
|
spans.push(format!("+{added}").green());
|
||||||
|
spans.push(" ".into());
|
||||||
|
spans.push(format!("-{removed}").red());
|
||||||
|
spans.push(")".into());
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
let render_path = |row: &Row| -> Vec<RtSpan<'static>> {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
spans.push(display_path_for(&row.path, cwd).into());
|
||||||
|
if let Some(move_path) = &row.move_path {
|
||||||
|
spans.push(format!(" → {}", display_path_for(move_path, cwd)).into());
|
||||||
}
|
}
|
||||||
match change {
|
spans
|
||||||
|
};
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let total_added: usize = rows.iter().map(|r| r.added).sum();
|
||||||
|
let total_removed: usize = rows.iter().map(|r| r.removed).sum();
|
||||||
|
let file_count = rows.len();
|
||||||
|
let noun = if file_count == 1 { "file" } else { "files" };
|
||||||
|
let mut header_spans: Vec<RtSpan<'static>> = vec!["• ".into()];
|
||||||
|
match header_kind {
|
||||||
|
HeaderKind::ProposedChange => {
|
||||||
|
header_spans.push("Proposed Change".bold());
|
||||||
|
if let [row] = &rows[..] {
|
||||||
|
header_spans.push(" ".into());
|
||||||
|
header_spans.extend(render_path(row));
|
||||||
|
header_spans.push(" ".into());
|
||||||
|
header_spans.extend(render_line_count_summary(row.added, row.removed));
|
||||||
|
} else {
|
||||||
|
header_spans.push(format!(" to {file_count} {noun} ").into());
|
||||||
|
header_spans.extend(render_line_count_summary(total_added, total_removed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HeaderKind::Edited => {
|
||||||
|
if let [row] = &rows[..] {
|
||||||
|
let verb = match &row.change {
|
||||||
|
FileChange::Add { .. } => "Added",
|
||||||
|
FileChange::Delete { .. } => "Deleted",
|
||||||
|
_ => "Edited",
|
||||||
|
};
|
||||||
|
header_spans.push(verb.bold());
|
||||||
|
header_spans.push(" ".into());
|
||||||
|
header_spans.extend(render_path(row));
|
||||||
|
header_spans.push(" ".into());
|
||||||
|
header_spans.extend(render_line_count_summary(row.added, row.removed));
|
||||||
|
} else {
|
||||||
|
header_spans.push("Edited".bold());
|
||||||
|
header_spans.push(format!(" {file_count} {noun} ").into());
|
||||||
|
header_spans.extend(render_line_count_summary(total_added, total_removed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HeaderKind::ChangeApproved => {
|
||||||
|
header_spans.push("Change Approved".bold());
|
||||||
|
if let [row] = &rows[..] {
|
||||||
|
header_spans.push(" ".into());
|
||||||
|
header_spans.extend(render_path(row));
|
||||||
|
header_spans.push(" ".into());
|
||||||
|
header_spans.extend(render_line_count_summary(row.added, row.removed));
|
||||||
|
} else {
|
||||||
|
header_spans.push(format!(" {file_count} {noun} ").into());
|
||||||
|
header_spans.extend(render_line_count_summary(total_added, total_removed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(RtLine::from(header_spans));
|
||||||
|
|
||||||
|
// For Change Approved, we only show the header summary and no per-file/diff details.
|
||||||
|
if matches!(header_kind, HeaderKind::ChangeApproved) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, r) in rows.into_iter().enumerate() {
|
||||||
|
// Insert a blank separator between file chunks (except before the first)
|
||||||
|
if idx > 0 {
|
||||||
|
out.push("".into());
|
||||||
|
}
|
||||||
|
// File header line (skip when single-file header already shows the name)
|
||||||
|
let skip_file_header =
|
||||||
|
matches!(header_kind, HeaderKind::ProposedChange | HeaderKind::Edited)
|
||||||
|
&& file_count == 1;
|
||||||
|
if !skip_file_header {
|
||||||
|
let mut header: Vec<RtSpan<'static>> = Vec::new();
|
||||||
|
header.push(" └ ".dim());
|
||||||
|
header.extend(render_path(&r));
|
||||||
|
header.push(" ".into());
|
||||||
|
header.extend(render_line_count_summary(r.added, r.removed));
|
||||||
|
out.push(RtLine::from(header));
|
||||||
|
}
|
||||||
|
|
||||||
|
match r.change {
|
||||||
FileChange::Add { content } => {
|
FileChange::Add { content } => {
|
||||||
for (i, raw) in content.lines().enumerate() {
|
for (i, raw) in content.lines().enumerate() {
|
||||||
let ln = i + 1;
|
|
||||||
out.extend(push_wrapped_diff_line(
|
out.extend(push_wrapped_diff_line(
|
||||||
ln,
|
i + 1,
|
||||||
DiffLineType::Insert,
|
DiffLineType::Insert,
|
||||||
raw,
|
raw,
|
||||||
term_cols,
|
term_cols,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FileChange::Delete => {
|
FileChange::Delete { content } => {
|
||||||
let original = std::fs::read_to_string(path).unwrap_or_default();
|
for (i, raw) in content.lines().enumerate() {
|
||||||
for (i, raw) in original.lines().enumerate() {
|
|
||||||
let ln = i + 1;
|
|
||||||
out.extend(push_wrapped_diff_line(
|
out.extend(push_wrapped_diff_line(
|
||||||
ln,
|
i + 1,
|
||||||
DiffLineType::Delete,
|
DiffLineType::Delete,
|
||||||
raw,
|
raw,
|
||||||
term_cols,
|
term_cols,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FileChange::Update {
|
FileChange::Update { unified_diff, .. } => {
|
||||||
unified_diff,
|
if let Ok(patch) = diffy::Patch::from_str(&unified_diff) {
|
||||||
move_path: _,
|
|
||||||
} => {
|
|
||||||
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
|
|
||||||
let mut is_first_hunk = true;
|
let mut is_first_hunk = true;
|
||||||
for h in patch.hunks() {
|
for h in patch.hunks() {
|
||||||
// Render a simple separator between non-contiguous hunks
|
|
||||||
// instead of diff-style @@ headers.
|
|
||||||
if !is_first_hunk {
|
if !is_first_hunk {
|
||||||
out.push(RtLine::from(vec![
|
out.push(RtLine::from(vec![" ".into(), "⋮".dim()]));
|
||||||
RtSpan::raw(" "),
|
|
||||||
RtSpan::styled("⋮", style_dim()),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
is_first_hunk = false;
|
is_first_hunk = false;
|
||||||
|
|
||||||
@@ -265,13 +260,41 @@ fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(RtLine::from(RtSpan::raw("")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn display_path_for(path: &Path, cwd: &Path) -> String {
|
||||||
|
let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) {
|
||||||
|
(Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
let chosen = if path_in_same_repo {
|
||||||
|
pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf())
|
||||||
|
} else {
|
||||||
|
relativize_to_home(path).unwrap_or_else(|| path.to_path_buf())
|
||||||
|
};
|
||||||
|
chosen.display().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) {
|
||||||
|
if let Ok(patch) = diffy::Patch::from_str(diff) {
|
||||||
|
patch
|
||||||
|
.hunks()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|h| h.lines())
|
||||||
|
.fold((0, 0), |(a, d), l| match l {
|
||||||
|
diffy::Line::Insert(_) => (a + 1, d),
|
||||||
|
diffy::Line::Delete(_) => (a, d + 1),
|
||||||
|
diffy::Line::Context(_) => (a, d),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// For unparsable diffs, return 0 for both counts.
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn push_wrapped_diff_line(
|
fn push_wrapped_diff_line(
|
||||||
line_number: usize,
|
line_number: usize,
|
||||||
kind: DiffLineType,
|
kind: DiffLineType,
|
||||||
@@ -290,10 +313,10 @@ fn push_wrapped_diff_line(
|
|||||||
let prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
|
let prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
|
||||||
|
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
let (sign_opt, line_style) = match kind {
|
let (sign_char, line_style) = match kind {
|
||||||
DiffLineType::Insert => (Some('+'), Some(style_add())),
|
DiffLineType::Insert => ('+', style_add()),
|
||||||
DiffLineType::Delete => (Some('-'), Some(style_del())),
|
DiffLineType::Delete => ('-', style_del()),
|
||||||
DiffLineType::Context => (None, None),
|
DiffLineType::Context => (' ', style_context()),
|
||||||
};
|
};
|
||||||
let mut lines: Vec<RtLine<'static>> = Vec::new();
|
let mut lines: Vec<RtLine<'static>> = Vec::new();
|
||||||
|
|
||||||
@@ -301,9 +324,7 @@ fn push_wrapped_diff_line(
|
|||||||
// Fit the content for the current terminal row:
|
// Fit the content for the current terminal row:
|
||||||
// compute how many columns are available after the prefix, then split
|
// compute how many columns are available after the prefix, then split
|
||||||
// at a UTF-8 character boundary so this row's chunk fits exactly.
|
// at a UTF-8 character boundary so this row's chunk fits exactly.
|
||||||
let available_content_cols = term_cols
|
let available_content_cols = term_cols.saturating_sub(prefix_cols + 1).max(1);
|
||||||
.saturating_sub(if first { prefix_cols + 1 } else { prefix_cols })
|
|
||||||
.max(1);
|
|
||||||
let split_at_byte_index = remaining_text
|
let split_at_byte_index = remaining_text
|
||||||
.char_indices()
|
.char_indices()
|
||||||
.nth(available_content_cols)
|
.nth(available_content_cols)
|
||||||
@@ -313,41 +334,22 @@ fn push_wrapped_diff_line(
|
|||||||
remaining_text = rest;
|
remaining_text = rest;
|
||||||
|
|
||||||
if first {
|
if first {
|
||||||
let mut spans: Vec<RtSpan<'static>> = Vec::new();
|
// Build gutter (indent + line number + spacing) as a dimmed span
|
||||||
spans.push(RtSpan::raw(indent));
|
let gutter = format!("{indent}{ln_str}{}", " ".repeat(gap_after_ln));
|
||||||
spans.push(RtSpan::styled(ln_str.clone(), style_dim()));
|
// Content with a sign ('+'/'-'/' ') styled per diff kind
|
||||||
spans.push(RtSpan::raw(" ".repeat(gap_after_ln)));
|
let content = format!("{sign_char}{chunk}");
|
||||||
// Always include a sign character at the start of the displayed chunk
|
lines.push(RtLine::from(vec![
|
||||||
// ('+' for insert, '-' for delete, ' ' for context) so gutters align.
|
RtSpan::styled(gutter, style_gutter()),
|
||||||
let sign_char = sign_opt.unwrap_or(' ');
|
RtSpan::styled(content, line_style),
|
||||||
let display_chunk = format!("{sign_char}{chunk}");
|
]));
|
||||||
let content_span = match line_style {
|
|
||||||
Some(style) => RtSpan::styled(display_chunk, style),
|
|
||||||
None => RtSpan::raw(display_chunk),
|
|
||||||
};
|
|
||||||
spans.push(content_span);
|
|
||||||
let mut line = RtLine::from(spans);
|
|
||||||
if let Some(style) = line_style {
|
|
||||||
line.style = line.style.patch(style);
|
|
||||||
}
|
|
||||||
lines.push(line);
|
|
||||||
first = false;
|
first = false;
|
||||||
} else {
|
} else {
|
||||||
// Continuation lines keep a space for the sign column so content aligns
|
// Continuation lines keep a space for the sign column so content aligns
|
||||||
let hang_prefix = format!(
|
let gutter = format!("{indent}{} ", " ".repeat(ln_str.len() + gap_after_ln));
|
||||||
"{indent}{}{} ",
|
lines.push(RtLine::from(vec![
|
||||||
" ".repeat(ln_str.len()),
|
RtSpan::styled(gutter, style_gutter()),
|
||||||
" ".repeat(gap_after_ln)
|
RtSpan::styled(chunk.to_string(), line_style),
|
||||||
);
|
]));
|
||||||
let content_span = match line_style {
|
|
||||||
Some(style) => RtSpan::styled(chunk.to_string(), style),
|
|
||||||
None => RtSpan::raw(chunk.to_string()),
|
|
||||||
};
|
|
||||||
let mut line = RtLine::from(vec![RtSpan::raw(hang_prefix), content_span]);
|
|
||||||
if let Some(style) = line_style {
|
|
||||||
line.style = line.style.patch(style);
|
|
||||||
}
|
|
||||||
lines.push(line);
|
|
||||||
}
|
}
|
||||||
if remaining_text.is_empty() {
|
if remaining_text.is_empty() {
|
||||||
break;
|
break;
|
||||||
@@ -356,10 +358,14 @@ fn push_wrapped_diff_line(
|
|||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn style_dim() -> Style {
|
fn style_gutter() -> Style {
|
||||||
Style::default().add_modifier(Modifier::DIM)
|
Style::default().add_modifier(Modifier::DIM)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn style_context() -> Style {
|
||||||
|
Style::default()
|
||||||
|
}
|
||||||
|
|
||||||
fn style_add() -> Style {
|
fn style_add() -> Style {
|
||||||
Style::default().fg(Color::Green)
|
Style::default().fg(Color::Green)
|
||||||
}
|
}
|
||||||
@@ -378,6 +384,12 @@ mod tests {
|
|||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
use ratatui::widgets::Wrap;
|
use ratatui::widgets::Wrap;
|
||||||
|
fn diff_summary_for_tests(
|
||||||
|
changes: &HashMap<PathBuf, FileChange>,
|
||||||
|
event_type: PatchEventType,
|
||||||
|
) -> Vec<RtLine<'static>> {
|
||||||
|
create_diff_summary(changes, event_type, &PathBuf::from("/"), 80)
|
||||||
|
}
|
||||||
|
|
||||||
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
|
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
|
||||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
|
||||||
@@ -391,6 +403,23 @@ mod tests {
|
|||||||
assert_snapshot!(name, terminal.backend());
|
assert_snapshot!(name, terminal.backend());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) {
|
||||||
|
// Convert Lines to plain text rows and trim trailing spaces so it's
|
||||||
|
// easier to validate indentation visually in snapshots.
|
||||||
|
let text = lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| {
|
||||||
|
l.spans
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.content.as_ref())
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.map(|s| s.trim_end().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert_snapshot!(name, text);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ui_snapshot_add_details() {
|
fn ui_snapshot_add_details() {
|
||||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
@@ -401,8 +430,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines =
|
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
||||||
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
snapshot_lines("add_details", lines, 80, 10);
|
snapshot_lines("add_details", lines, 80, 10);
|
||||||
}
|
}
|
||||||
@@ -423,8 +451,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines =
|
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
||||||
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
snapshot_lines("update_details_with_rename", lines, 80, 12);
|
snapshot_lines("update_details_with_rename", lines, 80, 12);
|
||||||
}
|
}
|
||||||
@@ -435,11 +462,10 @@ mod tests {
|
|||||||
let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
|
let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
|
||||||
|
|
||||||
// Call the wrapping function directly so we can precisely control the width
|
// Call the wrapping function directly so we can precisely control the width
|
||||||
let lines =
|
let lines = push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80);
|
||||||
push_wrapped_diff_line(1, DiffLineType::Insert, long_line, DEFAULT_WRAP_COLS.into());
|
|
||||||
|
|
||||||
// Render into a small terminal to capture the visual layout
|
// Render into a small terminal to capture the visual layout
|
||||||
snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8);
|
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -458,8 +484,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines =
|
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
||||||
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
snapshot_lines("single_line_replacement_counts", lines, 80, 8);
|
snapshot_lines("single_line_replacement_counts", lines, 80, 8);
|
||||||
}
|
}
|
||||||
@@ -480,8 +505,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines =
|
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
||||||
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
snapshot_lines("blank_context_line", lines, 80, 10);
|
snapshot_lines("blank_context_line", lines, 80, 10);
|
||||||
}
|
}
|
||||||
@@ -503,10 +527,232 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines =
|
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
||||||
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
// Height is large enough to show both hunks and the separator
|
// Height is large enough to show both hunks and the separator
|
||||||
snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16);
|
snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_snapshot_apply_update_block() {
|
||||||
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
|
let original = "line one\nline two\nline three\n";
|
||||||
|
let modified = "line one\nline two changed\nline three\n";
|
||||||
|
let patch = diffy::create_patch(original, modified).to_string();
|
||||||
|
|
||||||
|
changes.insert(
|
||||||
|
PathBuf::from("example.txt"),
|
||||||
|
FileChange::Update {
|
||||||
|
unified_diff: patch,
|
||||||
|
move_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (name, auto_approved) in [
|
||||||
|
("apply_update_block", true),
|
||||||
|
("apply_update_block_manual", false),
|
||||||
|
] {
|
||||||
|
let lines =
|
||||||
|
diff_summary_for_tests(&changes, PatchEventType::ApplyBegin { auto_approved });
|
||||||
|
|
||||||
|
snapshot_lines(name, lines, 80, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_snapshot_apply_update_with_rename_block() {
|
||||||
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
|
let original = "A\nB\nC\n";
|
||||||
|
let modified = "A\nB changed\nC\n";
|
||||||
|
let patch = diffy::create_patch(original, modified).to_string();
|
||||||
|
|
||||||
|
changes.insert(
|
||||||
|
PathBuf::from("old_name.rs"),
|
||||||
|
FileChange::Update {
|
||||||
|
unified_diff: patch,
|
||||||
|
move_path: Some(PathBuf::from("new_name.rs")),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let lines = diff_summary_for_tests(
|
||||||
|
&changes,
|
||||||
|
PatchEventType::ApplyBegin {
|
||||||
|
auto_approved: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_snapshot_apply_multiple_files_block() {
|
||||||
|
// Two files: one update and one add, to exercise combined header and per-file rows
|
||||||
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
|
|
||||||
|
// File a.txt: single-line replacement (one delete, one insert)
|
||||||
|
let patch_a = diffy::create_patch("one\n", "one changed\n").to_string();
|
||||||
|
changes.insert(
|
||||||
|
PathBuf::from("a.txt"),
|
||||||
|
FileChange::Update {
|
||||||
|
unified_diff: patch_a,
|
||||||
|
move_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// File b.txt: newly added with one line
|
||||||
|
changes.insert(
|
||||||
|
PathBuf::from("b.txt"),
|
||||||
|
FileChange::Add {
|
||||||
|
content: "new\n".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let lines = diff_summary_for_tests(
|
||||||
|
&changes,
|
||||||
|
PatchEventType::ApplyBegin {
|
||||||
|
auto_approved: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_snapshot_apply_add_block() {
|
||||||
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
|
changes.insert(
|
||||||
|
PathBuf::from("new_file.txt"),
|
||||||
|
FileChange::Add {
|
||||||
|
content: "alpha\nbeta\n".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let lines = diff_summary_for_tests(
|
||||||
|
&changes,
|
||||||
|
PatchEventType::ApplyBegin {
|
||||||
|
auto_approved: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
snapshot_lines("apply_add_block", lines, 80, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_snapshot_apply_delete_block() {
|
||||||
|
// Write a temporary file so the delete renderer can read original content
|
||||||
|
let tmp_path = PathBuf::from("tmp_delete_example.txt");
|
||||||
|
std::fs::write(&tmp_path, "first\nsecond\nthird\n").expect("write tmp file");
|
||||||
|
|
||||||
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
|
changes.insert(
|
||||||
|
tmp_path.clone(),
|
||||||
|
FileChange::Delete {
|
||||||
|
content: "first\nsecond\nthird\n".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let lines = diff_summary_for_tests(
|
||||||
|
&changes,
|
||||||
|
PatchEventType::ApplyBegin {
|
||||||
|
auto_approved: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup best-effort; rendering has already read the file
|
||||||
|
let _ = std::fs::remove_file(&tmp_path);
|
||||||
|
|
||||||
|
snapshot_lines("apply_delete_block", lines, 80, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_snapshot_apply_update_block_wraps_long_lines() {
|
||||||
|
// Create a patch with a long modified line to force wrapping
|
||||||
|
let original = "line 1\nshort\nline 3\n";
|
||||||
|
let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n";
|
||||||
|
let patch = diffy::create_patch(original, modified).to_string();
|
||||||
|
|
||||||
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
|
changes.insert(
|
||||||
|
PathBuf::from("long_example.txt"),
|
||||||
|
FileChange::Update {
|
||||||
|
unified_diff: patch,
|
||||||
|
move_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let lines = create_diff_summary(
|
||||||
|
&changes,
|
||||||
|
PatchEventType::ApplyBegin {
|
||||||
|
auto_approved: true,
|
||||||
|
},
|
||||||
|
&PathBuf::from("/"),
|
||||||
|
72,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
|
||||||
|
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_snapshot_apply_update_block_wraps_long_lines_text() {
|
||||||
|
// This mirrors the desired layout example: sign only on first inserted line,
|
||||||
|
// subsequent wrapped pieces start aligned under the line number gutter.
|
||||||
|
let original = "1\n2\n3\n4\n";
|
||||||
|
let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n";
|
||||||
|
let patch = diffy::create_patch(original, modified).to_string();
|
||||||
|
|
||||||
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
|
changes.insert(
|
||||||
|
PathBuf::from("wrap_demo.txt"),
|
||||||
|
FileChange::Update {
|
||||||
|
unified_diff: patch,
|
||||||
|
move_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut lines = create_diff_summary(
|
||||||
|
&changes,
|
||||||
|
PatchEventType::ApplyBegin {
|
||||||
|
auto_approved: true,
|
||||||
|
},
|
||||||
|
&PathBuf::from("/"),
|
||||||
|
28,
|
||||||
|
);
|
||||||
|
// Drop the combined header for this text-only snapshot
|
||||||
|
if !lines.is_empty() {
|
||||||
|
lines.remove(0);
|
||||||
|
}
|
||||||
|
snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_snapshot_apply_update_block_relativizes_path() {
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
|
||||||
|
let abs_old = cwd.join("abs_old.rs");
|
||||||
|
let abs_new = cwd.join("abs_new.rs");
|
||||||
|
|
||||||
|
let original = "X\nY\n";
|
||||||
|
let modified = "X changed\nY\n";
|
||||||
|
let patch = diffy::create_patch(original, modified).to_string();
|
||||||
|
|
||||||
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
|
changes.insert(
|
||||||
|
abs_old.clone(),
|
||||||
|
FileChange::Update {
|
||||||
|
unified_diff: patch,
|
||||||
|
move_path: Some(abs_new.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let lines = create_diff_summary(
|
||||||
|
&changes,
|
||||||
|
PatchEventType::ApplyBegin {
|
||||||
|
auto_approved: true,
|
||||||
|
},
|
||||||
|
&cwd,
|
||||||
|
80,
|
||||||
|
);
|
||||||
|
|
||||||
|
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,8 @@ use ratatui::text::Span;
|
|||||||
use textwrap::Options as TwOptions;
|
use textwrap::Options as TwOptions;
|
||||||
use textwrap::WordSplitter;
|
use textwrap::WordSplitter;
|
||||||
|
|
||||||
/// Insert `lines` above the viewport.
|
/// Insert `lines` above the viewport using the terminal's backend writer
|
||||||
|
/// (avoids direct stdout references).
|
||||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec<Line>) {
|
pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec<Line>) {
|
||||||
let mut out = std::io::stdout();
|
let mut out = std::io::stdout();
|
||||||
insert_history_lines_to_writer(terminal, &mut out, lines);
|
insert_history_lines_to_writer(terminal, &mut out, lines);
|
||||||
@@ -262,7 +263,10 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Word-aware wrapping for a list of `Line`s preserving styles.
|
/// Word-aware wrapping for a list of `Line`s preserving styles.
|
||||||
pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec<Line<'static>> {
|
pub(crate) fn word_wrap_lines<'a, I>(lines: I, width: u16) -> Vec<Line<'static>>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = &'a Line<'a>>,
|
||||||
|
{
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let w = width.max(1) as usize;
|
let w = width.max(1) as usize;
|
||||||
for line in lines {
|
for line in lines {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ mod chatwidget;
|
|||||||
mod citation_regex;
|
mod citation_regex;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod clipboard_paste;
|
mod clipboard_paste;
|
||||||
mod common;
|
|
||||||
pub mod custom_terminal;
|
pub mod custom_terminal;
|
||||||
mod diff_render;
|
mod diff_render;
|
||||||
mod exec_command;
|
mod exec_command;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use codex_core::util::is_inside_git_repo;
|
use codex_core::git_info::get_git_repo_root;
|
||||||
use codex_login::AuthManager;
|
use codex_login::AuthManager;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
@@ -88,7 +88,7 @@ impl OnboardingScreen {
|
|||||||
auth_manager,
|
auth_manager,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
let is_git_repo = is_inside_git_repo(&cwd);
|
let is_git_repo = get_git_repo_root(&cwd).is_some();
|
||||||
let highlighted = if is_git_repo {
|
let highlighted = if is_git_repo {
|
||||||
TrustDirectorySelection::Trust
|
TrustDirectorySelection::Trust
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -140,16 +140,6 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
LOGGER.write_json_line(value);
|
||||||
}
|
}
|
||||||
// Internal UI events; still log for fidelity, but avoid heavy payloads.
|
|
||||||
AppEvent::InsertHistoryLines(lines) => {
|
|
||||||
let value = json!({
|
|
||||||
"ts": now_ts(),
|
|
||||||
"dir": "to_tui",
|
|
||||||
"kind": "insert_history",
|
|
||||||
"lines": lines.len(),
|
|
||||||
});
|
|
||||||
LOGGER.write_json_line(value);
|
|
||||||
}
|
|
||||||
AppEvent::InsertHistoryCell(cell) => {
|
AppEvent::InsertHistoryCell(cell) => {
|
||||||
let value = json!({
|
let value = json!({
|
||||||
"ts": now_ts(),
|
"ts": now_ts(),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/diff_render.rs
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 765
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"proposed patch to 1 file (+2 -0) "
|
"• Proposed Change README.md (+2 -0) "
|
||||||
" └ README.md "
|
|
||||||
" 1 +first line "
|
" 1 +first line "
|
||||||
" 2 +second line "
|
" 2 +second line "
|
||||||
" "
|
" "
|
||||||
@@ -12,3 +12,4 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
|
" "
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"• Added new_file.txt (+2 -0) "
|
||||||
|
" 1 +alpha "
|
||||||
|
" 2 +beta "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"• Deleted tmp_delete_example.txt (+0 -3) "
|
||||||
|
" 1 -first "
|
||||||
|
" 2 -second "
|
||||||
|
" 3 -third "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"• Edited 2 files (+2 -1) "
|
||||||
|
" └ a.txt (+1 -1) "
|
||||||
|
" 1 -one "
|
||||||
|
" 1 +one changed "
|
||||||
|
" "
|
||||||
|
" └ b.txt (+1 -0) "
|
||||||
|
" 1 +new "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 748
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"• Edited example.txt (+1 -1) "
|
||||||
|
" 1 line one "
|
||||||
|
" 2 -line two "
|
||||||
|
" 2 +line two changed "
|
||||||
|
" 3 line three "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"• Change Approved example.txt (+1 -1) "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 748
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"• Edited abs_old.rs → abs_new.rs (+1 -1) "
|
||||||
|
" 1 -X "
|
||||||
|
" 1 +X changed "
|
||||||
|
" 2 Y "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 748
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"• Edited long_example.txt (+1 -1) "
|
||||||
|
" 1 line 1 "
|
||||||
|
" 2 -short "
|
||||||
|
" 2 +short this_is_a_very_long_modified_line_that_should_wrap_acro "
|
||||||
|
" ss_multiple_terminal_columns_and_continue_even_further_beyond "
|
||||||
|
" _eighty_columns_to_force_multiple_wraps "
|
||||||
|
" 3 line 3 "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
expression: text
|
||||||
|
---
|
||||||
|
1 1
|
||||||
|
2 -2
|
||||||
|
2 +added long line w
|
||||||
|
hich wraps and_if
|
||||||
|
_there_is_a_long_
|
||||||
|
token_it_will_be_
|
||||||
|
broken
|
||||||
|
3 3
|
||||||
|
4 -4
|
||||||
|
4 +4 context line wh
|
||||||
|
ich also wraps ac
|
||||||
|
ross
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 748
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"• Edited old_name.rs → new_name.rs (+1 -1) "
|
||||||
|
" 1 A "
|
||||||
|
" 2 -B "
|
||||||
|
" 2 +B changed "
|
||||||
|
" 3 C "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/diff_render.rs
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 765
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"proposed patch to 1 file (+1 -1) "
|
"• Proposed Change example.txt (+1 -1) "
|
||||||
" └ example.txt "
|
|
||||||
" 1 "
|
" 1 "
|
||||||
" 2 -Y "
|
" 2 -Y "
|
||||||
" 2 +Y changed "
|
" 2 +Y changed "
|
||||||
@@ -12,3 +12,4 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
|
" "
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/diff_render.rs
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 765
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"proposed patch to 1 file (+1 -1) "
|
"• Proposed Change README.md (+1 -1) "
|
||||||
" └ README.md "
|
|
||||||
" 1 -# Codex CLI (Rust Implementation) "
|
" 1 -# Codex CLI (Rust Implementation) "
|
||||||
" 1 +# Codex CLI (Rust Implementation) banana "
|
" 1 +# Codex CLI (Rust Implementation) banana "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
|
" "
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/diff_render.rs
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 765
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"proposed patch to 1 file (+1 -1) "
|
"• Proposed Change src/lib.rs → src/lib_new.rs (+1 -1) "
|
||||||
" └ src/lib.rs → src/lib_new.rs "
|
|
||||||
" 1 line one "
|
" 1 line one "
|
||||||
" 2 -line two "
|
" 2 -line two "
|
||||||
" 2 +line two changed "
|
" 2 +line two changed "
|
||||||
@@ -14,3 +14,4 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
|
" "
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/diff_render.rs
|
source: tui/src/diff_render.rs
|
||||||
|
assertion_line: 765
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"proposed patch to 1 file (+2 -2) "
|
"• Proposed Change example.txt (+2 -2) "
|
||||||
" └ example.txt "
|
|
||||||
" 1 line 1 "
|
" 1 line 1 "
|
||||||
" 2 -line 2 "
|
" 2 -line 2 "
|
||||||
" 2 +line two changed "
|
" 2 +line two changed "
|
||||||
@@ -18,3 +18,4 @@ expression: terminal.backend()
|
|||||||
" 9 +line nine changed "
|
" 9 +line nine changed "
|
||||||
" 10 line 10 "
|
" 10 line 10 "
|
||||||
" "
|
" "
|
||||||
|
" "
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Explored
|
||||||
|
└ Read auth.rs, shimmer.rs
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Explored
|
||||||
|
└ Search shimmer_spans
|
||||||
|
Read shimmer.rs, status_indicator_widget.rs
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Explored
|
||||||
|
└ Search shimmer_spans
|
||||||
|
Read shimmer.rs
|
||||||
|
Read status_indicator_widget.rs
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Ran
|
||||||
|
└ first_token_is_long_
|
||||||
|
enough_to_wrap
|
||||||
|
second_token_is_also
|
||||||
|
_long_enough_to_wrap
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Ran
|
||||||
|
└ echo one
|
||||||
|
echo two
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Ran
|
||||||
|
└ set -o pipefail
|
||||||
|
cargo test
|
||||||
|
--all-features
|
||||||
|
--quiet
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Updated Plan
|
||||||
|
└ I’ll update Grafana call
|
||||||
|
error handling by adding
|
||||||
|
retries and clearer
|
||||||
|
messages when the backend is
|
||||||
|
unreachable.
|
||||||
|
✔ Investigate existing error
|
||||||
|
paths and logging around
|
||||||
|
HTTP timeouts
|
||||||
|
□ Harden Grafana client
|
||||||
|
error handling with retry/
|
||||||
|
backoff and user‑friendly
|
||||||
|
messages
|
||||||
|
□ Add tests for transient
|
||||||
|
failure scenarios and
|
||||||
|
surfacing to the UI
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Updated Plan
|
||||||
|
└ □ Define error taxonomy
|
||||||
|
□ Implement mapping to user messages
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Ran
|
||||||
|
└ echo
|
||||||
|
this_is_a_very_long_
|
||||||
|
single_token_that_wi
|
||||||
|
ll_wrap_across_the_a
|
||||||
|
vailable_width
|
||||||
|
error: first line on
|
||||||
|
stderr
|
||||||
|
error: second line on
|
||||||
|
stderr
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Ran echo ok
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Ran
|
||||||
|
└ a_very_long_toke
|
||||||
|
n_without_spaces
|
||||||
|
_to_force_wrappi
|
||||||
|
ng
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
• Ran seq 1 10 1>&2 && false
|
||||||
|
└ 1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/history_cell.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
▌one two
|
||||||
|
▌three four
|
||||||
|
▌five six
|
||||||
|
▌seven
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::history_cell;
|
||||||
|
use crate::history_cell::HistoryCell;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
|
|
||||||
@@ -6,7 +8,7 @@ use super::StreamState;
|
|||||||
|
|
||||||
/// Sink for history insertions and animation control.
|
/// Sink for history insertions and animation control.
|
||||||
pub(crate) trait HistorySink {
|
pub(crate) trait HistorySink {
|
||||||
fn insert_history(&self, lines: Vec<Line<'static>>);
|
fn insert_history_cell(&self, cell: Box<dyn HistoryCell>);
|
||||||
fn start_commit_animation(&self);
|
fn start_commit_animation(&self);
|
||||||
fn stop_commit_animation(&self);
|
fn stop_commit_animation(&self);
|
||||||
}
|
}
|
||||||
@@ -15,9 +17,9 @@ pub(crate) trait HistorySink {
|
|||||||
pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEventSender);
|
pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEventSender);
|
||||||
|
|
||||||
impl HistorySink for AppEventHistorySink {
|
impl HistorySink for AppEventHistorySink {
|
||||||
fn insert_history(&self, lines: Vec<Line<'static>>) {
|
fn insert_history_cell(&self, cell: Box<dyn crate::history_cell::HistoryCell>) {
|
||||||
self.0
|
self.0
|
||||||
.send(crate::app_event::AppEvent::InsertHistoryLines(lines))
|
.send(crate::app_event::AppEvent::InsertHistoryCell(cell))
|
||||||
}
|
}
|
||||||
fn start_commit_animation(&self) {
|
fn start_commit_animation(&self) {
|
||||||
self.0
|
self.0
|
||||||
@@ -66,10 +68,6 @@ impl StreamController {
|
|||||||
// leave header state unchanged; caller decides when to reset
|
// leave header state unchanged; caller decides when to reset
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_header_if_needed(&mut self, out_lines: &mut Lines) -> bool {
|
|
||||||
self.header.maybe_emit(out_lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Begin an answer stream. Does not emit header yet; it is emitted on first commit.
|
/// Begin an answer stream. Does not emit header yet; it is emitted on first commit.
|
||||||
pub(crate) fn begin(&mut self, _sink: &impl HistorySink) {
|
pub(crate) fn begin(&mut self, _sink: &impl HistorySink) {
|
||||||
// Starting a new stream cancels any pending finish-from-previous-stream animation.
|
// Starting a new stream cancels any pending finish-from-previous-stream animation.
|
||||||
@@ -124,10 +122,11 @@ impl StreamController {
|
|||||||
out_lines.extend(step.history);
|
out_lines.extend(step.history);
|
||||||
}
|
}
|
||||||
if !out_lines.is_empty() {
|
if !out_lines.is_empty() {
|
||||||
let mut lines_with_header: Lines = Vec::new();
|
// Insert as a HistoryCell so display drops the header while transcript keeps it.
|
||||||
self.emit_header_if_needed(&mut lines_with_header);
|
sink.insert_history_cell(Box::new(history_cell::AgentMessageCell::new(
|
||||||
lines_with_header.extend(out_lines);
|
out_lines,
|
||||||
sink.insert_history(lines_with_header);
|
self.header.maybe_emit_header(),
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
@@ -159,11 +158,10 @@ impl StreamController {
|
|||||||
}
|
}
|
||||||
let step = { self.state.step() };
|
let step = { self.state.step() };
|
||||||
if !step.history.is_empty() {
|
if !step.history.is_empty() {
|
||||||
let mut lines: Lines = Vec::new();
|
sink.insert_history_cell(Box::new(history_cell::AgentMessageCell::new(
|
||||||
self.emit_header_if_needed(&mut lines);
|
step.history,
|
||||||
let mut out = lines;
|
self.header.maybe_emit_header(),
|
||||||
out.extend(step.history);
|
)));
|
||||||
sink.insert_history(out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_idle = self.state.is_idle();
|
let is_idle = self.state.is_idle();
|
||||||
@@ -244,8 +242,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl HistorySink for TestSink {
|
impl HistorySink for TestSink {
|
||||||
fn insert_history(&self, lines: Vec<Line<'static>>) {
|
fn insert_history_cell(&self, cell: Box<dyn crate::history_cell::HistoryCell>) {
|
||||||
self.lines.borrow_mut().push(lines);
|
// For tests, store the transcript representation of the cell.
|
||||||
|
self.lines.borrow_mut().push(cell.transcript_lines());
|
||||||
}
|
}
|
||||||
fn start_commit_animation(&self) {}
|
fn start_commit_animation(&self) {}
|
||||||
fn stop_commit_animation(&self) {}
|
fn stop_commit_animation(&self) {}
|
||||||
|
|||||||
@@ -62,20 +62,13 @@ impl HeaderEmitter {
|
|||||||
self.emitted_this_turn = false;
|
self.emitted_this_turn = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec<ratatui::text::Line<'static>>) -> bool {
|
pub(crate) fn maybe_emit_header(&mut self) -> bool {
|
||||||
if !self.emitted_in_stream && !self.emitted_this_turn {
|
if !self.emitted_in_stream && !self.emitted_this_turn {
|
||||||
// Add a leading blank line before the header for visual spacing
|
|
||||||
out_lines.push(ratatui::text::Line::from(""));
|
|
||||||
out_lines.push(render_header_line());
|
|
||||||
self.emitted_in_stream = true;
|
self.emitted_in_stream = true;
|
||||||
self.emitted_this_turn = true;
|
self.emitted_this_turn = true;
|
||||||
return true;
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_header_line() -> ratatui::text::Line<'static> {
|
|
||||||
use ratatui::style::Stylize;
|
|
||||||
ratatui::text::Line::from("codex".magenta().bold())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ impl Tui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use synchronized update via backend instead of stdout()
|
||||||
std::io::stdout().sync_update(|_| {
|
std::io::stdout().sync_update(|_| {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -560,8 +561,7 @@ impl Tui {
|
|||||||
}
|
}
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
draw_fn(frame);
|
draw_fn(frame);
|
||||||
})?;
|
})
|
||||||
Ok(())
|
|
||||||
})?
|
})?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use ratatui::widgets::Wrap;
|
|||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::exec_command::strip_bash_lc_and_escape;
|
use crate::exec_command::strip_bash_lc_and_escape;
|
||||||
|
use crate::history_cell;
|
||||||
|
|
||||||
/// Request coming from the agent that needs user approval.
|
/// Request coming from the agent that needs user approval.
|
||||||
pub(crate) enum ApprovalRequest {
|
pub(crate) enum ApprovalRequest {
|
||||||
@@ -258,12 +259,11 @@ impl UserApprovalWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
|
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
|
||||||
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
|
|
||||||
match &self.approval_request {
|
match &self.approval_request {
|
||||||
ApprovalRequest::Exec { command, .. } => {
|
ApprovalRequest::Exec { command, .. } => {
|
||||||
let cmd = strip_bash_lc_and_escape(command);
|
let cmd = strip_bash_lc_and_escape(command);
|
||||||
let mut cmd_span: Span = cmd.clone().into();
|
// TODO: move this rendering into history_cell.
|
||||||
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
|
let mut lines: Vec<Line<'static>> = vec![];
|
||||||
|
|
||||||
// Result line based on decision.
|
// Result line based on decision.
|
||||||
match decision {
|
match decision {
|
||||||
@@ -316,18 +316,22 @@ impl UserApprovalWidget {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !feedback.trim().is_empty() {
|
||||||
|
lines.push(Line::from("feedback:"));
|
||||||
|
for l in feedback.lines() {
|
||||||
|
lines.push(Line::from(l.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||||
|
history_cell::new_user_approval_decision(lines),
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
ApprovalRequest::ApplyPatch { .. } => {
|
ApprovalRequest::ApplyPatch { .. } => {
|
||||||
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
|
// No history line for patch approval decisions.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !feedback.trim().is_empty() {
|
|
||||||
lines.push(Line::from("feedback:"));
|
|
||||||
for l in feedback.lines() {
|
|
||||||
lines.push(Line::from(l.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.app_event_tx.send(AppEvent::InsertHistoryLines(lines));
|
|
||||||
|
|
||||||
let op = match &self.approval_request {
|
let op = match &self.approval_request {
|
||||||
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
|
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
To get started, describe a task or try one of these commands:
|
|
||||||
|
|
||||||
/init - create an AGENTS.md file with instructions for Codex
|
|
||||||
/status - show current session configuration and token usage
|
|
||||||
/approvals - choose what Codex can do without approval
|
|
||||||
/model - choose what model and reasoning effort to use
|
|
||||||
|
|
||||||
codex
|
|
||||||
I’m going to scan the workspace and Cargo manifests to see build profiles and
|
|
||||||
dependencies that impact binary size. Then I’ll summarize the main causes.
|
|
||||||
|
|
||||||
_
|
|
||||||
✓ ls -la
|
|
||||||
└ total 6696
|
|
||||||
drwxr-xr-x@ 39 easong staff 1248 Aug 9 08:49 .
|
|
||||||
drwxr-xr-x@ 29 easong staff 928 Aug 9 08:50 ..
|
|
||||||
-rw-r--r--@ 1 easong staff 10244 Aug 8 21:13 .DS_Store
|
|
||||||
-rw-r--r--@ 1 easong staff 212 Aug 4 18:49 .gitignore
|
|
||||||
… +30 lines
|
|
||||||
-rw-r--r--@ 1 easong staff 9971 Aug 8 23:07 simpler.md
|
|
||||||
drwxr-xr-x@ 6 easong staff 192 Aug 4 22:40 target
|
|
||||||
drwxr-xr-x@ 6 easong staff 192 Aug 7 19:20 tui
|
|
||||||
-rw-r--r--@ 1 easong staff 8639 Aug 6 23:30 typewriter.md
|
|
||||||
-rw-r--r--@ 1 easong staff 7673 Aug 6 21:58 wrap-plan.md
|
|
||||||
✓ sed -n '1,200p' Cargo.toml
|
|
||||||
└ [workspace]
|
|
||||||
members = [
|
|
||||||
"ansi-escape",
|
|
||||||
"apply-patch",
|
|
||||||
"arg0",
|
|
||||||
… +38 lines
|
|
||||||
codegen-units = 1
|
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
# ratatui = { path = "../../ratatui" }
|
|
||||||
ratatui = { git = "https://github.com/nornagon/ratatui", branch =
|
|
||||||
"nornagon-v0.29.0-patch" }
|
|
||||||
✓ for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
|
|
||||||
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do
|
|
||||||
echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
|
|
||||||
└ --- ansi-escape/Cargo.toml
|
|
||||||
[package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "codex-ansi-escape"
|
|
||||||
version = { workspace = true }
|
|
||||||
… +237 lines
|
|
||||||
"process",
|
|
||||||
"rt-multi-thread",
|
|
||||||
"signal",
|
|
||||||
] }
|
|
||||||
tracing = { version
|
|
||||||
✓ sed -n '1,200p' execpolicy/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
name = "codex-execpolicy"
|
|
||||||
version = { workspace = true }
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
… +23 lines
|
|
||||||
serde_json = "1.0.142"
|
|
||||||
serde_with = { version = "3", features = ["macros"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3.13.0"
|
|
||||||
✓ sed -n '1,200p' file-search/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "codex-file-search"
|
|
||||||
version = { workspace = true }
|
|
||||||
|
|
||||||
… +11 lines
|
|
||||||
ignore = "0.4.23"
|
|
||||||
nucleo-matcher = "0.3.1"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1.0.142"
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
✓ sed -n '1,200p' linux-sandbox/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "codex-linux-sandbox"
|
|
||||||
version = { workspace = true }
|
|
||||||
|
|
||||||
… +24 lines
|
|
||||||
"macros",
|
|
||||||
"process",
|
|
||||||
"rt-multi-thread",
|
|
||||||
"signal",
|
|
||||||
] }
|
|
||||||
✓ sed -n '1,200p' login/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "codex-login"
|
|
||||||
version = { workspace = true }
|
|
||||||
|
|
||||||
… +17 lines
|
|
||||||
] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
pretty_assertions = "1.4.1"
|
|
||||||
tempfile = "3"
|
|
||||||
✓ sed -n '1,200p' mcp-client/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
name = "codex-mcp-client"
|
|
||||||
version = { workspace = true }
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
… +13 lines
|
|
||||||
"process",
|
|
||||||
"rt-multi-thread",
|
|
||||||
"sync",
|
|
||||||
"time",
|
|
||||||
] }
|
|
||||||
✓ sed -n '1,200p' mcp-server/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "codex-mcp-server"
|
|
||||||
version = { workspace = true }
|
|
||||||
|
|
||||||
… +35 lines
|
|
||||||
mcp_test_support = { path = "tests/common" }
|
|
||||||
pretty_assertions = "1.4.1"
|
|
||||||
tempfile = "3"
|
|
||||||
tokio-test = "0.4"
|
|
||||||
wiremock = "0.6"
|
|
||||||
✓ sed -n '1,200p' mcp-types/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "mcp-types"
|
|
||||||
version = { workspace = true }
|
|
||||||
|
|
||||||
… +1 lines
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
✓ sed -n '1,200p' ollama/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "codex-ollama"
|
|
||||||
version = { workspace = true }
|
|
||||||
|
|
||||||
… +22 lines
|
|
||||||
tracing = { version = "0.1.41", features = ["log"] }
|
|
||||||
wiremock = "0.6"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
✓ sed -n '1,200p' tui/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "codex-tui"
|
|
||||||
version = { workspace = true }
|
|
||||||
|
|
||||||
… +74 lines
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
insta = "1.43.1"
|
|
||||||
pretty_assertions = "1"
|
|
||||||
rand = "0.8"
|
|
||||||
vt100 = "0.16.2"
|
|
||||||
✓ sed -n '1,200p' chatgpt/Cargo.toml
|
|
||||||
└ [package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "codex-chatgpt"
|
|
||||||
version = { workspace = true }
|
|
||||||
|
|
||||||
… +11 lines
|
|
||||||
serde_json = "1"
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
|
|
||||||
codex
|
|
||||||
Here’s what’s driving size in this workspace’s binaries.
|
|
||||||
|
|
||||||
Main Causes
|
|
||||||
|
|
||||||
- Static linking style: Each bin (codex, codex-tui, codex-exec,
|
|
||||||
codex-mcp-server, etc.) statically links its full dependency graph, so common
|
|
||||||
code isn’t shared at runtime across executables.
|
|
||||||
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors, and a TLS
|
|
||||||
stack (rustls by default; OpenSSL on musl). In core, login, tui, and ollama you
|
|
||||||
enable reqwest with json/stream, which still pulls a large transitive set.
|
|
||||||
- Image/terminal stack: codex-tui includes image (with jpeg), ratatui,
|
|
||||||
crossterm, and ratatui-image, which together contribute significant code size
|
|
||||||
(decoders, pixel ops, terminal backends).
|
|
||||||
- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and
|
|
||||||
starlark (in execpolicy) include sizeable parser/VM tables and runtimes.
|
|
||||||
- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process, signal)
|
|
||||||
across many crates inflate code size even if only subsets are used per bin.
|
|
||||||
- Panic + backtraces: Default panic = unwind and backtrace support keep
|
|
||||||
unwinding tables and symbols that add weight.
|
|
||||||
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl-sys
|
|
||||||
with vendored, compiling OpenSSL into the binary—this adds multiple megabytes
|
|
||||||
per executable.
|
|
||||||
|
|
||||||
Build-Mode Notes
|
|
||||||
|
|
||||||
- Release settings: You use lto = "fat" and codegen-units = 1 (good for size),
|
|
||||||
but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single
|
|
||||||
contributor; if you build in release with that setting, binaries can still be
|
|
||||||
large.
|
|
||||||
- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and
|
|
||||||
assertions—outputs are much larger than cargo build --release.
|
|
||||||
|
|
||||||
If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-level
|
|
||||||
= "z", panic abort, tighter tokio/reqwest features) and estimate impact per
|
|
||||||
binary.
|
|
||||||
Reference in New Issue
Block a user