mirror of
https://github.com/openai/codex.git
synced 2026-02-02 23:13:37 +00:00
Compare commits
6 Commits
dev/cc/new
...
daniel/exp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e59ad2687e | ||
|
|
bf0fb63e5f | ||
|
|
259c2b1287 | ||
|
|
fb7a5b591a | ||
|
|
4217f624df | ||
|
|
5d66eb2a1c |
@@ -194,6 +194,9 @@ pub struct Config {
|
||||
/// All characters are inserted as they are received, and no buffering
|
||||
/// or placeholder replacement will occur for fast keypress bursts.
|
||||
pub disable_paste_burst: bool,
|
||||
|
||||
/// Experimental feature flags for the TUI loaded from `[tui.experimental]`.
|
||||
pub experimental_flags: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -1053,6 +1056,12 @@ impl Config {
|
||||
.as_ref()
|
||||
.map(|t| t.notifications.clone())
|
||||
.unwrap_or_default(),
|
||||
experimental_flags: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
.and_then(|t| t.experimental.as_ref())
|
||||
.map(|e| e.flags.clone())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -1631,6 +1640,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("o3".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
experimental_flags: HashMap::new(),
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -1689,6 +1699,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("gpt3".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
experimental_flags: HashMap::new(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -1762,6 +1773,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("zdr".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
experimental_flags: HashMap::new(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
@@ -1821,6 +1833,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("gpt5".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
experimental_flags: HashMap::new(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
|
||||
|
||||
@@ -212,11 +212,77 @@ fn remove_toml_edit_segments(doc: &mut DocumentMut, segments: &[&str]) -> bool {
|
||||
current.remove(segments[segments.len() - 1]).is_some()
|
||||
}
|
||||
|
||||
/// Persist boolean overrides into `config.toml`. Keys are specified by explicit
|
||||
/// segments (e.g., `["tui", "experimental", "my-flag"]`). When a profile is
|
||||
/// active, values are written under `profiles.<name>.…` unless the provided
|
||||
/// segments already start with `profiles`.
|
||||
pub async fn persist_bool_overrides(
|
||||
codex_home: &Path,
|
||||
profile: Option<&str>,
|
||||
overrides: &[(&[&str], bool)],
|
||||
) -> Result<()> {
|
||||
if overrides.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let read_result = tokio::fs::read_to_string(&config_path).await;
|
||||
let mut doc = match read_result {
|
||||
Ok(contents) => contents.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tokio::fs::create_dir_all(codex_home).await?;
|
||||
DocumentMut::new()
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let effective_profile = if let Some(p) = profile {
|
||||
Some(p.to_owned())
|
||||
} else {
|
||||
doc.get("profile")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
let mut mutated = false;
|
||||
for (segments, value) in overrides.iter().copied() {
|
||||
let mut seg_buf: Vec<&str> = Vec::new();
|
||||
let segments_to_apply: &[&str];
|
||||
if let Some(ref name) = effective_profile {
|
||||
if segments.first().copied() == Some("profiles") {
|
||||
segments_to_apply = segments;
|
||||
} else {
|
||||
seg_buf.reserve(2 + segments.len());
|
||||
seg_buf.push("profiles");
|
||||
seg_buf.push(name.as_str());
|
||||
seg_buf.extend_from_slice(segments);
|
||||
segments_to_apply = seg_buf.as_slice();
|
||||
}
|
||||
} else {
|
||||
segments_to_apply = segments;
|
||||
}
|
||||
|
||||
let item_value = toml_edit::value(value);
|
||||
apply_toml_edit_override_segments(&mut doc, segments_to_apply, item_value);
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tmp_file = NamedTempFile::new_in(codex_home)?;
|
||||
tokio::fs::write(tmp_file.path(), doc.to_string()).await?;
|
||||
tmp_file.persist(config_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// Verifies model and effort are written at top-level when no profile is set.
|
||||
#[tokio::test]
|
||||
@@ -242,6 +308,69 @@ model_reasoning_effort = "high"
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
// `read_config` helper is defined at the bottom of this tests module.
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_bool_overrides_top_level_tui_experimental() {
|
||||
let tmpdir = tempdir().expect("tmp");
|
||||
let codex_home = tmpdir.path();
|
||||
|
||||
persist_bool_overrides(
|
||||
codex_home,
|
||||
None,
|
||||
&[
|
||||
(&["tui", "experimental", "feat-a"], true),
|
||||
(&["tui", "experimental", "feat-b"], false),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("persist bool overrides");
|
||||
|
||||
let contents = read_config(codex_home).await;
|
||||
let parsed: TomlValue = toml::from_str(&contents).expect("valid toml");
|
||||
let feat_a = parsed
|
||||
.get("tui")
|
||||
.and_then(|t| t.get("experimental"))
|
||||
.and_then(|e| e.get("feat-a"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap();
|
||||
let feat_b = parsed
|
||||
.get("tui")
|
||||
.and_then(|t| t.get("experimental"))
|
||||
.and_then(|e| e.get("feat-b"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap();
|
||||
assert!(feat_a);
|
||||
assert!(!feat_b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_bool_overrides_profile_tui_experimental() {
|
||||
let tmpdir = tempdir().expect("tmp");
|
||||
let codex_home = tmpdir.path();
|
||||
|
||||
// Persist under the "dev" profile.
|
||||
persist_bool_overrides(
|
||||
codex_home,
|
||||
Some("dev"),
|
||||
&[(&["tui", "experimental", "alpha"], true)],
|
||||
)
|
||||
.await
|
||||
.expect("persist bool overrides");
|
||||
|
||||
let contents = read_config(codex_home).await;
|
||||
let parsed: TomlValue = toml::from_str(&contents).expect("valid toml");
|
||||
let alpha = parsed
|
||||
.get("profiles")
|
||||
.and_then(|p| p.get("dev"))
|
||||
.and_then(|d| d.get("tui"))
|
||||
.and_then(|t| t.get("experimental"))
|
||||
.and_then(|e| e.get("alpha"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap();
|
||||
assert!(alpha);
|
||||
}
|
||||
|
||||
/// Verifies values are written under the active profile when `profile` is set.
|
||||
#[tokio::test]
|
||||
async fn set_defaults_update_profile_when_profile_set() {
|
||||
|
||||
@@ -96,6 +96,18 @@ pub struct Tui {
|
||||
/// Defaults to `false`.
|
||||
#[serde(default)]
|
||||
pub notifications: Notifications,
|
||||
|
||||
/// Experimental feature flags scoped to the TUI. Keys are arbitrary
|
||||
/// kebab-case strings and values are booleans.
|
||||
#[serde(default)]
|
||||
pub experimental: Option<TuiExperimental>,
|
||||
}
|
||||
|
||||
/// Flattened map of experimental feature flags under the `[tui.experimental]` table.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct TuiExperimental {
|
||||
#[serde(default, flatten)]
|
||||
pub flags: std::collections::HashMap<String, bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
|
||||
@@ -202,6 +202,11 @@ async fn get_default_branch(cwd: &Path) -> Option<String> {
|
||||
}
|
||||
|
||||
// No remote-derived default; try common local defaults if they exist
|
||||
get_default_branch_local(cwd).await
|
||||
}
|
||||
|
||||
/// Attempt to determine the repository's default branch name from local branches.
|
||||
async fn get_default_branch_local(cwd: &Path) -> Option<String> {
|
||||
for candidate in ["main", "master"] {
|
||||
if let Some(verify) = run_git_command_with_timeout(
|
||||
&[
|
||||
@@ -485,6 +490,46 @@ pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
|
||||
git_dir_path.parent().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Returns a list of local git branches.
|
||||
/// Includes the default branch at the beginning of the list, if it exists.
|
||||
pub async fn local_git_branches(cwd: &Path) -> Vec<String> {
|
||||
let mut branches: Vec<String> = if let Some(out) =
|
||||
run_git_command_with_timeout(&["branch", "--format=%(refname:short)"], cwd).await
|
||||
&& out.status.success()
|
||||
{
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
branches.sort_unstable();
|
||||
|
||||
if let Some(base) = get_default_branch_local(cwd).await
|
||||
&& let Some(pos) = branches.iter().position(|name| name == &base)
|
||||
{
|
||||
let base_branch = branches.remove(pos);
|
||||
branches.insert(0, base_branch);
|
||||
}
|
||||
|
||||
branches
|
||||
}
|
||||
|
||||
/// Returns the current checked out branch name.
|
||||
pub async fn current_branch_name(cwd: &Path) -> Option<String> {
|
||||
let out = run_git_command_with_timeout(&["branch", "--show-current"], cwd).await?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(out.stdout)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -355,6 +355,68 @@ impl App {
|
||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||
self.chat_widget.set_sandbox_policy(policy);
|
||||
}
|
||||
AppEvent::OpenReviewBranchPicker(cwd) => {
|
||||
self.chat_widget.show_review_branch_picker(&cwd).await;
|
||||
}
|
||||
AppEvent::OpenReviewCustomPrompt => {
|
||||
self.chat_widget.show_review_custom_prompt();
|
||||
}
|
||||
AppEvent::UpdateExperimentalFlags(flags) => {
|
||||
self.config.experimental_flags = flags.clone();
|
||||
self.chat_widget.set_experimental_flags(flags.clone());
|
||||
self.chat_widget
|
||||
.add_info_message("Experimental features updated".to_string(), None);
|
||||
}
|
||||
AppEvent::PersistExperimentalFlags(flags) => {
|
||||
use codex_core::config_edit::persist_bool_overrides;
|
||||
// Persist into [tui.experimental]
|
||||
let profile = self.active_profile.as_deref();
|
||||
let mut entries: Vec<(Vec<String>, bool)> = Vec::new();
|
||||
for (k, v) in flags.iter() {
|
||||
entries.push((
|
||||
vec!["tui".to_string(), "experimental".to_string(), k.clone()],
|
||||
*v,
|
||||
));
|
||||
}
|
||||
// Convert to segments for the helper
|
||||
let segs: Vec<(Vec<&str>, bool)> = entries
|
||||
.iter()
|
||||
.map(|(p, v)| (p.iter().map(|s| s.as_str()).collect::<Vec<&str>>(), *v))
|
||||
.collect();
|
||||
|
||||
// Transform to borrowed tuples
|
||||
let borrowed: Vec<(&[&str], bool)> =
|
||||
segs.iter().map(|(v, b)| (v.as_slice(), *b)).collect();
|
||||
|
||||
match persist_bool_overrides(&self.config.codex_home, profile, &borrowed).await {
|
||||
Ok(()) => {
|
||||
let count = flags.len();
|
||||
if let Some(p) = profile {
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Saved {count} experimental flags for profile {p}"),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Saved {count} experimental flags"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("failed to persist experimental flags: {err}");
|
||||
if let Some(p) = profile {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save experimental flags for profile `{p}`: {err}"
|
||||
));
|
||||
} else {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save experimental flags: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -7,6 +9,7 @@ use crate::history_cell::HistoryCell;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
@@ -65,4 +68,16 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Forwarded conversation history snapshot from the current conversation.
|
||||
ConversationHistory(ConversationPathResponseEvent),
|
||||
|
||||
/// Open the branch picker option from the review popup.
|
||||
OpenReviewBranchPicker(PathBuf),
|
||||
|
||||
/// Open the custom prompt option from the review popup.
|
||||
OpenReviewCustomPrompt,
|
||||
|
||||
/// Update the current experimental feature flags in memory.
|
||||
UpdateExperimentalFlags(HashMap<String, bool>),
|
||||
|
||||
/// Persist experimental feature flags into config.toml under [tui.experimental].
|
||||
PersistExperimentalFlags(HashMap<String, bool>),
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane));
|
||||
assert!(view.queue.is_empty());
|
||||
|
||||
@@ -28,6 +28,17 @@ pub(crate) trait BottomPaneView {
|
||||
/// Render the view: this will be displayed in place of the composer.
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
|
||||
/// Optional paste handler. Return true if the view modified its state and
|
||||
/// needs a redraw.
|
||||
fn handle_paste(&mut self, _pane: &mut BottomPane, _pasted: String) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Cursor position when this view is active.
|
||||
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to handle approval request; return the original value if not
|
||||
/// consumed.
|
||||
fn try_consume_approval_request(
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) struct ChatComposer {
|
||||
// When true, disables paste-burst logic and inserts characters immediately.
|
||||
disable_paste_burst: bool,
|
||||
custom_prompts: Vec<CustomPrompt>,
|
||||
include_comment_command: bool,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -107,6 +108,7 @@ impl ChatComposer {
|
||||
enhanced_keys_supported: bool,
|
||||
placeholder_text: String,
|
||||
disable_paste_burst: bool,
|
||||
include_comment_command: bool,
|
||||
) -> Self {
|
||||
let use_shift_enter_hint = enhanced_keys_supported;
|
||||
|
||||
@@ -130,6 +132,7 @@ impl ChatComposer {
|
||||
paste_burst: PasteBurst::default(),
|
||||
disable_paste_burst: false,
|
||||
custom_prompts: Vec::new(),
|
||||
include_comment_command,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -1157,7 +1160,10 @@ impl ChatComposer {
|
||||
}
|
||||
_ => {
|
||||
if input_starts_with_slash {
|
||||
let mut command_popup = CommandPopup::new(self.custom_prompts.clone());
|
||||
let mut command_popup = CommandPopup::new(
|
||||
self.custom_prompts.clone(),
|
||||
self.include_comment_command,
|
||||
);
|
||||
command_popup.on_composer_text_change(first_line.to_string());
|
||||
self.active_popup = ActivePopup::Command(command_popup);
|
||||
}
|
||||
@@ -1172,6 +1178,15 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_include_comment_command(&mut self, include: bool) {
|
||||
self.include_comment_command = include;
|
||||
// If the command popup is open, rebuild it to reflect the new builtins set.
|
||||
if let ActivePopup::Command(_) = &self.active_popup {
|
||||
// Re-sync based on current text to rebuild.
|
||||
self.sync_command_popup();
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
||||
/// Note this is only called when self.active_popup is NOT Command.
|
||||
fn sync_file_search_popup(&mut self) {
|
||||
@@ -1392,6 +1407,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
@@ -1599,6 +1615,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let needs_redraw = composer.handle_paste("hello".to_string());
|
||||
@@ -1628,6 +1645,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Ensure composer is empty and press Enter.
|
||||
@@ -1655,6 +1673,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
||||
@@ -1690,6 +1709,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
composer.handle_paste(large);
|
||||
@@ -1731,6 +1751,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
if let Some(text) = input {
|
||||
@@ -1774,6 +1795,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Type "/mo" humanlike so paste-burst doesn’t interfere.
|
||||
@@ -1802,6 +1824,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
||||
|
||||
@@ -1845,6 +1868,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Type the slash command.
|
||||
@@ -1882,6 +1906,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'c']);
|
||||
@@ -1907,6 +1932,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']);
|
||||
@@ -1942,6 +1968,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Define test cases: (paste content, is_large)
|
||||
@@ -2021,6 +2048,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Define test cases: (content, is_large)
|
||||
@@ -2093,6 +2121,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Define test cases: (cursor_position_from_end, expected_pending_count)
|
||||
@@ -2141,6 +2170,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
composer.attach_image(path.clone(), 32, 16, "PNG");
|
||||
@@ -2165,6 +2195,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image2.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
@@ -2190,6 +2221,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image3.png");
|
||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||
@@ -2231,6 +2263,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Insert an image placeholder at the start
|
||||
@@ -2257,6 +2290,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||||
@@ -2304,6 +2338,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
||||
@@ -2326,6 +2361,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Inject prompts as if received via event.
|
||||
@@ -2360,6 +2396,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let count = 32;
|
||||
@@ -2404,6 +2441,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder
|
||||
@@ -2436,6 +2474,7 @@ mod tests {
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config
|
||||
|
||||
@@ -28,8 +28,8 @@ pub(crate) struct CommandPopup {
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>) -> Self {
|
||||
let builtins = built_in_slash_commands();
|
||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, include_comment: bool) -> Self {
|
||||
let builtins = built_in_slash_commands(include_comment);
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||
prompts.retain(|p| !exclude.contains(&p.name));
|
||||
@@ -221,7 +221,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn filter_includes_init_when_typing_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new());
|
||||
let mut popup = CommandPopup::new(Vec::new(), false);
|
||||
// Simulate the composer line starting with '/in' so the popup filters
|
||||
// matching commands by prefix.
|
||||
popup.on_composer_text_change("/in".to_string());
|
||||
@@ -241,7 +241,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selecting_init_by_exact_match() {
|
||||
let mut popup = CommandPopup::new(Vec::new());
|
||||
let mut popup = CommandPopup::new(Vec::new(), false);
|
||||
popup.on_composer_text_change("/init".to_string());
|
||||
|
||||
// When an exact match exists, the selected command should be that
|
||||
@@ -256,7 +256,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn model_is_first_suggestion_for_mo() {
|
||||
let mut popup = CommandPopup::new(Vec::new());
|
||||
let mut popup = CommandPopup::new(Vec::new(), false);
|
||||
popup.on_composer_text_change("/mo".to_string());
|
||||
let matches = popup.filtered_items();
|
||||
match matches.first() {
|
||||
@@ -282,7 +282,7 @@ mod tests {
|
||||
content: "hello from bar".to_string(),
|
||||
},
|
||||
];
|
||||
let popup = CommandPopup::new(prompts);
|
||||
let popup = CommandPopup::new(prompts, false);
|
||||
let items = popup.filtered_items();
|
||||
let mut prompt_names: Vec<String> = items
|
||||
.into_iter()
|
||||
@@ -298,11 +298,14 @@ mod tests {
|
||||
#[test]
|
||||
fn prompt_name_collision_with_builtin_is_ignored() {
|
||||
// Create a prompt named like a builtin (e.g. "init").
|
||||
let popup = CommandPopup::new(vec![CustomPrompt {
|
||||
name: "init".to_string(),
|
||||
path: "/tmp/init.md".to_string().into(),
|
||||
content: "should be ignored".to_string(),
|
||||
}]);
|
||||
let popup = CommandPopup::new(
|
||||
vec![CustomPrompt {
|
||||
name: "init".to_string(),
|
||||
path: "/tmp/init.md".to_string().into(),
|
||||
content: "should be ignored".to_string(),
|
||||
}],
|
||||
false,
|
||||
);
|
||||
let items = popup.filtered_items();
|
||||
let has_collision_prompt = items.into_iter().any(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup.prompt_name(i) == Some("init"),
|
||||
@@ -313,4 +316,30 @@ mod tests {
|
||||
"prompt with builtin name should be ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_hidden_by_default() {
|
||||
let popup = CommandPopup::new(Vec::new(), false);
|
||||
let items = popup.filtered_items();
|
||||
let has_comment = items.into_iter().any(|it| match it {
|
||||
CommandItem::Builtin(cmd) => cmd.command() == "comment",
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
!has_comment,
|
||||
"/comment should be hidden when feature disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_visible_when_enabled() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), true);
|
||||
popup.on_composer_text_change("/comm".to_string());
|
||||
let items = popup.filtered_items();
|
||||
let has_comment = items.into_iter().any(|it| match it {
|
||||
CommandItem::Builtin(cmd) => cmd.command() == "comment",
|
||||
_ => false,
|
||||
});
|
||||
assert!(has_comment, "/comment should be shown when feature enabled");
|
||||
}
|
||||
}
|
||||
|
||||
238
codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
238
codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::textarea::TextArea;
|
||||
use super::textarea::TextAreaState;
|
||||
|
||||
/// Callback invoked when the user submits a custom prompt.
|
||||
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
/// Minimal multi-line text input view to collect custom review instructions.
|
||||
pub(crate) struct CustomPromptView {
|
||||
title: String,
|
||||
placeholder: String,
|
||||
context_label: Option<String>,
|
||||
on_submit: PromptSubmitted,
|
||||
|
||||
// UI state
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl CustomPromptView {
|
||||
pub(crate) fn new(
|
||||
title: String,
|
||||
placeholder: String,
|
||||
context_label: Option<String>,
|
||||
on_submit: PromptSubmitted,
|
||||
) -> Self {
|
||||
Self {
|
||||
title,
|
||||
placeholder,
|
||||
context_label,
|
||||
on_submit,
|
||||
textarea: TextArea::new(),
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
complete: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for CustomPromptView {
|
||||
fn handle_key_event(&mut self, _pane: &mut super::BottomPane, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
let text = self.textarea.text().trim().to_string();
|
||||
if !text.is_empty() {
|
||||
(self.on_submit)(text);
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
..
|
||||
} => {
|
||||
self.textarea.input(key_event);
|
||||
}
|
||||
other => {
|
||||
self.textarea.input(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||
1u16 + extra_top + self.input_height(width) + 3u16
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let input_height = self.input_height(area.width);
|
||||
|
||||
// Title line
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let title_spans: Vec<Span<'static>> = vec![gutter(), self.title.clone().bold()];
|
||||
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
|
||||
|
||||
// Optional context line
|
||||
let mut input_y = area.y.saturating_add(1);
|
||||
if let Some(context_label) = &self.context_label {
|
||||
let context_area = Rect {
|
||||
x: area.x,
|
||||
y: input_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let spans: Vec<Span<'static>> = vec![gutter(), context_label.clone().cyan()];
|
||||
Paragraph::new(Line::from(spans)).render(context_area, buf);
|
||||
input_y = input_y.saturating_add(1);
|
||||
}
|
||||
|
||||
// Input line
|
||||
let input_area = Rect {
|
||||
x: area.x,
|
||||
y: input_y,
|
||||
width: area.width,
|
||||
height: input_height,
|
||||
};
|
||||
if input_area.width >= 2 {
|
||||
for row in 0..input_area.height {
|
||||
Paragraph::new(Line::from(vec![gutter()])).render(
|
||||
Rect {
|
||||
x: input_area.x,
|
||||
y: input_area.y.saturating_add(row),
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
let text_area_height = input_area.height.saturating_sub(1);
|
||||
if text_area_height > 0 {
|
||||
if input_area.width > 2 {
|
||||
let blank_rect = Rect {
|
||||
x: input_area.x.saturating_add(2),
|
||||
y: input_area.y,
|
||||
width: input_area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
Clear.render(blank_rect, buf);
|
||||
}
|
||||
let textarea_rect = Rect {
|
||||
x: input_area.x.saturating_add(2),
|
||||
y: input_area.y.saturating_add(1),
|
||||
width: input_area.width.saturating_sub(2),
|
||||
height: text_area_height,
|
||||
};
|
||||
let mut state = self.textarea_state.borrow_mut();
|
||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||
if self.textarea.text().is_empty() {
|
||||
Paragraph::new(Line::from(self.placeholder.clone().dim()))
|
||||
.render(textarea_rect, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hint_blank_y = input_area.y.saturating_add(input_height);
|
||||
if hint_blank_y < area.y.saturating_add(area.height) {
|
||||
let blank_area = Rect {
|
||||
x: area.x,
|
||||
y: hint_blank_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Clear.render(blank_area, buf);
|
||||
}
|
||||
|
||||
let hint_y = hint_blank_y.saturating_add(1);
|
||||
if hint_y < area.y.saturating_add(area.height) {
|
||||
Paragraph::new(STANDARD_POPUP_HINT_LINE).render(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: hint_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, _pane: &mut super::BottomPane, pasted: String) -> bool {
|
||||
if pasted.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.textarea.insert_str(&pasted);
|
||||
true
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if area.height < 2 || area.width <= 2 {
|
||||
return None;
|
||||
}
|
||||
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
||||
if text_area_height == 0 {
|
||||
return None;
|
||||
}
|
||||
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||
let top_line_count = 1u16 + extra_offset;
|
||||
let textarea_rect = Rect {
|
||||
x: area.x.saturating_add(2),
|
||||
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: text_area_height,
|
||||
};
|
||||
let state = self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, &state)
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomPromptView {
|
||||
fn input_height(&self, width: u16) -> u16 {
|
||||
let usable_width = width.saturating_sub(2);
|
||||
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
|
||||
text_height.saturating_add(1).min(9)
|
||||
}
|
||||
}
|
||||
|
||||
fn gutter() -> Span<'static> {
|
||||
"▌ ".cyan()
|
||||
}
|
||||
@@ -23,11 +23,35 @@ use super::selection_popup_common::render_rows;
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
/// Callback invoked when a multi‑select view is accepted.
|
||||
/// The provided `Vec<usize>` contains the indices (into the `items` vector)
|
||||
/// of all entries that are currently checked.
|
||||
pub(crate) type MultiSelectAcceptAction = Box<dyn Fn(&AppEventSender, &Vec<usize>) + Send + Sync>;
|
||||
|
||||
pub(crate) struct SelectionItem {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub actions: Vec<SelectionAction>,
|
||||
pub dismiss_on_select: bool,
|
||||
pub search_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SelectionViewParams {
|
||||
pub title: String,
|
||||
pub subtitle: Option<String>,
|
||||
pub footer_hint: Option<String>,
|
||||
pub items: Vec<SelectionItem>,
|
||||
pub is_searchable: bool,
|
||||
pub search_placeholder: Option<String>,
|
||||
pub empty_message: Option<String>,
|
||||
/// When true, the list supports toggling multiple items via Space and
|
||||
/// submitting all selections with Enter.
|
||||
pub is_multi_select: bool,
|
||||
/// Optional callback invoked on Enter when `is_multi_select` is true.
|
||||
/// If `None`, accepting the view will simply dismiss it.
|
||||
pub on_accept_multi: Option<MultiSelectAcceptAction>,
|
||||
}
|
||||
|
||||
pub(crate) struct ListSelectionView {
|
||||
@@ -38,6 +62,15 @@ pub(crate) struct ListSelectionView {
|
||||
state: ScrollState,
|
||||
complete: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
is_searchable: bool,
|
||||
search_query: String,
|
||||
search_placeholder: Option<String>,
|
||||
empty_message: Option<String>,
|
||||
filtered_indices: Vec<usize>,
|
||||
is_multi_select: bool,
|
||||
/// Set of item indices (into `items`) that are currently checked.
|
||||
checked: std::collections::HashSet<usize>,
|
||||
on_accept_multi: Option<MultiSelectAcceptAction>,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
@@ -49,49 +82,180 @@ impl ListSelectionView {
|
||||
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
|
||||
para.render(area, buf);
|
||||
}
|
||||
pub fn new(
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
footer_hint: Option<String>,
|
||||
items: Vec<SelectionItem>,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Self {
|
||||
|
||||
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
let mut s = Self {
|
||||
title,
|
||||
subtitle,
|
||||
footer_hint,
|
||||
items,
|
||||
title: params.title,
|
||||
subtitle: params.subtitle,
|
||||
footer_hint: params.footer_hint,
|
||||
items: params.items,
|
||||
state: ScrollState::new(),
|
||||
complete: false,
|
||||
app_event_tx,
|
||||
is_searchable: params.is_searchable,
|
||||
search_query: String::new(),
|
||||
search_placeholder: if params.is_searchable {
|
||||
params.search_placeholder
|
||||
} else {
|
||||
None
|
||||
},
|
||||
empty_message: params.empty_message,
|
||||
filtered_indices: Vec::new(),
|
||||
is_multi_select: params.is_multi_select,
|
||||
checked: Default::default(),
|
||||
on_accept_multi: params.on_accept_multi,
|
||||
};
|
||||
let len = s.items.len();
|
||||
if let Some(idx) = s.items.iter().position(|it| it.is_current) {
|
||||
s.state.selected_idx = Some(idx);
|
||||
if s.is_multi_select {
|
||||
// Seed checked set from items marked as current.
|
||||
for (idx, it) in s.items.iter().enumerate() {
|
||||
if it.is_current {
|
||||
s.checked.insert(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.state.clamp_selection(len);
|
||||
s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
||||
s.apply_filter();
|
||||
s
|
||||
}
|
||||
|
||||
fn visible_len(&self) -> usize {
|
||||
self.filtered_indices.len()
|
||||
}
|
||||
|
||||
fn max_visible_rows(len: usize) -> usize {
|
||||
MAX_POPUP_ROWS.min(len.max(1))
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let previously_selected = self
|
||||
.state
|
||||
.selected_idx
|
||||
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied())
|
||||
.or_else(|| {
|
||||
(!self.is_searchable)
|
||||
.then(|| self.items.iter().position(|item| item.is_current))
|
||||
.flatten()
|
||||
});
|
||||
|
||||
if self.is_searchable && !self.search_query.is_empty() {
|
||||
let query_lower = self.search_query.to_lowercase();
|
||||
self.filtered_indices = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, item)| {
|
||||
let matches = if let Some(search_value) = &item.search_value {
|
||||
search_value.to_lowercase().contains(&query_lower)
|
||||
} else {
|
||||
let mut matches = item.name.to_lowercase().contains(&query_lower);
|
||||
if !matches && let Some(desc) = &item.description {
|
||||
matches = desc.to_lowercase().contains(&query_lower);
|
||||
}
|
||||
matches
|
||||
};
|
||||
matches.then_some(idx)
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
self.filtered_indices = (0..self.items.len()).collect();
|
||||
}
|
||||
|
||||
let len = self.filtered_indices.len();
|
||||
self.state.selected_idx = self
|
||||
.state
|
||||
.selected_idx
|
||||
.and_then(|visible_idx| {
|
||||
self.filtered_indices
|
||||
.get(visible_idx)
|
||||
.and_then(|idx| self.filtered_indices.iter().position(|cur| cur == idx))
|
||||
})
|
||||
.or_else(|| {
|
||||
previously_selected.and_then(|actual_idx| {
|
||||
self.filtered_indices
|
||||
.iter()
|
||||
.position(|idx| *idx == actual_idx)
|
||||
})
|
||||
})
|
||||
.or_else(|| (len > 0).then_some(0));
|
||||
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.clamp_selection(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn build_rows(&self) -> Vec<GenericDisplayRow> {
|
||||
self.filtered_indices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(visible_idx, actual_idx)| {
|
||||
self.items.get(*actual_idx).map(|item| {
|
||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let name = item.name.as_str();
|
||||
let name_with_marker = if self.is_multi_select {
|
||||
// In multi‑select mode, `is_current` seeds the initial checkbox
|
||||
// state and is reflected via [x]/[ ] rather than "(current)".
|
||||
item.name.clone()
|
||||
} else if item.is_current {
|
||||
format!("{name} (current)")
|
||||
} else {
|
||||
item.name.clone()
|
||||
};
|
||||
let n = visible_idx + 1;
|
||||
let display_name = if self.is_multi_select {
|
||||
let actual = *actual_idx;
|
||||
let checked = if self.checked.contains(&actual) {
|
||||
"[x]"
|
||||
} else {
|
||||
"[ ]"
|
||||
};
|
||||
format!("{prefix} {n}. {checked} {name_with_marker}")
|
||||
} else {
|
||||
format!("{prefix} {n}. {name_with_marker}")
|
||||
};
|
||||
GenericDisplayRow {
|
||||
name: display_name,
|
||||
match_indices: None,
|
||||
is_current: item.is_current,
|
||||
description: item.description.clone(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let len = self.items.len();
|
||||
let len = self.visible_len();
|
||||
self.state.move_up_wrap(len);
|
||||
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let len = self.items.len();
|
||||
let len = self.visible_len();
|
||||
self.state.move_down_wrap(len);
|
||||
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn accept(&mut self) {
|
||||
if let Some(idx) = self.state.selected_idx {
|
||||
if let Some(item) = self.items.get(idx) {
|
||||
for act in &item.actions {
|
||||
act(&self.app_event_tx);
|
||||
}
|
||||
if self.is_multi_select {
|
||||
if let Some(cb) = &self.on_accept_multi {
|
||||
let mut selected: Vec<usize> = self.checked.iter().copied().collect();
|
||||
selected.sort_unstable();
|
||||
cb(&self.app_event_tx, &selected);
|
||||
}
|
||||
self.complete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(idx) = self.state.selected_idx
|
||||
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||
&& let Some(item) = self.items.get(*actual_idx)
|
||||
{
|
||||
for act in &item.actions {
|
||||
act(&self.app_event_tx);
|
||||
}
|
||||
if item.dismiss_on_select {
|
||||
self.complete = true;
|
||||
}
|
||||
} else {
|
||||
@@ -99,9 +263,10 @@ impl ListSelectionView {
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
// Close the popup without performing any actions.
|
||||
self.complete = true;
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_search_query(&mut self, query: String) {
|
||||
self.search_query = query;
|
||||
self.apply_filter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,8 +281,37 @@ impl BottomPaneView for ListSelectionView {
|
||||
..
|
||||
} => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => self.cancel(),
|
||||
code: KeyCode::Char(' '),
|
||||
..
|
||||
} if self.is_multi_select => {
|
||||
if let Some(visible_idx) = self.state.selected_idx
|
||||
&& let Some(actual_idx) = self.filtered_indices.get(visible_idx)
|
||||
{
|
||||
if self.checked.contains(actual_idx) {
|
||||
self.checked.remove(actual_idx);
|
||||
} else {
|
||||
self.checked.insert(*actual_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} if self.is_searchable => {
|
||||
self.search_query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if self.is_searchable
|
||||
&& !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
self.search_query.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
@@ -139,33 +333,16 @@ impl BottomPaneView for ListSelectionView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
||||
// Build the same display rows used by the renderer so wrapping math matches.
|
||||
let rows: Vec<GenericDisplayRow> = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, it)| {
|
||||
let is_selected = self.state.selected_idx == Some(i);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let name_with_marker = if it.is_current {
|
||||
format!("{} (current)", it.name)
|
||||
} else {
|
||||
it.name.clone()
|
||||
};
|
||||
let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker);
|
||||
GenericDisplayRow {
|
||||
name: display_name,
|
||||
match_indices: None,
|
||||
is_current: it.is_current,
|
||||
description: it.description.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let rows = self.build_rows();
|
||||
|
||||
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
||||
|
||||
// +1 for the title row, +1 for a spacer line beneath the header,
|
||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
||||
let mut height = rows_height + 2;
|
||||
if self.is_searchable {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
if self.subtitle.is_some() {
|
||||
// +1 for subtitle (the spacer is accounted for above)
|
||||
height = height.saturating_add(1);
|
||||
@@ -194,6 +371,25 @@ impl BottomPaneView for ListSelectionView {
|
||||
title_para.render(title_area, buf);
|
||||
|
||||
let mut next_y = area.y.saturating_add(1);
|
||||
if self.is_searchable {
|
||||
let search_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let query_span: Span<'static> = if self.search_query.is_empty() {
|
||||
self.search_placeholder
|
||||
.as_ref()
|
||||
.map(|placeholder| placeholder.clone().dim())
|
||||
.unwrap_or_else(|| "".into())
|
||||
} else {
|
||||
self.search_query.clone().into()
|
||||
};
|
||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
|
||||
.render(search_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
}
|
||||
if let Some(sub) = &self.subtitle {
|
||||
let subtitle_area = Rect {
|
||||
x: area.x,
|
||||
@@ -228,27 +424,7 @@ impl BottomPaneView for ListSelectionView {
|
||||
.saturating_sub(footer_reserved),
|
||||
};
|
||||
|
||||
let rows: Vec<GenericDisplayRow> = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, it)| {
|
||||
let is_selected = self.state.selected_idx == Some(i);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let name_with_marker = if it.is_current {
|
||||
format!("{} (current)", it.name)
|
||||
} else {
|
||||
it.name.clone()
|
||||
};
|
||||
let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker);
|
||||
GenericDisplayRow {
|
||||
name: display_name,
|
||||
match_indices: None,
|
||||
is_current: it.is_current,
|
||||
description: it.description.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let rows = self.build_rows();
|
||||
if rows_area.height > 0 {
|
||||
render_rows(
|
||||
rows_area,
|
||||
@@ -257,7 +433,7 @@ impl BottomPaneView for ListSelectionView {
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
true,
|
||||
"no matches",
|
||||
self.empty_message.as_deref().unwrap_or("no matches"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,8 +455,13 @@ mod tests {
|
||||
use super::BottomPaneView;
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||
use crate::tui::FrameRequester;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::layout::Rect;
|
||||
// KeyEvent::new suffices; the view doesn't check kind/state.
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
|
||||
@@ -292,19 +473,26 @@ mod tests {
|
||||
description: Some("Codex can read files".to_string()),
|
||||
is_current: true,
|
||||
actions: vec![],
|
||||
dismiss_on_select: true,
|
||||
search_value: None,
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Full Access".to_string(),
|
||||
description: Some("Codex can edit files".to_string()),
|
||||
is_current: false,
|
||||
actions: vec![],
|
||||
dismiss_on_select: true,
|
||||
search_value: None,
|
||||
},
|
||||
];
|
||||
ListSelectionView::new(
|
||||
"Select Approval Mode".to_string(),
|
||||
subtitle.map(str::to_string),
|
||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
||||
items,
|
||||
SelectionViewParams {
|
||||
title: "Select Approval Mode".to_string(),
|
||||
subtitle: subtitle.map(str::to_string),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}
|
||||
@@ -347,4 +535,129 @@ mod tests {
|
||||
let view = make_selection_view(Some("Switch between Codex approval presets"));
|
||||
assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_search_query_line_when_enabled() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let items = vec![SelectionItem {
|
||||
name: "Read Only".to_string(),
|
||||
description: Some("Codex can read files".to_string()),
|
||||
is_current: false,
|
||||
actions: vec![],
|
||||
dismiss_on_select: true,
|
||||
search_value: None,
|
||||
}];
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: "Select Approval Mode".to_string(),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search branches".to_string()),
|
||||
empty_message: Some("no matches".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
view.set_search_query("filters".to_string());
|
||||
|
||||
let lines = render_lines(&view);
|
||||
assert!(lines.contains("▌ filters"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_select_toggles_and_accepts() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Read Only".to_string(),
|
||||
description: Some("Codex can read files".to_string()),
|
||||
is_current: true, // pre-checked via seed
|
||||
actions: vec![],
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Full Access".to_string(),
|
||||
description: Some("Codex can edit files".to_string()),
|
||||
is_current: false,
|
||||
actions: vec![],
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Third".to_string(),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![],
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
},
|
||||
];
|
||||
|
||||
let accepted: Arc<Mutex<Option<Vec<usize>>>> = Arc::new(Mutex::new(None));
|
||||
let accepted_clone = accepted.clone();
|
||||
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: "Experimental features".to_string(),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
is_multi_select: true,
|
||||
on_accept_multi: Some(Box::new(move |_tx, selected| {
|
||||
*accepted_clone.lock().unwrap() = Some(selected.clone());
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
// Initially, first item should be checked ([x]) via is_current seed
|
||||
let initial = render_lines(&view);
|
||||
assert!(initial.contains("1. [x] Read Only"));
|
||||
assert!(initial.contains("2. [ ] Full Access"));
|
||||
|
||||
// Build a minimal BottomPane to deliver key events to the view.
|
||||
let (tx2_raw, _rx2) = unbounded_channel::<AppEvent>();
|
||||
let tx2 = AppEventSender::new(tx2_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx2,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: String::new(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
|
||||
// Toggle first item off with Space
|
||||
view.handle_key_event(
|
||||
&mut pane,
|
||||
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
|
||||
);
|
||||
let after_toggle = render_lines(&view);
|
||||
assert!(after_toggle.contains("1. [ ] Read Only"));
|
||||
|
||||
// Move to second item and toggle it on
|
||||
view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
view.handle_key_event(
|
||||
&mut pane,
|
||||
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
|
||||
);
|
||||
let after_second = render_lines(&view);
|
||||
assert!(after_second.contains("2. [x] Full Access"));
|
||||
|
||||
// Accept selection with Enter: should call the callback with [1]
|
||||
assert!(!view.is_complete());
|
||||
view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(view.is_complete());
|
||||
|
||||
let accepted_vec = accepted.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(accepted_vec, vec![1]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::user_approval_widget::ApprovalRequest;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
@@ -20,10 +21,12 @@ mod bottom_pane_view;
|
||||
mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
mod command_popup;
|
||||
pub mod custom_prompt_view;
|
||||
mod file_search_popup;
|
||||
mod list_selection_view;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
mod paste_burst;
|
||||
mod popup_consts;
|
||||
pub mod popup_consts;
|
||||
mod scroll_state;
|
||||
mod selection_popup_common;
|
||||
mod textarea;
|
||||
@@ -49,8 +52,8 @@ pub(crate) struct BottomPane {
|
||||
/// input state is retained when the view is closed.
|
||||
composer: ChatComposer,
|
||||
|
||||
/// If present, this is displayed instead of the `composer` (e.g. modals).
|
||||
active_view: Option<Box<dyn BottomPaneView>>,
|
||||
/// Stack of views displayed instead of the composer (e.g. popups/modals).
|
||||
view_stack: Vec<Box<dyn BottomPaneView>>,
|
||||
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
@@ -73,6 +76,7 @@ pub(crate) struct BottomPaneParams {
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) placeholder_text: String,
|
||||
pub(crate) disable_paste_burst: bool,
|
||||
pub(crate) include_comment_command: bool,
|
||||
}
|
||||
|
||||
impl BottomPane {
|
||||
@@ -86,8 +90,9 @@ impl BottomPane {
|
||||
enhanced_keys_supported,
|
||||
params.placeholder_text,
|
||||
params.disable_paste_burst,
|
||||
params.include_comment_command,
|
||||
),
|
||||
active_view: None,
|
||||
view_stack: Vec::new(),
|
||||
app_event_tx: params.app_event_tx,
|
||||
frame_requester: params.frame_requester,
|
||||
has_input_focus: params.has_input_focus,
|
||||
@@ -99,12 +104,21 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
fn active_view(&self) -> Option<&dyn BottomPaneView> {
|
||||
self.view_stack.last().map(|view| view.as_ref())
|
||||
}
|
||||
|
||||
fn push_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.view_stack.push(view);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
// 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.
|
||||
let base = match self.active_view.as_ref() {
|
||||
let base = match self.active_view() {
|
||||
Some(view) => view.desired_height(width),
|
||||
None => self.composer.desired_height(width).saturating_add(
|
||||
self.status
|
||||
@@ -131,7 +145,7 @@ impl BottomPane {
|
||||
width: area.width,
|
||||
height: area.height - top_margin - bottom_margin,
|
||||
};
|
||||
match self.active_view.as_ref() {
|
||||
match self.active_view() {
|
||||
Some(_) => [Rect::ZERO, area],
|
||||
None => {
|
||||
let status_height = self
|
||||
@@ -148,22 +162,33 @@ impl BottomPane {
|
||||
// status indicator shown while a task is running, or approval modal).
|
||||
// In these states the textarea is not interactable, so we should not
|
||||
// show its caret.
|
||||
if self.active_view.is_some() {
|
||||
None
|
||||
let [_, content] = self.layout(area);
|
||||
if let Some(view) = self.active_view() {
|
||||
view.cursor_pos(content)
|
||||
} else {
|
||||
let [_, content] = self.layout(area);
|
||||
self.composer.cursor_pos(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a key event to the active view or the composer.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
||||
if let Some(mut view) = self.active_view.take() {
|
||||
// If a modal/view is active, treat Esc like Ctrl+C to let the view
|
||||
// handle dismissal. Otherwise, forward Esc to the composer so its
|
||||
// popups (e.g., slash/file search) can process it.
|
||||
if !self.view_stack.is_empty() && key_event.code == KeyCode::Esc {
|
||||
self.on_ctrl_c();
|
||||
return InputResult::None;
|
||||
}
|
||||
|
||||
if let Some(mut view) = self.view_stack.pop() {
|
||||
let reinsertion_index = self.view_stack.len();
|
||||
view.handle_key_event(self, key_event);
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else {
|
||||
if view.is_complete() {
|
||||
self.view_stack.clear();
|
||||
self.on_active_view_complete();
|
||||
} else {
|
||||
let idx = reinsertion_index;
|
||||
self.view_stack.insert(idx, view);
|
||||
}
|
||||
self.request_redraw();
|
||||
InputResult::None
|
||||
@@ -193,7 +218,7 @@ impl BottomPane {
|
||||
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
|
||||
/// chance to consume the event (e.g. to dismiss itself).
|
||||
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
let mut view = match self.active_view.take() {
|
||||
let mut view = match self.view_stack.pop() {
|
||||
Some(view) => view,
|
||||
None => {
|
||||
return if self.composer_is_empty() {
|
||||
@@ -209,22 +234,32 @@ impl BottomPane {
|
||||
let event = view.on_ctrl_c(self);
|
||||
match event {
|
||||
CancellationEvent::Handled => {
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else {
|
||||
if view.is_complete() {
|
||||
self.on_active_view_complete();
|
||||
} else {
|
||||
self.view_stack.push(view);
|
||||
}
|
||||
self.show_ctrl_c_quit_hint();
|
||||
}
|
||||
CancellationEvent::NotHandled => {
|
||||
self.active_view = Some(view);
|
||||
self.view_stack.push(view);
|
||||
}
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, pasted: String) {
|
||||
if self.active_view.is_none() {
|
||||
if let Some(mut view) = self.view_stack.pop() {
|
||||
let needs_redraw = view.handle_paste(self, pasted);
|
||||
if view.is_complete() {
|
||||
self.on_active_view_complete();
|
||||
} else {
|
||||
self.view_stack.push(view);
|
||||
}
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
} else {
|
||||
let needs_redraw = self.composer.handle_paste(pasted);
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
@@ -237,6 +272,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn set_include_comment_command(&mut self, include: bool) {
|
||||
self.composer.set_include_comment_command(include);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Replace the composer text with `text`.
|
||||
pub(crate) fn set_composer_text(&mut self, text: String) {
|
||||
self.composer.set_text_content(text);
|
||||
@@ -318,22 +358,9 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
/// Show a generic list selection view with the provided items.
|
||||
pub(crate) fn show_selection_view(
|
||||
&mut self,
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
footer_hint: Option<String>,
|
||||
items: Vec<SelectionItem>,
|
||||
) {
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
title,
|
||||
subtitle,
|
||||
footer_hint,
|
||||
items,
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.active_view = Some(Box::new(view));
|
||||
self.request_redraw();
|
||||
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
|
||||
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
|
||||
/// Update the queued messages shown under the status header.
|
||||
@@ -363,7 +390,7 @@ impl BottomPane {
|
||||
/// overlays or popups and not running a task. This is the safe context to
|
||||
/// use Esc-Esc for backtracking from the main view.
|
||||
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
|
||||
!self.is_task_running && self.active_view.is_none() && !self.composer.popup_active()
|
||||
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
|
||||
}
|
||||
|
||||
/// Update the *context-window remaining* indicator in the composer. This
|
||||
@@ -373,9 +400,13 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.push_view(view);
|
||||
}
|
||||
|
||||
/// Called when the agent requests user approval.
|
||||
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
|
||||
let request = if let Some(view) = self.active_view.as_mut() {
|
||||
let request = if let Some(view) = self.view_stack.last_mut() {
|
||||
match view.try_consume_approval_request(request) {
|
||||
Some(request) => request,
|
||||
None => {
|
||||
@@ -390,8 +421,7 @@ impl BottomPane {
|
||||
// Otherwise create a new approval modal overlay.
|
||||
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
|
||||
self.pause_status_timer_for_modal();
|
||||
self.active_view = Some(Box::new(modal));
|
||||
self.request_redraw()
|
||||
self.push_view(Box::new(modal));
|
||||
}
|
||||
|
||||
fn on_active_view_complete(&mut self) {
|
||||
@@ -460,7 +490,7 @@ impl BottomPane {
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
if self.active_view.is_none() {
|
||||
if self.view_stack.is_empty() {
|
||||
self.composer
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.request_redraw();
|
||||
@@ -477,7 +507,7 @@ impl WidgetRef for &BottomPane {
|
||||
let [status_area, content] = self.layout(area);
|
||||
|
||||
// When a modal view is active, it owns the whole content area.
|
||||
if let Some(view) = &self.active_view {
|
||||
if let Some(view) = self.active_view() {
|
||||
view.render(content, buf);
|
||||
} else {
|
||||
// No active modal:
|
||||
@@ -519,6 +549,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
pane.push_approval_request(exec_request());
|
||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
@@ -539,6 +570,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
|
||||
// Create an approval modal (active view).
|
||||
@@ -570,6 +602,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
|
||||
// Start a running task so the status indicator is active above the composer.
|
||||
@@ -587,7 +620,7 @@ mod tests {
|
||||
// After denial, since the task is still running, the status indicator should be
|
||||
// visible above the composer. The modal should be gone.
|
||||
assert!(
|
||||
pane.active_view.is_none(),
|
||||
pane.view_stack.is_empty(),
|
||||
"no active modal view after denial"
|
||||
);
|
||||
|
||||
@@ -638,6 +671,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
|
||||
// Begin a task: show initial status.
|
||||
@@ -669,6 +703,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
|
||||
// Activate spinner (status view replaces composer) with no live ring.
|
||||
@@ -720,6 +755,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
/// Maximum number of rows any popup should attempt to display.
|
||||
/// Keep this consistent across all popups for a uniform feel.
|
||||
pub(crate) const MAX_POPUP_ROWS: usize = 8;
|
||||
|
||||
/// Standard footer hint text used by popups.
|
||||
pub(crate) const STANDARD_POPUP_HINT_LINE: &str = "Press Enter to confirm or Esc to go back";
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::sync::Arc;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_types::Notifications;
|
||||
use codex_core::git_info::current_branch_name;
|
||||
use codex_core::git_info::local_git_branches;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
@@ -62,6 +64,9 @@ use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
|
||||
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
@@ -96,6 +101,7 @@ use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::path::Path;
|
||||
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
@@ -689,6 +695,7 @@ impl ChatWidget {
|
||||
enhanced_keys_supported,
|
||||
placeholder_text: placeholder,
|
||||
disable_paste_burst: config.disable_paste_burst,
|
||||
include_comment_command: crate::experimental::is_enabled(&config, "comment"),
|
||||
}),
|
||||
active_exec_cell: None,
|
||||
config: config.clone(),
|
||||
@@ -746,6 +753,7 @@ impl ChatWidget {
|
||||
enhanced_keys_supported,
|
||||
placeholder_text: placeholder,
|
||||
disable_paste_burst: config.disable_paste_burst,
|
||||
include_comment_command: crate::experimental::is_enabled(&config, "comment"),
|
||||
}),
|
||||
active_exec_cell: None,
|
||||
config: config.clone(),
|
||||
@@ -883,13 +891,7 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||
}
|
||||
SlashCommand::Review => {
|
||||
// Simplified flow: directly send a review op for current changes.
|
||||
self.submit_op(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "review current changes".to_string(),
|
||||
user_facing_hint: "current changes".to_string(),
|
||||
},
|
||||
});
|
||||
self.open_review_popup();
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
self.open_model_popup();
|
||||
@@ -932,6 +934,34 @@ impl ChatWidget {
|
||||
SlashCommand::Mcp => {
|
||||
self.add_mcp_output();
|
||||
}
|
||||
SlashCommand::Comment => {
|
||||
use std::process::Command as ProcessCommand;
|
||||
let url = "http://localhost:3000";
|
||||
let result = {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
ProcessCommand::new("open").arg(url).spawn()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
ProcessCommand::new("cmd")
|
||||
.args(["/C", "start", url])
|
||||
.spawn()
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
ProcessCommand::new("xdg-open").arg(url).spawn()
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
self.add_error_message(format!("Failed to open browser: {e}"));
|
||||
} else {
|
||||
self.add_info_message("Opening comments page in browser".to_string(), None);
|
||||
}
|
||||
}
|
||||
SlashCommand::Experimental => {
|
||||
self.open_experimental_popup();
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => {
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -1297,6 +1327,50 @@ impl ChatWidget {
|
||||
));
|
||||
}
|
||||
|
||||
/// Open a popup to choose experimental features to enable/disable.
|
||||
pub(crate) fn open_experimental_popup(&mut self) {
|
||||
use crate::experimental::ALL_FEATURES;
|
||||
let mut items: Vec<SelectionItem> = Vec::with_capacity(ALL_FEATURES.len());
|
||||
|
||||
for feature in ALL_FEATURES.iter() {
|
||||
let enabled = crate::experimental::is_enabled(&self.config, feature.key);
|
||||
items.push(SelectionItem {
|
||||
name: feature.name.to_string(),
|
||||
description: Some(feature.description.to_string()),
|
||||
is_current: enabled, // seed checked state in multi‑select mode
|
||||
actions: vec![],
|
||||
dismiss_on_select: false,
|
||||
search_value: Some(feature.key.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let footer = "Space to toggle • Enter to save • Esc to cancel".to_string();
|
||||
let keys: Vec<String> = ALL_FEATURES.iter().map(|f| f.key.to_string()).collect();
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: "Experimental features".to_string(),
|
||||
subtitle: Some("Toggle experimental TUI options".to_string()),
|
||||
footer_hint: Some(footer),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to filter features".to_string()),
|
||||
empty_message: Some("no features".to_string()),
|
||||
is_multi_select: true,
|
||||
on_accept_multi: Some(Box::new(move |tx2, selected_indices| {
|
||||
use std::collections::HashMap;
|
||||
let mut flags: HashMap<String, bool> = HashMap::new();
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
let enabled = selected_indices.contains(&i);
|
||||
flags.insert(key.clone(), enabled);
|
||||
}
|
||||
tx2.send(crate::app_event::AppEvent::UpdateExperimentalFlags(
|
||||
flags.clone(),
|
||||
));
|
||||
tx2.send(crate::app_event::AppEvent::PersistExperimentalFlags(flags));
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a popup to choose the model preset (model + reasoning effort).
|
||||
pub(crate) fn open_model_popup(&mut self) {
|
||||
let current_model = self.config.model.clone();
|
||||
@@ -1344,15 +1418,20 @@ impl ChatWidget {
|
||||
description,
|
||||
is_current,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
search_value: None,
|
||||
});
|
||||
}
|
||||
|
||||
self.bottom_pane.show_selection_view(
|
||||
"Select model and reasoning level".to_string(),
|
||||
Some("Switch between OpenAI models for this and future Codex CLI session".to_string()),
|
||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: "Select model and reasoning level".to_string(),
|
||||
subtitle: Some(
|
||||
"Switch between OpenAI models for this and future Codex CLI session".to_string(),
|
||||
),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
);
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
||||
@@ -1385,15 +1464,17 @@ impl ChatWidget {
|
||||
description,
|
||||
is_current,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
search_value: None,
|
||||
});
|
||||
}
|
||||
|
||||
self.bottom_pane.show_selection_view(
|
||||
"Select Approval Mode".to_string(),
|
||||
None,
|
||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: "Select Approval Mode".to_string(),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
);
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the approval policy in the widget's config copy.
|
||||
@@ -1411,6 +1492,16 @@ impl ChatWidget {
|
||||
self.config.model_reasoning_effort = effort;
|
||||
}
|
||||
|
||||
pub(crate) fn set_experimental_flags(
|
||||
&mut self,
|
||||
flags: std::collections::HashMap<String, bool>,
|
||||
) {
|
||||
self.config.experimental_flags = flags;
|
||||
let include_comment = crate::experimental::is_enabled(&self.config, "comment");
|
||||
self.bottom_pane
|
||||
.set_include_comment_command(include_comment);
|
||||
}
|
||||
|
||||
/// Set the model in the widget's config copy.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
self.session_header.set_model(model);
|
||||
@@ -1502,6 +1593,121 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_custom_prompts(ev.custom_prompts);
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: "Review current changes".to_string(),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![Box::new(
|
||||
move |tx: &AppEventSender| {
|
||||
tx.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string(),
|
||||
user_facing_hint: "current changes".to_string(),
|
||||
},
|
||||
}));
|
||||
},
|
||||
)],
|
||||
dismiss_on_select: true,
|
||||
search_value: None,
|
||||
});
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: "Review against a base branch".to_string(),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![Box::new({
|
||||
let cwd = self.config.cwd.clone();
|
||||
move |tx| {
|
||||
tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone()));
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
});
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: "Custom review instructions".to_string(),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenReviewCustomPrompt);
|
||||
})],
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
});
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: "Select a review preset".into(),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) {
|
||||
let branches = local_git_branches(cwd).await;
|
||||
let current_branch = current_branch_name(cwd)
|
||||
.await
|
||||
.unwrap_or_else(|| "(detached HEAD)".to_string());
|
||||
let mut items: Vec<SelectionItem> = Vec::with_capacity(branches.len());
|
||||
|
||||
for option in branches {
|
||||
let branch = option.clone();
|
||||
items.push(SelectionItem {
|
||||
name: format!("{current_branch} -> {branch}"),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||||
tx3.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: format!(
|
||||
"Review the code changes against the base branch '{branch}'. Start by finding the fork point between the current branch and {branch} e.g. (git merge-base HEAD {branch}), then run `git diff` against that fork point to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings."
|
||||
),
|
||||
user_facing_hint: format!("changes against '{branch}'"),
|
||||
},
|
||||
}));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
search_value: Some(option),
|
||||
});
|
||||
}
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: "Select a base branch".to_string(),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search branches".to_string()),
|
||||
empty_message: Some("no matches".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn show_review_custom_prompt(&mut self) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let view = CustomPromptView::new(
|
||||
"Custom review instructions".to_string(),
|
||||
"Type instructions and press Enter".to_string(),
|
||||
None,
|
||||
Box::new(move |prompt: String| {
|
||||
let trimmed = prompt.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
tx.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: trimmed.clone(),
|
||||
user_facing_hint: trimmed,
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
/// Programmatically submit a user text message as if typed in the
|
||||
/// composer. The text will be added to conversation history and sent to
|
||||
/// the agent.
|
||||
|
||||
@@ -308,6 +308,7 @@ fn make_chatwidget_manual() -> (
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
include_comment_command: false,
|
||||
});
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let widget = ChatWidget {
|
||||
@@ -644,6 +645,73 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
assert!(blob.to_lowercase().contains("bloop"), "expected error text");
|
||||
}
|
||||
|
||||
/// Selecting the custom prompt option from the review popup sends
|
||||
/// OpenReviewCustomPrompt to the app event channel.
|
||||
#[test]
|
||||
fn review_popup_custom_prompt_action_sends_event() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Open the preset selection popup
|
||||
chat.open_review_popup();
|
||||
|
||||
// Move selection down to the third item: "Custom review instructions"
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
// Activate
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Drain events and ensure we saw the OpenReviewCustomPrompt request
|
||||
let mut found = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::OpenReviewCustomPrompt = ev {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found, "expected OpenReviewCustomPrompt event to be sent");
|
||||
}
|
||||
|
||||
/// Submitting the custom prompt view sends Op::Review with the typed prompt
|
||||
/// and uses the same text for the user-facing hint.
|
||||
#[test]
|
||||
fn custom_prompt_submit_sends_review_op() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.show_review_custom_prompt();
|
||||
// Paste prompt text via ChatWidget handler, then submit
|
||||
chat.handle_paste(" please audit dependencies ".to_string());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt
|
||||
let evt = rx.try_recv().expect("expected one app event");
|
||||
match evt {
|
||||
AppEvent::CodexOp(Op::Review { review_request }) => {
|
||||
assert_eq!(
|
||||
review_request.prompt,
|
||||
"please audit dependencies".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
review_request.user_facing_hint,
|
||||
"please audit dependencies".to_string()
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected app event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hitting Enter on an empty custom prompt view does not submit.
|
||||
#[test]
|
||||
fn custom_prompt_enter_empty_does_not_send() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.show_review_custom_prompt();
|
||||
// Enter without any text
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// No AppEvent::CodexOp should be sent
|
||||
assert!(rx.try_recv().is_err(), "no app event should be sent");
|
||||
}
|
||||
|
||||
// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗
|
||||
// marker (replacing the spinner) and flushes it into history.
|
||||
#[test]
|
||||
@@ -673,6 +741,96 @@ fn interrupt_exec_marks_failed_snapshot() {
|
||||
assert_snapshot!("interrupt_exec_marks_failed", exec_blob);
|
||||
}
|
||||
|
||||
/// Opening custom prompt from the review popup, pressing Esc returns to the
|
||||
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
|
||||
#[test]
|
||||
fn review_custom_prompt_escape_navigates_back_then_dismisses() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Open the Review presets parent popup.
|
||||
chat.open_review_popup();
|
||||
|
||||
// Open the custom prompt submenu (child view) directly.
|
||||
chat.show_review_custom_prompt();
|
||||
|
||||
// Verify child view is on top.
|
||||
let header = render_bottom_first_row(&chat, 60);
|
||||
assert!(
|
||||
header.contains("Custom review instructions"),
|
||||
"expected custom prompt view header: {header:?}"
|
||||
);
|
||||
|
||||
// Esc once: child view closes, parent (review presets) remains.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let header = render_bottom_first_row(&chat, 60);
|
||||
assert!(
|
||||
header.contains("Select a review preset"),
|
||||
"expected to return to parent review popup: {header:?}"
|
||||
);
|
||||
|
||||
// Esc again: parent closes; back to normal composer state.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.is_normal_backtrack_mode(),
|
||||
"expected to be back in normal composer mode"
|
||||
);
|
||||
}
|
||||
|
||||
/// Opening base-branch picker from the review popup, pressing Esc returns to the
|
||||
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn review_branch_picker_escape_navigates_back_then_dismisses() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Open the Review presets parent popup.
|
||||
chat.open_review_popup();
|
||||
|
||||
// Open the branch picker submenu (child view). Using a temp cwd with no git repo is fine.
|
||||
let cwd = std::env::temp_dir();
|
||||
chat.show_review_branch_picker(&cwd).await;
|
||||
|
||||
// Verify child view header.
|
||||
let header = render_bottom_first_row(&chat, 60);
|
||||
assert!(
|
||||
header.contains("Select a base branch"),
|
||||
"expected branch picker header: {header:?}"
|
||||
);
|
||||
|
||||
// Esc once: child view closes, parent remains.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let header = render_bottom_first_row(&chat, 60);
|
||||
assert!(
|
||||
header.contains("Select a review preset"),
|
||||
"expected to return to parent review popup: {header:?}"
|
||||
);
|
||||
|
||||
// Esc again: parent closes; back to normal composer state.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.is_normal_backtrack_mode(),
|
||||
"expected to be back in normal composer mode"
|
||||
);
|
||||
}
|
||||
|
||||
fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
|
||||
let height = chat.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(chat).render_ref(area, &mut buf);
|
||||
let mut row = String::new();
|
||||
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
|
||||
let y = 1u16.min(height.saturating_sub(1));
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
row.push(' ');
|
||||
} else {
|
||||
row.push_str(s);
|
||||
}
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_extends_previous_when_consecutive() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
36
codex-rs/tui/src/experimental.rs
Normal file
36
codex-rs/tui/src/experimental.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use codex_core::config::Config;
|
||||
|
||||
/// Definition of a single experimental feature toggle.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Feature {
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub default_on: bool,
|
||||
}
|
||||
|
||||
/// Central registry of experimental features.
|
||||
/// Add new toggles here and gate UI code using `is_enabled(cfg, feature.key)`.
|
||||
pub const ALL_FEATURES: &[Feature] = &[Feature {
|
||||
key: "comment",
|
||||
name: "/comment command",
|
||||
description: "Enable a /comment command that opens a browser",
|
||||
default_on: false,
|
||||
}];
|
||||
|
||||
fn default_for(key: &str) -> bool {
|
||||
ALL_FEATURES
|
||||
.iter()
|
||||
.find(|f| f.key == key)
|
||||
.map(|f| f.default_on)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns whether a feature is enabled using the current config and built-in default.
|
||||
pub fn is_enabled(config: &Config, key: &str) -> bool {
|
||||
config
|
||||
.experimental_flags
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or_else(|| default_for(key))
|
||||
}
|
||||
@@ -42,6 +42,7 @@ mod clipboard_paste;
|
||||
pub mod custom_terminal;
|
||||
mod diff_render;
|
||||
mod exec_command;
|
||||
mod experimental;
|
||||
mod file_search;
|
||||
mod frames;
|
||||
mod get_git_diff;
|
||||
|
||||
@@ -22,8 +22,10 @@ pub enum SlashCommand {
|
||||
Mention,
|
||||
Status,
|
||||
Mcp,
|
||||
Comment,
|
||||
Logout,
|
||||
Quit,
|
||||
Experimental,
|
||||
#[cfg(debug_assertions)]
|
||||
TestApproval,
|
||||
}
|
||||
@@ -43,7 +45,9 @@ impl SlashCommand {
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Comment => "open comments page in browser",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
SlashCommand::Experimental => "toggle experimental TUI features",
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => "test approval request",
|
||||
}
|
||||
@@ -63,12 +67,14 @@ impl SlashCommand {
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Comment
|
||||
| SlashCommand::Quit => true,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -78,6 +84,12 @@ impl SlashCommand {
|
||||
}
|
||||
|
||||
/// Return all built-in commands in a Vec paired with their command string.
|
||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||
pub fn built_in_slash_commands(include_comment: bool) -> Vec<(&'static str, SlashCommand)> {
|
||||
SlashCommand::iter()
|
||||
.filter(|c| match c {
|
||||
SlashCommand::Comment => include_comment,
|
||||
_ => true,
|
||||
})
|
||||
.map(|c| (c.command(), c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user