From 9f59ca11a3914db3cda6a3a491946252920b1f35 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 23 Jun 2026 22:22:38 +0800 Subject: [PATCH 1/4] feat: high-visibility approval banner for pending tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pending tool approval only surfaced as a one-line worker row — same visual weight as the idle/thinking indicator — plus an inline tool card that scrolls away. Easy to miss in a busy autonomous session. Add a dedicated, high-contrast banner above the prompt that appears only while the focused session has a tool awaiting confirmation: a bold-bordered "⚠ APPROVAL NEEDED" block showing the tool + what it wants to do, with [Y] approve / [D] deny / [Esc] cancel as colored chips. The worker row no longer duplicates the approval text. The banner only occupies layout space when there's something to approve. `pending_tool` is a pure, unit-tested lookup. Co-Authored-By: Claude Opus 4.8 --- crates/codeoid-tui/src/ui/approval.rs | 217 ++++++++++++++++++++++++++ crates/codeoid-tui/src/ui/mod.rs | 44 ++++-- crates/codeoid-tui/src/ui/worker.rs | 24 +-- 3 files changed, 252 insertions(+), 33 deletions(-) create mode 100644 crates/codeoid-tui/src/ui/approval.rs diff --git a/crates/codeoid-tui/src/ui/approval.rs b/crates/codeoid-tui/src/ui/approval.rs new file mode 100644 index 0000000..6416810 --- /dev/null +++ b/crates/codeoid-tui/src/ui/approval.rs @@ -0,0 +1,217 @@ +//! High-visibility approval banner. +//! +//! A pending tool approval used to surface only as a one-line worker row +//! (same visual weight as the idle/thinking indicator) plus an inline tool +//! card that scrolls away — easy to miss in a busy session. This renders a +//! dedicated, high-contrast banner above the prompt whenever the focused +//! session has a tool awaiting confirmation, so the request is impossible +//! to overlook and the accept/deny keys are spelled out unmissably. + +use codeoid_protocol::{SessionMessage, ToolState}; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::state::AppState; + +/// Height (rows) of the banner zone, including its border. 2 border rows + +/// 2 content rows (the action + the key prompt). +pub const HEIGHT: u16 = 4; + +const ACCENT: Color = Color::Yellow; + +/// The pending tool approval in a message list, if any: `(tool, description)`. +/// Pure so it can be unit-tested without an [`AppState`]. Scans newest-first +/// so the most recent pending tool wins. +fn pending_tool(msgs: &[SessionMessage]) -> Option<(String, String)> { + msgs.iter().rev().find_map(|m| { + let tool = m.tool.as_ref()?; + match &tool.state { + ToolState::WaitingConfirmation { description, .. } => { + Some((tool.name.clone(), description.clone())) + } + _ => None, + } + }) +} + +/// The focused session's pending approval, if any. +fn pending(state: &AppState) -> Option<(String, String)> { + let session = state.sessions.focused()?; + pending_tool(state.messages.messages(&session.id)) +} + +/// Whether to reserve a banner row this frame. +#[must_use] +pub fn is_pending(state: &AppState) -> bool { + pending(state).is_some() +} + +pub fn render(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let Some((tool, description)) = pending(state) else { + return; + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)) + .title(Span::styled( + " ⚠ APPROVAL NEEDED ", + Style::default() + .bg(ACCENT) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + )); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Line 1: the action — tool name + what it wants to do. + let action = Line::from(vec![ + Span::raw(" "), + Span::styled( + tool, + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + truncate(&description, inner.width.saturating_sub(2) as usize), + Style::default().fg(Color::White), + ), + ]); + + // Line 2: the keys, as high-contrast chips so they read at a glance. + let chip = |label: &str, bg: Color| { + Span::styled( + format!(" {label} "), + Style::default() + .bg(bg) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) + }; + let keys = Line::from(vec![ + Span::raw(" "), + chip("Y", Color::Green), + Span::styled( + " approve ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + chip("D", Color::Red), + Span::styled( + " deny ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + chip("Esc", Color::Gray), + Span::styled(" cancel", Style::default().fg(Color::DarkGray)), + ]); + + frame.render_widget(Paragraph::new(vec![action, keys]), inner); +} + +/// Truncate to a column budget with an ellipsis. Best-effort by `char` +/// count (descriptions are ASCII-ish); the banner is one line so we never +/// want it to wrap. +fn truncate(s: &str, max: usize) -> String { + if max == 0 { + return String::new(); + } + if s.chars().count() <= max { + return s.to_string(); + } + let keep = max.saturating_sub(1); + let mut out: String = s.chars().take(keep).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::{pending_tool, truncate}; + use codeoid_protocol::{ + IdentityType, MessageIdentity, MessageRole, SessionMessage, ToolInfo, ToolState, + }; + use serde_json::json; + + fn ident() -> MessageIdentity { + MessageIdentity { + sub: "spiffe://x/agent/test".into(), + name: None, + kind: IdentityType::Agent, + } + } + + fn msg(role: MessageRole, tool: Option) -> SessionMessage { + SessionMessage { + session_id: "s".into(), + message_id: "m".into(), + role, + content: String::new(), + parts: None, + identity: ident(), + tool, + metadata: None, + timestamp: "2026-06-23T00:00:00Z".into(), + } + } + + fn tool(name: &str, state: ToolState) -> ToolInfo { + ToolInfo { + tool_id: "t".into(), + name: name.into(), + state, + } + } + + #[test] + fn finds_a_pending_tool() { + let msgs = vec![ + msg(MessageRole::User, None), + msg( + MessageRole::ToolCall, + Some(tool( + "Edit", + ToolState::WaitingConfirmation { + input: json!({}), + description: "edit src/main.rs".into(), + approval_id: "a1".into(), + }, + )), + ), + ]; + assert_eq!( + pending_tool(&msgs), + Some(("Edit".into(), "edit src/main.rs".into())) + ); + } + + #[test] + fn none_when_no_tool_waiting() { + let msgs = vec![ + msg(MessageRole::User, None), + msg( + MessageRole::ToolCall, + Some(tool( + "Read", + ToolState::Completed { + success: true, + output: None, + elapsed_ms: None, + confirmed_by: None, + }, + )), + ), + ]; + assert_eq!(pending_tool(&msgs), None); + } + + #[test] + fn truncate_adds_ellipsis() { + assert_eq!(truncate("hello world", 5), "hell…"); + assert_eq!(truncate("hi", 5), "hi"); + assert_eq!(truncate("anything", 0), ""); + } +} diff --git a/crates/codeoid-tui/src/ui/mod.rs b/crates/codeoid-tui/src/ui/mod.rs index 371348a..8cbd6ee 100644 --- a/crates/codeoid-tui/src/ui/mod.rs +++ b/crates/codeoid-tui/src/ui/mod.rs @@ -8,6 +8,7 @@ //! 4. Prompt editor (5 rows) //! 5. Keybinding hints (1 row) +mod approval; mod hints; mod modal; mod prompt; @@ -23,22 +24,41 @@ use crate::state::AppState; pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { let area = frame.area(); + // A high-visibility approval banner is inserted between the transcript + // and the worker row, but only while the focused session has a tool + // awaiting confirmation. Otherwise the layout is exactly as before. + let show_approval = approval::is_pending(state); + + let mut constraints = vec![ + Constraint::Length(3), // tabs + Constraint::Min(5), // transcript + ]; + if show_approval { + constraints.push(Constraint::Length(approval::HEIGHT)); // approval banner + } + constraints.push(Constraint::Length(1)); // worker / scroll indicator + constraints.push(Constraint::Length(5)); // prompt + constraints.push(Constraint::Length(1)); // hints + let rows = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // 1. tabs - Constraint::Min(5), // 2. transcript - Constraint::Length(1), // 3. worker / scroll indicator - Constraint::Length(5), // 4. prompt - Constraint::Length(1), // 5. hints - ]) + .constraints(constraints) .split(area); - tabs::render(frame, rows[0], state); - scrollback::render(frame, rows[1], state); - worker::render(frame, rows[2], state); - prompt::render(frame, rows[3], state); - hints::render(frame, rows[4], state); + let mut i = 0; + tabs::render(frame, rows[i], state); + i += 1; + scrollback::render(frame, rows[i], state); + i += 1; + if show_approval { + approval::render(frame, rows[i], state); + i += 1; + } + worker::render(frame, rows[i], state); + i += 1; + prompt::render(frame, rows[i], state); + i += 1; + hints::render(frame, rows[i], state); modal::render(frame, state); } diff --git a/crates/codeoid-tui/src/ui/worker.rs b/crates/codeoid-tui/src/ui/worker.rs index 1c67c30..757ffcc 100644 --- a/crates/codeoid-tui/src/ui/worker.rs +++ b/crates/codeoid-tui/src/ui/worker.rs @@ -135,27 +135,9 @@ fn build_line(state: &AppState) -> Option> { return Some(l); } - // Waiting on approval — static. - if msgs.iter().any(|m| { - m.tool - .as_ref() - .is_some_and(|t| matches!(&t.state, ToolState::WaitingConfirmation { .. })) - }) { - return Some(Line::from(vec![ - Span::styled( - " ⚠ ", - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "waiting on your approval — press [y] accept or [d] deny", - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ), - ])); - } + // Pending approval is now carried by the dedicated high-visibility + // approval banner (see `ui::approval`), so the worker row no longer + // duplicates it. // Session-level signal: daemon tells us it's working. Trusted. if matches!(session.status, SessionStatus::Working) { From 0a0f54f04d4df9cb3d970479a925d636b8b65a93 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 23 Jun 2026 22:32:42 +0800 Subject: [PATCH 2/4] test: cover the approval banner render via TestBackend Extract render_banner so the drawing is exercised without an AppState, and assert the rendered buffer carries the title, tool, description, and the approve/deny key prompts. Lifts patch coverage on the new render code. Co-Authored-By: Claude Opus 4.8 --- crates/codeoid-tui/src/ui/approval.rs | 42 +++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/codeoid-tui/src/ui/approval.rs b/crates/codeoid-tui/src/ui/approval.rs index 6416810..cdd9e0e 100644 --- a/crates/codeoid-tui/src/ui/approval.rs +++ b/crates/codeoid-tui/src/ui/approval.rs @@ -50,10 +50,15 @@ pub fn is_pending(state: &AppState) -> bool { } pub fn render(frame: &mut Frame<'_>, area: Rect, state: &AppState) { - let Some((tool, description)) = pending(state) else { - return; - }; + if let Some((tool, description)) = pending(state) { + render_banner(frame, area, &tool, &description); + } +} +/// Draw the banner for a known pending tool. Split out from [`render`] so it +/// can be exercised against a `TestBackend` without standing up an +/// [`AppState`]. +fn render_banner(frame: &mut Frame<'_>, area: Rect, tool: &str, description: &str) { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)) @@ -71,12 +76,12 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let action = Line::from(vec![ Span::raw(" "), Span::styled( - tool, + tool.to_string(), Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled( - truncate(&description, inner.width.saturating_sub(2) as usize), + truncate(description, inner.width.saturating_sub(2) as usize), Style::default().fg(Color::White), ), ]); @@ -214,4 +219,31 @@ mod tests { assert_eq!(truncate("hi", 5), "hi"); assert_eq!(truncate("anything", 0), ""); } + + #[test] + fn banner_renders_title_action_and_keys() { + use ratatui::backend::TestBackend; + use ratatui::buffer::Cell; + use ratatui::Terminal; + + let mut terminal = Terminal::new(TestBackend::new(60, super::HEIGHT)).unwrap(); + terminal + .draw(|f| super::render_banner(f, f.area(), "Edit", "edit src/main.rs")) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(Cell::symbol) + .collect(); + + assert!(text.contains("APPROVAL NEEDED"), "title missing in: {text}"); + assert!(text.contains("Edit"), "tool name missing"); + assert!(text.contains("edit src/main.rs"), "description missing"); + assert!( + text.contains("approve") && text.contains("deny"), + "key prompts missing" + ); + } } From 940dd47548eef94c9ca946196932c7a1a72bd717 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 23 Jun 2026 22:39:01 +0800 Subject: [PATCH 3/4] test: cover the approval layout insertion end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the full ui::render with a session whose tool is awaiting confirmation and assert the banner appears — covering the conditional layout zone in ui::mod plus pending/is_pending/render. Co-Authored-By: Claude Opus 4.8 --- crates/codeoid-tui/src/ui/approval.rs | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/codeoid-tui/src/ui/approval.rs b/crates/codeoid-tui/src/ui/approval.rs index cdd9e0e..926ced0 100644 --- a/crates/codeoid-tui/src/ui/approval.rs +++ b/crates/codeoid-tui/src/ui/approval.rs @@ -246,4 +246,70 @@ mod tests { "key prompts missing" ); } + + #[test] + fn full_layout_inserts_the_banner_when_a_tool_is_pending() { + use crate::state::AppState; + use codeoid_protocol::{AuthOkMsg, SessionInfo, SessionStatus}; + use ratatui::backend::TestBackend; + use ratatui::buffer::Cell; + use ratatui::Terminal; + + let mut state = AppState::new(AuthOkMsg { + identity: MessageIdentity { + sub: "spiffe://x".into(), + name: Some("Me".into()), + kind: IdentityType::Human, + }, + scopes: vec![], + protocol_version: Some(1), + }); + state.sessions.upsert(SessionInfo { + id: "s1".into(), + name: "demo".into(), + workdir: "/tmp".into(), + status: SessionStatus::WaitingApproval, + created_by: "u".into(), + created_at: "2026-06-23T00:00:00Z".into(), + attached_clients: 0, + mode: None, + turns_remaining: None, + pinned_files: None, + agent_uri: None, + subagents: None, + usage: None, + rotation: None, + queued_messages: None, + model: None, + fallback_model: None, + }); + let mut m = msg( + MessageRole::ToolCall, + Some(tool( + "Edit", + ToolState::WaitingConfirmation { + input: json!({}), + description: "edit src/main.rs".into(), + approval_id: "a1".into(), + }, + )), + ); + m.session_id = "s1".into(); + state.messages.apply_message(m); + + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|f| crate::ui::render(f, &mut state)).unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(Cell::symbol) + .collect(); + + assert!( + text.contains("APPROVAL NEEDED"), + "the full layout should insert the approval banner: {text}" + ); + } } From d95229966166ea4d81b8ab61b09c1ce67e9d6b75 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 23 Jun 2026 22:42:26 +0800 Subject: [PATCH 4/4] fix: budget approval description against the tool-prefix width (CodeRabbit) truncate() was given inner.width - 2, ignoring the rendered ' ' prefix, so a long tool+description could overflow and wrap the banner. Subtract the prefix columns so the line stays single-row. Co-Authored-By: Claude Opus 4.8 --- crates/codeoid-tui/src/ui/approval.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/codeoid-tui/src/ui/approval.rs b/crates/codeoid-tui/src/ui/approval.rs index 926ced0..c71e33b 100644 --- a/crates/codeoid-tui/src/ui/approval.rs +++ b/crates/codeoid-tui/src/ui/approval.rs @@ -72,7 +72,12 @@ fn render_banner(frame: &mut Frame<'_>, area: Rect, tool: &str, description: &st let inner = block.inner(area); frame.render_widget(block, area); - // Line 1: the action — tool name + what it wants to do. + // Line 1: the action — tool name + what it wants to do. Budget the + // description against the room left after the " " prefix so the + // banner never overflows into a wrapped second row. Tool names are ASCII + // identifiers, so a char count is the column count. + let prefix_cols = 1 + tool.chars().count() + 2; // " " + tool + " " + let desc_max = usize::from(inner.width).saturating_sub(prefix_cols); let action = Line::from(vec![ Span::raw(" "), Span::styled( @@ -81,7 +86,7 @@ fn render_banner(frame: &mut Frame<'_>, area: Rect, tool: &str, description: &st ), Span::raw(" "), Span::styled( - truncate(description, inner.width.saturating_sub(2) as usize), + truncate(description, desc_max), Style::default().fg(Color::White), ), ]);