1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
//! **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, "");
}
}