mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Compare commits
2 Commits
turncontex
...
daniel/rev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0f311d79 | ||
|
|
118e659848 |
@@ -3293,7 +3293,7 @@ async fn exit_review_mode(
|
||||
<results>
|
||||
{findings_str}
|
||||
</results>
|
||||
</user_tool>
|
||||
</user_action>
|
||||
"#));
|
||||
} else {
|
||||
user_message.push_str(r#"<user_action>
|
||||
@@ -3302,7 +3302,7 @@ async fn exit_review_mode(
|
||||
<results>
|
||||
None.
|
||||
</results>
|
||||
</user_tool>
|
||||
</user_action>
|
||||
"#);
|
||||
}
|
||||
|
||||
|
||||
@@ -558,6 +558,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
TurnAbortReason::Replaced => {
|
||||
ts_println!(self, "task aborted: replaced by a new task");
|
||||
}
|
||||
TurnAbortReason::ReviewEnded => {
|
||||
ts_println!(self, "task aborted: review ended");
|
||||
}
|
||||
},
|
||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||
EventMsg::ConversationPath(_) => {}
|
||||
|
||||
@@ -1240,6 +1240,7 @@ pub struct TurnAbortedEvent {
|
||||
pub enum TurnAbortReason {
|
||||
Interrupted,
|
||||
Replaced,
|
||||
ReviewEnded,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -268,6 +268,9 @@ impl App {
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::SubmitUserText(text) => {
|
||||
self.chat_widget.submit_text_message(text);
|
||||
}
|
||||
AppEvent::ConversationHistory(ev) => {
|
||||
self.on_conversation_history_for_backtrack(tui, ev).await?;
|
||||
}
|
||||
@@ -291,6 +294,13 @@ impl App {
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::DiffShortstat {
|
||||
shortstat,
|
||||
request_id,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.on_diff_shortstat_ready(shortstat, request_id);
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
if !query.is_empty() {
|
||||
self.file_search.on_user_query(query);
|
||||
|
||||
@@ -2,6 +2,7 @@ use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
|
||||
use crate::git_shortstat::DiffShortStat;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
@@ -23,6 +24,10 @@ pub(crate) enum AppEvent {
|
||||
/// bubbling channels through layers of widgets.
|
||||
CodexOp(codex_core::protocol::Op),
|
||||
|
||||
/// Ask the ChatWidget to submit a user text message using its
|
||||
/// standard helpers.
|
||||
SubmitUserText(String),
|
||||
|
||||
/// Kick off an asynchronous file search for the given query (text after
|
||||
/// the `@`). Previous searches may be cancelled by the app layer so there
|
||||
/// is at most one in-flight search.
|
||||
@@ -39,6 +44,12 @@ pub(crate) enum AppEvent {
|
||||
/// Result of computing a `/diff` command.
|
||||
DiffResult(String),
|
||||
|
||||
/// Result of computing `git diff --shortstat`.
|
||||
DiffShortstat {
|
||||
shortstat: Option<DiffShortStat>,
|
||||
request_id: u64,
|
||||
},
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
|
||||
StartCommitAnimation,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::any::Any;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
@@ -42,6 +43,10 @@ impl ApprovalModalView {
|
||||
}
|
||||
|
||||
impl BottomPaneView for ApprovalModalView {
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
self.current.handle_key_event(key_event);
|
||||
self.maybe_advance();
|
||||
|
||||
@@ -2,12 +2,13 @@ use crate::user_approval_widget::ApprovalRequest;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::any::Any;
|
||||
|
||||
use super::BottomPane;
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// Trait implemented by every view that can be shown in the bottom pane.
|
||||
pub(crate) trait BottomPaneView {
|
||||
pub(crate) trait BottomPaneView: Any {
|
||||
/// Handle a key event while the view is active. A redraw is always
|
||||
/// scheduled after this call.
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, _key_event: KeyEvent) {}
|
||||
@@ -28,6 +29,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(
|
||||
@@ -36,4 +48,6 @@ pub(crate) trait BottomPaneView {
|
||||
) -> Option<ApprovalRequest> {
|
||||
Some(request)
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
207
codex-rs/tui/src/bottom_pane/branch_picker_view.rs
Normal file
207
codex-rs/tui/src/bottom_pane/branch_picker_view.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use std::any::Any;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::git_shortstat::get_diff_shortstat_against;
|
||||
|
||||
use super::BottomPane;
|
||||
use super::SearchableTablePickerView;
|
||||
use super::TablePickerItem;
|
||||
use super::TablePickerOnSelected;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Callback invoked when a branch is selected.
|
||||
pub(crate) type BranchSelected = TablePickerOnSelected<String>;
|
||||
|
||||
/// A searchable branch picker view. Shows the repository's branches with the
|
||||
/// default/base branch pinned to the top when present.
|
||||
pub(crate) struct BranchPickerView {
|
||||
inner: SearchableTablePickerView<String>,
|
||||
}
|
||||
|
||||
impl BranchPickerView {
|
||||
pub(crate) fn new(
|
||||
cwd: PathBuf,
|
||||
app_event_tx: AppEventSender,
|
||||
on_selected: BranchSelected,
|
||||
) -> Self {
|
||||
let branch_items = load_branch_items(&cwd);
|
||||
let inner = SearchableTablePickerView::new(
|
||||
"Select a base branch".to_string(),
|
||||
"Type to search branches".to_string(),
|
||||
"no matches".to_string(),
|
||||
branch_items,
|
||||
MAX_POPUP_ROWS,
|
||||
app_event_tx,
|
||||
on_selected,
|
||||
);
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for BranchPickerView {
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, pane: &mut BottomPane, key_event: crossterm::event::KeyEvent) {
|
||||
self.inner.handle_key_event(pane, key_event);
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.inner.is_complete()
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, pane: &mut super::BottomPane) -> super::CancellationEvent {
|
||||
self.inner.on_ctrl_c(pane)
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.inner.desired_height(width)
|
||||
}
|
||||
|
||||
fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
self.inner.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_branch_items(cwd: &PathBuf) -> Vec<TablePickerItem<String>> {
|
||||
let branches = gather_branches(cwd);
|
||||
let current_branch = current_branch_name(cwd).unwrap_or_else(|| "(detached HEAD)".to_string());
|
||||
|
||||
branches
|
||||
.into_iter()
|
||||
.map(|branch| {
|
||||
let label = format!("{current_branch} -> {branch}");
|
||||
let search_value = format!("{current_branch} {branch}");
|
||||
let cwd = cwd.clone();
|
||||
let detail_branch = branch.clone();
|
||||
TablePickerItem {
|
||||
value: branch,
|
||||
label,
|
||||
description: None,
|
||||
search_value,
|
||||
// Wrap the async function in a blocking call for sync context
|
||||
detail_builder: Some(Box::new(move || {
|
||||
// Use block_in_place to avoid blocking the async runtime if present
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::try_current()
|
||||
.ok()
|
||||
.and_then(|handle| {
|
||||
handle.block_on(branch_shortstat(&cwd, &detail_branch))
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback: create a new runtime if not in one
|
||||
let rt = tokio::runtime::Runtime::new().ok()?;
|
||||
rt.block_on(branch_shortstat(&cwd, &detail_branch))
|
||||
})
|
||||
})
|
||||
})),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gather_branches(cwd: &PathBuf) -> Vec<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["branch", "--format=%(refname:short)"])
|
||||
.current_dir(cwd)
|
||||
.output();
|
||||
|
||||
let mut branches: Vec<String> = match out {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
branches.sort_unstable();
|
||||
|
||||
if let Some(base) = default_branch(cwd, &branches)
|
||||
&& let Some(pos) = branches.iter().position(|name| name == &base)
|
||||
{
|
||||
let base_branch = branches.remove(pos);
|
||||
branches.insert(0, base_branch);
|
||||
}
|
||||
|
||||
branches
|
||||
}
|
||||
|
||||
fn default_branch(cwd: &PathBuf, branches: &[String]) -> Option<String> {
|
||||
let configured_default = if let Ok(out) = std::process::Command::new("git")
|
||||
.args(["config", "--get", "init.defaultBranch"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
{
|
||||
if out.status.success() {
|
||||
String::from_utf8(out.stdout)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(default_branch) = configured_default
|
||||
&& branches.iter().any(|branch| branch == &default_branch)
|
||||
{
|
||||
return Some(default_branch);
|
||||
}
|
||||
|
||||
for candidate in ["main", "master"] {
|
||||
if branches.iter().any(|branch| branch == candidate) {
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn current_branch_name(cwd: &PathBuf) -> Option<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8(out.stdout)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
async fn branch_shortstat(cwd: &Path, branch: &str) -> Option<Vec<Span<'static>>> {
|
||||
let stats = get_diff_shortstat_against(cwd, branch)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
if stats.files_changed == 0 && stats.insertions == 0 && stats.deletions == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_label = if stats.files_changed == 1 {
|
||||
"file changed"
|
||||
} else {
|
||||
"files changed"
|
||||
};
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(format!("- {} {file_label} (", stats.files_changed).dim());
|
||||
spans.push(format!("+{}", stats.insertions).green());
|
||||
spans.push(" ".dim());
|
||||
spans.push(format!("-{}", stats.deletions).red());
|
||||
spans.push(")".dim());
|
||||
Some(spans)
|
||||
}
|
||||
@@ -142,16 +142,14 @@ impl ChatComposer {
|
||||
.desired_height(width.saturating_sub(LIVE_PREFIX_COLS))
|
||||
+ match &self.active_popup {
|
||||
ActivePopup::None => FOOTER_HEIGHT_WITH_HINT,
|
||||
ActivePopup::Command(c) => c.calculate_required_height(width),
|
||||
ActivePopup::Command(c) => c.calculate_required_height(),
|
||||
ActivePopup::File(c) => c.calculate_required_height(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let popup_constraint = match &self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
Constraint::Max(popup.calculate_required_height(area.width))
|
||||
}
|
||||
ActivePopup::Command(popup) => Constraint::Max(popup.calculate_required_height()),
|
||||
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
|
||||
ActivePopup::None => Constraint::Max(FOOTER_HEIGHT_WITH_HINT),
|
||||
};
|
||||
@@ -1234,10 +1232,7 @@ impl ChatComposer {
|
||||
impl WidgetRef for ChatComposer {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let (popup_constraint, hint_spacing) = match &self.active_popup {
|
||||
ActivePopup::Command(popup) => (
|
||||
Constraint::Max(popup.calculate_required_height(area.width)),
|
||||
0,
|
||||
),
|
||||
ActivePopup::Command(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
|
||||
ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
|
||||
ActivePopup::None => (
|
||||
Constraint::Length(FOOTER_HEIGHT_WITH_HINT),
|
||||
|
||||
@@ -92,35 +92,10 @@ impl CommandPopup {
|
||||
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||
}
|
||||
|
||||
/// Determine the preferred height of the popup for a given width.
|
||||
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
|
||||
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
let matches = self.filtered();
|
||||
let rows_all: Vec<GenericDisplayRow> = if matches.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|(item, indices, _)| match item {
|
||||
CommandItem::Builtin(cmd) => GenericDisplayRow {
|
||||
name: format!("/{}", cmd.command()),
|
||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||
is_current: false,
|
||||
description: Some(cmd.description().to_string()),
|
||||
},
|
||||
CommandItem::UserPrompt(i) => GenericDisplayRow {
|
||||
name: format!("/{}", self.prompts[i].name),
|
||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||
is_current: false,
|
||||
description: Some("send saved prompt".to_string()),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
measure_rows_height(&rows_all, &self.state, MAX_POPUP_ROWS, width)
|
||||
/// Determine the preferred height of the popup. This is the number of
|
||||
/// rows required to show at most MAX_POPUP_ROWS commands.
|
||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||
self.filtered_items().len().clamp(1, MAX_POPUP_ROWS) as u16
|
||||
}
|
||||
|
||||
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
||||
@@ -210,12 +185,14 @@ impl WidgetRef for CommandPopup {
|
||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||
is_current: false,
|
||||
description: Some(cmd.description().to_string()),
|
||||
styled_name: None,
|
||||
},
|
||||
CommandItem::UserPrompt(i) => GenericDisplayRow {
|
||||
name: format!("/{}", self.prompts[i].name),
|
||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||
is_current: false,
|
||||
description: Some("send saved prompt".to_string()),
|
||||
styled_name: None,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
|
||||
134
codex-rs/tui/src/bottom_pane/commit_picker_view.rs
Normal file
134
codex-rs/tui/src/bottom_pane/commit_picker_view.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::any::Any;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
use super::BottomPane;
|
||||
use super::SearchableTablePickerView;
|
||||
use super::TablePickerItem;
|
||||
use super::TablePickerOnSelected;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CommitSelection {
|
||||
pub full_sha: String,
|
||||
pub short_sha: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
pub(crate) type CommitSelected = TablePickerOnSelected<CommitSelection>;
|
||||
|
||||
pub(crate) struct CommitPickerView {
|
||||
inner: SearchableTablePickerView<CommitSelection>,
|
||||
}
|
||||
|
||||
impl CommitPickerView {
|
||||
pub(crate) fn new(
|
||||
cwd: PathBuf,
|
||||
app_event_tx: AppEventSender,
|
||||
on_selected: CommitSelected,
|
||||
) -> Self {
|
||||
let commits = load_recent_commits(&cwd);
|
||||
let inner = SearchableTablePickerView::new(
|
||||
"Select a commit".to_string(),
|
||||
"Type to search commits".to_string(),
|
||||
"no commits found".to_string(),
|
||||
commits,
|
||||
10,
|
||||
app_event_tx,
|
||||
on_selected,
|
||||
);
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl super::bottom_pane_view::BottomPaneView for CommitPickerView {
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, pane: &mut BottomPane, key_event: crossterm::event::KeyEvent) {
|
||||
self.inner.handle_key_event(pane, key_event);
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.inner.is_complete()
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, pane: &mut super::BottomPane) -> super::CancellationEvent {
|
||||
self.inner.on_ctrl_c(pane)
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.inner.desired_height(width)
|
||||
}
|
||||
|
||||
fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
self.inner.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_recent_commits(cwd: &PathBuf) -> Vec<TablePickerItem<CommitSelection>> {
|
||||
const FIELDS_SEPARATOR: char = '\u{1f}';
|
||||
let output = std::process::Command::new("git")
|
||||
.args([
|
||||
"log",
|
||||
"-n",
|
||||
"100",
|
||||
"--pretty=format:%H%x1f%h%x1f%an%x1f%ar%x1f%s",
|
||||
])
|
||||
.current_dir(cwd)
|
||||
.output();
|
||||
|
||||
let stdout = match output {
|
||||
Ok(out) if out.status.success() => out.stdout,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
String::from_utf8_lossy(&stdout)
|
||||
.lines()
|
||||
.filter_map(|line| parse_log_line(line, FIELDS_SEPARATOR))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_log_line(line: &str, separator: char) -> Option<TablePickerItem<CommitSelection>> {
|
||||
let mut parts = line.split(separator);
|
||||
let full_sha = parts.next()?.trim().to_string();
|
||||
let short_sha = parts.next()?.trim().to_string();
|
||||
let author = parts.next()?.trim().to_string();
|
||||
let relative_time = parts.next()?.trim().to_string();
|
||||
let summary = parts.next().unwrap_or_default().trim().to_string();
|
||||
|
||||
if full_sha.is_empty() || short_sha.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let label = if summary.is_empty() {
|
||||
short_sha.clone()
|
||||
} else {
|
||||
format!("{short_sha} {summary}")
|
||||
};
|
||||
|
||||
let description = if author.is_empty() && relative_time.is_empty() {
|
||||
None
|
||||
} else if author.is_empty() {
|
||||
Some(relative_time.clone())
|
||||
} else if relative_time.is_empty() {
|
||||
Some(author.clone())
|
||||
} else {
|
||||
Some(format!("{author} · {relative_time}"))
|
||||
};
|
||||
|
||||
let search_value = format!("{full_sha} {short_sha} {summary} {author} {relative_time}");
|
||||
|
||||
Some(TablePickerItem {
|
||||
value: CommitSelection {
|
||||
full_sha,
|
||||
short_sha,
|
||||
summary,
|
||||
},
|
||||
label,
|
||||
description,
|
||||
search_value,
|
||||
detail_builder: None,
|
||||
})
|
||||
}
|
||||
243
codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
243
codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
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::any::Any;
|
||||
use std::cell::RefCell;
|
||||
|
||||
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>,
|
||||
allow_empty_submit: bool,
|
||||
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>,
|
||||
allow_empty_submit: bool,
|
||||
on_submit: PromptSubmitted,
|
||||
) -> Self {
|
||||
Self {
|
||||
title,
|
||||
placeholder,
|
||||
context_label,
|
||||
allow_empty_submit,
|
||||
on_submit,
|
||||
textarea: TextArea::new(),
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
complete: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for CustomPromptView {
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _pane: &mut super::BottomPane, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.complete = true;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
let text = self.textarea.text().trim().to_string();
|
||||
if self.allow_empty_submit || !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 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(super::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()
|
||||
}
|
||||
@@ -128,6 +128,7 @@ impl WidgetRef for &FileSearchPopup {
|
||||
.map(|v| v.iter().map(|&i| i as usize).collect()),
|
||||
is_current: false,
|
||||
description: None,
|
||||
styled_name: None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -6,8 +6,10 @@ 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::Widget;
|
||||
use std::any::Any;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
@@ -17,23 +19,24 @@ use super::bottom_pane_view::BottomPaneView;
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use super::standard_popup_hint_line;
|
||||
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&mut BottomPane, &AppEventSender) + Send + Sync>;
|
||||
|
||||
pub(crate) struct SelectionItem {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub actions: Vec<SelectionAction>,
|
||||
pub styled_label: Option<Vec<Span<'static>>>,
|
||||
pub dismiss_on_select: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ListSelectionView {
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
footer_hint: Option<String>,
|
||||
items: Vec<SelectionItem>,
|
||||
state: ScrollState,
|
||||
complete: bool,
|
||||
@@ -46,20 +49,22 @@ impl ListSelectionView {
|
||||
}
|
||||
|
||||
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
Clear.render(area, buf);
|
||||
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 {
|
||||
let mut s = Self {
|
||||
title,
|
||||
subtitle,
|
||||
footer_hint,
|
||||
items,
|
||||
state: ScrollState::new(),
|
||||
complete: false,
|
||||
@@ -74,6 +79,19 @@ impl ListSelectionView {
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub(crate) fn update_item<F>(&mut self, index: usize, mut update: F)
|
||||
where
|
||||
F: FnMut(&mut SelectionItem),
|
||||
{
|
||||
if let Some(item) = self.items.get_mut(index) {
|
||||
update(item);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let len = self.items.len();
|
||||
self.state.move_up_wrap(len);
|
||||
@@ -86,13 +104,15 @@ impl ListSelectionView {
|
||||
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
||||
}
|
||||
|
||||
fn accept(&mut self) {
|
||||
fn accept(&mut self, pane: &mut BottomPane) {
|
||||
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);
|
||||
act(pane, &self.app_event_tx);
|
||||
}
|
||||
if item.dismiss_on_select {
|
||||
self.complete = true;
|
||||
}
|
||||
self.complete = true;
|
||||
}
|
||||
} else {
|
||||
self.complete = true;
|
||||
@@ -106,7 +126,11 @@ impl ListSelectionView {
|
||||
}
|
||||
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
@@ -122,7 +146,7 @@ impl BottomPaneView for ListSelectionView {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.accept(),
|
||||
} => self.accept(pane),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -136,44 +160,16 @@ impl BottomPaneView for ListSelectionView {
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
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_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
let rows = (self.items.len()).clamp(1, MAX_POPUP_ROWS);
|
||||
// +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;
|
||||
// +1 for optional subtitle, +1 for optional footer
|
||||
let mut height = rows as u16 + 2;
|
||||
if self.subtitle.is_some() {
|
||||
// +1 for subtitle (the spacer is accounted for above)
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
if self.footer_hint.is_some() {
|
||||
height = height.saturating_add(2);
|
||||
}
|
||||
height
|
||||
height.saturating_add(2)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -217,7 +213,7 @@ impl BottomPaneView for ListSelectionView {
|
||||
Self::render_dim_prefix_line(spacer_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
|
||||
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
|
||||
let footer_reserved = 2;
|
||||
let rows_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
@@ -235,17 +231,28 @@ impl BottomPaneView for ListSelectionView {
|
||||
.map(|(i, it)| {
|
||||
let is_selected = self.state.selected_idx == Some(i);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let number = i + 1;
|
||||
let label_prefix = format!("{prefix} {number}. ");
|
||||
let styled_name = if let Some(styled_label) = it.styled_label.as_ref() {
|
||||
let mut spans = Vec::new();
|
||||
spans.push(label_prefix.into());
|
||||
spans.extend(styled_label.clone());
|
||||
Some(spans)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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);
|
||||
let display_name = format!("{prefix} {number}. {name_with_marker}");
|
||||
GenericDisplayRow {
|
||||
name: display_name,
|
||||
match_indices: None,
|
||||
is_current: it.is_current,
|
||||
description: it.description.clone(),
|
||||
styled_name,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -261,16 +268,22 @@ impl BottomPaneView for ListSelectionView {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(hint) = &self.footer_hint {
|
||||
let footer_area = Rect {
|
||||
if area.height >= 2 {
|
||||
let spacer_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + area.height - 1,
|
||||
y: area.y + area.height - 2,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let footer_para = Paragraph::new(hint.clone().dim());
|
||||
footer_para.render(footer_area, buf);
|
||||
Clear.render(spacer_area, buf);
|
||||
}
|
||||
let footer_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + area.height - 1,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(standard_popup_hint_line()).render(footer_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,18 +305,21 @@ mod tests {
|
||||
description: Some("Codex can read files".to_string()),
|
||||
is_current: true,
|
||||
actions: vec![],
|
||||
styled_label: None,
|
||||
dismiss_on_select: true,
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Full Access".to_string(),
|
||||
description: Some("Codex can edit files".to_string()),
|
||||
is_current: false,
|
||||
actions: vec![],
|
||||
styled_label: None,
|
||||
dismiss_on_select: true,
|
||||
},
|
||||
];
|
||||
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,
|
||||
tx,
|
||||
)
|
||||
|
||||
@@ -12,22 +12,35 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::time::Duration;
|
||||
|
||||
mod approval_modal_view;
|
||||
mod bottom_pane_view;
|
||||
pub(crate) mod bottom_pane_view;
|
||||
mod branch_picker_view;
|
||||
mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
mod command_popup;
|
||||
mod commit_picker_view;
|
||||
mod custom_prompt_view;
|
||||
mod file_search_popup;
|
||||
mod list_selection_view;
|
||||
mod paste_burst;
|
||||
mod popup_consts;
|
||||
mod review_selection_view;
|
||||
mod scroll_state;
|
||||
mod searchable_table_picker_view;
|
||||
mod selection_popup_common;
|
||||
mod textarea;
|
||||
|
||||
pub(crate) const STANDARD_POPUP_HINT: &str = "Press Enter to confirm or Esc to go back";
|
||||
|
||||
pub(crate) fn standard_popup_hint_line() -> Line<'static> {
|
||||
Line::from(vec![STANDARD_POPUP_HINT.dim()])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CancellationEvent {
|
||||
Handled,
|
||||
@@ -40,8 +53,15 @@ use codex_protocol::custom_prompts::CustomPrompt;
|
||||
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
use approval_modal_view::ApprovalModalView;
|
||||
pub(crate) use branch_picker_view::BranchPickerView;
|
||||
pub(crate) use commit_picker_view::CommitPickerView;
|
||||
pub(crate) use custom_prompt_view::CustomPromptView;
|
||||
pub(crate) use list_selection_view::SelectionAction;
|
||||
pub(crate) use list_selection_view::SelectionItem;
|
||||
pub(crate) use review_selection_view::ReviewSelectionView;
|
||||
pub(crate) use searchable_table_picker_view::SearchableTablePickerView;
|
||||
pub(crate) use searchable_table_picker_view::TablePickerItem;
|
||||
pub(crate) use searchable_table_picker_view::TablePickerOnSelected;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
pub(crate) struct BottomPane {
|
||||
@@ -49,8 +69,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,
|
||||
@@ -87,7 +107,7 @@ impl BottomPane {
|
||||
params.placeholder_text,
|
||||
params.disable_paste_burst,
|
||||
),
|
||||
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 +119,30 @@ 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(crate) fn clear_views(&mut self) {
|
||||
if self.view_stack.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.view_stack.clear();
|
||||
self.resume_status_timer_after_modal();
|
||||
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 +169,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,20 +186,22 @@ 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 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);
|
||||
let idx = reinsertion_index.min(self.view_stack.len());
|
||||
self.view_stack.insert(idx, view);
|
||||
} else {
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
@@ -193,7 +233,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() {
|
||||
@@ -210,21 +250,31 @@ impl BottomPane {
|
||||
match event {
|
||||
CancellationEvent::Handled => {
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
self.view_stack.push(view);
|
||||
} else {
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
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.view_stack.push(view);
|
||||
} else {
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
} else {
|
||||
let needs_redraw = self.composer.handle_paste(pasted);
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
@@ -322,18 +372,37 @@ impl BottomPane {
|
||||
&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();
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn update_active_selection_view<F>(&mut self, update: F)
|
||||
where
|
||||
F: FnOnce(&mut list_selection_view::ListSelectionView) -> bool,
|
||||
{
|
||||
let Some(view) = self.view_stack.last_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(selection_view) = view
|
||||
.as_any_mut()
|
||||
.downcast_mut::<list_selection_view::ListSelectionView>()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if update(selection_view) {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_active_view(&self) -> bool {
|
||||
!self.view_stack.is_empty()
|
||||
}
|
||||
|
||||
/// Update the queued messages shown under the status header.
|
||||
@@ -363,7 +432,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 +442,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 +463,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 +532,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 +549,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:
|
||||
@@ -587,7 +659,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"
|
||||
);
|
||||
|
||||
|
||||
641
codex-rs/tui/src/bottom_pane/review_selection_view.rs
Normal file
641
codex-rs/tui/src/bottom_pane/review_selection_view.rs
Normal file
@@ -0,0 +1,641 @@
|
||||
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::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::any::Any;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
|
||||
use crate::bottom_pane::scroll_state::ScrollState;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::key_hint;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
use codex_core::protocol::ReviewFinding;
|
||||
use codex_core::review_format;
|
||||
|
||||
pub(crate) struct ReviewSelectionView {
|
||||
title: String,
|
||||
comments: Vec<ReviewFinding>,
|
||||
selected: Vec<bool>,
|
||||
state: ScrollState,
|
||||
complete: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl ReviewSelectionView {
|
||||
const TITLE_ROWS: u16 = 1;
|
||||
const TOP_SPACER_ROWS: u16 = 1;
|
||||
const BOTTOM_RESERVED_ROWS: u16 = 3;
|
||||
|
||||
pub fn new(title: String, comments: Vec<ReviewFinding>, app_event_tx: AppEventSender) -> Self {
|
||||
let len = comments.len();
|
||||
Self {
|
||||
title,
|
||||
comments,
|
||||
selected: vec![true; len],
|
||||
state: ScrollState::new(),
|
||||
complete: false,
|
||||
app_event_tx,
|
||||
}
|
||||
.init_selection()
|
||||
}
|
||||
|
||||
fn desired_rows(&self, width: u16) -> u16 {
|
||||
if width == 0 {
|
||||
return 0;
|
||||
}
|
||||
let wrap_width = Self::wrap_width_for(width);
|
||||
let total_lines: usize = self.compute_heights(wrap_width).into_iter().sum();
|
||||
let total = Self::base_rows() as usize + total_lines;
|
||||
total.min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
fn toggle_current(&mut self) {
|
||||
if let Some(idx) = self.state.selected_idx
|
||||
&& let Some(v) = self.selected.get_mut(idx)
|
||||
{
|
||||
*v = !*v;
|
||||
}
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let len = self.comments.len();
|
||||
self.state.move_up_wrap(len);
|
||||
self.state.ensure_visible(len, len);
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let len = self.comments.len();
|
||||
self.state.move_down_wrap(len);
|
||||
self.state.ensure_visible(len, len);
|
||||
}
|
||||
|
||||
fn accept(&mut self) {
|
||||
use crate::app_event::AppEvent;
|
||||
|
||||
let selected: Vec<&ReviewFinding> = self
|
||||
.comments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, comment)| {
|
||||
self.selected
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
.then_some(comment)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
self.complete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let message_text =
|
||||
review_format::format_review_findings_block(&self.comments, Some(&self.selected));
|
||||
let message_lines: Vec<Line<'static>> = message_text
|
||||
.lines()
|
||||
.map(|s| Line::from(s.to_string()))
|
||||
.collect();
|
||||
let agent_cell = AgentMessageCell::new(message_lines, true);
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(agent_cell)));
|
||||
|
||||
let mut user_message = String::new();
|
||||
user_message.push_str(if selected.len() == 1 {
|
||||
"Please fix this review comment:\n"
|
||||
} else {
|
||||
"Please fix these review comments:\n"
|
||||
});
|
||||
for comment in &selected {
|
||||
let title = &comment.title;
|
||||
let location = Self::format_location(comment);
|
||||
user_message.push_str(&format!("\n- {title} — {location}\n"));
|
||||
for body_line in comment.body.lines() {
|
||||
if body_line.is_empty() {
|
||||
user_message.push_str(" \n");
|
||||
} else {
|
||||
user_message.push_str(&format!(" {body_line}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::SubmitUserText(
|
||||
user_message.trim_end().to_string(),
|
||||
));
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
fn init_selection(mut self) -> Self {
|
||||
let len = self.comments.len();
|
||||
if len > 0 {
|
||||
self.state.selected_idx = Some(0);
|
||||
// Default to top when opening; render pass will ensure visibility.
|
||||
self.state.ensure_visible(len, len);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn wrap_width_for(width: u16) -> usize {
|
||||
width.saturating_sub(2).max(1) as usize
|
||||
}
|
||||
|
||||
fn base_rows() -> u16 {
|
||||
Self::TITLE_ROWS + Self::TOP_SPACER_ROWS + Self::BOTTOM_RESERVED_ROWS
|
||||
}
|
||||
|
||||
fn dim_prefix_span() -> Span<'static> {
|
||||
"▌ ".dim()
|
||||
}
|
||||
|
||||
// Note: we render dim prefix lines inline where needed for clarity.
|
||||
fn format_location(item: &ReviewFinding) -> String {
|
||||
let path = item.code_location.absolute_file_path.display();
|
||||
let start = item.code_location.line_range.start;
|
||||
let end = item.code_location.line_range.end;
|
||||
format!("{path}:{start}-{end}")
|
||||
}
|
||||
|
||||
fn title_with_priority(item: &ReviewFinding) -> String {
|
||||
let t = item.title.as_str();
|
||||
if t.trim_start().starts_with('[') {
|
||||
t.to_string()
|
||||
} else {
|
||||
let priority = item.priority;
|
||||
format!("[P{priority}] {t}")
|
||||
}
|
||||
}
|
||||
|
||||
fn header_text(item: &ReviewFinding) -> String {
|
||||
let title_with_priority = Self::title_with_priority(item);
|
||||
let loc = Self::format_location(item);
|
||||
format!("{title_with_priority} — {loc}")
|
||||
}
|
||||
|
||||
/// Build the item's prefix (selection marker + checkbox) and return the
|
||||
/// prefix as a styled Line plus its display width in characters for
|
||||
/// subsequent indent alignment.
|
||||
fn header_prefix(is_selected: bool, checked: bool) -> (Line<'static>, usize) {
|
||||
let selected_marker = if is_selected { '>' } else { ' ' };
|
||||
let width_hint = format!("{selected_marker} [x] ").chars().count();
|
||||
let line = if checked {
|
||||
Line::from(vec![
|
||||
Span::from(format!("{selected_marker} [")),
|
||||
"x".cyan(),
|
||||
Span::from("] "),
|
||||
])
|
||||
} else {
|
||||
Line::from(format!("{selected_marker} [ ] "))
|
||||
};
|
||||
(line, width_hint)
|
||||
}
|
||||
|
||||
fn measure_item_lines(&self, idx: usize, wrap_width: usize) -> usize {
|
||||
if idx >= self.comments.len() {
|
||||
return 0;
|
||||
}
|
||||
let item = &self.comments[idx];
|
||||
|
||||
// Compute header (title + location) wrapped height with indent for marker + checkbox.
|
||||
let is_selected = self.state.selected_idx == Some(idx);
|
||||
let is_checked = self.selected.get(idx).copied().unwrap_or(false);
|
||||
let (prefix_line, prefix_width) = Self::header_prefix(is_selected, is_checked);
|
||||
let header_line = Line::from(Self::header_text(item));
|
||||
let header_subseq = " ".repeat(prefix_width);
|
||||
let header_opts = RtOptions::new(wrap_width)
|
||||
.initial_indent(prefix_line)
|
||||
.subsequent_indent(Line::from(header_subseq));
|
||||
let header_len = word_wrap_line(&header_line, header_opts).len();
|
||||
|
||||
// Compute body wrapped height (no preview cap; show full body).
|
||||
let body_line = Line::from(item.body.as_str());
|
||||
let body_opts = RtOptions::new(wrap_width)
|
||||
.initial_indent(Line::from(" "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
let body_len = word_wrap_line(&body_line, body_opts).len();
|
||||
|
||||
let spacer = if idx + 1 < self.comments.len() { 1 } else { 0 };
|
||||
header_len + body_len + spacer
|
||||
}
|
||||
|
||||
fn compute_heights(&self, wrap_width: usize) -> Vec<usize> {
|
||||
(0..self.comments.len())
|
||||
.map(|i| self.measure_item_lines(i, wrap_width))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn choose_start_for_visibility(
|
||||
&self,
|
||||
current_start: usize,
|
||||
selected_idx: usize,
|
||||
heights: &[usize],
|
||||
window_rows: usize,
|
||||
) -> usize {
|
||||
if heights.is_empty() || window_rows == 0 {
|
||||
return 0;
|
||||
}
|
||||
let n = heights.len();
|
||||
let sel = selected_idx.min(n - 1);
|
||||
let start = current_start.min(n - 1);
|
||||
|
||||
// Check visibility from current start.
|
||||
let mut sum = 0usize;
|
||||
let mut end = start;
|
||||
while end < n {
|
||||
let h = heights[end];
|
||||
if sum.saturating_add(h) > window_rows {
|
||||
break;
|
||||
}
|
||||
sum += h;
|
||||
end += 1;
|
||||
}
|
||||
if sel >= start && sel < end {
|
||||
return start;
|
||||
}
|
||||
|
||||
// Slide window so sel is visible; include as many items above as fit.
|
||||
let mut acc = heights[sel];
|
||||
let mut s = sel;
|
||||
while s > 0 {
|
||||
let h = heights[s - 1];
|
||||
if acc.saturating_add(h) > window_rows {
|
||||
break;
|
||||
}
|
||||
acc += h;
|
||||
s -= 1;
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ReviewSelectionView {
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => self.move_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
} => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(' '),
|
||||
..
|
||||
} => self.toggle_current(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.complete = true;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.accept(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.desired_rows(width)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let render_prefixed_blank_line = |x: u16, y: u16, width: u16, buf: &mut Buffer| {
|
||||
if width == 0 {
|
||||
return;
|
||||
}
|
||||
let rest_width = width.saturating_sub(2);
|
||||
let mut spans = vec![Self::dim_prefix_span()];
|
||||
if rest_width > 0 {
|
||||
spans.push(" ".repeat(rest_width as usize).into());
|
||||
}
|
||||
Paragraph::new(Line::from(spans)).render(
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
};
|
||||
|
||||
// Title
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(Line::from(vec![
|
||||
Self::dim_prefix_span(),
|
||||
self.title.as_str().bold(),
|
||||
]))
|
||||
.render(title_area, buf);
|
||||
if area.height > Self::TITLE_ROWS {
|
||||
render_prefixed_blank_line(
|
||||
area.x,
|
||||
area.y.saturating_add(Self::TITLE_ROWS),
|
||||
area.width,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
// Rows area
|
||||
// Rows area: reserve 2 rows at top (title + top hint)
|
||||
// and 3 rows at bottom for two spacers + key hint.
|
||||
let rows_area = Rect {
|
||||
x: area.x,
|
||||
y: area
|
||||
.y
|
||||
.saturating_add(Self::TITLE_ROWS + Self::TOP_SPACER_ROWS),
|
||||
width: area.width,
|
||||
height: area.height.saturating_sub(Self::base_rows()),
|
||||
};
|
||||
let wrap_width = Self::wrap_width_for(rows_area.width);
|
||||
let heights = self.compute_heights(wrap_width);
|
||||
let window_rows = rows_area.height as usize;
|
||||
let selected_idx = self
|
||||
.state
|
||||
.selected_idx
|
||||
.unwrap_or(0)
|
||||
.min(self.comments.len().saturating_sub(1));
|
||||
let start = self.choose_start_for_visibility(
|
||||
self.state.scroll_top,
|
||||
selected_idx,
|
||||
&heights,
|
||||
window_rows,
|
||||
);
|
||||
let mut y = rows_area.y;
|
||||
let mut idx = start;
|
||||
while idx < self.comments.len() && y < rows_area.y.saturating_add(rows_area.height) {
|
||||
let item = &self.comments[idx];
|
||||
|
||||
// Header: marker + checkbox + wrapped title/location
|
||||
let is_selected = self.state.selected_idx == Some(idx);
|
||||
let is_checked = self.selected.get(idx).copied().unwrap_or(false);
|
||||
let (header_prefix_line, header_subseq_len) =
|
||||
Self::header_prefix(is_selected, is_checked);
|
||||
let header_subseq = " ".repeat(header_subseq_len);
|
||||
let header_opts = RtOptions::new(wrap_width)
|
||||
.initial_indent(header_prefix_line)
|
||||
.subsequent_indent(Line::from(header_subseq));
|
||||
let name_line = Line::from(Self::header_text(item));
|
||||
let header_wrapped = word_wrap_line(&name_line, header_opts);
|
||||
let mut header_owned: Vec<Line<'static>> = Vec::new();
|
||||
push_owned_lines(&header_wrapped, &mut header_owned);
|
||||
let header_prefixed = prefix_lines(
|
||||
header_owned,
|
||||
Self::dim_prefix_span(),
|
||||
Self::dim_prefix_span(),
|
||||
);
|
||||
for l in header_prefixed {
|
||||
if y >= rows_area.y.saturating_add(rows_area.height) {
|
||||
break;
|
||||
}
|
||||
Paragraph::new(l).render(
|
||||
Rect {
|
||||
x: rows_area.x,
|
||||
y,
|
||||
width: rows_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
|
||||
// Body: fully wrapped (dim), no preview cap
|
||||
if y >= rows_area.y.saturating_add(rows_area.height) {
|
||||
break;
|
||||
}
|
||||
let body_line = Line::from(item.body.as_str().dim());
|
||||
let body_opts = RtOptions::new(wrap_width)
|
||||
.initial_indent(Line::from(" "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
let body_wrapped = word_wrap_line(&body_line, body_opts);
|
||||
let mut body_owned: Vec<Line<'static>> = Vec::new();
|
||||
push_owned_lines(&body_wrapped, &mut body_owned);
|
||||
let body_prefixed =
|
||||
prefix_lines(body_owned, Self::dim_prefix_span(), Self::dim_prefix_span());
|
||||
for l in body_prefixed {
|
||||
if y >= rows_area.y.saturating_add(rows_area.height) {
|
||||
break;
|
||||
}
|
||||
Paragraph::new(l).render(
|
||||
Rect {
|
||||
x: rows_area.x,
|
||||
y,
|
||||
width: rows_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
|
||||
// Spacer line between items (not after the last item).
|
||||
if idx + 1 < self.comments.len() && y < rows_area.y.saturating_add(rows_area.height) {
|
||||
render_prefixed_blank_line(rows_area.x, y, rows_area.width, buf);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let pane_bottom = area.y.saturating_add(area.height);
|
||||
if y < pane_bottom {
|
||||
render_prefixed_blank_line(rows_area.x, y, rows_area.width, buf);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
|
||||
// Hint with blue keys, matching chat input hint styling.
|
||||
let hint_spans: Vec<Span<'static>> = vec![
|
||||
Self::dim_prefix_span(),
|
||||
key_hint::plain("Enter"),
|
||||
"=fix selected issues".dim(),
|
||||
" ".into(),
|
||||
key_hint::plain("Space"),
|
||||
"=toggle".dim(),
|
||||
" ".into(),
|
||||
key_hint::plain("↑/↓"),
|
||||
"=scroll".dim(),
|
||||
" ".into(),
|
||||
key_hint::plain("Esc"),
|
||||
"=cancel".dim(),
|
||||
];
|
||||
if y < pane_bottom {
|
||||
Paragraph::new(Line::from(hint_spans)).render(
|
||||
Rect {
|
||||
x: rows_area.x,
|
||||
y,
|
||||
width: rows_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
|
||||
while y < pane_bottom {
|
||||
render_prefixed_blank_line(rows_area.x, y, rows_area.width, buf);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use codex_core::protocol::ReviewCodeLocation;
|
||||
use codex_core::protocol::ReviewFinding;
|
||||
use codex_core::protocol::ReviewLineRange;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
/// Accepting the view submits a user message summarizing checked findings
|
||||
/// and renders history output that reflects each checkbox state.
|
||||
#[test]
|
||||
fn accept_emits_history_and_user_message_for_selected_findings() {
|
||||
let (sender, mut rx) = test_sender();
|
||||
let findings = vec![
|
||||
finding("Leak fix", "Close the file handle", 4),
|
||||
finding("Rename", "Use snake case", 10),
|
||||
];
|
||||
let mut view =
|
||||
ReviewSelectionView::new("Select review comments".to_string(), findings, sender);
|
||||
|
||||
// Deselect the second finding so only the first is submitted.
|
||||
view.move_down();
|
||||
view.toggle_current();
|
||||
|
||||
view.accept();
|
||||
|
||||
let mut history_lines = Vec::new();
|
||||
let mut user_messages = Vec::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
history_lines.push(lines_to_strings(cell.display_lines(120)));
|
||||
}
|
||||
AppEvent::SubmitUserText(text) => {
|
||||
user_messages.push(text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
view.complete,
|
||||
"view should close after accepting selections"
|
||||
);
|
||||
assert_eq!(
|
||||
user_messages,
|
||||
vec![String::from(
|
||||
"Please fix this review comment:\n\n- Leak fix — src/lib.rs:4-5\n Close the file handle",
|
||||
),]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
history_lines,
|
||||
vec![vec![
|
||||
String::from("> Full review comments:"),
|
||||
String::from(" "),
|
||||
String::from(" - [x] Leak fix — src/lib.rs:4-5"),
|
||||
String::from(" Close the file handle"),
|
||||
String::from(" "),
|
||||
String::from(" - [ ] Rename — src/lib.rs:10-11"),
|
||||
String::from(" Use snake case"),
|
||||
]]
|
||||
);
|
||||
}
|
||||
|
||||
/// Accepting with every finding unchecked completes and leaves the event
|
||||
/// stream untouched.
|
||||
#[test]
|
||||
fn accept_with_no_selected_findings_completes_without_emitting_events() {
|
||||
let (sender, mut rx) = test_sender();
|
||||
let findings = vec![finding("First", "body", 1), finding("Second", "body", 3)];
|
||||
let mut view =
|
||||
ReviewSelectionView::new("Select review comments".to_string(), findings, sender);
|
||||
|
||||
// Deselect both findings so none remain checked.
|
||||
view.toggle_current();
|
||||
view.move_down();
|
||||
view.toggle_current();
|
||||
|
||||
view.accept();
|
||||
|
||||
assert!(view.complete);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"no events should be emitted when nothing is selected"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_sender() -> (
|
||||
AppEventSender,
|
||||
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) {
|
||||
let (tx, rx) = unbounded_channel();
|
||||
(AppEventSender::new(tx), rx)
|
||||
}
|
||||
|
||||
fn finding(title: &str, body: &str, start: u32) -> ReviewFinding {
|
||||
ReviewFinding {
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
confidence_score: 0.75,
|
||||
priority: 1,
|
||||
code_location: ReviewCodeLocation {
|
||||
absolute_file_path: PathBuf::from("src/lib.rs"),
|
||||
line_range: ReviewLineRange {
|
||||
start,
|
||||
end: start + 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn lines_to_strings(lines: Vec<Line<'static>>) -> Vec<String> {
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.to_string())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
368
codex-rs/tui/src/bottom_pane/searchable_table_picker_view.rs
Normal file
368
codex-rs/tui/src/bottom_pane/searchable_table_picker_view.rs
Normal file
@@ -0,0 +1,368 @@
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
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::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::any::Any;
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::BottomPane;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use super::standard_popup_hint_line;
|
||||
|
||||
pub(crate) type TablePickerOnSelected<T> =
|
||||
Box<dyn Fn(&AppEventSender, &mut BottomPane, T) + Send + Sync>;
|
||||
|
||||
pub(crate) struct TablePickerItem<T: Clone> {
|
||||
pub value: T,
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
pub search_value: String,
|
||||
pub detail_builder: Option<Box<dyn Fn() -> Option<Vec<Span<'static>>>>>,
|
||||
}
|
||||
|
||||
pub(crate) struct SearchableTablePickerView<T: Clone> {
|
||||
title: String,
|
||||
search_placeholder: String,
|
||||
empty_message: String,
|
||||
items: Vec<TablePickerItem<T>>,
|
||||
filtered_indices: Vec<usize>,
|
||||
query: String,
|
||||
state: ScrollState,
|
||||
complete: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
on_selected: TablePickerOnSelected<T>,
|
||||
max_visible_rows: usize,
|
||||
detail_cache: Vec<RefCell<Option<Vec<Span<'static>>>>>,
|
||||
}
|
||||
|
||||
impl<T: Clone> SearchableTablePickerView<T> {
|
||||
fn clear_area(area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let x_end = area.x.saturating_add(area.width);
|
||||
let y_end = area.y.saturating_add(area.height);
|
||||
for y in area.y..y_end {
|
||||
for x in area.x..x_end {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
cell.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_left_gutter_line(area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
Self::clear_area(area, buf);
|
||||
Paragraph::new(Line::from("▌ ".dim())).render(area, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
title: String,
|
||||
search_placeholder: String,
|
||||
empty_message: String,
|
||||
items: Vec<TablePickerItem<T>>,
|
||||
max_visible_rows: usize,
|
||||
app_event_tx: AppEventSender,
|
||||
on_selected: TablePickerOnSelected<T>,
|
||||
) -> Self {
|
||||
let max_visible_rows = max_visible_rows.max(1);
|
||||
let mut view = Self {
|
||||
title,
|
||||
search_placeholder,
|
||||
empty_message,
|
||||
items,
|
||||
filtered_indices: Vec::new(),
|
||||
query: String::new(),
|
||||
state: ScrollState::new(),
|
||||
complete: false,
|
||||
app_event_tx,
|
||||
on_selected,
|
||||
max_visible_rows,
|
||||
detail_cache: Vec::new(),
|
||||
};
|
||||
view.reset_detail_cache();
|
||||
view.apply_filter();
|
||||
view
|
||||
}
|
||||
|
||||
fn detail_for(&self, idx: usize) -> Option<Vec<Span<'static>>> {
|
||||
let cache = self.detail_cache.get(idx)?;
|
||||
if let Some(detail) = cache.borrow().clone() {
|
||||
return Some(detail);
|
||||
}
|
||||
let detail = self
|
||||
.items
|
||||
.get(idx)
|
||||
.and_then(|item| item.detail_builder.as_ref())
|
||||
.and_then(|builder| builder());
|
||||
cache.replace(detail.clone());
|
||||
detail
|
||||
}
|
||||
|
||||
fn reset_detail_cache(&mut self) {
|
||||
self.detail_cache = self.items.iter().map(|_| RefCell::new(None)).collect();
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
self.filtered_indices = if self.query.is_empty() {
|
||||
(0..self.items.len()).collect()
|
||||
} else {
|
||||
let query_lower = self.query.to_lowercase();
|
||||
self.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, item)| {
|
||||
let haystack = item.search_value.to_lowercase();
|
||||
haystack.contains(&query_lower).then_some(idx)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let len = self.filtered_indices.len();
|
||||
self.state.clamp_selection(len);
|
||||
self.state
|
||||
.ensure_visible(len, len.min(self.max_visible_rows).max(1));
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let len = self.filtered_indices.len();
|
||||
self.state.move_up_wrap(len);
|
||||
self.state
|
||||
.ensure_visible(len, len.min(self.max_visible_rows).max(1));
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let len = self.filtered_indices.len();
|
||||
self.state.move_down_wrap(len);
|
||||
self.state
|
||||
.ensure_visible(len, len.min(self.max_visible_rows).max(1));
|
||||
}
|
||||
|
||||
fn accept(&mut self, pane: &mut BottomPane) {
|
||||
if let Some(selected_idx) = self.state.selected_idx
|
||||
&& let Some(actual_idx) = self.filtered_indices.get(selected_idx)
|
||||
&& let Some(item) = self.items.get(*actual_idx)
|
||||
{
|
||||
(self.on_selected)(&self.app_event_tx, pane, item.value.clone());
|
||||
self.complete = true;
|
||||
} else {
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
fn render_title(&self, area: Rect, buf: &mut Buffer) {
|
||||
let spans = vec!["▌ ".dim(), self.title.clone().bold()];
|
||||
Paragraph::new(Line::from(spans)).render(area, buf);
|
||||
}
|
||||
|
||||
fn render_search(&self, area: Rect, buf: &mut Buffer) {
|
||||
let line = if self.query.is_empty() {
|
||||
Line::from(vec!["▌ ".dim(), self.search_placeholder.clone().dim()])
|
||||
} else {
|
||||
let query = &self.query;
|
||||
Line::from(vec!["▌ ".dim(), query.into()])
|
||||
};
|
||||
Paragraph::new(line).render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + 'static> BottomPaneView for SearchableTablePickerView<T> {
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, pane: &mut super::BottomPane, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => self.move_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
} => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => self.cancel(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.accept(pane),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} => {
|
||||
self.query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
..
|
||||
} => {
|
||||
if !key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::ALT)
|
||||
{
|
||||
self.query.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane) -> super::CancellationEvent {
|
||||
self.cancel();
|
||||
super::CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
let rows = self
|
||||
.filtered_indices
|
||||
.len()
|
||||
.clamp(1, self.max_visible_rows)
|
||||
.max(1) as u16;
|
||||
5 + rows
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
self.render_title(title_area, buf);
|
||||
|
||||
let search_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y.saturating_add(1),
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
self.render_search(search_area, buf);
|
||||
|
||||
let (spacer_height, rows_offset) = if area.height > 2 { (1, 3) } else { (0, 2) };
|
||||
if spacer_height == 1 {
|
||||
let spacer_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y.saturating_add(2),
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Self::render_left_gutter_line(spacer_area, buf);
|
||||
}
|
||||
|
||||
let remaining_height = area.height.saturating_sub(rows_offset);
|
||||
let hint_reserved = match remaining_height {
|
||||
h if h >= 2 => 2,
|
||||
1 => 1,
|
||||
_ => 0,
|
||||
};
|
||||
let rows_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y.saturating_add(rows_offset),
|
||||
width: area.width,
|
||||
height: remaining_height.saturating_sub(hint_reserved),
|
||||
};
|
||||
|
||||
let rows: Vec<GenericDisplayRow> = self
|
||||
.filtered_indices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(visible_idx, source_idx)| {
|
||||
self.items.get(*source_idx).map(|item| {
|
||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let number = visible_idx + 1;
|
||||
let label = &item.label;
|
||||
let prefix_str = format!("{prefix} {number}. ");
|
||||
let display = format!("{prefix_str}{label}");
|
||||
let detail_spans = if is_selected {
|
||||
self.detail_for(*source_idx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let styled_name = detail_spans.map(|mut detail| {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(display.clone().into());
|
||||
if !detail.is_empty() {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
spans.append(&mut detail);
|
||||
spans
|
||||
});
|
||||
GenericDisplayRow {
|
||||
name: display,
|
||||
match_indices: None,
|
||||
is_current: false,
|
||||
description: item.description.clone(),
|
||||
styled_name,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
render_rows(
|
||||
rows_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
self.max_visible_rows,
|
||||
true,
|
||||
&self.empty_message,
|
||||
);
|
||||
|
||||
if hint_reserved > 0 {
|
||||
let hint_y = area.y.saturating_add(area.height).saturating_sub(1);
|
||||
if hint_y >= area.y {
|
||||
let hint_area = Rect {
|
||||
x: area.x,
|
||||
y: hint_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(standard_popup_hint_line()).render(hint_area, buf);
|
||||
}
|
||||
|
||||
if hint_reserved >= 2 {
|
||||
let spacer_y = hint_y.saturating_sub(1);
|
||||
if spacer_y >= area.y {
|
||||
let spacer_area = Rect {
|
||||
x: area.x,
|
||||
y: spacer_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Self::clear_area(spacer_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
// Note: Table-based layout previously used Constraint; the manual renderer
|
||||
// below no longer requires it.
|
||||
use ratatui::prelude::Constraint;
|
||||
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
@@ -11,7 +11,9 @@ use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Cell;
|
||||
use ratatui::widgets::Row;
|
||||
use ratatui::widgets::Table;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use super::scroll_state::ScrollState;
|
||||
@@ -22,65 +24,11 @@ pub(crate) struct GenericDisplayRow {
|
||||
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
|
||||
pub is_current: bool,
|
||||
pub description: Option<String>, // optional grey text after the name
|
||||
pub styled_name: Option<Vec<Span<'static>>>,
|
||||
}
|
||||
|
||||
impl GenericDisplayRow {}
|
||||
|
||||
/// Compute a shared description-column start based on the widest visible name
|
||||
/// plus two spaces of padding. Ensures at least one column is left for the
|
||||
/// description.
|
||||
fn compute_desc_col(
|
||||
rows_all: &[GenericDisplayRow],
|
||||
start_idx: usize,
|
||||
visible_items: usize,
|
||||
content_width: u16,
|
||||
) -> usize {
|
||||
let visible_range = start_idx..(start_idx + visible_items);
|
||||
let max_name_width = rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| visible_range.contains(i))
|
||||
.map(|(_, r)| Line::from(r.name.clone()).width())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut desc_col = max_name_width.saturating_add(2);
|
||||
if (desc_col as u16) >= content_width {
|
||||
desc_col = content_width.saturating_sub(1) as usize;
|
||||
}
|
||||
desc_col
|
||||
}
|
||||
|
||||
/// Build the full display line for a row with the description padded to start
|
||||
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
|
||||
/// dims the description.
|
||||
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
|
||||
if let Some(idxs) = row.match_indices.as_ref() {
|
||||
let mut idx_iter = idxs.iter().peekable();
|
||||
for (char_idx, ch) in row.name.chars().enumerate() {
|
||||
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
|
||||
idx_iter.next();
|
||||
name_spans.push(ch.to_string().bold());
|
||||
} else {
|
||||
name_spans.push(ch.to_string().into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
name_spans.push(row.name.clone().into());
|
||||
}
|
||||
|
||||
let this_name_width = Line::from(name_spans.clone()).width();
|
||||
let mut full_spans: Vec<Span> = name_spans;
|
||||
if let Some(desc) = row.description.as_ref() {
|
||||
let gap = desc_col.saturating_sub(this_name_width);
|
||||
if gap > 0 {
|
||||
full_spans.push(" ".repeat(gap).into());
|
||||
}
|
||||
full_spans.push(desc.clone().dim());
|
||||
}
|
||||
Line::from(full_spans)
|
||||
}
|
||||
|
||||
/// Render a list of rows using the provided ScrollState, with shared styling
|
||||
/// and behavior for selection popups.
|
||||
pub(crate) fn render_rows(
|
||||
@@ -92,168 +40,102 @@ pub(crate) fn render_rows(
|
||||
_dim_non_selected: bool,
|
||||
empty_message: &str,
|
||||
) {
|
||||
// Always draw a dim left border to match other popups.
|
||||
let block = Block::default()
|
||||
.borders(Borders::LEFT)
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(Style::default().add_modifier(Modifier::DIM));
|
||||
block.render(area, buf);
|
||||
|
||||
// Content renders to the right of the border.
|
||||
let content_area = Rect {
|
||||
x: area.x.saturating_add(1),
|
||||
y: area.y,
|
||||
width: area.width.saturating_sub(1),
|
||||
height: area.height,
|
||||
};
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
|
||||
if rows_all.is_empty() {
|
||||
if content_area.height > 0 {
|
||||
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
|
||||
para.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: content_area.y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rows.push(Row::new(vec![Cell::from(Line::from(
|
||||
empty_message.dim().italic(),
|
||||
))]));
|
||||
} else {
|
||||
let max_rows_from_area = area.height as usize;
|
||||
let visible_rows = max_results
|
||||
.min(rows_all.len())
|
||||
.min(max_rows_from_area.max(1));
|
||||
|
||||
// Determine which logical rows (items) are visible given the selection and
|
||||
// the max_results clamp. Scrolling is still item-based for simplicity.
|
||||
let max_rows_from_area = content_area.height as usize;
|
||||
let visible_items = max_results
|
||||
.min(rows_all.len())
|
||||
.min(max_rows_from_area.max(1));
|
||||
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_items > 0 {
|
||||
let bottom = start_idx + visible_items - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_items;
|
||||
// Compute starting index based on scroll state and selection.
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_rows > 0 {
|
||||
let bottom = start_idx + visible_rows - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_rows;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
|
||||
for (i, row) in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_rows)
|
||||
{
|
||||
let GenericDisplayRow {
|
||||
name,
|
||||
match_indices,
|
||||
is_current: _is_current,
|
||||
description,
|
||||
styled_name,
|
||||
} = row;
|
||||
|
||||
// Render items, wrapping descriptions and aligning wrapped lines under the
|
||||
// shared description column. Stop when we run out of vertical space.
|
||||
let mut cur_y = content_area.y;
|
||||
for (i, row) in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
{
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
break;
|
||||
}
|
||||
// Highlight fuzzy indices when present.
|
||||
let mut spans: Vec<Span> = if let Some(styled) = styled_name.as_ref() {
|
||||
let mut spans = styled.clone();
|
||||
if Some(i) == state.selected_idx
|
||||
&& let Some(label_idx) = spans
|
||||
.iter()
|
||||
.position(|span| !span.content.trim().is_empty())
|
||||
&& let Some(label_span) = spans.get_mut(label_idx)
|
||||
{
|
||||
let content = label_span.content.clone().into_owned();
|
||||
*label_span = content.cyan().bold();
|
||||
}
|
||||
spans
|
||||
} else {
|
||||
let mut spans = Vec::with_capacity(name.len());
|
||||
if let Some(idxs) = match_indices.as_ref() {
|
||||
let mut idx_iter = idxs.iter().peekable();
|
||||
for (char_idx, ch) in name.chars().enumerate() {
|
||||
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
|
||||
idx_iter.next();
|
||||
spans.push(ch.to_string().bold());
|
||||
} else {
|
||||
spans.push(ch.to_string().into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spans.push(name.clone().into());
|
||||
}
|
||||
spans
|
||||
};
|
||||
|
||||
let GenericDisplayRow {
|
||||
name,
|
||||
match_indices,
|
||||
is_current: _is_current,
|
||||
description,
|
||||
} = row;
|
||||
|
||||
let full_line = build_full_line(
|
||||
&GenericDisplayRow {
|
||||
name: name.clone(),
|
||||
match_indices: match_indices.clone(),
|
||||
is_current: *_is_current,
|
||||
description: description.clone(),
|
||||
},
|
||||
desc_col,
|
||||
);
|
||||
|
||||
// Wrap with subsequent indent aligned to the description column.
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let options = RtOptions::new(content_area.width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
let wrapped = word_wrap_line(&full_line, options);
|
||||
|
||||
// Render the wrapped lines.
|
||||
for mut line in wrapped {
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
break;
|
||||
if let Some(desc) = description.as_ref() {
|
||||
spans.push(" ".into());
|
||||
spans.push(desc.clone().dim());
|
||||
}
|
||||
|
||||
let mut cell = Cell::from(Line::from(spans));
|
||||
|
||||
if Some(i) == state.selected_idx {
|
||||
// Match previous behavior: cyan + bold for the selected row.
|
||||
line.style = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
let para = Paragraph::new(line);
|
||||
para.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cur_y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cur_y = cur_y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the number of terminal rows required to render up to `max_results`
|
||||
/// items from `rows_all` given the current scroll/selection state and the
|
||||
/// available `width`. Accounts for description wrapping and alignment so the
|
||||
/// caller can allocate sufficient vertical space.
|
||||
pub(crate) fn measure_rows_height(
|
||||
rows_all: &[GenericDisplayRow],
|
||||
state: &ScrollState,
|
||||
max_results: usize,
|
||||
width: u16,
|
||||
) -> u16 {
|
||||
if rows_all.is_empty() {
|
||||
return 1; // placeholder "no matches" line
|
||||
}
|
||||
|
||||
let content_width = width.saturating_sub(1).max(1);
|
||||
|
||||
let visible_items = max_results.min(rows_all.len());
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_items > 0 {
|
||||
let bottom = start_idx + visible_items - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_items;
|
||||
cell = cell.style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
rows.push(Row::new(vec![cell]));
|
||||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width);
|
||||
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT)
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(Style::default().add_modifier(Modifier::DIM)),
|
||||
)
|
||||
.widths([Constraint::Percentage(100)]);
|
||||
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let mut total: u16 = 0;
|
||||
for row in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
.map(|(_, r)| r)
|
||||
{
|
||||
let full_line = build_full_line(row, desc_col);
|
||||
let opts = RtOptions::new(content_width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16);
|
||||
}
|
||||
total.max(1)
|
||||
table.render(area, buf);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ expression: terminal.backend()
|
||||
---
|
||||
"▌ /mo "
|
||||
"▌ "
|
||||
"▌/model choose what model and reasoning effort to use "
|
||||
"▌/model choose what model and reasoning effort to use "
|
||||
"▌/mention mention a file "
|
||||
|
||||
@@ -6,6 +6,6 @@ expression: render_lines(&view)
|
||||
▌ Switch between Codex approval presets
|
||||
▌
|
||||
▌> 1. Read Only (current) Codex can read files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
|
||||
Press Enter to confirm or Esc to go back
|
||||
|
||||
@@ -5,6 +5,6 @@ expression: render_lines(&view)
|
||||
▌ Select Approval Mode
|
||||
▌
|
||||
▌> 1. Read Only (current) Codex can read files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
|
||||
Press Enter to confirm or Esc to go back
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ExecCommandBeginEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::InputMessageKind;
|
||||
use codex_core::protocol::ListCustomPromptsResponseEvent;
|
||||
@@ -27,6 +28,7 @@ use codex_core::protocol::McpToolCallBeginEvent;
|
||||
use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
@@ -36,6 +38,7 @@ use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::protocol::UserMessageEvent;
|
||||
use codex_core::protocol::WebSearchBeginEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -62,6 +65,7 @@ use crate::bottom_pane::SelectionItem;
|
||||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::git_shortstat::DiffShortStat;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::ExecCell;
|
||||
@@ -79,6 +83,10 @@ use self::agent::spawn_agent;
|
||||
use self::agent::spawn_agent_from_existing;
|
||||
mod session_header;
|
||||
use self::session_header::SessionHeader;
|
||||
mod review;
|
||||
use self::review::ReviewController;
|
||||
use self::review::ReviewExitResult;
|
||||
use self::review::ReviewState;
|
||||
use crate::streaming::controller::AppEventHistorySink;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
@@ -91,7 +99,6 @@ 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 codex_protocol::mcp_protocol::ConversationId;
|
||||
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
@@ -141,6 +148,7 @@ pub(crate) struct ChatWidget {
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// Pending notification to show when unfocused on next Draw
|
||||
pending_notification: Option<Notification>,
|
||||
review_state: ReviewState,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -279,11 +287,13 @@ impl ChatWidget {
|
||||
}
|
||||
/// Finalize any active exec as failed, push an error message into history,
|
||||
/// and stop/clear running UI state.
|
||||
fn finalize_turn_with_error_message(&mut self, message: String) {
|
||||
fn finalize_turn_with_error_message(&mut self, message: Option<String>) {
|
||||
// Ensure any spinner is replaced by a red ✗ and flushed into history.
|
||||
self.finalize_active_exec_cell_as_failed();
|
||||
// Emit the provided error message/history cell.
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
if let Some(message) = message {
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
}
|
||||
// Reset running state and clear streaming buffers.
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.running_commands.clear();
|
||||
@@ -291,7 +301,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn on_error(&mut self, message: String) {
|
||||
self.finalize_turn_with_error_message(message);
|
||||
self.finalize_turn_with_error_message(Some(message));
|
||||
self.request_redraw();
|
||||
|
||||
// After an error ends the turn, try sending the next queued input.
|
||||
@@ -301,11 +311,13 @@ impl ChatWidget {
|
||||
/// Handle a turn aborted due to user interrupt (Esc).
|
||||
/// When there are queued user messages, restore them into the composer
|
||||
/// separated by newlines rather than auto‑submitting the next one.
|
||||
fn on_interrupted_turn(&mut self) {
|
||||
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
|
||||
// Finalize, log a gentle prompt, and clear running state.
|
||||
self.finalize_turn_with_error_message(
|
||||
"Conversation interrupted - tell the model what to do differently".to_owned(),
|
||||
);
|
||||
self.finalize_turn_with_error_message(if reason == TurnAbortReason::ReviewEnded {
|
||||
None
|
||||
} else {
|
||||
Some("Conversation interrupted - tell the model what to do differently".to_owned())
|
||||
});
|
||||
|
||||
// If any messages were queued during the task, restore them into the composer.
|
||||
if !self.queued_user_messages.is_empty() {
|
||||
@@ -398,9 +410,9 @@ impl ChatWidget {
|
||||
|
||||
fn on_web_search_end(&mut self, ev: WebSearchEndEvent) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
let query = ev.query;
|
||||
self.add_to_history(history_cell::new_web_search_call(format!(
|
||||
"Searched: {}",
|
||||
ev.query
|
||||
"Searched: {query}"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -631,7 +643,11 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height);
|
||||
let bottom_min = if self.review_state.is_review_mode() {
|
||||
3.min(area.height)
|
||||
} else {
|
||||
self.bottom_pane.desired_height(area.width).min(area.height)
|
||||
};
|
||||
let remaining = area.height.saturating_sub(bottom_min);
|
||||
|
||||
let active_desired = self
|
||||
@@ -700,6 +716,7 @@ impl ChatWidget {
|
||||
show_welcome_banner: true,
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
review_state: ReviewState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,6 +773,7 @@ impl ChatWidget {
|
||||
show_welcome_banner: true,
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
review_state: ReviewState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,6 +888,9 @@ impl ChatWidget {
|
||||
self.clear_token_usage();
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||
}
|
||||
SlashCommand::Review => {
|
||||
self.open_review_popup();
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
self.open_model_popup();
|
||||
}
|
||||
@@ -976,8 +997,8 @@ impl ChatWidget {
|
||||
|
||||
fn flush_active_exec_cell(&mut self) {
|
||||
if let Some(active) = self.active_exec_cell.take() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(active)));
|
||||
let cell: Box<dyn history_cell::HistoryCell> = Box::new(active);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,11 +1108,14 @@ impl ChatWidget {
|
||||
EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
|
||||
EventMsg::TurnAborted(ev) => match ev.reason {
|
||||
TurnAbortReason::Interrupted => {
|
||||
self.on_interrupted_turn();
|
||||
self.on_interrupted_turn(ev.reason);
|
||||
}
|
||||
TurnAbortReason::Replaced => {
|
||||
self.on_error("Turn aborted: replaced by a new task".to_owned())
|
||||
}
|
||||
TurnAbortReason::ReviewEnded => {
|
||||
self.on_interrupted_turn(ev.reason);
|
||||
}
|
||||
},
|
||||
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
|
||||
EventMsg::ExecApprovalRequest(ev) => {
|
||||
@@ -1128,11 +1152,83 @@ impl ChatWidget {
|
||||
self.app_event_tx
|
||||
.send(crate::app_event::AppEvent::ConversationHistory(ev));
|
||||
}
|
||||
EventMsg::EnteredReviewMode(_) => {}
|
||||
EventMsg::ExitedReviewMode(_) => {}
|
||||
EventMsg::EnteredReviewMode(review_request) => {
|
||||
self.on_entered_review_mode(review_request)
|
||||
}
|
||||
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_entered_review_mode(&mut self, review: ReviewRequest) {
|
||||
if let Some(banner) = ReviewController::new(
|
||||
&mut self.review_state,
|
||||
&self.config,
|
||||
&mut self.bottom_pane,
|
||||
&self.app_event_tx,
|
||||
)
|
||||
.enter_review_mode(&review.user_facing_hint)
|
||||
{
|
||||
self.add_to_history(history_cell::new_review_status_line(banner));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) {
|
||||
let update: review::ReviewExitUpdate = ReviewController::new(
|
||||
&mut self.review_state,
|
||||
&self.config,
|
||||
&mut self.bottom_pane,
|
||||
&self.app_event_tx,
|
||||
)
|
||||
.exit_review_mode(review.review_output);
|
||||
|
||||
if update.should_flush_stream {
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.flush_interrupt_queue();
|
||||
self.flush_active_exec_cell();
|
||||
}
|
||||
|
||||
match update.result {
|
||||
ReviewExitResult::None => {}
|
||||
ReviewExitResult::ShowMessage(body_lines) => {
|
||||
let body_cell = crate::history_cell::AgentMessageCell::new(body_lines, false);
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
|
||||
}
|
||||
ReviewExitResult::ShowFindings(findings) => {
|
||||
// Always emit a full review findings block in history so results are visible
|
||||
// even if the selection overlay is skipped due to queued input.
|
||||
let message_text =
|
||||
codex_core::review_format::format_review_findings_block(&findings, None);
|
||||
let message_lines: Vec<ratatui::text::Line<'static>> = message_text
|
||||
.lines()
|
||||
.map(|s| ratatui::text::Line::from(s.to_string()))
|
||||
.collect();
|
||||
let body_cell = crate::history_cell::AgentMessageCell::new(message_lines, true);
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
|
||||
|
||||
// Only show the review selection panel if there isn't another
|
||||
// user message queued. Otherwise the queued message would start
|
||||
// a new turn and run behind the overlay.
|
||||
if self.queued_user_messages.is_empty() {
|
||||
let view = crate::bottom_pane::ReviewSelectionView::new(
|
||||
"Select review comments to add to chat".to_string(),
|
||||
findings,
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
} else {
|
||||
// Skip opening the panel; queued message will run next.
|
||||
}
|
||||
}
|
||||
}
|
||||
// Append the finishing banner after emitting any findings/results so the
|
||||
// banner appears last in history for this turn.
|
||||
self.add_to_history(history_cell::new_review_status_line(update.banner));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn on_user_message_event(&mut self, event: UserMessageEvent) {
|
||||
match event.kind {
|
||||
Some(InputMessageKind::EnvironmentContext)
|
||||
@@ -1220,6 +1316,32 @@ impl ChatWidget {
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn on_diff_shortstat_ready(
|
||||
&mut self,
|
||||
shortstat: Option<DiffShortStat>,
|
||||
request_id: u64,
|
||||
) {
|
||||
let mut controller = ReviewController::new(
|
||||
&mut self.review_state,
|
||||
&self.config,
|
||||
&mut self.bottom_pane,
|
||||
&self.app_event_tx,
|
||||
);
|
||||
if controller.on_diff_shortstat_ready(shortstat, request_id) {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut controller = ReviewController::new(
|
||||
&mut self.review_state,
|
||||
&self.config,
|
||||
&mut self.bottom_pane,
|
||||
&self.app_event_tx,
|
||||
);
|
||||
controller.open_review_popup();
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -1235,7 +1357,7 @@ impl ChatWidget {
|
||||
let model_slug = preset.model.to_string();
|
||||
let effort = preset.effort;
|
||||
let current_model = current_model.clone();
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |_, tx| {
|
||||
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
@@ -1267,13 +1389,14 @@ impl ChatWidget {
|
||||
description,
|
||||
is_current,
|
||||
actions,
|
||||
styled_label: None,
|
||||
dismiss_on_select: true,
|
||||
});
|
||||
}
|
||||
|
||||
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()),
|
||||
items,
|
||||
);
|
||||
}
|
||||
@@ -1291,7 +1414,7 @@ impl ChatWidget {
|
||||
let sandbox = preset.sandbox.clone();
|
||||
let name = preset.label.to_string();
|
||||
let description = Some(preset.description.to_string());
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |_, tx| {
|
||||
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(approval),
|
||||
@@ -1308,15 +1431,13 @@ impl ChatWidget {
|
||||
description,
|
||||
is_current,
|
||||
actions,
|
||||
styled_label: None,
|
||||
dismiss_on_select: true,
|
||||
});
|
||||
}
|
||||
|
||||
self.bottom_pane.show_selection_view(
|
||||
"Select Approval Mode".to_string(),
|
||||
None,
|
||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
||||
items,
|
||||
);
|
||||
self.bottom_pane
|
||||
.show_selection_view("Select Approval Mode".to_string(), None, items);
|
||||
}
|
||||
|
||||
/// Set the approval policy in the widget's config copy.
|
||||
@@ -1406,7 +1527,10 @@ impl ChatWidget {
|
||||
self.bottom_pane.clear_esc_backtrack_hint();
|
||||
}
|
||||
/// Forward an `Op` directly to codex.
|
||||
pub(crate) fn submit_op(&self, op: Op) {
|
||||
pub(crate) fn submit_op(&mut self, op: Op) {
|
||||
if matches!(op, Op::Review { .. }) {
|
||||
self.bottom_pane.clear_views();
|
||||
}
|
||||
// Record outbound operation for session replay fidelity.
|
||||
crate::session_log::log_outbound_op(&op);
|
||||
if let Err(e) = self.codex_op_tx.send(op) {
|
||||
@@ -1432,7 +1556,14 @@ impl ChatWidget {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.submit_user_message(text.into());
|
||||
|
||||
let user_message: UserMessage = text.into();
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.queued_user_messages.push_back(user_message);
|
||||
self.refresh_queued_user_messages();
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> TokenUsage {
|
||||
@@ -1467,13 +1598,15 @@ impl WidgetRef for &ChatWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||||
(&self.bottom_pane).render(bottom_pane_area, buf);
|
||||
if !active_cell_area.is_empty()
|
||||
self.review_state.render_hint(area, bottom_pane_area, buf);
|
||||
if !self.bottom_pane.has_active_view()
|
||||
&& !active_cell_area.is_empty()
|
||||
&& let Some(cell) = &self.active_exec_cell
|
||||
{
|
||||
let mut active_cell_area = active_cell_area;
|
||||
active_cell_area.y = active_cell_area.y.saturating_add(1);
|
||||
active_cell_area.height -= 1;
|
||||
cell.render_ref(active_cell_area, buf);
|
||||
let mut area_to_render = active_cell_area;
|
||||
area_to_render.y = area_to_render.y.saturating_add(1);
|
||||
area_to_render.height = area_to_render.height.saturating_sub(1);
|
||||
cell.render_ref(area_to_render, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
557
codex-rs/tui/src/chatwidget/review.rs
Normal file
557
codex-rs/tui/src/chatwidget/review.rs
Normal file
@@ -0,0 +1,557 @@
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewFinding;
|
||||
use codex_core::protocol::ReviewOutputEvent;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line as RtLine;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::path::PathBuf;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::spawn;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BranchPickerView;
|
||||
use crate::bottom_pane::CommitPickerView;
|
||||
use crate::bottom_pane::CustomPromptView;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::git_shortstat::DiffShortStat;
|
||||
use crate::git_shortstat::get_diff_shortstat;
|
||||
use codex_core::review_format;
|
||||
|
||||
pub(crate) struct ReviewState {
|
||||
is_review_mode: bool,
|
||||
diff_shortstat: Option<DiffShortStat>,
|
||||
diff_shortstat_inflight: Option<u64>,
|
||||
diff_shortstat_generation: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct ReviewExitUpdate {
|
||||
pub banner: String,
|
||||
pub should_flush_stream: bool,
|
||||
pub result: ReviewExitResult,
|
||||
}
|
||||
|
||||
pub(crate) enum ReviewExitResult {
|
||||
None,
|
||||
ShowMessage(Vec<RtLine<'static>>),
|
||||
ShowFindings(Vec<ReviewFinding>),
|
||||
}
|
||||
|
||||
impl ReviewState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
is_review_mode: false,
|
||||
diff_shortstat: None,
|
||||
diff_shortstat_inflight: None,
|
||||
diff_shortstat_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn is_review_mode(&self) -> bool {
|
||||
self.is_review_mode
|
||||
}
|
||||
|
||||
pub(crate) fn enter_review_mode(&mut self, user_facing_hint: &str) -> Option<String> {
|
||||
let banner = format!(">> Code review started: {user_facing_hint} <<");
|
||||
let should_emit_banner = !self.is_review_mode;
|
||||
self.is_review_mode = true;
|
||||
should_emit_banner.then_some(banner)
|
||||
}
|
||||
|
||||
pub(crate) fn exit_review_mode(
|
||||
&mut self,
|
||||
review_output: Option<ReviewOutputEvent>,
|
||||
) -> ReviewExitUpdate {
|
||||
self.is_review_mode = false;
|
||||
let mut result = ReviewExitResult::None;
|
||||
let mut should_flush_stream = false;
|
||||
if let Some(output) = review_output {
|
||||
should_flush_stream = true;
|
||||
if output.findings.is_empty() {
|
||||
// Use the shared formatter to keep header/structure consistent
|
||||
// with other review displays. For an empty findings list this
|
||||
// yields a simple "Review comment:" header.
|
||||
let mut body_lines: Vec<RtLine<'static>> =
|
||||
review_format::format_review_findings_block(&[], None)
|
||||
.lines()
|
||||
.map(|s| RtLine::from(s.to_string()))
|
||||
.collect();
|
||||
|
||||
// Add details (overall explanation or a fallback message).
|
||||
body_lines.push(RtLine::from(""));
|
||||
if !output.overall_explanation.trim().is_empty() {
|
||||
for line in output.overall_explanation.lines() {
|
||||
body_lines.push(RtLine::from(line.to_string()));
|
||||
}
|
||||
} else {
|
||||
body_lines.push(RtLine::from("Review failed -- no response found"));
|
||||
}
|
||||
result = ReviewExitResult::ShowMessage(body_lines);
|
||||
} else {
|
||||
result = ReviewExitResult::ShowFindings(output.findings);
|
||||
}
|
||||
}
|
||||
|
||||
ReviewExitUpdate {
|
||||
banner: "<< Code review finished >>".to_string(),
|
||||
should_flush_stream,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_diff_shortstat_ready(
|
||||
&mut self,
|
||||
shortstat: Option<DiffShortStat>,
|
||||
request_id: u64,
|
||||
) -> bool {
|
||||
if let Some(expected) = self.diff_shortstat_inflight {
|
||||
if expected != request_id {
|
||||
return false;
|
||||
}
|
||||
} else if request_id != self.diff_shortstat_generation {
|
||||
return false;
|
||||
}
|
||||
self.diff_shortstat_inflight = None;
|
||||
self.diff_shortstat = shortstat;
|
||||
true
|
||||
}
|
||||
|
||||
fn current_changes_styled_label(&self) -> Option<(String, Vec<Span<'static>>)> {
|
||||
let stats = self.diff_shortstat?;
|
||||
|
||||
let files_changed = stats.files_changed;
|
||||
let insertions = stats.insertions;
|
||||
let deletions = stats.deletions;
|
||||
let file_label = if stats.files_changed == 1 {
|
||||
"file changed"
|
||||
} else {
|
||||
"files changed"
|
||||
};
|
||||
|
||||
let summary_text = format!(
|
||||
"Review current changes - {files_changed} {file_label} (+{insertions} -{deletions})"
|
||||
);
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push("Review current changes".into());
|
||||
|
||||
let prefix = format!(" - {files_changed} {file_label} (").dim();
|
||||
spans.push(prefix);
|
||||
|
||||
spans.push(format!("+{insertions}").green());
|
||||
spans.push(" ".dim());
|
||||
spans.push(format!("-{deletions}").red());
|
||||
spans.push(")".dim());
|
||||
|
||||
Some((summary_text, spans))
|
||||
}
|
||||
|
||||
pub(crate) fn render_hint(&self, area: Rect, bottom_pane_area: Rect, buf: &mut Buffer) {
|
||||
if !self.is_review_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let hint_y = bottom_pane_area
|
||||
.y
|
||||
.saturating_add(bottom_pane_area.height)
|
||||
.saturating_sub(1);
|
||||
if hint_y < bottom_pane_area.y || hint_y >= area.y.saturating_add(area.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let blank_line = " ".repeat(bottom_pane_area.width as usize);
|
||||
let render_blank_line = |buf: &mut Buffer, y: u16| {
|
||||
if y < bottom_pane_area.y || y >= area.y.saturating_add(area.height) {
|
||||
return;
|
||||
}
|
||||
Paragraph::new(RtLine::from(blank_line.as_str())).render(
|
||||
Rect {
|
||||
x: bottom_pane_area.x,
|
||||
y,
|
||||
width: bottom_pane_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
};
|
||||
|
||||
if bottom_pane_area.width > 0 && hint_y > bottom_pane_area.y {
|
||||
render_blank_line(buf, hint_y.saturating_sub(1));
|
||||
}
|
||||
|
||||
let hint_area = Rect {
|
||||
x: bottom_pane_area.x,
|
||||
y: hint_y,
|
||||
width: bottom_pane_area.width,
|
||||
height: 1,
|
||||
};
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
let line = RtLine::from("✎ Review in progress (esc to cancel)".yellow());
|
||||
Paragraph::new(line).render(hint_area, buf);
|
||||
|
||||
let below_y = hint_y.saturating_add(1);
|
||||
if bottom_pane_area.width > 0 && below_y < area.y.saturating_add(area.height) {
|
||||
render_blank_line(buf, below_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ReviewController<'a> {
|
||||
state: &'a mut ReviewState,
|
||||
config: &'a Config,
|
||||
bottom_pane: &'a mut BottomPane,
|
||||
app_event_tx: &'a AppEventSender,
|
||||
}
|
||||
|
||||
impl<'a> ReviewController<'a> {
|
||||
pub(crate) fn new(
|
||||
state: &'a mut ReviewState,
|
||||
config: &'a Config,
|
||||
bottom_pane: &'a mut BottomPane,
|
||||
app_event_tx: &'a AppEventSender,
|
||||
) -> Self {
|
||||
Self {
|
||||
state,
|
||||
config,
|
||||
bottom_pane,
|
||||
app_event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enter_review_mode(&mut self, user_facing_hint: &str) -> Option<String> {
|
||||
self.state.enter_review_mode(user_facing_hint)
|
||||
}
|
||||
|
||||
pub(crate) fn exit_review_mode(
|
||||
&mut self,
|
||||
review_output: Option<ReviewOutputEvent>,
|
||||
) -> ReviewExitUpdate {
|
||||
self.state.exit_review_mode(review_output)
|
||||
}
|
||||
|
||||
pub(crate) fn request_diff_shortstat(&mut self, force: bool) {
|
||||
if Handle::try_current().is_err() {
|
||||
return;
|
||||
}
|
||||
if !force && self.state.diff_shortstat_inflight.is_some() {
|
||||
return;
|
||||
}
|
||||
let request_id = self.state.diff_shortstat_generation.wrapping_add(1);
|
||||
self.state.diff_shortstat_generation = request_id;
|
||||
self.state.diff_shortstat_inflight = Some(request_id);
|
||||
let tx = self.app_event_tx.clone();
|
||||
let cwd = self.config.cwd.clone();
|
||||
spawn(async move {
|
||||
let shortstat = match get_diff_shortstat(&cwd).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
debug!(error = ?err, "failed to compute git shortstat");
|
||||
None
|
||||
}
|
||||
};
|
||||
tx.send(AppEvent::DiffShortstat {
|
||||
shortstat,
|
||||
request_id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn on_diff_shortstat_ready(
|
||||
&mut self,
|
||||
shortstat: Option<DiffShortStat>,
|
||||
request_id: u64,
|
||||
) -> bool {
|
||||
let updated = self.state.on_diff_shortstat_ready(shortstat, request_id);
|
||||
if updated {
|
||||
self.refresh_review_preset_label();
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
self.request_diff_shortstat(true);
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
let build_review_actions =
|
||||
|review_request: ReviewRequest, context_line: String| -> Vec<SelectionAction> {
|
||||
vec![Box::new(move |pane, tx: &AppEventSender| {
|
||||
let base_prompt = review_request.prompt.clone();
|
||||
let hint = review_request.user_facing_hint.clone();
|
||||
// Always show custom prompt as last step; empty input submits base prompt.
|
||||
let title = "Custom Instructions".to_string();
|
||||
let placeholder =
|
||||
"Add anything else the reviewer should know (optional)".to_string();
|
||||
let context_label = context_line.clone();
|
||||
let on_submit = {
|
||||
let tx = tx.clone();
|
||||
move |custom: String| {
|
||||
let trimmed = custom.trim().to_string();
|
||||
let prompt = if trimmed.is_empty() {
|
||||
base_prompt.clone()
|
||||
} else {
|
||||
format!("{base_prompt}\n\n{trimmed}")
|
||||
};
|
||||
let user_facing_hint = if trimmed.is_empty() {
|
||||
hint.clone()
|
||||
} else {
|
||||
format!("{hint} ({trimmed})")
|
||||
};
|
||||
tx.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
let view = CustomPromptView::new(
|
||||
title,
|
||||
placeholder,
|
||||
Some(context_label),
|
||||
true, // allow empty submit to send base prompt
|
||||
Box::new(on_submit),
|
||||
);
|
||||
pane.show_view(Box::new(view));
|
||||
})]
|
||||
};
|
||||
|
||||
let (name, styled_label) = self
|
||||
.state
|
||||
.current_changes_styled_label()
|
||||
.map(|(summary, spans)| (summary, Some(spans)))
|
||||
.unwrap_or_else(|| ("Review current changes".to_string(), None));
|
||||
let base_prompt = "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string();
|
||||
let user_facing_hint = "current changes".to_string();
|
||||
let context_line = "Review current changes".to_string();
|
||||
let actions = build_review_actions(
|
||||
ReviewRequest {
|
||||
prompt: base_prompt,
|
||||
user_facing_hint,
|
||||
},
|
||||
context_line,
|
||||
);
|
||||
items.push(SelectionItem {
|
||||
name,
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions,
|
||||
styled_label,
|
||||
dismiss_on_select: true,
|
||||
});
|
||||
|
||||
let commit_cwd = self.config.cwd.clone();
|
||||
items.push(SelectionItem {
|
||||
name: "Review commit".to_string(),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![Self::commit_picker_action(commit_cwd)],
|
||||
styled_label: None,
|
||||
dismiss_on_select: false,
|
||||
});
|
||||
|
||||
let branch_cwd = self.config.cwd.clone();
|
||||
items.push(SelectionItem {
|
||||
name: "Review against a base branch".to_string(),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![Self::branch_picker_action(branch_cwd)],
|
||||
styled_label: None,
|
||||
dismiss_on_select: false,
|
||||
});
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: "Custom Instructions".to_string(),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![Self::custom_prompt_action(
|
||||
"Enter custom review instructions".to_string(),
|
||||
)],
|
||||
styled_label: None,
|
||||
dismiss_on_select: false,
|
||||
});
|
||||
|
||||
self.bottom_pane
|
||||
.show_selection_view("Select a review preset".into(), None, items);
|
||||
}
|
||||
|
||||
fn current_changes_label(&self) -> (String, Option<Vec<Span<'static>>>) {
|
||||
self.state
|
||||
.current_changes_styled_label()
|
||||
.map(|(summary, spans)| (summary, Some(spans)))
|
||||
.unwrap_or_else(|| ("Review current changes".to_string(), None))
|
||||
}
|
||||
|
||||
fn refresh_review_preset_label(&mut self) {
|
||||
let (name, styled_label) = self.current_changes_label();
|
||||
self.bottom_pane.update_active_selection_view(|view| {
|
||||
if view.title() != "Select a review preset" {
|
||||
return false;
|
||||
}
|
||||
view.update_item(0, |item| {
|
||||
item.name = name.clone();
|
||||
item.styled_label = styled_label.clone();
|
||||
});
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
fn branch_picker_action(cwd: PathBuf) -> SelectionAction {
|
||||
Box::new(move |pane, tx| {
|
||||
Self::open_branch_picker(cwd.clone(), pane, tx);
|
||||
})
|
||||
}
|
||||
|
||||
fn commit_picker_action(cwd: PathBuf) -> SelectionAction {
|
||||
Box::new(move |pane, tx| {
|
||||
Self::open_commit_picker(cwd.clone(), pane, tx);
|
||||
})
|
||||
}
|
||||
|
||||
fn custom_prompt_action(title: String) -> SelectionAction {
|
||||
Box::new(move |pane, tx| {
|
||||
Self::open_custom_prompt(pane, tx, title.clone());
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn open_branch_picker(
|
||||
cwd: PathBuf,
|
||||
bottom_pane: &mut BottomPane,
|
||||
app_event_tx: &AppEventSender,
|
||||
) {
|
||||
let view = BranchPickerView::new(
|
||||
cwd,
|
||||
app_event_tx.clone(),
|
||||
Box::new(move |tx2, bottom_pane, branch: String| {
|
||||
let prompt = format!(
|
||||
"Review the code changes against the base branch '{branch}'. Start by running `git diff {branch}`. Provide prioritized, actionable findings."
|
||||
);
|
||||
let context = format!("changes against '{branch}'");
|
||||
let title = "Custom Instructions".to_string();
|
||||
let placeholder =
|
||||
"Add anything else the reviewer should know (optional)".to_string();
|
||||
let context_label = format!("Review against base branch '{branch}'");
|
||||
let tx3 = tx2.clone();
|
||||
let on_submit = move |custom: String| {
|
||||
let trimmed = custom.trim().to_string();
|
||||
let full_prompt = if trimmed.is_empty() {
|
||||
prompt.clone()
|
||||
} else {
|
||||
format!("{prompt}\n\n{trimmed}")
|
||||
};
|
||||
let user_facing_hint = if trimmed.is_empty() {
|
||||
context.clone()
|
||||
} else {
|
||||
format!("{context} ({trimmed})")
|
||||
};
|
||||
tx3.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: full_prompt,
|
||||
user_facing_hint,
|
||||
},
|
||||
}));
|
||||
};
|
||||
let view = CustomPromptView::new(
|
||||
title,
|
||||
placeholder,
|
||||
Some(context_label),
|
||||
true,
|
||||
Box::new(on_submit),
|
||||
);
|
||||
bottom_pane.show_view(Box::new(view));
|
||||
}),
|
||||
);
|
||||
bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn open_commit_picker(
|
||||
cwd: PathBuf,
|
||||
bottom_pane: &mut BottomPane,
|
||||
app_event_tx: &AppEventSender,
|
||||
) {
|
||||
let view = CommitPickerView::new(
|
||||
cwd,
|
||||
app_event_tx.clone(),
|
||||
Box::new(move |tx, bottom_pane, selection| {
|
||||
let full_sha = selection.full_sha;
|
||||
let summary_label = if selection.summary.is_empty() {
|
||||
selection.short_sha
|
||||
} else {
|
||||
format!("{} {}", selection.short_sha, selection.summary)
|
||||
};
|
||||
let context = format!("commit {summary_label}");
|
||||
let prompt = format!(
|
||||
"Review commit {summary_label} and provide prioritized, actionable findings. Start by running `git show {full_sha}`."
|
||||
);
|
||||
let title = "Custom Instructions".to_string();
|
||||
let placeholder =
|
||||
"Add anything else the reviewer should know (optional)".to_string();
|
||||
let context_label = format!("Review commit {summary_label}");
|
||||
let tx2 = tx.clone();
|
||||
let on_submit = move |custom: String| {
|
||||
let trimmed = custom.trim().to_string();
|
||||
let full_prompt = if trimmed.is_empty() {
|
||||
prompt.clone()
|
||||
} else {
|
||||
format!("{prompt}\n\n{trimmed}")
|
||||
};
|
||||
let user_facing_hint = if trimmed.is_empty() {
|
||||
context.clone()
|
||||
} else {
|
||||
format!("{context} ({trimmed})")
|
||||
};
|
||||
tx2.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: full_prompt,
|
||||
user_facing_hint,
|
||||
},
|
||||
}));
|
||||
};
|
||||
let view = CustomPromptView::new(
|
||||
title,
|
||||
placeholder,
|
||||
Some(context_label),
|
||||
true,
|
||||
Box::new(on_submit),
|
||||
);
|
||||
bottom_pane.show_view(Box::new(view));
|
||||
}),
|
||||
);
|
||||
bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn open_custom_prompt(
|
||||
bottom_pane: &mut BottomPane,
|
||||
app_event_tx: &AppEventSender,
|
||||
title: String,
|
||||
) {
|
||||
let tx = app_event_tx.clone();
|
||||
let view = CustomPromptView::new(
|
||||
title,
|
||||
"Type instructions and press Enter".to_string(),
|
||||
None,
|
||||
false,
|
||||
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,
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::review::ReviewState;
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -19,10 +20,17 @@ use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ExecCommandBeginEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::InputMessageKind;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::ReviewCodeLocation;
|
||||
use codex_core::protocol::ReviewFinding;
|
||||
use codex_core::protocol::ReviewLineRange;
|
||||
use codex_core::protocol::ReviewOutputEvent;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TaskStartedEvent;
|
||||
@@ -184,6 +192,187 @@ fn resumed_initial_messages_render_history() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Entering review mode uses the hint provided by the review request.
|
||||
#[test]
|
||||
fn entered_review_mode_uses_request_hint() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-start".into(),
|
||||
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||
prompt: "Review the latest changes".to_string(),
|
||||
user_facing_hint: "feature branch".to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let banner = lines_to_single_string(cells.last().expect("review banner"));
|
||||
assert_eq!(banner, ">> Code review started: feature branch <<\n");
|
||||
assert!(chat.review_state.is_review_mode());
|
||||
}
|
||||
|
||||
/// Entering review mode renders the current changes banner when requested.
|
||||
#[test]
|
||||
fn entered_review_mode_defaults_to_current_changes_banner() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-start".into(),
|
||||
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||
prompt: "Review the current changes".to_string(),
|
||||
user_facing_hint: "current changes".to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let banner = lines_to_single_string(cells.last().expect("review banner"));
|
||||
assert_eq!(banner, ">> Code review started: current changes <<\n");
|
||||
assert!(chat.review_state.is_review_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_commit_picker_reveals_review_selection() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
chat.open_review_popup();
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.bottom_pane.has_active_view(),
|
||||
"review selection should remain after closing commit picker"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.bottom_pane.is_normal_backtrack_mode(),
|
||||
"second escape should return to composer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_branch_picker_reveals_review_selection() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
chat.open_review_popup();
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.bottom_pane.has_active_view(),
|
||||
"review selection should remain after closing branch picker"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.bottom_pane.is_normal_backtrack_mode(),
|
||||
"second escape should return to composer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_custom_prompt_reveals_review_selection() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
chat.open_review_popup();
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.bottom_pane.has_active_view(),
|
||||
"review selection should remain after closing custom prompt"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.bottom_pane.is_normal_backtrack_mode(),
|
||||
"second escape should return to composer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_changes_action_sends_review_op() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.open_review_popup();
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
// Custom prompt opens as last step; submit empty to send base prompt.
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let event = rx.try_recv().expect("expected review app event");
|
||||
match event {
|
||||
AppEvent::CodexOp(Op::Review { review_request }) => {
|
||||
assert_eq!(
|
||||
review_request.user_facing_hint, "current changes",
|
||||
"review preset should set current-changes hint"
|
||||
);
|
||||
assert!(
|
||||
review_request
|
||||
.prompt
|
||||
.contains("Review the current code changes"),
|
||||
"review prompt should describe current changes"
|
||||
);
|
||||
}
|
||||
other => panic!("expected review app event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Completing review with findings shows the selection popup and finishes with
|
||||
/// the closing banner while clearing review mode state.
|
||||
#[test]
|
||||
fn exited_review_mode_with_findings_opens_selection_view() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
let review = ReviewOutputEvent {
|
||||
findings: vec![ReviewFinding {
|
||||
title: "[P1] Fix bug".to_string(),
|
||||
body: "Something went wrong".to_string(),
|
||||
confidence_score: 0.9,
|
||||
priority: 1,
|
||||
code_location: ReviewCodeLocation {
|
||||
absolute_file_path: PathBuf::from("src/lib.rs"),
|
||||
line_range: ReviewLineRange { start: 10, end: 12 },
|
||||
},
|
||||
}],
|
||||
overall_correctness: "needs work".to_string(),
|
||||
overall_explanation: "Investigate the failure".to_string(),
|
||||
overall_confidence_score: 0.5,
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-end".into(),
|
||||
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
|
||||
review_output: Some(review),
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(
|
||||
!chat.bottom_pane.is_normal_backtrack_mode(),
|
||||
"review selection view should suspend normal composer"
|
||||
);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let banner = lines_to_single_string(cells.last().expect("finished banner"));
|
||||
assert_eq!(banner, "<< Code review finished >>\n");
|
||||
assert!(!chat.review_state.is_review_mode());
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
target_os = "macos",
|
||||
ignore = "system configuration APIs are blocked under macOS seatbelt"
|
||||
@@ -252,6 +441,7 @@ fn make_chatwidget_manual() -> (
|
||||
queued_user_messages: VecDeque::new(),
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
review_state: ReviewState::new(),
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
137
codex-rs/tui/src/git_shortstat.rs
Normal file
137
codex-rs/tui/src/git_shortstat.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Parsed summary of `git diff --shortstat` output.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct DiffShortStat {
|
||||
pub files_changed: u32,
|
||||
pub insertions: u32,
|
||||
pub deletions: u32,
|
||||
}
|
||||
|
||||
/// Run `git diff --shortstat` for the current workspace directory and parse the
|
||||
/// resulting summary if available.
|
||||
pub(crate) async fn get_diff_shortstat(cwd: &Path) -> io::Result<Option<DiffShortStat>> {
|
||||
let output = match Command::new("git")
|
||||
.current_dir(cwd)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.args(["diff", "HEAD", "--shortstat"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
if !output.status.success() && output.status.code() != Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(parse_shortstat(stdout.trim()))
|
||||
}
|
||||
|
||||
pub(crate) async fn get_diff_shortstat_against(
|
||||
cwd: &Path,
|
||||
base: &str,
|
||||
) -> io::Result<Option<DiffShortStat>> {
|
||||
if base.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let output = match Command::new("git")
|
||||
.current_dir(cwd)
|
||||
.args(["diff", base, "--shortstat"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
if !(output.status.success() || output.status.code() == Some(1)) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(parse_shortstat(stdout.trim()))
|
||||
}
|
||||
|
||||
fn parse_shortstat(stdout: &str) -> Option<DiffShortStat> {
|
||||
if stdout.is_empty() {
|
||||
// Zero-diff should still show shortstat with zeros.
|
||||
return Some(DiffShortStat::default());
|
||||
}
|
||||
|
||||
let mut files_changed: Option<u32> = None;
|
||||
let mut insertions: Option<u32> = None;
|
||||
let mut deletions: Option<u32> = None;
|
||||
|
||||
for part in stdout.split(',') {
|
||||
let trimmed = part.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = match trimmed.split_whitespace().next() {
|
||||
Some(num) => match num.parse::<u32>() {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if trimmed.contains("file") {
|
||||
files_changed = Some(value);
|
||||
} else if trimmed.contains("insertion") {
|
||||
insertions = Some(value);
|
||||
} else if trimmed.contains("deletion") {
|
||||
deletions = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
Some(DiffShortStat {
|
||||
files_changed: files_changed.unwrap_or_default(),
|
||||
insertions: insertions.unwrap_or_default(),
|
||||
deletions: deletions.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_shortstat_recognizes_full_output() {
|
||||
let summary = " 2 files changed, 7 insertions(+), 3 deletions(-)";
|
||||
|
||||
let parsed = parse_shortstat(summary).expect("should parse stats");
|
||||
|
||||
assert_eq!(parsed.files_changed, 2);
|
||||
assert_eq!(parsed.insertions, 7);
|
||||
assert_eq!(parsed.deletions, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shortstat_handles_missing_fields() {
|
||||
let summary = " 1 file changed";
|
||||
|
||||
let parsed = parse_shortstat(summary).expect("should parse stats");
|
||||
|
||||
assert_eq!(parsed.files_changed, 1);
|
||||
assert_eq!(parsed.insertions, 0);
|
||||
assert_eq!(parsed.deletions, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shortstat_returns_zeros_for_empty_stdout() {
|
||||
let parsed = parse_shortstat("").expect("should parse stats");
|
||||
assert_eq!(parsed.files_changed, 0);
|
||||
assert_eq!(parsed.insertions, 0);
|
||||
assert_eq!(parsed.deletions, 0);
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,28 @@ impl HistoryCell for TranscriptOnlyHistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a transcript line describing the current review status.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ReviewStatusHistoryCell {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl HistoryCell for ReviewStatusHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
vec![Line::from(self.message.clone().cyan())]
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
// Treat status lines as part of the same history block to avoid an extra
|
||||
// blank separator when following other content in the same turn.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_review_status_line(message: String) -> ReviewStatusHistoryCell {
|
||||
ReviewStatusHistoryCell { message }
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PatchHistoryCell {
|
||||
event_type: PatchEventType,
|
||||
@@ -691,6 +713,11 @@ pub(crate) fn new_session_info(
|
||||
"/model".into(),
|
||||
" - choose what model and reasoning effort to use".dim(),
|
||||
]),
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
"/review".into(),
|
||||
" - review current changes and find issues".dim(),
|
||||
]),
|
||||
];
|
||||
|
||||
CompositeHistoryCell {
|
||||
|
||||
@@ -44,6 +44,7 @@ mod exec_command;
|
||||
mod file_search;
|
||||
mod frames;
|
||||
mod get_git_diff;
|
||||
mod git_shortstat;
|
||||
mod history_cell;
|
||||
pub mod insert_history;
|
||||
mod key_hint;
|
||||
|
||||
@@ -339,7 +339,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
let q = if state.query.is_empty() {
|
||||
"Type to search".dim().to_string()
|
||||
} else {
|
||||
format!("Search: {}", state.query)
|
||||
state.query.clone()
|
||||
};
|
||||
frame.render_widget_ref(Line::from(q), search);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub enum SlashCommand {
|
||||
// more frequently used commands should be listed first.
|
||||
Model,
|
||||
Approvals,
|
||||
Review,
|
||||
New,
|
||||
Init,
|
||||
Compact,
|
||||
@@ -34,6 +35,7 @@ impl SlashCommand {
|
||||
SlashCommand::New => "start a new chat during a conversation",
|
||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Quit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
@@ -61,6 +63,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Mention
|
||||
|
||||
Reference in New Issue
Block a user