Compare commits

...

17 Commits

Author SHA1 Message Date
rhan-oai
dc7e3a5800 Merge branch 'main' into rhan/cli-follow-up 2026-03-03 14:40:03 -08:00
rhan-oai
383415cb08 Merge branch 'main' into rhan/cli-follow-up 2026-03-03 12:22:15 -08:00
rhan-oai
a84bdf60fb Merge branch 'main' into rhan/cli-follow-up 2026-03-03 11:33:49 -08:00
Roy Han
65ece1047d small fix 2026-03-03 11:31:45 -08:00
rhan-oai
c0e8e101d1 Merge branch 'main' into rhan/cli-follow-up 2026-03-03 10:42:16 -08:00
Roy Han
78c3847ee4 newline tui tweak 2026-03-03 10:37:52 -08:00
Roy Han
d650a9391f change esc message 2026-03-03 10:32:08 -08:00
Roy Han
48e38a127d simplified patch keyboard logic 2026-03-03 10:13:22 -08:00
Roy Han
9a4b8c2f7c refactor cli patch 2026-03-02 19:04:49 -08:00
Roy Han
8ccc42dfd6 remove tests for now 2026-03-02 18:49:18 -08:00
Roy Han
1e552d66a8 test condense 2026-03-02 18:37:42 -08:00
Roy Han
cefe70e10e bring back shortcuts 2026-03-02 18:23:38 -08:00
Roy Han
5295338f0f undo reject message change 2026-03-02 18:09:17 -08:00
rhan-oai
55085124ac Delete codex-rs/tmp/approval-smoke.txt 2026-03-02 18:01:23 -08:00
Roy Han
173447fdd3 ui changes 2026-03-02 18:00:25 -08:00
Roy Han
bfe50e41cc initial draft 2026-03-02 17:07:29 -08:00
Roy Han
c24437a2d9 initial draft 2026-03-02 16:21:36 -08:00
8 changed files with 726 additions and 29 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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'))],
},
]

View 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(),
}
}

View File

@@ -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)
}

View File

@@ -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(),

View File

@@ -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

View File

@@ -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);