mirror of
https://github.com/openai/codex.git
synced 2026-04-18 11:44:46 +00:00
Compare commits
17 Commits
dev/shaqay
...
rhan/cli-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7e3a5800 | ||
|
|
383415cb08 | ||
|
|
a84bdf60fb | ||
|
|
65ece1047d | ||
|
|
c0e8e101d1 | ||
|
|
78c3847ee4 | ||
|
|
d650a9391f | ||
|
|
48e38a127d | ||
|
|
9a4b8c2f7c | ||
|
|
8ccc42dfd6 | ||
|
|
1e552d66a8 | ||
|
|
cefe70e10e | ||
|
|
5295338f0f | ||
|
|
55085124ac | ||
|
|
173447fdd3 | ||
|
|
bfe50e41cc | ||
|
|
c24437a2d9 |
@@ -72,11 +72,13 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::FinalOutput;
|
||||
use codex_protocol::protocol::ListSkillsResponseEvent;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SkillErrorInfo;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
@@ -1040,7 +1042,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit_op_to_thread(&mut self, thread_id: ThreadId, op: Op) {
|
||||
async fn submit_op_to_thread(&mut self, thread_id: ThreadId, op: Op) -> bool {
|
||||
let replay_state_op =
|
||||
ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone());
|
||||
let submitted = if self.active_thread_id == Some(thread_id) {
|
||||
@@ -1069,6 +1071,48 @@ impl App {
|
||||
self.note_thread_outbound_op(thread_id, op).await;
|
||||
self.refresh_pending_thread_approvals().await;
|
||||
}
|
||||
submitted
|
||||
}
|
||||
|
||||
async fn reject_patch_approval_with_notes(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
approval_id: String,
|
||||
text: String,
|
||||
) {
|
||||
let denied = self
|
||||
.submit_op_to_thread(
|
||||
thread_id,
|
||||
Op::PatchApproval {
|
||||
id: approval_id,
|
||||
decision: ReviewDecision::Denied,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if !denied {
|
||||
return;
|
||||
}
|
||||
|
||||
let steered = self
|
||||
.submit_op_to_thread(
|
||||
thread_id,
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if steered && self.active_thread_id == Some(thread_id) {
|
||||
self.chat_widget.render_submitted_user_message(
|
||||
text,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_pending_thread_approvals(&mut self) {
|
||||
@@ -2173,7 +2217,15 @@ impl App {
|
||||
}
|
||||
}
|
||||
AppEvent::SubmitThreadOp { thread_id, op } => {
|
||||
self.submit_op_to_thread(thread_id, op).await;
|
||||
let _ = self.submit_op_to_thread(thread_id, op).await;
|
||||
}
|
||||
AppEvent::RejectPatchApprovalWithNotes {
|
||||
thread_id,
|
||||
approval_id,
|
||||
text,
|
||||
} => {
|
||||
self.reject_patch_approval_with_notes(thread_id, approval_id, text)
|
||||
.await;
|
||||
}
|
||||
AppEvent::DiffResult(text) => {
|
||||
// Clear the in-progress state in the bottom pane
|
||||
|
||||
@@ -80,6 +80,13 @@ pub(crate) enum AppEvent {
|
||||
op: codex_protocol::protocol::Op,
|
||||
},
|
||||
|
||||
/// Reject a patch approval without interrupting the turn, then steer the same thread.
|
||||
RejectPatchApprovalWithNotes {
|
||||
thread_id: ThreadId,
|
||||
approval_id: String,
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Forward an event from a non-primary thread into the app-level thread router.
|
||||
ThreadEvent {
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -38,6 +38,13 @@ use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
mod patch_ui;
|
||||
|
||||
use patch_ui::PATCH_REJECT_OPTION_INDEX;
|
||||
use patch_ui::PatchFocus;
|
||||
use patch_ui::PatchLayout;
|
||||
use patch_ui::PatchOverlayState;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ApprovalRequest {
|
||||
@@ -93,6 +100,7 @@ pub(crate) struct ApprovalOverlay {
|
||||
app_event_tx: AppEventSender,
|
||||
list: ListSelectionView,
|
||||
options: Vec<ApprovalOption>,
|
||||
patch_state: Option<PatchOverlayState>,
|
||||
current_complete: bool,
|
||||
done: bool,
|
||||
features: Features,
|
||||
@@ -106,6 +114,7 @@ impl ApprovalOverlay {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
list: ListSelectionView::new(Default::default(), app_event_tx),
|
||||
options: Vec::new(),
|
||||
patch_state: None,
|
||||
current_complete: false,
|
||||
done: false,
|
||||
features,
|
||||
@@ -122,6 +131,8 @@ impl ApprovalOverlay {
|
||||
self.current_complete = false;
|
||||
let header = build_header(&request);
|
||||
let (options, params) = Self::build_options(&request, header, &self.features);
|
||||
self.patch_state = matches!(request, ApprovalRequest::ApplyPatch { .. })
|
||||
.then(|| PatchOverlayState::new(&self.app_event_tx));
|
||||
self.current_request = Some(request);
|
||||
self.options = options;
|
||||
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
@@ -196,9 +207,13 @@ impl ApprovalOverlay {
|
||||
if self.current_complete {
|
||||
return;
|
||||
}
|
||||
let Some(option) = self.options.get(actual_idx) else {
|
||||
let Some(option) = self.options.get(actual_idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
if matches!(option.decision, ApprovalDecision::PatchRejectWithNotes) {
|
||||
self.open_patch_notes();
|
||||
return;
|
||||
}
|
||||
if let Some(request) = self.current_request.as_ref() {
|
||||
match (request, &option.decision) {
|
||||
(ApprovalRequest::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => {
|
||||
@@ -225,6 +240,51 @@ impl ApprovalOverlay {
|
||||
self.advance_queue();
|
||||
}
|
||||
|
||||
fn patch_state(&self) -> Option<&PatchOverlayState> {
|
||||
self.patch_state.as_ref()
|
||||
}
|
||||
|
||||
fn patch_state_mut(&mut self) -> Option<&mut PatchOverlayState> {
|
||||
self.patch_state.as_mut()
|
||||
}
|
||||
|
||||
fn patch_selected_index(&self) -> Option<usize> {
|
||||
self.patch_state()
|
||||
.and_then(|_| self.list.scroll_state().selected_idx)
|
||||
}
|
||||
|
||||
fn patch_note_text(&self) -> String {
|
||||
self.patch_state()
|
||||
.map(PatchOverlayState::note_text)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn patch_layout(&self, width: u16) -> Option<PatchLayout> {
|
||||
self.current_request.as_ref().and_then(|request| {
|
||||
PatchLayout::new(
|
||||
request,
|
||||
&self.options,
|
||||
self.list.scroll_state(),
|
||||
self.patch_state(),
|
||||
width,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn open_patch_notes(&mut self) {
|
||||
if self.options.len() <= PATCH_REJECT_OPTION_INDEX {
|
||||
return;
|
||||
}
|
||||
self.list
|
||||
.set_selected_visible_index(PATCH_REJECT_OPTION_INDEX);
|
||||
if let Some(state) = self.patch_state_mut() {
|
||||
state.notes_visible = true;
|
||||
state.focus = PatchFocus::Notes;
|
||||
state.note_submit_attempted = false;
|
||||
state.composer.move_cursor_to_end();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
|
||||
let Some(request) = self.current_request.as_ref() else {
|
||||
return;
|
||||
@@ -292,7 +352,7 @@ impl ApprovalOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
||||
fn try_handle_global_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press,
|
||||
@@ -325,28 +385,136 @@ impl ApprovalOverlay {
|
||||
false
|
||||
}
|
||||
}
|
||||
e => {
|
||||
if let Some(idx) = self
|
||||
.options
|
||||
.iter()
|
||||
.position(|opt| opt.shortcuts().any(|s| s.is_press(*e)))
|
||||
{
|
||||
self.apply_selection(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
||||
if self.try_handle_global_shortcut(key_event) {
|
||||
return true;
|
||||
}
|
||||
if self
|
||||
.patch_state()
|
||||
.is_some_and(PatchOverlayState::focus_is_notes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.options
|
||||
.iter()
|
||||
.position(|opt| {
|
||||
opt.shortcuts()
|
||||
.any(|shortcut| shortcut.is_press(*key_event))
|
||||
})
|
||||
.map(|idx| self.apply_selection(idx))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn sync_patch_state_with_list_selection(&mut self) {
|
||||
let selected_idx = self.patch_selected_index();
|
||||
if let Some(state) = self.patch_state_mut() {
|
||||
if selected_idx != Some(PATCH_REJECT_OPTION_INDEX) {
|
||||
state.notes_visible = false;
|
||||
}
|
||||
state.note_submit_attempted = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_patch_notes_key_event(&mut self, key_event: KeyEvent) -> bool {
|
||||
if key_event.kind == KeyEventKind::Release {
|
||||
return true;
|
||||
}
|
||||
if self.done || self.current_complete {
|
||||
return true;
|
||||
}
|
||||
if !self
|
||||
.patch_state()
|
||||
.is_some_and(PatchOverlayState::focus_is_notes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.try_handle_global_shortcut(&key_event) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Tab,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
) {
|
||||
if let Some(state) = self.patch_state_mut() {
|
||||
state.focus = PatchFocus::Options;
|
||||
state.note_submit_attempted = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
) {
|
||||
let text = self.patch_note_text();
|
||||
if text.trim().is_empty() {
|
||||
if let Some(state) = self.patch_state_mut() {
|
||||
state.note_submit_attempted = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some(ApprovalRequest::ApplyPatch { thread_id, id, .. }) =
|
||||
self.current_request.as_ref()
|
||||
{
|
||||
self.app_event_tx
|
||||
.send(AppEvent::RejectPatchApprovalWithNotes {
|
||||
thread_id: *thread_id,
|
||||
approval_id: id.clone(),
|
||||
text,
|
||||
});
|
||||
self.current_complete = true;
|
||||
self.advance_queue();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some(state) = self.patch_state_mut() {
|
||||
let _ = state.composer.handle_key_event(key_event);
|
||||
if !state.composer.current_text_with_pending().trim().is_empty() {
|
||||
state.note_submit_attempted = false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ApprovalOverlay {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if self.handle_patch_notes_key_event(key_event) {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
self.current_request,
|
||||
Some(ApprovalRequest::ApplyPatch { .. })
|
||||
) && matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Tab,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
) {
|
||||
self.open_patch_notes();
|
||||
return;
|
||||
}
|
||||
if self.try_handle_shortcut(&key_event) {
|
||||
return;
|
||||
}
|
||||
self.list.handle_key_event(key_event);
|
||||
if self.patch_state().is_some() {
|
||||
self.sync_patch_state_with_list_selection();
|
||||
}
|
||||
if let Some(idx) = self.list.take_last_selected_index() {
|
||||
self.apply_selection(idx);
|
||||
}
|
||||
@@ -395,19 +563,62 @@ impl BottomPaneView for ApprovalOverlay {
|
||||
self.enqueue_request(request);
|
||||
None
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
if pasted.is_empty()
|
||||
|| !matches!(
|
||||
self.current_request,
|
||||
Some(ApprovalRequest::ApplyPatch { .. })
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.open_patch_notes();
|
||||
self.patch_state_mut()
|
||||
.map(|state| {
|
||||
state.note_submit_attempted = false;
|
||||
state.composer.handle_paste(pasted)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||
self.patch_state_mut()
|
||||
.map(|state| state.composer.flush_paste_burst_if_due())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_in_paste_burst(&self) -> bool {
|
||||
self.patch_state()
|
||||
.is_some_and(|state| state.composer.is_in_paste_burst())
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ApprovalOverlay {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.list.desired_height(width)
|
||||
self.patch_layout(width).map_or_else(
|
||||
|| self.list.desired_height(width),
|
||||
|layout| layout.total_height(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.list.render(area, buf);
|
||||
let Some(layout) = self.patch_layout(area.width) else {
|
||||
self.list.render(area, buf);
|
||||
return;
|
||||
};
|
||||
layout.render(area, self.patch_state(), buf);
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.list.cursor_pos(area)
|
||||
let Some(state) = self.patch_state() else {
|
||||
return self.list.cursor_pos(area);
|
||||
};
|
||||
if !state.focus_is_notes() || !state.notes_visible(self.patch_selected_index()) {
|
||||
return self.list.cursor_pos(area);
|
||||
}
|
||||
self.patch_layout(area.width)
|
||||
.and_then(|layout| layout.cursor_pos(area, state))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +740,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
|
||||
enum ApprovalDecision {
|
||||
Review(ReviewDecision),
|
||||
McpElicitation(ElicitationAction),
|
||||
PatchRejectWithNotes,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -683,8 +895,8 @@ fn patch_options() -> Vec<ApprovalOption> {
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "No, and tell Codex what to do differently".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Abort),
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
|
||||
decision: ApprovalDecision::PatchRejectWithNotes,
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Tab)),
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
},
|
||||
]
|
||||
|
||||
381
codex-rs/tui/src/bottom_pane/approval_overlay/patch_ui.rs
Normal file
381
codex-rs/tui/src/bottom_pane/approval_overlay/patch_ui.rs
Normal file
@@ -0,0 +1,381 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::ChatComposerConfig;
|
||||
use crate::bottom_pane::scroll_state::ScrollState;
|
||||
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
|
||||
use crate::bottom_pane::selection_popup_common::measure_rows_height;
|
||||
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
|
||||
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
|
||||
use crate::bottom_pane::selection_popup_common::render_menu_surface;
|
||||
use crate::bottom_pane::selection_popup_common::render_rows;
|
||||
use crate::bottom_pane::selection_popup_common::wrap_styled_line;
|
||||
use crate::render::renderable::Renderable;
|
||||
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 unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::ApprovalOption;
|
||||
use super::ApprovalRequest;
|
||||
use super::build_header;
|
||||
|
||||
pub(super) const PATCH_REJECT_OPTION_INDEX: usize = 2;
|
||||
const PATCH_NOTES_PLACEHOLDER: &str = "Tell Codex what to do differently";
|
||||
const PATCH_EMPTY_NOTES_MESSAGE: &str = "Add guidance before sending.";
|
||||
const PATCH_MIN_OVERLAY_HEIGHT: u16 = 8;
|
||||
const PATCH_MIN_COMPOSER_HEIGHT: u16 = 3;
|
||||
const PATCH_MAX_COMPOSER_HEIGHT: u16 = 8;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum PatchFocus {
|
||||
Options,
|
||||
Notes,
|
||||
}
|
||||
|
||||
pub(super) struct PatchOverlayState {
|
||||
pub(super) focus: PatchFocus,
|
||||
pub(super) composer: ChatComposer,
|
||||
pub(super) notes_visible: bool,
|
||||
pub(super) note_submit_attempted: bool,
|
||||
}
|
||||
|
||||
impl PatchOverlayState {
|
||||
pub(super) fn new(app_event_tx: &AppEventSender) -> Self {
|
||||
let mut composer = ChatComposer::new_with_config(
|
||||
true,
|
||||
app_event_tx.clone(),
|
||||
false,
|
||||
PATCH_NOTES_PLACEHOLDER.to_string(),
|
||||
false,
|
||||
ChatComposerConfig::plain_text(),
|
||||
);
|
||||
composer.set_footer_hint_override(Some(Vec::new()));
|
||||
Self {
|
||||
focus: PatchFocus::Options,
|
||||
composer,
|
||||
notes_visible: false,
|
||||
note_submit_attempted: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn focus_is_notes(&self) -> bool {
|
||||
matches!(self.focus, PatchFocus::Notes)
|
||||
}
|
||||
|
||||
pub(super) fn note_text(&self) -> String {
|
||||
self.composer.current_text_with_pending()
|
||||
}
|
||||
|
||||
pub(super) fn notes_visible(&self, selected_idx: Option<usize>) -> bool {
|
||||
selected_idx == Some(PATCH_REJECT_OPTION_INDEX)
|
||||
&& (self.notes_visible || !self.note_text().trim().is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn note_error_visible(&self, selected_idx: Option<usize>) -> bool {
|
||||
self.notes_visible(selected_idx)
|
||||
&& self.note_submit_attempted
|
||||
&& self.note_text().trim().is_empty()
|
||||
}
|
||||
|
||||
fn notes_input_height(&self, width: u16) -> u16 {
|
||||
self.composer
|
||||
.desired_height(width.max(1))
|
||||
.clamp(PATCH_MIN_COMPOSER_HEIGHT, PATCH_MAX_COMPOSER_HEIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PatchLayout {
|
||||
title_lines: Vec<Line<'static>>,
|
||||
header: Box<dyn Renderable>,
|
||||
header_height: u16,
|
||||
rows: Vec<GenericDisplayRow>,
|
||||
options_state: ScrollState,
|
||||
options_height: u16,
|
||||
hint_lines: Vec<Line<'static>>,
|
||||
validation_lines: Vec<Line<'static>>,
|
||||
notes_height: u16,
|
||||
show_notes: bool,
|
||||
}
|
||||
|
||||
impl PatchLayout {
|
||||
pub(super) fn new(
|
||||
request: &ApprovalRequest,
|
||||
options: &[ApprovalOption],
|
||||
options_state: ScrollState,
|
||||
state: Option<&PatchOverlayState>,
|
||||
width: u16,
|
||||
) -> Option<Self> {
|
||||
if !matches!(request, ApprovalRequest::ApplyPatch { .. }) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let width = menu_surface_inset(Rect::new(0, 0, width, u16::MAX))
|
||||
.width
|
||||
.max(1);
|
||||
let title_lines = wrap_patch_title(width);
|
||||
let header = build_header(request);
|
||||
let header_height = header.desired_height(width);
|
||||
let mut options_state = options_state;
|
||||
if options_state.selected_idx.is_none() {
|
||||
options_state.selected_idx = Some(0);
|
||||
}
|
||||
let rows_width = width.saturating_add(2);
|
||||
let rows = patch_option_rows(options, options_state.selected_idx);
|
||||
let options_height =
|
||||
measure_rows_height(&rows, &options_state, rows.len().max(1), rows_width.max(1));
|
||||
let hint_lines = patch_hint_lines(request, options_state.selected_idx, state, width);
|
||||
let validation_lines = patch_validation_lines(options_state.selected_idx, state, width);
|
||||
let show_notes = state.is_some_and(|state| state.notes_visible(options_state.selected_idx));
|
||||
let notes_height = if show_notes {
|
||||
state
|
||||
.map(|state| state.notes_input_height(width))
|
||||
.unwrap_or(PATCH_MIN_COMPOSER_HEIGHT)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
title_lines,
|
||||
header,
|
||||
header_height,
|
||||
rows,
|
||||
options_state,
|
||||
options_height,
|
||||
hint_lines,
|
||||
validation_lines,
|
||||
notes_height,
|
||||
show_notes,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn total_height(&self) -> u16 {
|
||||
let height = self.title_lines.len() as u16
|
||||
+ 1
|
||||
+ self.header_height
|
||||
+ 1
|
||||
+ self.options_height
|
||||
+ 1
|
||||
+ self.hint_lines.len() as u16
|
||||
+ self.notes_height
|
||||
+ self.validation_lines.len() as u16
|
||||
+ menu_surface_padding_height();
|
||||
height.max(PATCH_MIN_OVERLAY_HEIGHT)
|
||||
}
|
||||
|
||||
pub(super) fn render(&self, area: Rect, state: Option<&PatchOverlayState>, buf: &mut Buffer) {
|
||||
let content_area = render_menu_surface(area, buf);
|
||||
if content_area.width == 0 || content_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let notes_area = self.notes_area(content_area);
|
||||
let mut cursor_y = content_area.y;
|
||||
for line in &self.title_lines {
|
||||
Paragraph::new(line.clone()).render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cursor_y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
|
||||
self.header.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cursor_y,
|
||||
width: content_area.width,
|
||||
height: self.header_height.min(
|
||||
content_area
|
||||
.height
|
||||
.saturating_sub(cursor_y - content_area.y),
|
||||
),
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y
|
||||
.saturating_add(self.header_height)
|
||||
.saturating_add(1);
|
||||
|
||||
render_rows(
|
||||
Rect {
|
||||
x: content_area.x.saturating_sub(2),
|
||||
y: cursor_y,
|
||||
width: content_area.width.saturating_add(2),
|
||||
height: self.options_height,
|
||||
},
|
||||
buf,
|
||||
&self.rows,
|
||||
&self.options_state,
|
||||
self.rows.len().max(1),
|
||||
"No options",
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(self.options_height);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
|
||||
for line in &self.hint_lines {
|
||||
Paragraph::new(line.clone()).render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cursor_y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if let (Some(state), Some(notes_area)) = (state, notes_area) {
|
||||
state.composer.render(notes_area, buf);
|
||||
cursor_y = cursor_y.saturating_add(notes_area.height);
|
||||
}
|
||||
|
||||
for line in &self.validation_lines {
|
||||
Paragraph::new(line.clone()).render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cursor_y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cursor_pos(&self, area: Rect, state: &PatchOverlayState) -> Option<(u16, u16)> {
|
||||
let content_area = menu_surface_inset(area);
|
||||
if content_area.width == 0 || content_area.height == 0 {
|
||||
return None;
|
||||
}
|
||||
self.notes_area(content_area)
|
||||
.and_then(|notes_area| state.composer.cursor_pos(notes_area))
|
||||
}
|
||||
|
||||
fn notes_area(&self, content_area: Rect) -> Option<Rect> {
|
||||
if !self.show_notes {
|
||||
return None;
|
||||
}
|
||||
|
||||
let notes_y = content_area.y
|
||||
+ self.title_lines.len() as u16
|
||||
+ 1
|
||||
+ self.header_height
|
||||
+ 1
|
||||
+ self.options_height
|
||||
+ 1
|
||||
+ self.hint_lines.len() as u16;
|
||||
let validation_height = self.validation_lines.len() as u16;
|
||||
Some(Rect {
|
||||
x: content_area.x,
|
||||
y: notes_y,
|
||||
width: content_area.width,
|
||||
height: self.notes_height.min(
|
||||
content_area
|
||||
.height
|
||||
.saturating_sub(notes_y.saturating_sub(content_area.y) + validation_height),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_patch_title(width: u16) -> Vec<Line<'static>> {
|
||||
let line = Line::from("Would you like to make the following edits?".bold());
|
||||
wrap_styled_line(&line, width.max(1))
|
||||
.into_iter()
|
||||
.map(line_to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn patch_option_rows(
|
||||
options: &[ApprovalOption],
|
||||
selected_idx: Option<usize>,
|
||||
) -> Vec<GenericDisplayRow> {
|
||||
options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, option)| {
|
||||
let prefix = if selected_idx == Some(idx) {
|
||||
'›'
|
||||
} else {
|
||||
' '
|
||||
};
|
||||
let prefix_label = format!("{prefix} {}. ", idx + 1);
|
||||
GenericDisplayRow {
|
||||
name: format!("{prefix_label}{}", option.label),
|
||||
display_shortcut: option
|
||||
.display_shortcut
|
||||
.or_else(|| option.additional_shortcuts.first().copied()),
|
||||
wrap_indent: Some(UnicodeWidthStr::width(prefix_label.as_str())),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn patch_hint_lines(
|
||||
request: &ApprovalRequest,
|
||||
selected_idx: Option<usize>,
|
||||
state: Option<&PatchOverlayState>,
|
||||
width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut hint = if state.is_some_and(|state| state.notes_visible(selected_idx)) {
|
||||
if state.is_some_and(PatchOverlayState::focus_is_notes) {
|
||||
"Press enter to send or tab to go back or esc to cancel".to_string()
|
||||
} else {
|
||||
"Press enter or tab to edit follow up or esc to cancel".to_string()
|
||||
}
|
||||
} else {
|
||||
"Press enter to confirm or esc to cancel".to_string()
|
||||
};
|
||||
if request.thread_label().is_some() {
|
||||
hint.push_str(" | o to open thread");
|
||||
}
|
||||
|
||||
wrap_styled_line(&Line::from(hint).dim(), width.max(1))
|
||||
.into_iter()
|
||||
.map(line_to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn patch_validation_lines(
|
||||
selected_idx: Option<usize>,
|
||||
state: Option<&PatchOverlayState>,
|
||||
width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
if !state.is_some_and(|state| state.note_error_visible(selected_idx)) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
wrap_styled_line(&Line::from(PATCH_EMPTY_NOTES_MESSAGE).red(), width.max(1))
|
||||
.into_iter()
|
||||
.map(line_to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn line_to_owned(line: Line<'_>) -> Line<'static> {
|
||||
Line {
|
||||
style: line.style,
|
||||
alignment: line.alignment,
|
||||
spans: line
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| Span {
|
||||
style: span.style,
|
||||
content: Cow::Owned(span.content.into_owned()),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -476,6 +476,26 @@ impl ListSelectionView {
|
||||
self.last_selected_actual_idx.take()
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_state(&self) -> ScrollState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub(crate) fn set_selected_visible_index(&mut self, idx: usize) {
|
||||
let before = self.selected_actual_idx();
|
||||
let len = self.visible_len();
|
||||
if len == 0 {
|
||||
self.state.reset();
|
||||
return;
|
||||
}
|
||||
self.state.selected_idx = Some(idx.min(len - 1));
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.clamp_selection(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
if self.selected_actual_idx() != before {
|
||||
self.fire_selection_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_width(total_width: u16) -> u16 {
|
||||
total_width.saturating_sub(2)
|
||||
}
|
||||
|
||||
@@ -2612,10 +2612,14 @@ impl ChatWidget {
|
||||
&mut self,
|
||||
event: codex_protocol::protocol::PatchApplyEndEvent,
|
||||
) {
|
||||
// If the patch was successful, just let the "Edited" block stand.
|
||||
// Otherwise, add a failure block.
|
||||
if !event.success {
|
||||
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
|
||||
match event.status {
|
||||
codex_protocol::protocol::PatchApplyStatus::Completed => {}
|
||||
codex_protocol::protocol::PatchApplyStatus::Failed => {
|
||||
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
|
||||
}
|
||||
codex_protocol::protocol::PatchApplyStatus::Declined => {
|
||||
self.add_to_history(history_cell::new_patch_apply_declined());
|
||||
}
|
||||
}
|
||||
// Mark that actual work was done (patch applied)
|
||||
self.had_work_activity = true;
|
||||
@@ -4292,12 +4296,26 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
// Show replayable user content in conversation history.
|
||||
let local_image_paths = local_images
|
||||
.into_iter()
|
||||
.map(|img| img.path)
|
||||
.collect::<Vec<_>>();
|
||||
self.render_submitted_user_message(
|
||||
text,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
remote_image_urls,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn render_submitted_user_message(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
remote_image_urls: Vec<String>,
|
||||
) {
|
||||
if !text.is_empty() {
|
||||
let local_image_paths = local_images
|
||||
.into_iter()
|
||||
.map(|img| img.path)
|
||||
.collect::<Vec<_>>();
|
||||
self.last_rendered_user_message_event =
|
||||
Some(Self::rendered_user_message_event_from_parts(
|
||||
text.clone(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 6996
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
@@ -15,6 +16,6 @@ expression: terminal.backend().vt100().screen().contents()
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. Yes, and don't ask again for these files (a)
|
||||
3. No, and tell Codex what to do differently (esc)
|
||||
3. No, and tell Codex what to do differently (tab)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
|
||||
@@ -2185,6 +2185,12 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_patch_apply_declined() -> PlainHistoryCell {
|
||||
PlainHistoryCell {
|
||||
lines: vec![vec!["• ".dim(), "Accepting revision".bold()].into()],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistoryCell {
|
||||
let display_path = display_path_for(&path, cwd);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user