fix(core/parse,tui): restore jq search + alt scroll

This commit is contained in:
James Peter
2025-09-17 02:00:40 +10:00
parent 4c89393844
commit 8c250e46e6
5 changed files with 340 additions and 93 deletions
+2 -3
View File
@@ -173,11 +173,10 @@ Code supports MCP for extended capabilities:
- **API integrations**: Connect to external services
- **Custom tools**: Build your own extensions
Configure MCP in `~/.codex/config.toml`:
Configure MCP in `~/.codex/config.toml`. Define each server under a named table like `[mcp_servers.<name>]` (this maps to the JSON `mcpServers` object used by other clients):
```toml
[[mcp_servers]]
name = "filesystem"
[mcp_servers.filesystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
```
+179 -90
View File
@@ -207,6 +207,19 @@ mod tests {
);
}
#[test]
fn supports_rg_with_path_and_glob_filter() {
let inner = "rg \"codex-run-as-apply-patch\" -n codex-rs -g\"*.rs\"";
assert_parsed(
&vec_str(&["bash", "-lc", inner]),
vec![ParsedCommand::Search {
cmd: "rg codex-run-as-apply-patch -n codex-rs '-g*.rs'".to_string(),
query: Some("codex-run-as-apply-patch".to_string()),
path: Some("codex-rs".to_string()),
}],
);
}
#[test]
fn supports_rg_files_then_head() {
let inner = "rg --files | head -n 50";
@@ -225,6 +238,30 @@ mod tests {
);
}
#[test]
fn supports_jq_with_basic_filter() {
assert_parsed(
&vec_str(&["jq", ".name, .description", "package.json"]),
vec![ParsedCommand::Search {
cmd: "jq '.name, .description' package.json".to_string(),
query: Some(".name, .description".to_string()),
path: Some("package.json".to_string()),
}],
);
}
#[test]
fn supports_jq_with_directory_path() {
assert_parsed(
&vec_str(&["jq", "-r", ".name, .bin", "codex-cli/package.json"]),
vec![ParsedCommand::Search {
cmd: "jq -r '.name, .bin' codex-cli/package.json".to_string(),
query: Some(".name, .bin".to_string()),
path: Some("codex-cli/package.json".to_string()),
}],
);
}
#[test]
fn supports_cat() {
let inner = "cat webview/README.md";
@@ -1171,6 +1208,52 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>
(query, path)
}
fn parse_jq_filter_and_path(args: &[String]) -> (Option<String>, Option<String>) {
let mut query: Option<String> = None;
let mut path: Option<String> = None;
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "--" {
// Treat everything after `--` as positional operands.
i += 1;
while i < args.len() {
let positional = &args[i];
if query.is_none() {
query = Some(positional.clone());
} else if path.is_none() {
path = Some(positional.clone());
break;
}
i += 1;
}
break;
}
if arg.starts_with('-') {
let flag = arg.as_str();
let mut skip = match flag {
"-f" | "--from-file" | "-L" | "--module-path" | "--indent" => 1,
"--arg" | "--argjson" | "--argfile" | "--rawfile" | "--slurpfile" => 2,
_ => 0,
};
i += 1;
while skip > 0 && i < args.len() {
i += 1;
skip -= 1;
}
continue;
}
if query.is_none() {
query = Some(arg.clone());
} else if path.is_none() {
path = Some(arg.clone());
break;
}
i += 1;
}
(query, path)
}
fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
let [bash, flag, script] = original else {
return None;
@@ -1180,111 +1263,108 @@ fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
}
if let Some(tree) = try_parse_bash(script) {
if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) {
if !all_commands.is_empty() {
let script_tokens = shlex_split(script)
.unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]);
// Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
// bias toward the primary command when pipelines are present.
// First, drop obvious small formatting helpers (e.g., wc/awk/etc).
let had_multiple_commands = all_commands.len() > 1;
// The bash AST walker yields commands in right-to-left order for
// connector/pipeline sequences. Reverse to reflect actual execution order.
let mut filtered_commands = drop_small_formatting_commands(all_commands);
filtered_commands.reverse();
if filtered_commands.is_empty() {
return Some(vec![ParsedCommand::Unknown {
cmd: script.clone(),
}]);
}
let mut commands: Vec<ParsedCommand> = filtered_commands
.into_iter()
.map(|tokens| summarize_main_tokens(&tokens))
.collect();
if commands.len() > 1 {
commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
// Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`.
while let Some(next) = simplify_once(&commands) {
commands = next;
if all_commands.is_empty() {
return None;
}
}
if commands.len() == 1 {
// If we reduced to a single command, attribute the full original script
// for clearer UX in file-reading and listing scenarios, or when there were
// no connectors in the original script. For search commands that came from
// a pipeline (e.g. `rg --files | sed -n`), keep only the primary command.
let had_connectors = had_multiple_commands
|| script_tokens
.iter()
.any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
commands = commands
let script_tokens = shlex_split(script)
.unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]);
// Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
// bias toward the primary command when pipelines are present.
let had_multiple_commands = all_commands.len() > 1;
// The bash AST walker yields commands in right-to-left order for
// connector/pipeline sequences. Reverse to reflect actual execution order.
let mut filtered_commands = drop_small_formatting_commands(all_commands);
filtered_commands.reverse();
if filtered_commands.is_empty() {
return None;
}
let mut commands: Vec<ParsedCommand> = filtered_commands
.into_iter()
.map(|pc| match pc {
ParsedCommand::Read { name, cmd, .. } => {
if had_connectors {
let has_pipe = script_tokens.iter().any(|t| t == "|");
let has_sed_n = script_tokens.windows(2).any(|w| {
w.first().map(|s| s.as_str()) == Some("sed")
&& w.get(1).map(|s| s.as_str()) == Some("-n")
});
if has_pipe && has_sed_n {
ParsedCommand::Read {
cmd: script.clone(),
name,
.map(|tokens| summarize_main_tokens(&tokens))
.collect();
if commands.len() > 1 {
commands
.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
// Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`.
while let Some(next) = simplify_once(&commands) {
commands = next;
}
}
if commands.len() == 1 {
// If we reduced to a single command, attribute the full original script
// for clearer UX in file-reading and listing scenarios, or when there were
// no connectors in the original script. For search commands that came from
// a pipeline (e.g. `rg --files | sed -n`), keep only the primary command.
let had_connectors = had_multiple_commands
|| script_tokens
.iter()
.any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
commands = commands
.into_iter()
.map(|pc| match pc {
ParsedCommand::Read { name, cmd, .. } => {
if had_connectors {
let has_pipe = script_tokens.iter().any(|t| t == "|");
let has_sed_n = script_tokens.windows(2).any(|w| {
w.first().map(|s| s.as_str()) == Some("sed")
&& w.get(1).map(|s| s.as_str()) == Some("-n")
});
if has_pipe && has_sed_n {
ParsedCommand::Read {
cmd: script.clone(),
name,
}
} else {
ParsedCommand::Read {
cmd: cmd.clone(),
name,
}
}
} else {
ParsedCommand::Read {
cmd: cmd.clone(),
cmd: shlex_join(&script_tokens),
name,
}
}
} else {
ParsedCommand::Read {
cmd: shlex_join(&script_tokens),
name,
}
ParsedCommand::ListFiles { path, cmd, .. } => {
if had_connectors {
ParsedCommand::ListFiles {
cmd: cmd.clone(),
path,
}
} else {
ParsedCommand::ListFiles {
cmd: shlex_join(&script_tokens),
path,
}
}
}
}
ParsedCommand::ListFiles { path, cmd, .. } => {
if had_connectors {
ParsedCommand::ListFiles {
cmd: cmd.clone(),
path,
}
} else {
ParsedCommand::ListFiles {
cmd: shlex_join(&script_tokens),
path,
ParsedCommand::Search {
query, path, cmd, ..
} => {
if had_connectors {
ParsedCommand::Search {
cmd: cmd.clone(),
query,
path,
}
} else {
ParsedCommand::Search {
cmd: shlex_join(&script_tokens),
query,
path,
}
}
}
}
ParsedCommand::Search {
query, path, cmd, ..
} => {
if had_connectors {
ParsedCommand::Search {
cmd: cmd.clone(),
query,
path,
}
} else {
ParsedCommand::Search {
cmd: shlex_join(&script_tokens),
query,
path,
}
}
}
other => other,
})
.collect();
}
return Some(commands);
other => other,
})
.collect();
}
return Some(commands);
}
}
Some(vec![ParsedCommand::Unknown {
cmd: script.clone(),
}])
None
}
/// Return true if this looks like a small formatting helper in a pipeline.
@@ -1406,6 +1486,15 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
path,
}
}
Some((head, tail)) if head == "jq" => {
let args_no_connector = trim_at_connector(tail);
let (query, path) = parse_jq_filter_and_path(&args_no_connector);
ParsedCommand::Search {
cmd: shlex_join(main_cmd),
query,
path,
}
}
Some((head, tail)) if head == "cat" => {
// Support both `cat <file>` and `cat -- <file>` forms.
let effective_tail: &[String] = if tail.first().map(|s| s.as_str()) == Some("--") {
+105
View File
@@ -9564,6 +9564,111 @@ impl ChatWidget<'_> {
}
}
let local_default_ref = format!("refs/heads/{}", default_branch);
let local_default_exists = Command::new("git")
.current_dir(&git_root)
.args([
"rev-parse",
"--verify",
"--quiet",
&local_default_ref,
])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if local_default_exists {
let ff_local = Command::new("git")
.current_dir(&work_cwd)
.args(["merge", "--ff-only", &local_default_ref])
.output()
.await;
if !matches!(ff_local, Ok(ref o) if o.status.success()) {
let merge_local = Command::new("git")
.current_dir(&work_cwd)
.args(["merge", "--no-ff", "--no-commit", &local_default_ref])
.output()
.await;
if let Ok(out) = merge_local {
if out.status.success() {
let _ = Command::new("git")
.current_dir(&work_cwd)
.args([
"commit",
"-m",
&format!(
"merge local {} into {} before merge",
default_branch, branch_label
),
])
.output()
.await;
} else {
let updated_worktree_status = ChatWidget::git_short_status(&work_cwd)
.await
.map(|s| {
if s.trim().is_empty() {
"clean".to_string()
} else {
s
}
})
.unwrap_or_else(|err| format!("status unavailable: {}", err));
let updated_diff = ChatWidget::git_diff_stat(&work_cwd)
.await
.ok()
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty())
.or(worktree_diff_stat.clone());
send_agent_handoff(
vec![format!(
"merge conflicts while merging local '{}' into '{}'",
default_branch, branch_label
)],
Some(
"The worktree currently has an in-progress merge that needs to be resolved. Please complete it before retrying the final merge.".to_string(),
),
updated_worktree_status,
repo_status_for_agent.clone(),
updated_diff,
);
return;
}
} else {
let updated_worktree_status = ChatWidget::git_short_status(&work_cwd)
.await
.map(|s| {
if s.trim().is_empty() {
"clean".to_string()
} else {
s
}
})
.unwrap_or_else(|err| format!("status unavailable: {}", err));
let updated_diff = ChatWidget::git_diff_stat(&work_cwd)
.await
.ok()
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty())
.or(worktree_diff_stat.clone());
send_agent_handoff(
vec![format!(
"failed to merge local '{}' into '{}'",
default_branch, branch_label
)],
None,
updated_worktree_status,
repo_status_for_agent.clone(),
updated_diff,
);
return;
}
}
}
let on_default = match Command::new("git")
.current_dir(&git_root)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
+35
View File
@@ -940,6 +940,7 @@ pub(crate) struct ExecCell {
cached_out_lines: std::cell::RefCell<Option<Vec<Line<'static>>>>,
// Cached per-width wrap totals (rows) for finalized execs
cached_wrap: std::cell::RefCell<Option<ExecWrapCache>>,
has_bold_command: bool,
}
// Cache of wrapped-row totals for ExecCell at a given width.
@@ -1403,6 +1404,24 @@ impl HistoryCell for ExecCell {
};
HistoryCellType::Exec { kind, status }
}
fn gutter_symbol(&self) -> Option<&'static str> {
match self.kind() {
HistoryCellType::Exec {
kind: ExecKind::Run,
status,
} => {
if matches!(status, ExecStatus::Error) {
Some("✖")
} else if self.has_bold_command {
Some("❯")
} else {
None
}
}
HistoryCellType::Exec { .. } => None,
_ => None,
}
}
fn display_lines(&self) -> Vec<Line<'static>> {
// Fallback textual representation (used for height measurement only when custom rendering).
// For completed executions, cache the computed lines since they are immutable.
@@ -5397,6 +5416,20 @@ pub(crate) fn new_completed_exec_command(
new_exec_cell(command, parsed, Some(output))
}
fn command_has_bold_token(command: &[String]) -> bool {
let command_escaped = strip_bash_lc_and_escape(command);
let normalized = normalize_shell_command_display(&command_escaped);
let trimmed = normalized.trim_start();
if trimmed.is_empty() {
return false;
}
trimmed
.chars()
.take_while(|ch| !ch.is_whitespace())
.count()
> 4
}
fn new_exec_cell(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
@@ -5407,6 +5440,7 @@ fn new_exec_cell(
} else {
None
};
let has_bold_command = command_has_bold_token(&command);
ExecCell {
command,
parsed,
@@ -5418,6 +5452,7 @@ fn new_exec_cell(
cached_pre_lines: std::cell::RefCell::new(None),
cached_out_lines: std::cell::RefCell::new(None),
cached_wrap: std::cell::RefCell::new(None),
has_bold_command,
}
}
+19
View File
@@ -2,6 +2,7 @@ use std::io::Result;
use std::io::Stdout;
use std::io::stdout;
use std::io::BufWriter;
use std::io::Write;
use codex_core::config::Config;
use crossterm::cursor::MoveTo;
@@ -76,6 +77,7 @@ pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
crate::syntax_highlight::init_highlight_from_config(&config.tui.highlight);
execute!(stdout(), EnableBracketedPaste)?;
enable_alternate_scroll_mode()?;
// Enable focus change events so we can detect when the terminal window/tab
// regains focus and proactively repaint the UI (helps terminals that clear
// their alt‑screen buffer while unfocused). However, certain environments
@@ -247,6 +249,7 @@ pub fn restore() -> Result<()> {
let _ = execute!(stdout(), PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::empty()));
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
}
disable_alternate_scroll_mode()?;
execute!(stdout(), DisableBracketedPaste)?;
// Best‑effort: disable focus change notifications if supported.
let _ = execute!(stdout(), DisableFocusChange);
@@ -270,6 +273,7 @@ pub fn leave_alt_screen_only() -> Result<()> {
// being echoed into the normal buffer by some terminals.
let _ = execute!(stdout(), DisableBracketedPaste);
let _ = execute!(stdout(), DisableFocusChange);
let _ = disable_alternate_scroll_mode();
// Pop keyboard enhancement flags so keys like Enter/Arrows don't emit
// enhanced escape sequences (e.g., kitty/xterm modifyOtherKeys) into the buffer.
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
@@ -299,6 +303,7 @@ pub fn enter_alt_screen_only(theme_fg: ratatui::style::Color, theme_bg: ratatui:
let _ = execute!(stdout(), EnableFocusChange);
}
let _ = execute!(stdout(), EnableBracketedPaste);
let _ = enable_alternate_scroll_mode();
execute!(
stdout(),
crossterm::terminal::EnterAlternateScreen,
@@ -311,6 +316,20 @@ pub fn enter_alt_screen_only(theme_fg: ratatui::style::Color, theme_bg: ratatui:
Ok(())
}
fn enable_alternate_scroll_mode() -> Result<()> {
let mut handle = stdout();
handle.write_all(b"\x1b[?1007h")?;
handle.flush()?;
Ok(())
}
fn disable_alternate_scroll_mode() -> Result<()> {
let mut handle = stdout();
handle.write_all(b"\x1b[?1007l")?;
handle.flush()?;
Ok(())
}
/// Clear the current screen (normal buffer) with the theme background and reset cursor.
// Removed: clear_screen_with_theme — we no longer hard-clear the normal buffer in terminal mode.