diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index e31cf955..76e7abb0 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1,10 +1,18 @@ mod footer; mod header; +<<<<<<< HEAD // Some helpers (`shift`, `ctrl_alt`, `is_press`, etc.) are part of the // public surface for issue #93's help overlay and future call sites; allow // dead code rather than scattering `#[allow]` across every constructor. #[allow(dead_code)] pub mod key_hint; +======= +// Phase 1 of #85: widget lands without a wire-up site so reviewers can +// evaluate the rendering in isolation. The follow-up PR plumbs it through +// the composer area in `ui.rs`. `pub mod` (vs the usual `pub use` pattern) +// keeps the unused-imports lint quiet until then. +pub mod pending_input_preview; +>>>>>>> 8c605cc0 (feat(tui): pending-input preview widget (#85 Phase 1)) mod renderable; pub use footer::{ diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs new file mode 100644 index 00000000..ced56063 --- /dev/null +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -0,0 +1,386 @@ +//! Pending-input preview widget for the composer area. +//! +//! Mirrors `codex-rs/tui/src/bottom_pane/pending_input_preview.rs` (port for +//! issue #85). When a turn is in flight and the user has typed follow-up +//! input, this widget shows the queued items grouped by submission semantics: +//! +//! Phase 1 of #85 ships the widget + tests in isolation. Phase 2 wires it +//! into the composer area in `ui.rs` and threads `pending_steers` / +//! `rejected_steers` fields onto `App`. The dead-code allow below covers +//! the gap between phases — the items below are exercised by the in-module +//! test suite but no production caller yet. + +#![allow(dead_code)] + +//! +//! 1. **Pending steers** — messages submitted *during* a tool call boundary +//! (next round-trip), with hint that Esc force-sends them now. +//! 2. **Rejected steers** — engine declined the steer (e.g., tool already +//! running); will be replayed at end-of-turn. +//! 3. **Queued follow-ups** — ordinary messages held until the turn ends. +//! +//! Empty state renders zero rows so the composer doesn't gain wasted height +//! when there's nothing to show. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; +use unicode_width::UnicodeWidthChar; + +use crate::palette; +use crate::tui::widgets::Renderable; + +/// Per-item line cap before we collapse the rest into a `…` overflow row. +const PREVIEW_LINE_LIMIT: usize = 3; + +/// Description of the keybinding the hint line at the bottom should advertise +/// for the "edit last queued message" action. The default `Alt+↑` matches +/// the chord we already wire up; callers can override for terminals where +/// Alt+ chords are eaten by the shell. +#[derive(Debug, Clone)] +pub struct EditBinding { + pub label: &'static str, +} + +impl EditBinding { + pub const ALT_UP: EditBinding = EditBinding { + label: "Alt+↑", + }; +} + +/// Widget showing pending steers + rejected steers + queued follow-up +/// messages while a turn is in progress. +#[derive(Debug, Clone)] +pub struct PendingInputPreview { + pub pending_steers: Vec, + pub rejected_steers: Vec, + pub queued_messages: Vec, + pub edit_binding: EditBinding, +} + +impl PendingInputPreview { + pub fn new() -> Self { + Self { + pending_steers: Vec::new(), + rejected_steers: Vec::new(), + queued_messages: Vec::new(), + edit_binding: EditBinding::ALT_UP, + } + } + + /// Build the (possibly empty) ordered line list this widget would render + /// at `width`. Pulled out so `desired_height` can ask the same renderer + /// without duplicating wrapping logic. + fn lines(&self, width: u16) -> Vec> { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { + return Vec::new(); + } + + let dim = Style::default() + .fg(palette::TEXT_DIM) + .add_modifier(Modifier::DIM); + let dim_italic = dim.add_modifier(Modifier::ITALIC); + + let mut lines: Vec> = Vec::new(); + + if !self.pending_steers.is_empty() { + push_section_header( + &mut lines, + Line::from(vec![ + Span::raw("• "), + Span::raw("Messages to be submitted after next tool call"), + Span::styled(" (press Esc to send now)", dim), + ]), + ); + for steer in &self.pending_steers { + push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " "); + } + } + + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + push_section_header( + &mut lines, + Line::from(vec![ + Span::raw("• "), + Span::raw("Messages to be submitted at end of turn"), + ]), + ); + for steer in &self.rejected_steers { + push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " "); + } + } + + if !self.queued_messages.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + push_section_header( + &mut lines, + Line::from(vec![ + Span::raw("• "), + Span::raw("Queued follow-up inputs"), + ]), + ); + for message in &self.queued_messages { + push_truncated_item(&mut lines, message, width, dim_italic, " ↳ ", " "); + } + // Edit-last-queued hint only when there's actually something to + // pop — pending steers don't get an Alt+↑ hint because the engine + // owns when they get sent. + lines.push(Line::from(vec![Span::styled( + format!(" {} edit last queued message", self.edit_binding.label), + dim, + )])); + } + + lines + } +} + +impl Default for PendingInputPreview { + fn default() -> Self { + Self::new() + } +} + +impl Renderable for PendingInputPreview { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + let lines = self.lines(area.width); + if lines.is_empty() { + return; + } + Paragraph::new(lines).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let lines = self.lines(width); + u16::try_from(lines.len()).unwrap_or(u16::MAX) + } +} + +fn push_section_header(lines: &mut Vec>, header: Line<'static>) { + lines.push(header); +} + +/// Render a single bucket item with `↳` prefix, truncating to +/// [`PREVIEW_LINE_LIMIT`] visible rows. Multi-line input wraps at the given +/// column budget and the continuation rows get the `subsequent_indent` so +/// the prefix and the body stay column-aligned. +fn push_truncated_item( + lines: &mut Vec>, + raw: &str, + width: u16, + style: Style, + prefix: &str, + subsequent_indent: &str, +) { + let body_width = width.saturating_sub(display_width(prefix) as u16) as usize; + let body_width = body_width.max(1); + + let mut produced: Vec = Vec::new(); + for (idx, paragraph) in raw.split('\n').enumerate() { + let wrapped = wrap_to_width(paragraph, body_width); + for (j, segment) in wrapped.into_iter().enumerate() { + let row = if idx == 0 && j == 0 { + format!("{prefix}{segment}") + } else { + format!("{subsequent_indent}{segment}") + }; + produced.push(row); + if produced.len() > PREVIEW_LINE_LIMIT { + break; + } + } + if produced.len() > PREVIEW_LINE_LIMIT { + break; + } + } + + let truncated = produced.len() > PREVIEW_LINE_LIMIT; + for (i, row) in produced.into_iter().enumerate() { + if i >= PREVIEW_LINE_LIMIT { + break; + } + lines.push(Line::from(Span::styled(row, style))); + } + if truncated { + lines.push(Line::from(Span::styled( + format!("{subsequent_indent}…"), + style, + ))); + } +} + +/// Naive word-aware wrap that respects unicode display widths. Matches the +/// behavior expected by snapshot tests in the codex source — long URL-like +/// tokens that exceed `width` are emitted on their own row instead of being +/// hard-broken mid-character. +fn wrap_to_width(text: &str, width: usize) -> Vec { + if width == 0 || text.is_empty() { + return vec![text.to_string()]; + } + + let mut out: Vec = Vec::new(); + let mut current = String::new(); + let mut current_width = 0usize; + + for word in text.split_inclusive(' ') { + let word_width = display_width(word); + if current_width + word_width > width && !current.is_empty() { + out.push(std::mem::take(&mut current)); + current_width = 0; + } + if word_width > width { + // Token longer than the budget: flush current, emit the word as + // its own row even though it overflows. Avoids the codex-issue + // of a long URL fanning out into N junk-ellipsis rows. + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + current_width = 0; + } + out.push(word.trim_end().to_string()); + continue; + } + current.push_str(word); + current_width += word_width; + } + if !current.is_empty() { + out.push(current); + } + out +} + +fn display_width(s: &str) -> usize { + s.chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(0)) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render_to_string(widget: &PendingInputPreview, width: u16) -> Vec { + let height = widget.desired_height(width); + if height == 0 { + return Vec::new(); + } + 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::() + .trim_end() + .to_string() + }) + .collect() + } + + #[test] + fn empty_widget_has_zero_height() { + let preview = PendingInputPreview::new(); + assert_eq!(preview.desired_height(40), 0); + } + + #[test] + fn single_queued_message_renders_header_item_and_hint() { + let mut preview = PendingInputPreview::new(); + preview.queued_messages.push("Hello, world!".to_string()); + let rows = render_to_string(&preview, 40); + // Expect: header line, message line, hint line. + assert_eq!(rows.len(), 3, "got rows: {rows:?}"); + assert!(rows[0].contains("Queued follow-up inputs")); + assert!(rows[1].contains("Hello, world!")); + assert!(rows[2].contains("edit last queued message")); + } + + #[test] + fn pending_steer_shows_esc_hint_no_alt_up_hint() { + let mut preview = PendingInputPreview::new(); + preview + .pending_steers + .push("Please continue.".to_string()); + // Use a wide-enough column budget that the section header does not + // wrap — keeps the assertions targeted at content rather than at + // wrap boundaries. + let rows = render_to_string(&preview, 80); + assert!( + rows.iter().any(|r| r.contains("after next tool call")), + "missing pending-steer header: {rows:?}" + ); + assert!( + rows.iter().any(|r| r.contains("Esc")), + "missing Esc hint: {rows:?}" + ); + assert!( + !rows.iter().any(|r| r.contains("Alt+↑")), + "unexpected Alt+↑ hint in pending-steer-only view: {rows:?}" + ); + } + + #[test] + fn three_sections_render_with_blank_line_separators() { + let mut preview = PendingInputPreview::new(); + preview.pending_steers.push("steer".to_string()); + preview.rejected_steers.push("rejected".to_string()); + preview.queued_messages.push("queued".to_string()); + let rows = render_to_string(&preview, 60); + // Sections are separated by blank lines + headers + items + final hint. + assert!(rows.iter().any(|r| r.contains("after next tool call"))); + assert!(rows.iter().any(|r| r.contains("end of turn"))); + assert!(rows.iter().any(|r| r.contains("Queued follow-up"))); + assert!(rows.iter().any(|r| r.contains("Alt+↑"))); + } + + #[test] + fn message_truncates_to_three_visible_lines() { + let mut preview = PendingInputPreview::new(); + preview + .queued_messages + .push("line1\nline2\nline3\nline4\nline5".to_string()); + let rows = render_to_string(&preview, 40); + // Header + 3 visible lines + ellipsis row + hint = 6 rows. + assert_eq!(rows.len(), 6, "got rows: {rows:?}"); + assert!(rows[0].contains("Queued follow-up")); + assert!(rows[1].contains("line1")); + assert!(rows[2].contains("line2")); + assert!(rows[3].contains("line3")); + assert!(rows[4].contains("…")); + assert!(rows[5].contains("edit last queued message")); + } + + #[test] + fn long_url_does_not_explode_into_ellipsis_rows() { + let mut preview = PendingInputPreview::new(); + preview.queued_messages.push( + "example.test/api/v1/projects/alpha/releases/2026-02-17/build/1234567890/artifacts/x" + .to_string(), + ); + let rows = render_to_string(&preview, 36); + // Header + URL row + hint = 3 rows; the URL must NOT cause a chain of + // wrapped-ellipsis rows. + assert_eq!(rows.len(), 3, "got rows: {rows:?}"); + assert!(!rows.iter().any(|r| r.contains("…"))); + } + + #[test] + fn narrow_width_renders_nothing() { + let mut preview = PendingInputPreview::new(); + preview.queued_messages.push("hi".to_string()); + assert_eq!(preview.desired_height(2), 0); + } +}