newt-tui 0.8.0

Newt-Agent TUI surfaces (ratatui): code mode + pilot mode
//! **RichTUI free-text modal for a pending clarification batch** (#2524
//! item 7).
//!
//! `interaction_view::ModalInput` assumes a fixed set of discrete choices
//! (`ControlKind::Choice`); a clarification batch is answered in free text —
//! an ordinal (`2: ...`), `/discuss`, `/new`, or a bare `yes` taking an
//! outstanding proposal — so this is a small purpose-built reader instead of
//! stretching that widget to cover a shape it was not built for (Option 1 of
//! the addendum to #2515, approved by Shawn 2026-09-22).
//!
//! It draws the SAME [`crate::modal::frame`] chrome every other modal in this
//! crate wears. `/discuss` and the ordinal shape are named in the chrome's
//! HINT rather than appended to the scrollable batch text — the F4-inspect
//! bug #2524 records for that pattern (a hint folded into scrollable content
//! scrolls away with it instead of staying pinned).
//!
//! Inline viewport only, never the alternate screen —
//! `docs/decisions/plain_scroller_tui.md`; this module follows
//! `interaction_view`'s split exactly (a pure `requested_rows`/`draw` pair
//! usable in a unit test, and a terminal-owning `present`/`present_in` pair
//! gated to `rich-tui`).

use std::io;
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use newt_core::tty::raw_mode::RawModeGuard;
use newt_core::tty::OnCollision;
use ratatui::layout::{Constraint, Layout};
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Wrap};

use crate::chat::ReadOutcome;
use crate::inline_viewport::InlineTerm;

/// Rows the modal needs: the wrapped batch, two borders, a hint legend, and
/// one row for the free-text answer.
pub(crate) fn requested_rows(batch: &str, cols: u16) -> u16 {
    let width = usize::from(cols.saturating_sub(2)).max(1);
    let body_rows: usize = batch
        .lines()
        .map(|line| newt_core::tty::wrap_line(line, width).len().max(1))
        .sum();
    u16::try_from(body_rows + 4).unwrap_or(u16::MAX)
}

/// One free-text read, in progress: what the operator has typed so far.
struct FreeTextInput {
    answer: String,
}

/// What one key/paste event did to the read in progress.
enum Step {
    /// Nothing yet — keep reading.
    Continue,
    Done(ReadOutcome),
}

impl FreeTextInput {
    fn event(&mut self, event: Event) -> Step {
        match event {
            Event::Paste(text) => {
                self.answer
                    .extend(text.chars().filter(|ch| !ch.is_control()));
                Step::Continue
            }
            Event::Key(key) if key.kind == KeyEventKind::Press => {
                let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
                match key.code {
                    KeyCode::Char('c' | 'd') if ctrl => {
                        let outcome = if key.code == KeyCode::Char('c') {
                            ReadOutcome::Interrupted
                        } else {
                            ReadOutcome::Eof
                        };
                        Step::Done(outcome)
                    }
                    KeyCode::Enter => {
                        Step::Done(ReadOutcome::Line(std::mem::take(&mut self.answer)))
                    }
                    // #2540 round 2 item 1: dismiss to the ordinary read — the
                    // SAME shape the trait default's `present_clarification`
                    // produces (`print_newt(batch); self.read_line(prompt)`),
                    // an empty answer that resolves as a fresh operator turn
                    // rather than trapping the operator inside a batch they
                    // want out of.
                    KeyCode::Esc => Step::Done(ReadOutcome::Line(String::new())),
                    // #2540 round 2 item 1: Ctrl-U clears the whole line, the
                    // same accelerator the mounted editor and the permission
                    // modal's own free-text mode both honor.
                    KeyCode::Char('u') if ctrl => {
                        self.answer.clear();
                        Step::Continue
                    }
                    KeyCode::Backspace => {
                        self.answer.pop();
                        Step::Continue
                    }
                    KeyCode::Char(typed) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => {
                        self.answer.push(typed);
                        Step::Continue
                    }
                    _ => Step::Continue,
                }
            }
            _ => Step::Continue,
        }
    }
}

/// Draw the chrome + batch + answer line into `frame`.
fn draw(frame: &mut ratatui::Frame, batch: &str, hint: &str, answer: &str) {
    let body = crate::modal::frame(
        frame,
        frame.area(),
        &crate::modal::Chrome {
            title: "clarification",
            subtitle: Some("· 1 of 1".to_string()),
            hint: Some(hint),
        },
    );
    let [text, answer_area] =
        Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(body);
    let lines: Vec<Line<'static>> = batch.lines().map(|l| Line::from(l.to_string())).collect();
    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), text);
    let shown = format!("> {answer}");
    frame.render_widget(Paragraph::new(shown.as_str()), answer_area);
    if answer_area.width > 0 && answer_area.height > 0 {
        frame.set_cursor_position((
            (answer_area.x + newt_core::tty::str_width(&shown) as u16)
                .min(answer_area.x + answer_area.width.saturating_sub(1)),
            answer_area.y,
        ));
    }
}

/// Run the modal's event loop on an already-positioned terminal (the
/// cockpit's real-terminal handoff, mirroring `interaction_view::present_in`).
///
/// `#[cfg(unix)]` to match its only caller, [`present_in`], exactly as that
/// function is gated and for the same reason: the live cockpit is unix-only,
/// so on Windows this has nobody to serve and `-D warnings` correctly calls
/// it dead (measured on the Windows CI leg). The classic [`present`] opens
/// its own frame per pass and drives [`read_until_resize`] directly.
#[cfg(unix)]
fn run(terminal: &mut InlineTerm, batch: &str, hint: &str) -> io::Result<ReadOutcome> {
    let mut input = FreeTextInput {
        answer: String::new(),
    };
    loop {
        if let Some(outcome) = read_until_resize(terminal, batch, hint, &mut input)? {
            return Ok(outcome);
        }
    }
}

/// Draw and read until the operator is done, or `None` when the terminal was
/// resized, so a caller that owns the frame's rows can re-open it at the new
/// size. (The cockpit's rows are the presenter's, so [`run`] carries on.)
fn read_until_resize(
    terminal: &mut InlineTerm,
    batch: &str,
    hint: &str,
    input: &mut FreeTextInput,
) -> io::Result<Option<ReadOutcome>> {
    loop {
        terminal.draw(|f| draw(f, batch, hint, &input.answer))?;
        if !event::poll(Duration::from_millis(250))? {
            continue;
        }
        match event::read()? {
            Event::Resize(..) => return Ok(None),
            event => {
                if let Step::Done(outcome) = input.event(event) {
                    return Ok(Some(outcome));
                }
            }
        }
    }
}

#[cfg(unix)]
/// Present the modal on the terminal `terminal` already occupies (the
/// cockpit's saved real terminal — the presenter owns row reservation and
/// cleanup; this loop owns input until dismissal). The `_inline`
/// sibling this is, is [`present`] below.
///
/// #2540 round 2 item 1/5 (BLOCKING, measured live): this used to trust the
/// cockpit's ambient terminal state and read with no guard of its own — the
/// ONE difference from `interaction_view::present_in`, whose `ModalReader`
/// holds its OWN `RawModeGuard` for the read loop's lifetime
/// (`interaction_view::terminal::ModalReader::new`). Without it, keys arrived
/// as raw, undecoded bytes: a paste showed as literal `^[[200~…^[[201~`,
/// Enter as a literal `^M`, Esc/Ctrl-C/Ctrl-U did nothing — the modal could
/// neither be answered nor left. Entering the SAME guard here, the same way
/// the working modal does, is the fix: `RawModeGuard` composes under nesting
/// (its own doc), so this is harmless if the cockpit's ambient guard was
/// already up, and load-bearing if it was not. RAII restores on every exit
/// path (return, `?`, panic) — item 5.
pub(crate) fn present_in(
    terminal: &mut InlineTerm,
    batch: &str,
    hint: &str,
) -> io::Result<ReadOutcome> {
    let _raw = RawModeGuard::enter()?;
    run(terminal, batch, hint)
}

/// Present the modal on a freshly leased region of the classic (non-cockpit)
/// terminal, mirroring `interaction_view::present`.
pub(crate) fn present(batch: &str, hint: &str) -> io::Result<ReadOutcome> {
    let _raw = RawModeGuard::enter()?;
    let mut input = FreeTextInput {
        answer: String::new(),
    };
    // Each pass opens the frame afresh; a resize ends a pass, and the answer
    // typed so far carries over to the next.
    loop {
        let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
        let height = requested_rows(batch, cols).min(rows).max(1);
        let lease = crate::inline_viewport::lease_below_transcript(height, OnCollision::Shift)?;
        // Rows the arbiter placed come blank and are erased when the lease
        // drops, with the cursor returned to the transcript
        // (`Terminal::lease_below_cursor`). The bottom-anchored fallback is
        // ratatui's `Inline` viewport, which has to be cleared on open (a
        // fresh back buffer repaints only what the frame touches) and erased
        // on close, as `interaction_view::ModalReader` does for the same
        // reason.
        let erase_here = lease.cursor_return().is_none();
        let mut terminal = crate::inline_viewport::inline_terminal(lease)?;
        if erase_here {
            terminal.clear()?;
        }
        let outcome = read_until_resize(&mut terminal, batch, hint, &mut input);
        if erase_here {
            let _ = terminal.clear();
        }
        if let Some(outcome) = outcome? {
            return Ok(outcome);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::backend::TestBackend;
    use ratatui::Terminal;

    fn rendered(batch: &str, hint: &str, answer: &str, w: u16, h: u16) -> Vec<String> {
        let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
        term.draw(|f| draw(f, batch, hint, answer)).unwrap();
        let buf = term.backend().buffer().clone();
        (0..h)
            .map(|y| {
                (0..w)
                    .map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
                    .collect::<String>()
            })
            .collect()
    }

    /// **The rich path renders the batch inside chrome with `/discuss` in
    /// the hint** — the red-first assertion for item 7's rendering half.
    #[test]
    fn the_batch_renders_inside_chrome_with_discuss_in_the_hint() {
        let rows = rendered(
            "1: which lane?\n   a) build\n   b) ship",
            "reply with an ordinal — or /discuss to talk it through",
            "",
            60,
            10,
        );
        let text = rows.join("\n");
        assert!(text.contains("clarification"), "title: {text}");
        assert!(text.contains("1: which lane?"), "batch body: {text}");
        assert!(text.contains("/discuss"), "hint names /discuss: {text}");
        assert!(rows[0].starts_with('╭'), "modal chrome edge: {:?}", rows[0]);
    }

    /// The typed answer shows on the answer row, prefixed the same way
    /// `ModalInput`'s free-text mode prefixes its own — `> `.
    #[test]
    fn the_typed_answer_is_shown_on_the_answer_row() {
        let rows = rendered("1: which lane?", "hint", "2", 40, 6);
        assert!(rows.iter().any(|r| r.contains("> 2")), "{rows:?}");
    }

    #[test]
    fn requested_rows_grows_with_a_wrapped_batch() {
        let short = requested_rows("one line", 40);
        let long = requested_rows(&"x".repeat(200), 40);
        assert!(long > short);
    }

    #[test]
    fn enter_submits_the_typed_answer() {
        let mut input = FreeTextInput {
            answer: "2".to_string(),
        };
        let step = input.event(Event::Key(crossterm::event::KeyEvent::new(
            KeyCode::Enter,
            KeyModifiers::NONE,
        )));
        assert!(matches!(step, Step::Done(ReadOutcome::Line(a)) if a == "2"));
    }

    #[test]
    fn ctrl_c_interrupts_and_ctrl_d_is_eof() {
        let mut input = FreeTextInput {
            answer: String::new(),
        };
        let ctrl_c = Event::Key(crossterm::event::KeyEvent::new(
            KeyCode::Char('c'),
            KeyModifiers::CONTROL,
        ));
        assert!(matches!(
            input.event(ctrl_c),
            Step::Done(ReadOutcome::Interrupted)
        ));
        let ctrl_d = Event::Key(crossterm::event::KeyEvent::new(
            KeyCode::Char('d'),
            KeyModifiers::CONTROL,
        ));
        assert!(matches!(input.event(ctrl_d), Step::Done(ReadOutcome::Eof)));
    }

    #[test]
    fn typed_text_accumulates_and_backspace_removes_it() {
        let mut input = FreeTextInput {
            answer: String::new(),
        };
        for ch in ['/', 'd', 'i', 's', 'c'] {
            input.event(Event::Key(crossterm::event::KeyEvent::new(
                KeyCode::Char(ch),
                KeyModifiers::NONE,
            )));
        }
        assert_eq!(input.answer, "/disc");
        input.event(Event::Key(crossterm::event::KeyEvent::new(
            KeyCode::Backspace,
            KeyModifiers::NONE,
        )));
        assert_eq!(input.answer, "/dis");
    }

    /// #2540 round 2 item 1: Esc ends the modal read with an empty answer.
    /// The chat loop skips an empty line and, with the batch still pending,
    /// shows the same batch again: Esc leaves nothing locked and loses
    /// nothing, but it does not leave the gate (only an answer, `/discuss`,
    /// `/new` or `/exit` does).
    #[test]
    fn esc_dismisses_with_an_empty_answer() {
        let mut input = FreeTextInput {
            answer: "partial".to_string(),
        };
        let step = input.event(Event::Key(crossterm::event::KeyEvent::new(
            KeyCode::Esc,
            KeyModifiers::NONE,
        )));
        assert!(matches!(step, Step::Done(ReadOutcome::Line(a)) if a.is_empty()));
    }

    /// #2540 round 2 item 1: Ctrl-U clears the whole line, not just one char.
    #[test]
    fn ctrl_u_clears_the_whole_answer() {
        let mut input = FreeTextInput {
            answer: "/discuss why the plan changed".to_string(),
        };
        let step = input.event(Event::Key(crossterm::event::KeyEvent::new(
            KeyCode::Char('u'),
            KeyModifiers::CONTROL,
        )));
        assert!(matches!(step, Step::Continue));
        assert_eq!(input.answer, "");
    }
}