feat(tui): pending-input preview widget (#85 Phase 1)

Port of codex-rs's `bottom_pane/pending_input_preview.rs` for the queued /
pending steer / rejected steer surface. Phase 1 ships the widget + 7 unit
tests in isolation so reviewers can evaluate the rendering decisions
without also reviewing the composer-area integration. Phase 2 wires it
into `ui.rs` and threads the `pending_steers` / `rejected_steers` fields
onto `App`.

The widget renders three semantic buckets when any are non-empty:

  • Messages to be submitted after next tool call (press Esc to send now)
    ↳ <pending steer>
    ↳ <pending steer>

  • Messages to be submitted at end of turn
    ↳ <rejected steer>

  • Queued follow-up inputs
    ↳ <queued message>
        Alt+↑ edit last queued message

Items truncate to 3 visible rows with a `…` overflow indicator. Long
URL-like tokens emit on their own row instead of fanning out into junk
ellipsis rows (regression test included). Empty state renders zero rows
so the composer doesn't gain wasted height when nothing is queued.

Refs #85.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-26 17:29:37 -05:00
parent 32a977e58e
commit 2378fbc26f
2 changed files with 394 additions and 0 deletions
+8
View File
@@ -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::{
@@ -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<String>,
pub rejected_steers: Vec<String>,
pub queued_messages: Vec<String>,
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<Line<'static>> {
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<Line<'static>> = 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<Line<'static>>, 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<Line<'static>>,
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<String> = 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<String> {
if width == 0 || text.is_empty() {
return vec![text.to_string()];
}
let mut out: Vec<String> = 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<String> {
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::<String>()
.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);
}
}