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:
jif-oai
2026-02-25 11:40:11 +00:00
committed by GitHub
parent 93efcfd50d
commit bcd6e68054
7 changed files with 419 additions and 12 deletions

View File

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

View 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................"
);
}
}