mirror of
https://github.com/openai/codex.git
synced 2026-05-05 13:51:29 +03:00
Display pending child-thread approvals in TUI (#12767)
Summary - propagate approval policy from parent to spawned agents and drop the Never override so sub-agents respect the caller’s request - refresh the pending-approval list whenever events arrive or the active thread changes and surface the list above the composer for inactive threads - add widgets, helpers, and tests covering the new pending-thread approval UI state ![Uploading Screenshot 2026-02-25 at 11.02.18.png…]()
This commit is contained in:
@@ -17,6 +17,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
|
||||
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
|
||||
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
|
||||
use crate::key_hint;
|
||||
@@ -92,6 +93,7 @@ pub(crate) use skills_toggle_view::SkillsToggleView;
|
||||
pub(crate) use status_line_setup::StatusLineItem;
|
||||
pub(crate) use status_line_setup::StatusLineSetupView;
|
||||
mod paste_burst;
|
||||
mod pending_thread_approvals;
|
||||
pub mod popup_consts;
|
||||
mod queued_user_messages;
|
||||
mod scroll_state;
|
||||
@@ -171,6 +173,8 @@ pub(crate) struct BottomPane {
|
||||
unified_exec_footer: UnifiedExecFooter,
|
||||
/// Queued user messages to show above the composer while a turn is running.
|
||||
queued_user_messages: QueuedUserMessages,
|
||||
/// Inactive threads with pending approval requests.
|
||||
pending_thread_approvals: PendingThreadApprovals,
|
||||
context_window_percent: Option<i64>,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
}
|
||||
@@ -219,6 +223,7 @@ impl BottomPane {
|
||||
status: None,
|
||||
unified_exec_footer: UnifiedExecFooter::new(),
|
||||
queued_user_messages: QueuedUserMessages::new(),
|
||||
pending_thread_approvals: PendingThreadApprovals::new(),
|
||||
esc_backtrack_hint: false,
|
||||
animations_enabled,
|
||||
context_window_percent: None,
|
||||
@@ -759,6 +764,18 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Update the inactive-thread approval list shown above the composer.
|
||||
pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec<String>) {
|
||||
if self.pending_thread_approvals.set_threads(threads) {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pending_thread_approvals(&self) -> &[String] {
|
||||
self.pending_thread_approvals.threads()
|
||||
}
|
||||
|
||||
/// Update the unified-exec process set and refresh whichever summary surface is active.
|
||||
///
|
||||
/// The summary may be displayed inline in the status row or as a dedicated
|
||||
@@ -980,14 +997,20 @@ impl BottomPane {
|
||||
if self.status.is_none() && !self.unified_exec_footer.is_empty() {
|
||||
flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer));
|
||||
}
|
||||
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
|
||||
let has_queued_messages = !self.queued_user_messages.messages.is_empty();
|
||||
let has_status_or_footer =
|
||||
self.status.is_some() || !self.unified_exec_footer.is_empty();
|
||||
if has_queued_messages && has_status_or_footer {
|
||||
let has_inline_previews = has_pending_thread_approvals || has_queued_messages;
|
||||
if has_inline_previews && has_status_or_footer {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
flex.push(1, RenderableItem::Borrowed(&self.pending_thread_approvals));
|
||||
if has_pending_thread_approvals && has_queued_messages {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
|
||||
if !has_queued_messages && has_status_or_footer {
|
||||
if !has_inline_previews && has_status_or_footer {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
let mut flex2 = FlexRenderable::new();
|
||||
|
||||
147
codex-rs/tui/src/bottom_pane/pending_thread_approvals.rs
Normal file
147
codex-rs/tui/src/bottom_pane/pending_thread_approvals.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
|
||||
/// Widget that lists inactive threads with outstanding approval requests.
|
||||
pub(crate) struct PendingThreadApprovals {
|
||||
threads: Vec<String>,
|
||||
}
|
||||
|
||||
impl PendingThreadApprovals {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
threads: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_threads(&mut self, threads: Vec<String>) -> bool {
|
||||
if self.threads == threads {
|
||||
return false;
|
||||
}
|
||||
self.threads = threads;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.threads.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn threads(&self) -> &[String] {
|
||||
&self.threads
|
||||
}
|
||||
|
||||
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
|
||||
if self.threads.is_empty() || width < 4 {
|
||||
return Box::new(());
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for thread in self.threads.iter().take(3) {
|
||||
let wrapped = adaptive_wrap_lines(
|
||||
std::iter::once(Line::from(format!("Approval needed in {thread}"))),
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(vec![" ".into(), "!".red().bold(), " ".into()]))
|
||||
.subsequent_indent(Line::from(" ")),
|
||||
);
|
||||
lines.extend(wrapped);
|
||||
}
|
||||
|
||||
if self.threads.len() > 3 {
|
||||
lines.push(Line::from(" ...".dim().italic()));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
"/agent".cyan().bold(),
|
||||
" to switch threads".dim(),
|
||||
])
|
||||
.dim(),
|
||||
);
|
||||
|
||||
Paragraph::new(lines).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for PendingThreadApprovals {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.as_renderable(area.width).render(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.as_renderable(width).desired_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn snapshot_rows(widget: &PendingThreadApprovals, width: u16) -> String {
|
||||
let height = widget.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
widget.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
|
||||
(0..height)
|
||||
.map(|y| {
|
||||
(0..width)
|
||||
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desired_height_empty() {
|
||||
let widget = PendingThreadApprovals::new();
|
||||
assert_eq!(widget.desired_height(40), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_single_thread_snapshot() {
|
||||
let mut widget = PendingThreadApprovals::new();
|
||||
widget.set_threads(vec!["Robie [explorer]".to_string()]);
|
||||
|
||||
assert_snapshot!(
|
||||
snapshot_rows(&widget, 40).replace(' ', "."),
|
||||
@r"
|
||||
..!.Approval.needed.in.Robie.[explorer].
|
||||
..../agent.to.switch.threads............"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_multiple_threads_snapshot() {
|
||||
let mut widget = PendingThreadApprovals::new();
|
||||
widget.set_threads(vec![
|
||||
"Main [default]".to_string(),
|
||||
"Robie [explorer]".to_string(),
|
||||
"Inspector".to_string(),
|
||||
"Extra agent".to_string(),
|
||||
]);
|
||||
|
||||
assert_snapshot!(
|
||||
snapshot_rows(&widget, 44).replace(' ', "."),
|
||||
@r"
|
||||
..!.Approval.needed.in.Main.[default].......
|
||||
..!.Approval.needed.in.Robie.[explorer].....
|
||||
..!.Approval.needed.in.Inspector............
|
||||
............................................
|
||||
..../agent.to.switch.threads................"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user