feat: Add more /review options (#3961)
Adds the following options: 1. Review current changes 2. Review a specific commit 3. Review against a base branch (PR style) 4. Custom instructions <img width="487" height="330" alt="Screenshot 2025-09-20 at 2 11 36 PM" src="https://github.com/user-attachments/assets/edb0aaa5-5747-47fa-881f-cc4c4f7fe8bc" /> --- \+ Adds the following UI helpers: 1. Makes list selection searchable 2. Adds navigation to the bottom pane, so you could add a stack of popups 3. Basic custom prompt view
This commit is contained in:
@@ -108,6 +108,61 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
|||||||
Some(git_info)
|
Some(git_info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A minimal commit summary entry used for pickers (subject + timestamp + sha).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CommitLogEntry {
|
||||||
|
pub sha: String,
|
||||||
|
/// Unix timestamp (seconds since epoch) of the commit time (committer time).
|
||||||
|
pub timestamp: i64,
|
||||||
|
/// Single-line subject of the commit message.
|
||||||
|
pub subject: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the last `limit` commits reachable from HEAD for the current branch.
|
||||||
|
/// Each entry contains the SHA, commit timestamp (seconds), and subject line.
|
||||||
|
/// Returns an empty vector if not in a git repo or on error/timeout.
|
||||||
|
pub async fn recent_commits(cwd: &Path, limit: usize) -> Vec<CommitLogEntry> {
|
||||||
|
// Ensure we're in a git repo first to avoid noisy errors.
|
||||||
|
let Some(out) = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd).await else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !out.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fmt = "%H%x1f%ct%x1f%s"; // <sha> <US> <commit_time> <US> <subject>
|
||||||
|
let n = limit.max(1).to_string();
|
||||||
|
let Some(log_out) =
|
||||||
|
run_git_command_with_timeout(&["log", "-n", &n, &format!("--pretty=format:{fmt}")], cwd)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !log_out.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&log_out.stdout);
|
||||||
|
let mut entries: Vec<CommitLogEntry> = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let mut parts = line.split('\u{001f}');
|
||||||
|
let sha = parts.next().unwrap_or("").trim();
|
||||||
|
let ts_s = parts.next().unwrap_or("").trim();
|
||||||
|
let subject = parts.next().unwrap_or("").trim();
|
||||||
|
if sha.is_empty() || ts_s.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let timestamp = ts_s.parse::<i64>().unwrap_or(0);
|
||||||
|
entries.push(CommitLogEntry {
|
||||||
|
sha: sha.to_string(),
|
||||||
|
timestamp,
|
||||||
|
subject: subject.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
||||||
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
||||||
get_git_repo_root(cwd)?;
|
get_git_repo_root(cwd)?;
|
||||||
@@ -202,6 +257,11 @@ async fn get_default_branch(cwd: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No remote-derived default; try common local defaults if they exist
|
// 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"] {
|
for candidate in ["main", "master"] {
|
||||||
if let Some(verify) = run_git_command_with_timeout(
|
if let Some(verify) = run_git_command_with_timeout(
|
||||||
&[
|
&[
|
||||||
@@ -485,6 +545,46 @@ pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
|
|||||||
git_dir_path.parent().map(Path::to_path_buf)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -551,6 +651,80 @@ mod tests {
|
|||||||
repo_path
|
repo_path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_recent_commits_non_git_directory_returns_empty() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let entries = recent_commits(temp_dir.path(), 10).await;
|
||||||
|
assert!(entries.is_empty(), "expected no commits outside a git repo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_recent_commits_orders_and_limits() {
|
||||||
|
use tokio::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
||||||
|
|
||||||
|
// Make three distinct commits with small delays to ensure ordering by timestamp.
|
||||||
|
fs::write(repo_path.join("file.txt"), "one").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "file.txt"])
|
||||||
|
.current_dir(&repo_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.expect("git add");
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "first change"])
|
||||||
|
.current_dir(&repo_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.expect("git commit 1");
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(1100)).await;
|
||||||
|
|
||||||
|
fs::write(repo_path.join("file.txt"), "two").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "file.txt"])
|
||||||
|
.current_dir(&repo_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.expect("git add 2");
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "second change"])
|
||||||
|
.current_dir(&repo_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.expect("git commit 2");
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(1100)).await;
|
||||||
|
|
||||||
|
fs::write(repo_path.join("file.txt"), "three").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "file.txt"])
|
||||||
|
.current_dir(&repo_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.expect("git add 3");
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "third change"])
|
||||||
|
.current_dir(&repo_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.expect("git commit 3");
|
||||||
|
|
||||||
|
// Request the latest 3 commits; should be our three changes in reverse time order.
|
||||||
|
let entries = recent_commits(&repo_path, 3).await;
|
||||||
|
assert_eq!(entries.len(), 3);
|
||||||
|
assert_eq!(entries[0].subject, "third change");
|
||||||
|
assert_eq!(entries[1].subject, "second change");
|
||||||
|
assert_eq!(entries[2].subject, "first change");
|
||||||
|
// Basic sanity on SHA formatting
|
||||||
|
for e in entries {
|
||||||
|
assert!(e.sha.len() >= 7 && e.sha.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
|
async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
|
||||||
let repo_path = create_test_git_repo(temp_dir).await;
|
let repo_path = create_test_git_repo(temp_dir).await;
|
||||||
let remote_path = temp_dir.path().join("remote.git");
|
let remote_path = temp_dir.path().join("remote.git");
|
||||||
|
|||||||
@@ -354,6 +354,18 @@ impl App {
|
|||||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||||
self.chat_widget.set_sandbox_policy(policy);
|
self.chat_widget.set_sandbox_policy(policy);
|
||||||
}
|
}
|
||||||
|
AppEvent::OpenReviewBranchPicker(cwd) => {
|
||||||
|
self.chat_widget.show_review_branch_picker(&cwd).await;
|
||||||
|
}
|
||||||
|
AppEvent::OpenReviewCommitPicker(cwd) => {
|
||||||
|
self.chat_widget.show_review_commit_picker(&cwd).await;
|
||||||
|
}
|
||||||
|
AppEvent::OpenReviewCustomPrompt => {
|
||||||
|
self.chat_widget.show_review_custom_prompt();
|
||||||
|
}
|
||||||
|
AppEvent::OpenReviewPopup => {
|
||||||
|
self.chat_widget.open_review_popup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use codex_core::protocol::ConversationPathResponseEvent;
|
use codex_core::protocol::ConversationPathResponseEvent;
|
||||||
use codex_core::protocol::Event;
|
use codex_core::protocol::Event;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
@@ -65,4 +67,16 @@ pub(crate) enum AppEvent {
|
|||||||
|
|
||||||
/// Forwarded conversation history snapshot from the current conversation.
|
/// Forwarded conversation history snapshot from the current conversation.
|
||||||
ConversationHistory(ConversationPathResponseEvent),
|
ConversationHistory(ConversationPathResponseEvent),
|
||||||
|
|
||||||
|
/// Open the branch picker option from the review popup.
|
||||||
|
OpenReviewBranchPicker(PathBuf),
|
||||||
|
|
||||||
|
/// Open the commit picker option from the review popup.
|
||||||
|
OpenReviewCommitPicker(PathBuf),
|
||||||
|
|
||||||
|
/// Open the custom prompt option from the review popup.
|
||||||
|
OpenReviewCustomPrompt,
|
||||||
|
|
||||||
|
/// Open the top-level review presets popup.
|
||||||
|
OpenReviewPopup,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ pub(crate) trait BottomPaneView {
|
|||||||
/// Render the view: this will be displayed in place of the composer.
|
/// Render the view: this will be displayed in place of the composer.
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
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
|
/// Try to handle approval request; return the original value if not
|
||||||
/// consumed.
|
/// consumed.
|
||||||
fn try_consume_approval_request(
|
fn try_consume_approval_request(
|
||||||
|
|||||||
254
codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
254
codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
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 crate::app_event_sender::AppEventSender;
|
||||||
|
use crate::bottom_pane::SelectionAction;
|
||||||
|
|
||||||
|
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,
|
||||||
|
app_event_tx: AppEventSender,
|
||||||
|
on_escape: Option<SelectionAction>,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
textarea: TextArea,
|
||||||
|
textarea_state: RefCell<TextAreaState>,
|
||||||
|
complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomPromptView {
|
||||||
|
pub(crate) fn new(
|
||||||
|
title: String,
|
||||||
|
placeholder: String,
|
||||||
|
context_label: Option<String>,
|
||||||
|
app_event_tx: AppEventSender,
|
||||||
|
on_escape: Option<SelectionAction>,
|
||||||
|
on_submit: PromptSubmitted,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
title,
|
||||||
|
placeholder,
|
||||||
|
context_label,
|
||||||
|
on_submit,
|
||||||
|
app_event_tx,
|
||||||
|
on_escape,
|
||||||
|
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::Esc, ..
|
||||||
|
} => {
|
||||||
|
self.on_ctrl_c(_pane);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
if let Some(cb) = &self.on_escape {
|
||||||
|
cb(&self.app_event_tx);
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -28,6 +28,19 @@ pub(crate) struct SelectionItem {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub is_current: bool,
|
pub is_current: bool,
|
||||||
pub actions: Vec<SelectionAction>,
|
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 on_escape: Option<SelectionAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct ListSelectionView {
|
pub(crate) struct ListSelectionView {
|
||||||
@@ -38,6 +51,11 @@ pub(crate) struct ListSelectionView {
|
|||||||
state: ScrollState,
|
state: ScrollState,
|
||||||
complete: bool,
|
complete: bool,
|
||||||
app_event_tx: AppEventSender,
|
app_event_tx: AppEventSender,
|
||||||
|
on_escape: Option<SelectionAction>,
|
||||||
|
is_searchable: bool,
|
||||||
|
search_query: String,
|
||||||
|
search_placeholder: Option<String>,
|
||||||
|
filtered_indices: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListSelectionView {
|
impl ListSelectionView {
|
||||||
@@ -49,49 +67,145 @@ impl ListSelectionView {
|
|||||||
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
|
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
|
||||||
para.render(area, buf);
|
para.render(area, buf);
|
||||||
}
|
}
|
||||||
pub fn new(
|
|
||||||
title: String,
|
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||||
subtitle: Option<String>,
|
|
||||||
footer_hint: Option<String>,
|
|
||||||
items: Vec<SelectionItem>,
|
|
||||||
app_event_tx: AppEventSender,
|
|
||||||
) -> Self {
|
|
||||||
let mut s = Self {
|
let mut s = Self {
|
||||||
title,
|
title: params.title,
|
||||||
subtitle,
|
subtitle: params.subtitle,
|
||||||
footer_hint,
|
footer_hint: params.footer_hint,
|
||||||
items,
|
items: params.items,
|
||||||
state: ScrollState::new(),
|
state: ScrollState::new(),
|
||||||
complete: false,
|
complete: false,
|
||||||
app_event_tx,
|
app_event_tx,
|
||||||
|
on_escape: params.on_escape,
|
||||||
|
is_searchable: params.is_searchable,
|
||||||
|
search_query: String::new(),
|
||||||
|
search_placeholder: if params.is_searchable {
|
||||||
|
params.search_placeholder
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
filtered_indices: Vec::new(),
|
||||||
};
|
};
|
||||||
let len = s.items.len();
|
s.apply_filter();
|
||||||
if let Some(idx) = s.items.iter().position(|it| it.is_current) {
|
|
||||||
s.state.selected_idx = Some(idx);
|
|
||||||
}
|
|
||||||
s.state.clamp_selection(len);
|
|
||||||
s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
|
||||||
s
|
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 item.is_current {
|
||||||
|
format!("{name} (current)")
|
||||||
|
} else {
|
||||||
|
item.name.clone()
|
||||||
|
};
|
||||||
|
let n = visible_idx + 1;
|
||||||
|
let display_name = 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) {
|
fn move_up(&mut self) {
|
||||||
let len = self.items.len();
|
let len = self.visible_len();
|
||||||
self.state.move_up_wrap(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) {
|
fn move_down(&mut self) {
|
||||||
let len = self.items.len();
|
let len = self.visible_len();
|
||||||
self.state.move_down_wrap(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) {
|
fn accept(&mut self) {
|
||||||
if let Some(idx) = self.state.selected_idx {
|
if let Some(idx) = self.state.selected_idx
|
||||||
if let Some(item) = self.items.get(idx) {
|
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||||
for act in &item.actions {
|
&& let Some(item) = self.items.get(*actual_idx)
|
||||||
act(&self.app_event_tx);
|
{
|
||||||
}
|
for act in &item.actions {
|
||||||
|
act(&self.app_event_tx);
|
||||||
|
}
|
||||||
|
if item.dismiss_on_select {
|
||||||
self.complete = true;
|
self.complete = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -99,9 +213,10 @@ impl ListSelectionView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel(&mut self) {
|
#[cfg(test)]
|
||||||
// Close the popup without performing any actions.
|
pub(crate) fn set_search_query(&mut self, query: String) {
|
||||||
self.complete = true;
|
self.search_query = query;
|
||||||
|
self.apply_filter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +230,29 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
code: KeyCode::Down,
|
code: KeyCode::Down,
|
||||||
..
|
..
|
||||||
} => self.move_down(),
|
} => self.move_down(),
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Backspace,
|
||||||
|
..
|
||||||
|
} if self.is_searchable => {
|
||||||
|
self.search_query.pop();
|
||||||
|
self.apply_filter();
|
||||||
|
}
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Esc, ..
|
code: KeyCode::Esc, ..
|
||||||
} => self.cancel(),
|
} => {
|
||||||
|
self.on_ctrl_c(_pane);
|
||||||
|
}
|
||||||
|
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 {
|
KeyEvent {
|
||||||
code: KeyCode::Enter,
|
code: KeyCode::Enter,
|
||||||
modifiers: KeyModifiers::NONE,
|
modifiers: KeyModifiers::NONE,
|
||||||
@@ -133,39 +268,25 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
|
|
||||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||||
self.complete = true;
|
self.complete = true;
|
||||||
|
if let Some(cb) = &self.on_escape {
|
||||||
|
cb(&self.app_event_tx);
|
||||||
|
}
|
||||||
CancellationEvent::Handled
|
CancellationEvent::Handled
|
||||||
}
|
}
|
||||||
|
|
||||||
fn desired_height(&self, width: u16) -> u16 {
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
// 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.
|
// Build the same display rows used by the renderer so wrapping math matches.
|
||||||
let rows: Vec<GenericDisplayRow> = self
|
let rows = self.build_rows();
|
||||||
.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_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
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 the title row, +1 for a spacer line beneath the header,
|
||||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
||||||
let mut height = rows_height + 2;
|
let mut height = rows_height + 2;
|
||||||
|
if self.is_searchable {
|
||||||
|
height = height.saturating_add(1);
|
||||||
|
}
|
||||||
if self.subtitle.is_some() {
|
if self.subtitle.is_some() {
|
||||||
// +1 for subtitle (the spacer is accounted for above)
|
// +1 for subtitle (the spacer is accounted for above)
|
||||||
height = height.saturating_add(1);
|
height = height.saturating_add(1);
|
||||||
@@ -194,6 +315,25 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
title_para.render(title_area, buf);
|
title_para.render(title_area, buf);
|
||||||
|
|
||||||
let mut next_y = area.y.saturating_add(1);
|
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 {
|
if let Some(sub) = &self.subtitle {
|
||||||
let subtitle_area = Rect {
|
let subtitle_area = Rect {
|
||||||
x: area.x,
|
x: area.x,
|
||||||
@@ -228,27 +368,7 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
.saturating_sub(footer_reserved),
|
.saturating_sub(footer_reserved),
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows: Vec<GenericDisplayRow> = self
|
let rows = self.build_rows();
|
||||||
.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();
|
|
||||||
if rows_area.height > 0 {
|
if rows_area.height > 0 {
|
||||||
render_rows(
|
render_rows(
|
||||||
rows_area,
|
rows_area,
|
||||||
@@ -279,6 +399,7 @@ mod tests {
|
|||||||
use super::BottomPaneView;
|
use super::BottomPaneView;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
|
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||||
use insta::assert_snapshot;
|
use insta::assert_snapshot;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
@@ -292,19 +413,26 @@ mod tests {
|
|||||||
description: Some("Codex can read files".to_string()),
|
description: Some("Codex can read files".to_string()),
|
||||||
is_current: true,
|
is_current: true,
|
||||||
actions: vec![],
|
actions: vec![],
|
||||||
|
dismiss_on_select: true,
|
||||||
|
search_value: None,
|
||||||
},
|
},
|
||||||
SelectionItem {
|
SelectionItem {
|
||||||
name: "Full Access".to_string(),
|
name: "Full Access".to_string(),
|
||||||
description: Some("Codex can edit files".to_string()),
|
description: Some("Codex can edit files".to_string()),
|
||||||
is_current: false,
|
is_current: false,
|
||||||
actions: vec![],
|
actions: vec![],
|
||||||
|
dismiss_on_select: true,
|
||||||
|
search_value: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
ListSelectionView::new(
|
ListSelectionView::new(
|
||||||
"Select Approval Mode".to_string(),
|
SelectionViewParams {
|
||||||
subtitle.map(str::to_string),
|
title: "Select Approval Mode".to_string(),
|
||||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
subtitle: subtitle.map(str::to_string),
|
||||||
items,
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
|
items,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
tx,
|
tx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -347,4 +475,33 @@ mod tests {
|
|||||||
let view = make_selection_view(Some("Switch between Codex approval presets"));
|
let view = make_selection_view(Some("Switch between Codex approval presets"));
|
||||||
assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view));
|
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()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
view.set_search_query("filters".to_string());
|
||||||
|
|
||||||
|
let lines = render_lines(&view);
|
||||||
|
assert!(lines.contains("▌ filters"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ mod bottom_pane_view;
|
|||||||
mod chat_composer;
|
mod chat_composer;
|
||||||
mod chat_composer_history;
|
mod chat_composer_history;
|
||||||
mod command_popup;
|
mod command_popup;
|
||||||
|
pub mod custom_prompt_view;
|
||||||
mod file_search_popup;
|
mod file_search_popup;
|
||||||
mod list_selection_view;
|
mod list_selection_view;
|
||||||
|
pub(crate) use list_selection_view::SelectionViewParams;
|
||||||
mod paste_burst;
|
mod paste_burst;
|
||||||
mod popup_consts;
|
pub mod popup_consts;
|
||||||
mod scroll_state;
|
mod scroll_state;
|
||||||
mod selection_popup_common;
|
mod selection_popup_common;
|
||||||
mod textarea;
|
mod textarea;
|
||||||
@@ -148,10 +150,10 @@ impl BottomPane {
|
|||||||
// status indicator shown while a task is running, or approval modal).
|
// status indicator shown while a task is running, or approval modal).
|
||||||
// In these states the textarea is not interactable, so we should not
|
// In these states the textarea is not interactable, so we should not
|
||||||
// show its caret.
|
// show its caret.
|
||||||
if self.active_view.is_some() {
|
let [_, content] = self.layout(area);
|
||||||
None
|
if let Some(view) = self.active_view.as_ref() {
|
||||||
|
view.cursor_pos(content)
|
||||||
} else {
|
} else {
|
||||||
let [_, content] = self.layout(area);
|
|
||||||
self.composer.cursor_pos(content)
|
self.composer.cursor_pos(content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,7 +226,17 @@ impl BottomPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_paste(&mut self, pasted: String) {
|
pub fn handle_paste(&mut self, pasted: String) {
|
||||||
if self.active_view.is_none() {
|
if let Some(mut view) = self.active_view.take() {
|
||||||
|
let needs_redraw = view.handle_paste(self, pasted);
|
||||||
|
if view.is_complete() {
|
||||||
|
self.on_active_view_complete();
|
||||||
|
} else {
|
||||||
|
self.active_view = Some(view);
|
||||||
|
}
|
||||||
|
if needs_redraw {
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let needs_redraw = self.composer.handle_paste(pasted);
|
let needs_redraw = self.composer.handle_paste(pasted);
|
||||||
if needs_redraw {
|
if needs_redraw {
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
@@ -318,22 +330,9 @@ impl BottomPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Show a generic list selection view with the provided items.
|
/// Show a generic list selection view with the provided items.
|
||||||
pub(crate) fn show_selection_view(
|
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
|
||||||
&mut self,
|
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||||
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.active_view = Some(Box::new(view));
|
||||||
self.request_redraw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the queued messages shown under the status header.
|
/// Update the queued messages shown under the status header.
|
||||||
@@ -373,6 +372,11 @@ impl BottomPane {
|
|||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||||
|
self.active_view = Some(view);
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
/// Called when the agent requests user approval.
|
/// Called when the agent requests user approval.
|
||||||
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
|
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.active_view.as_mut() {
|
||||||
|
|||||||
@@ -3,3 +3,6 @@
|
|||||||
/// Maximum number of rows any popup should attempt to display.
|
/// Maximum number of rows any popup should attempt to display.
|
||||||
/// Keep this consistent across all popups for a uniform feel.
|
/// Keep this consistent across all popups for a uniform feel.
|
||||||
pub(crate) const MAX_POPUP_ROWS: usize = 8;
|
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";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use ratatui::widgets::BorderType;
|
|||||||
use ratatui::widgets::Borders;
|
use ratatui::widgets::Borders;
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
use super::scroll_state::ScrollState;
|
use super::scroll_state::ScrollState;
|
||||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||||
@@ -55,10 +56,24 @@ fn compute_desc_col(
|
|||||||
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
|
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
|
||||||
/// dims the description.
|
/// dims the description.
|
||||||
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||||
|
// Enforce single-line name: allow at most desc_col - 2 cells for name,
|
||||||
|
// reserving two spaces before the description column.
|
||||||
|
let name_limit = desc_col.saturating_sub(2);
|
||||||
|
|
||||||
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
|
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
|
||||||
|
let mut used_width = 0usize;
|
||||||
|
let mut truncated = false;
|
||||||
|
|
||||||
if let Some(idxs) = row.match_indices.as_ref() {
|
if let Some(idxs) = row.match_indices.as_ref() {
|
||||||
let mut idx_iter = idxs.iter().peekable();
|
let mut idx_iter = idxs.iter().peekable();
|
||||||
for (char_idx, ch) in row.name.chars().enumerate() {
|
for (char_idx, ch) in row.name.chars().enumerate() {
|
||||||
|
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||||
|
if used_width + ch_w > name_limit {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
used_width += ch_w;
|
||||||
|
|
||||||
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
|
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
|
||||||
idx_iter.next();
|
idx_iter.next();
|
||||||
name_spans.push(ch.to_string().bold());
|
name_spans.push(ch.to_string().bold());
|
||||||
@@ -67,7 +82,21 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
name_spans.push(row.name.clone().into());
|
for ch in row.name.chars() {
|
||||||
|
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||||
|
if used_width + ch_w > name_limit {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
used_width += ch_w;
|
||||||
|
name_spans.push(ch.to_string().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncated {
|
||||||
|
// If there is at least one cell available, add an ellipsis.
|
||||||
|
// When name_limit is 0, we still show an ellipsis to indicate truncation.
|
||||||
|
name_spans.push("…".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let this_name_width = Line::from(name_spans.clone()).width();
|
let this_name_width = Line::from(name_spans.clone()).width();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config_types::Notifications;
|
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::AgentMessageDeltaEvent;
|
||||||
use codex_core::protocol::AgentMessageEvent;
|
use codex_core::protocol::AgentMessageEvent;
|
||||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||||
@@ -63,6 +65,9 @@ use crate::bottom_pane::CancellationEvent;
|
|||||||
use crate::bottom_pane::InputResult;
|
use crate::bottom_pane::InputResult;
|
||||||
use crate::bottom_pane::SelectionAction;
|
use crate::bottom_pane::SelectionAction;
|
||||||
use crate::bottom_pane::SelectionItem;
|
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::clipboard_paste::paste_image_to_temp_png;
|
||||||
use crate::diff_render::display_path_for;
|
use crate::diff_render::display_path_for;
|
||||||
use crate::get_git_diff::get_git_diff;
|
use crate::get_git_diff::get_git_diff;
|
||||||
@@ -87,6 +92,7 @@ mod session_header;
|
|||||||
use self::session_header::SessionHeader;
|
use self::session_header::SessionHeader;
|
||||||
use crate::streaming::controller::AppEventHistorySink;
|
use crate::streaming::controller::AppEventHistorySink;
|
||||||
use crate::streaming::controller::StreamController;
|
use crate::streaming::controller::StreamController;
|
||||||
|
//
|
||||||
use codex_common::approval_presets::ApprovalPreset;
|
use codex_common::approval_presets::ApprovalPreset;
|
||||||
use codex_common::approval_presets::builtin_approval_presets;
|
use codex_common::approval_presets::builtin_approval_presets;
|
||||||
use codex_common::model_presets::ModelPreset;
|
use codex_common::model_presets::ModelPreset;
|
||||||
@@ -97,6 +103,7 @@ use codex_core::protocol::AskForApproval;
|
|||||||
use codex_core::protocol::SandboxPolicy;
|
use codex_core::protocol::SandboxPolicy;
|
||||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
// Track information about an in-flight exec command.
|
// Track information about an in-flight exec command.
|
||||||
struct RunningCommand {
|
struct RunningCommand {
|
||||||
@@ -941,13 +948,7 @@ impl ChatWidget {
|
|||||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||||
}
|
}
|
||||||
SlashCommand::Review => {
|
SlashCommand::Review => {
|
||||||
// Simplified flow: directly send a review op for current changes.
|
self.open_review_popup();
|
||||||
self.submit_op(Op::Review {
|
|
||||||
review_request: ReviewRequest {
|
|
||||||
prompt: "review current changes".to_string(),
|
|
||||||
user_facing_hint: "current changes".to_string(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
SlashCommand::Model => {
|
SlashCommand::Model => {
|
||||||
self.open_model_popup();
|
self.open_model_popup();
|
||||||
@@ -1417,15 +1418,20 @@ impl ChatWidget {
|
|||||||
description,
|
description,
|
||||||
is_current,
|
is_current,
|
||||||
actions,
|
actions,
|
||||||
|
dismiss_on_select: true,
|
||||||
|
search_value: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
self.bottom_pane.show_selection_view(
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
"Select model and reasoning level".to_string(),
|
title: "Select model and reasoning level".to_string(),
|
||||||
Some("Switch between OpenAI models for this and future Codex CLI session".to_string()),
|
subtitle: Some(
|
||||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
"Switch between OpenAI models for this and future Codex CLI session".to_string(),
|
||||||
|
),
|
||||||
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
items,
|
items,
|
||||||
);
|
..Default::default()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
||||||
@@ -1458,15 +1464,17 @@ impl ChatWidget {
|
|||||||
description,
|
description,
|
||||||
is_current,
|
is_current,
|
||||||
actions,
|
actions,
|
||||||
|
dismiss_on_select: true,
|
||||||
|
search_value: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
self.bottom_pane.show_selection_view(
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
"Select Approval Mode".to_string(),
|
title: "Select Approval Mode".to_string(),
|
||||||
None,
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
|
||||||
items,
|
items,
|
||||||
);
|
..Default::default()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the approval policy in the widget's config copy.
|
/// Set the approval policy in the widget's config copy.
|
||||||
@@ -1575,6 +1583,181 @@ impl ChatWidget {
|
|||||||
self.bottom_pane.set_custom_prompts(ev.custom_prompts);
|
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 uncommitted 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// New: Review a specific commit (opens commit picker)
|
||||||
|
items.push(SelectionItem {
|
||||||
|
name: "Review a commit".to_string(),
|
||||||
|
description: None,
|
||||||
|
is_current: false,
|
||||||
|
actions: vec![Box::new({
|
||||||
|
let cwd = self.config.cwd.clone();
|
||||||
|
move |tx| {
|
||||||
|
tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone()));
|
||||||
|
}
|
||||||
|
})],
|
||||||
|
dismiss_on_select: false,
|
||||||
|
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,
|
||||||
|
on_escape: None,
|
||||||
|
..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 merge diff between the current branch and {branch} e.g. (git merge-base HEAD {branch}), then run `git diff` against that SHA 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()),
|
||||||
|
on_escape: Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) {
|
||||||
|
let commits = codex_core::git_info::recent_commits(cwd, 100).await;
|
||||||
|
|
||||||
|
let mut items: Vec<SelectionItem> = Vec::with_capacity(commits.len());
|
||||||
|
for entry in commits {
|
||||||
|
let subject = entry.subject.clone();
|
||||||
|
let sha = entry.sha.clone();
|
||||||
|
let short = sha.chars().take(7).collect::<String>();
|
||||||
|
let search_val = format!("{subject} {sha}");
|
||||||
|
|
||||||
|
items.push(SelectionItem {
|
||||||
|
name: subject.clone(),
|
||||||
|
description: None,
|
||||||
|
is_current: false,
|
||||||
|
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||||||
|
let hint = format!("commit {short}");
|
||||||
|
let prompt = format!(
|
||||||
|
"Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings."
|
||||||
|
);
|
||||||
|
tx3.send(AppEvent::CodexOp(Op::Review {
|
||||||
|
review_request: ReviewRequest {
|
||||||
|
prompt,
|
||||||
|
user_facing_hint: hint,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
})],
|
||||||
|
dismiss_on_select: true,
|
||||||
|
search_value: Some(search_val),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
|
title: "Select a commit to review".to_string(),
|
||||||
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
|
items,
|
||||||
|
is_searchable: true,
|
||||||
|
search_placeholder: Some("Type to search commits".to_string()),
|
||||||
|
on_escape: Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))),
|
||||||
|
..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,
|
||||||
|
self.app_event_tx.clone(),
|
||||||
|
Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))),
|
||||||
|
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
|
/// Programmatically submit a user text message as if typed in the
|
||||||
/// composer. The text will be added to conversation history and sent to
|
/// composer. The text will be added to conversation history and sent to
|
||||||
/// the agent.
|
/// the agent.
|
||||||
@@ -1731,5 +1914,48 @@ fn extract_first_bold(s: &str) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn show_review_commit_picker_with_entries(
|
||||||
|
chat: &mut ChatWidget,
|
||||||
|
entries: Vec<codex_core::git_info::CommitLogEntry>,
|
||||||
|
) {
|
||||||
|
let mut items: Vec<SelectionItem> = Vec::with_capacity(entries.len());
|
||||||
|
for entry in entries {
|
||||||
|
let subject = entry.subject.clone();
|
||||||
|
let sha = entry.sha.clone();
|
||||||
|
let short = sha.chars().take(7).collect::<String>();
|
||||||
|
let search_val = format!("{subject} {sha}");
|
||||||
|
|
||||||
|
items.push(SelectionItem {
|
||||||
|
name: subject.clone(),
|
||||||
|
description: None,
|
||||||
|
is_current: false,
|
||||||
|
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||||||
|
let hint = format!("commit {short}");
|
||||||
|
let prompt = format!(
|
||||||
|
"Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings."
|
||||||
|
);
|
||||||
|
tx3.send(AppEvent::CodexOp(Op::Review {
|
||||||
|
review_request: ReviewRequest {
|
||||||
|
prompt,
|
||||||
|
user_facing_hint: hint,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
})],
|
||||||
|
dismiss_on_select: true,
|
||||||
|
search_value: Some(search_val),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chat.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
|
title: "Select a commit to review".to_string(),
|
||||||
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
|
items,
|
||||||
|
is_searchable: true,
|
||||||
|
search_placeholder: Some("Type to search commits".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod tests;
|
pub(crate) mod tests;
|
||||||
|
|||||||
@@ -801,6 +801,135 @@ fn exec_history_cell_shows_working_then_failed() {
|
|||||||
assert!(blob.to_lowercase().contains("bloop"), "expected error text");
|
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 fourth item: "Custom review instructions"
|
||||||
|
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The commit picker shows only commit subjects (no timestamps).
|
||||||
|
#[test]
|
||||||
|
fn review_commit_picker_shows_subjects_without_timestamps() {
|
||||||
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||||
|
|
||||||
|
// Open the Review presets parent popup.
|
||||||
|
chat.open_review_popup();
|
||||||
|
|
||||||
|
// Show commit picker with synthetic entries.
|
||||||
|
let entries = vec![
|
||||||
|
codex_core::git_info::CommitLogEntry {
|
||||||
|
sha: "1111111deadbeef".to_string(),
|
||||||
|
timestamp: 0,
|
||||||
|
subject: "Add new feature X".to_string(),
|
||||||
|
},
|
||||||
|
codex_core::git_info::CommitLogEntry {
|
||||||
|
sha: "2222222cafebabe".to_string(),
|
||||||
|
timestamp: 0,
|
||||||
|
subject: "Fix bug Y".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
super::show_review_commit_picker_with_entries(&mut chat, entries);
|
||||||
|
|
||||||
|
// Render the bottom pane and inspect the lines for subjects and absence of time words.
|
||||||
|
let width = 72;
|
||||||
|
let height = chat.desired_height(width);
|
||||||
|
let area = ratatui::layout::Rect::new(0, 0, width, height);
|
||||||
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||||
|
(&chat).render_ref(area, &mut buf);
|
||||||
|
|
||||||
|
let mut blob = String::new();
|
||||||
|
for y in 0..area.height {
|
||||||
|
for x in 0..area.width {
|
||||||
|
let s = buf[(x, y)].symbol();
|
||||||
|
if s.is_empty() {
|
||||||
|
blob.push(' ');
|
||||||
|
} else {
|
||||||
|
blob.push_str(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blob.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
blob.contains("Add new feature X"),
|
||||||
|
"expected subject in output"
|
||||||
|
);
|
||||||
|
assert!(blob.contains("Fix bug Y"), "expected subject in output");
|
||||||
|
|
||||||
|
// Ensure no relative-time phrasing is present.
|
||||||
|
let lowered = blob.to_lowercase();
|
||||||
|
assert!(
|
||||||
|
!lowered.contains("ago")
|
||||||
|
&& !lowered.contains(" second")
|
||||||
|
&& !lowered.contains(" minute")
|
||||||
|
&& !lowered.contains(" hour")
|
||||||
|
&& !lowered.contains(" day"),
|
||||||
|
"expected no relative time in commit picker output: {blob:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 ✗
|
// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗
|
||||||
// marker (replacing the spinner) and flushes it into history.
|
// marker (replacing the spinner) and flushes it into history.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -830,6 +959,110 @@ fn interrupt_exec_marks_failed_snapshot() {
|
|||||||
assert_snapshot!("interrupt_exec_marks_failed", exec_blob);
|
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, mut 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));
|
||||||
|
// Process emitted app events to reopen the parent review popup.
|
||||||
|
while let Ok(ev) = rx.try_recv() {
|
||||||
|
if let AppEvent::OpenReviewPopup = ev {
|
||||||
|
chat.open_review_popup();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, mut 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));
|
||||||
|
// Process emitted app events to reopen the parent review popup.
|
||||||
|
while let Ok(ev) = rx.try_recv() {
|
||||||
|
if let AppEvent::OpenReviewPopup = ev {
|
||||||
|
chat.open_review_popup();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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]
|
#[test]
|
||||||
fn exec_history_extends_previous_when_consecutive() {
|
fn exec_history_extends_previous_when_consecutive() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ impl SlashCommand {
|
|||||||
SlashCommand::New => "start a new chat during a conversation",
|
SlashCommand::New => "start a new chat during a conversation",
|
||||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||||
SlashCommand::Review => "review my current changes and find issues",
|
SlashCommand::Review => "review my changes and find issues",
|
||||||
SlashCommand::Quit => "exit Codex",
|
SlashCommand::Quit => "exit Codex",
|
||||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||||
SlashCommand::Mention => "mention a file",
|
SlashCommand::Mention => "mention a file",
|
||||||
|
|||||||
Reference in New Issue
Block a user