返回 CodeWhale
pending_input_preview.rs
根目录 / crates / tui / src / tui / widgets / pending_input_preview.rs
1 //! Pending-input preview widget for the composer area.
2 //!
3 //! Port of `codex-rs/tui/src/bottom_pane/pending_input_preview.rs` for
4 //! issue #85. Renders queued/steered messages above the composer when a
5 //! turn is in flight, so user input typed during a running turn doesn't
6 //! disappear silently. The backing state still distinguishes queue/steer
7 //! origins, but the UI renders one coherent pending-input list.
8 //!
9 //! Empty state renders zero rows so the composer doesn't gain wasted height
10 //! when there's nothing to show.
11 //!
12 //! Wired into `ui.rs::render` between the chat area and the composer; the user
13 //! can see when typed input has been captured for later delivery.
14
15 use ratatui::buffer::Buffer;
16 use ratatui::layout::Rect;
17 use ratatui::style::{Modifier, Style};
18 use ratatui::text::{Line, Span};
19 use ratatui::widgets::{Paragraph, Widget};
20
21 use crate::palette;
22 use crate::tui::menu_style;
23 use crate::tui::widgets::Renderable;
24
25 /// Per-item line cap before we collapse the rest into a `…` overflow row.
26 const PREVIEW_LINE_LIMIT: usize = 3;
27 const PENDING_STEER_PREFIX: &str = " ↳ Live steer pending: ";
28 const REJECTED_STEER_PREFIX: &str = " ↳ Rejected live steer: ";
29 const EDITING_QUEUED_PREFIX: &str = " ↳ Editing queued follow-up: ";
30
31 /// Description of the keybinding the hint line at the bottom should advertise
32 /// for the "edit last queued message" action.
33 #[derive(Debug, Clone)]
34 pub struct EditBinding {
35 pub label: &'static str,
36 }
37
38 impl EditBinding {
39 pub const UP: EditBinding = EditBinding { label: "↑" };
40 }
41
42 /// Widget showing pending input while a turn is in progress.
43 #[derive(Debug, Clone)]
44 pub struct PendingInputPreview {
45 pub context_items: Vec<ContextPreviewItem>,
46 pub pending_steers: Vec<String>,
47 pub rejected_steers: Vec<String>,
48 pub queued_messages: Vec<String>,
49 pub editing_queued_message: Option<String>,
50 pub edit_binding: EditBinding,
51 }
52
53 /// Compact pre-send context row shown above the composer. `included=false`
54 /// marks unconfirmed, missing, or skipped context distinctly from files/media
55 /// already known to be sent or inlined.
56 #[derive(Debug, Clone, PartialEq, Eq)]
57 pub struct ContextPreviewItem {
58 pub kind: String,
59 pub label: String,
60 pub detail: Option<String>,
61 pub included: bool,
62 pub removable: bool,
63 pub selected: bool,
64 }
65
66 impl PendingInputPreview {
67 pub fn new() -> Self {
68 Self {
69 context_items: Vec::new(),
70 pending_steers: Vec::new(),
71 rejected_steers: Vec::new(),
72 queued_messages: Vec::new(),
73 editing_queued_message: None,
74 edit_binding: EditBinding::UP,
75 }
76 }
77
78 fn has_pending_inputs(&self) -> bool {
79 !self.pending_steers.is_empty()
80 || !self.rejected_steers.is_empty()
81 || !self.queued_messages.is_empty()
82 || self.editing_queued_message.is_some()
83 }
84
85 fn is_queued_only(&self) -> bool {
86 self.context_items.is_empty()
87 && self.pending_steers.is_empty()
88 && self.rejected_steers.is_empty()
89 && self.editing_queued_message.is_none()
90 && !self.queued_messages.is_empty()
91 }
92
93 /// Build the (possibly empty) ordered line list this widget would render
94 /// at `width`. Pulled out so `desired_height` can ask the same renderer
95 /// without duplicating wrapping logic.
96 fn lines(&self, width: u16) -> Vec<Line<'static>> {
97 if (self.context_items.is_empty() && !self.has_pending_inputs()) || width < 4 {
98 return Vec::new();
99 }
100
101 let dim = Style::default()
102 .fg(palette::TEXT_DIM)
103 .add_modifier(Modifier::DIM);
104 let dim_italic = dim.add_modifier(Modifier::ITALIC);
105
106 let mut lines: Vec<Line<'static>> = Vec::new();
107
108 // The common queued-only state must remain actionable at the release
109 // floor. A compact summary avoids spending scarce rows on a section
110 // heading and two separate command choruses.
111 if self.is_queued_only() {
112 let count = self.queued_messages.len();
113 let prefix = if count == 1 {
114 "Queued #1: ".to_string()
115 } else {
116 format!("Queued {count} · next: ")
117 };
118 let next = self.queued_messages[0].replace('\n', " ");
119 let summary = crate::localization::truncate_to_width(
120 &format!("{prefix}{next}"),
121 usize::from(width),
122 );
123 let controls = crate::localization::truncate_to_width(
124 &format!(
125 "Enter send now · {} edit · /queue drop 1",
126 self.edit_binding.label
127 ),
128 usize::from(width),
129 );
130 lines.push(Line::from(Span::styled(summary, dim_italic)));
131 lines.push(Line::from(Span::styled(controls, dim)));
132 return lines;
133 }
134
135 if !self.context_items.is_empty() {
136 push_section_header(
137 &mut lines,
138 Line::from(vec![Span::raw("• "), Span::raw("Context for next send")]),
139 );
140 for item in &self.context_items {
141 push_context_item(&mut lines, item, width);
142 }
143 }
144
145 if self.has_pending_inputs() {
146 if !lines.is_empty() {
147 lines.push(Line::from(""));
148 }
149 push_section_header(
150 &mut lines,
151 Line::from(vec![Span::raw("• "), Span::raw("Pending inputs")]),
152 );
153 let pending_steer_indent = continuation_indent(PENDING_STEER_PREFIX);
154 for steer in &self.pending_steers {
155 push_truncated_item(
156 &mut lines,
157 steer,
158 width,
159 dim,
160 PENDING_STEER_PREFIX,
161 &pending_steer_indent,
162 );
163 }
164 let rejected_steer_indent = continuation_indent(REJECTED_STEER_PREFIX);
165 for steer in &self.rejected_steers {
166 push_truncated_item(
167 &mut lines,
168 steer,
169 width,
170 dim,
171 REJECTED_STEER_PREFIX,
172 &rejected_steer_indent,
173 );
174 }
175 if let Some(draft) = self.editing_queued_message.as_deref() {
176 let editing_indent = continuation_indent(EDITING_QUEUED_PREFIX);
177 push_truncated_item(
178 &mut lines,
179 draft,
180 width,
181 dim_italic,
182 EDITING_QUEUED_PREFIX,
183 &editing_indent,
184 );
185 lines.push(Line::from(vec![Span::styled(
186 " Esc restores queued follow-up".to_string(),
187 dim,
188 )]));
189 }
190 for (idx, message) in self.queued_messages.iter().enumerate() {
191 let row_number = idx + 1;
192 let queued_prefix = format!(" ↳ Queued follow-up #{row_number}: ");
193 let queued_message_indent = continuation_indent(&queued_prefix);
194 push_truncated_item(
195 &mut lines,
196 message,
197 width,
198 dim_italic,
199 &queued_prefix,
200 &queued_message_indent,
201 );
202 lines.push(Line::from(vec![Span::styled(
203 format!(" /queue send {row_number} · drop {row_number} · clear"),
204 dim,
205 )]));
206 }
207 if !self.queued_messages.is_empty() {
208 lines.push(Line::from(vec![Span::styled(
209 format!(
210 " Enter send now · {} edit last queued",
211 self.edit_binding.label
212 ),
213 dim,
214 )]));
215 }
216 }
217
218 lines
219 }
220 }
221
222 impl Default for PendingInputPreview {
223 fn default() -> Self {
224 Self::new()
225 }
226 }
227
228 impl Renderable for PendingInputPreview {
229 fn render(&self, area: Rect, buf: &mut Buffer) {
230 if area.is_empty() {
231 return;
232 }
233 let mut lines = self.lines(area.width);
234 if lines.is_empty() {
235 return;
236 }
237 // If the rest of a 40x12 layout leaves one preview row, preserve the
238 // direct action rather than a non-actionable message summary.
239 if self.is_queued_only() && area.height == 1 && lines.len() == 2 {
240 lines.remove(0);
241 }
242 Paragraph::new(lines).render(area, buf);
243 }
244
245 fn desired_height(&self, width: u16) -> u16 {
246 let lines = self.lines(width);
247 u16::try_from(lines.len()).unwrap_or(u16::MAX)
248 }
249 }
250
251 fn continuation_indent(prefix: &str) -> String {
252 " ".repeat(display_width(prefix))
253 }
254
255 fn push_section_header(lines: &mut Vec<Line<'static>>, header: Line<'static>) {
256 lines.push(header);
257 }
258
259 fn push_context_item(lines: &mut Vec<Line<'static>>, item: &ContextPreviewItem, width: u16) {
260 let status_style = if item.selected {
261 menu_style::selected_row_style()
262 } else if item.included {
263 Style::default().fg(palette::TEXT_MUTED)
264 } else {
265 Style::default().fg(palette::STATUS_WARNING)
266 };
267 let label_style = if item.selected {
268 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
269 } else if item.included {
270 Style::default().fg(palette::TEXT_PRIMARY)
271 } else {
272 Style::default().fg(palette::TEXT_MUTED)
273 };
274 let detail = item
275 .detail
276 .as_deref()
277 .filter(|detail| !detail.trim().is_empty())
278 .map(|detail| format!(" · {detail}"))
279 .unwrap_or_default();
280 let action = if item.selected {
281 " · Backspace/Delete removes"
282 } else if item.removable {
283 " · removable"
284 } else {
285 ""
286 };
287 let body = format!("[{}] {}{}{}", item.kind, item.label, detail, action);
288 let body_width = width.saturating_sub(4).max(1) as usize;
289 for (idx, segment) in wrap_to_width(&body, body_width).into_iter().enumerate() {
290 let prefix = if idx == 0 {
291 if item.selected { " ▸ " } else { " ↳ " }
292 } else {
293 " "
294 };
295 lines.push(Line::from(vec![
296 Span::styled(prefix.to_string(), status_style),
297 Span::styled(segment, label_style),
298 ]));
299 }
300 }
301
302 /// Render a single bucket item with `↳` prefix, truncating to
303 /// [`PREVIEW_LINE_LIMIT`] visible rows. Multi-line input wraps at the given
304 /// column budget and the continuation rows get the `subsequent_indent` so
305 /// the prefix and the body stay column-aligned.
306 fn push_truncated_item(
307 lines: &mut Vec<Line<'static>>,
308 raw: &str,
309 width: u16,
310 style: Style,
311 prefix: &str,
312 subsequent_indent: &str,
313 ) {
314 let body_width = width.saturating_sub(display_width(prefix) as u16) as usize;
315 let body_width = body_width.max(1);
316
317 let mut produced: Vec<String> = Vec::new();
318 for (idx, paragraph) in raw.split('\n').enumerate() {
319 let wrapped = wrap_to_width(paragraph, body_width);
320 for (j, segment) in wrapped.into_iter().enumerate() {
321 let row = if idx == 0 && j == 0 {
322 format!("{prefix}{segment}")
323 } else {
324 format!("{subsequent_indent}{segment}")
325 };
326 produced.push(row);
327 if produced.len() > PREVIEW_LINE_LIMIT {
328 break;
329 }
330 }
331 if produced.len() > PREVIEW_LINE_LIMIT {
332 break;
333 }
334 }
335
336 let truncated = produced.len() > PREVIEW_LINE_LIMIT;
337 for (i, row) in produced.into_iter().enumerate() {
338 if i >= PREVIEW_LINE_LIMIT {
339 break;
340 }
341 lines.push(Line::from(Span::styled(row, style)));
342 }
343 if truncated {
344 lines.push(Line::from(Span::styled(
345 format!("{subsequent_indent}…"),
346 style,
347 )));
348 }
349 }
350
351 /// Naive word-aware wrap that respects unicode display widths. Matches the
352 /// behavior expected by snapshot tests in the codex source — long URL-like
353 /// tokens that exceed `width` are emitted on their own row instead of being
354 /// hard-broken mid-character.
355 fn wrap_to_width(text: &str, width: usize) -> Vec<String> {
356 if width == 0 || text.is_empty() {
357 return vec![text.to_string()];
358 }
359
360 let mut out: Vec<String> = Vec::new();
361 let mut current = String::new();
362 let mut current_width = 0usize;
363
364 for word in text.split_inclusive(' ') {
365 let word_width = display_width(word);
366 if current_width + word_width > width && !current.is_empty() {
367 out.push(std::mem::take(&mut current));
368 current_width = 0;
369 }
370 if word_width > width {
371 // Token longer than the budget: flush current, emit the word as
372 // its own row even though it overflows. Avoids the codex-issue
373 // of a long URL fanning out into N junk-ellipsis rows.
374 if !current.is_empty() {
375 out.push(std::mem::take(&mut current));
376 current_width = 0;
377 }
378 out.push(word.trim_end().to_string());
379 continue;
380 }
381 current.push_str(word);
382 current_width += word_width;
383 }
384 if !current.is_empty() {
385 out.push(current);
386 }
387 out
388 }
389
390 // Delegates to the canonical width contract (`ui_text::text_display_width`):
391 // tabs are 4 columns and control chars occupy one, matching what the renderer
392 // draws. The old local copy used `unwrap_or(0)` and ignored tabs, so preview
393 // word-wrap disagreed with the real layout on those inputs (#3924).
394 fn display_width(s: &str) -> usize {
395 crate::tui::ui_text::text_display_width(s)
396 }
397
398 #[cfg(test)]
399 mod tests {
400 use super::*;
401
402 fn render_to_string(widget: &PendingInputPreview, width: u16) -> Vec<String> {
403 let height = widget.desired_height(width);
404 if height == 0 {
405 return Vec::new();
406 }
407 let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
408 widget.render(Rect::new(0, 0, width, height), &mut buf);
409 (0..height)
410 .map(|y| {
411 (0..width)
412 .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
413 .collect::<String>()
414 .trim_end()
415 .to_string()
416 })
417 .collect()
418 }
419
420 fn render_in_area(widget: &PendingInputPreview, width: u16, height: u16) -> Vec<String> {
421 let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
422 widget.render(Rect::new(0, 0, width, height), &mut buf);
423 (0..height)
424 .map(|y| {
425 (0..width)
426 .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
427 .collect::<String>()
428 .trim_end()
429 .to_string()
430 })
431 .collect()
432 }
433
434 #[test]
435 fn empty_widget_has_zero_height() {
436 let preview = PendingInputPreview::new();
437 assert_eq!(preview.desired_height(40), 0);
438 }
439
440 #[test]
441 fn single_queued_message_renders_header_item_and_hint() {
442 let mut preview = PendingInputPreview::new();
443 preview.queued_messages.push("Hello, world!".to_string());
444 let rows = render_to_string(&preview, 40);
445 assert_eq!(rows.len(), 2, "got rows: {rows:?}");
446 assert!(rows[0].contains("Queued #1: Hello, world!"));
447 assert!(rows[1].contains("Enter send now"));
448 assert!(rows[1].contains("↑ edit"));
449 assert!(rows[1].contains("/queue drop 1"));
450 }
451
452 #[test]
453 fn compact_queue_keeps_send_control_in_one_two_and_three_row_areas() {
454 let mut preview = PendingInputPreview::new();
455 preview
456 .queued_messages
457 .push("ship the compact fix".to_string());
458
459 for (width, height) in [(40, 1), (40, 2), (60, 3)] {
460 let rows = render_in_area(&preview, width, height);
461 assert!(
462 rows.iter().any(|row| row.contains("Enter send now")),
463 "send control clipped at {width}x{height}: {rows:?}"
464 );
465 }
466 }
467
468 #[test]
469 fn editing_queued_message_renders_explicit_state_and_restore_hint() {
470 let mut preview = PendingInputPreview::new();
471 preview.editing_queued_message = Some("revise before sending".to_string());
472
473 let rows = render_to_string(&preview, 80);
474
475 assert!(rows[0].contains("Pending inputs"));
476 assert!(
477 rows.iter()
478 .any(|row| row.contains("Editing queued follow-up: revise before sending")),
479 "missing editing label: {rows:?}"
480 );
481 assert!(
482 rows.iter()
483 .any(|row| row.contains("Esc restores queued follow-up")),
484 "missing restore hint: {rows:?}"
485 );
486 assert!(
487 !rows.iter().any(|row| row.contains("edit last queued")),
488 "editing mode should not also advertise opening a queued edit: {rows:?}"
489 );
490 }
491
492 #[test]
493 fn context_items_render_before_queue_buckets() {
494 let mut preview = PendingInputPreview::new();
495 preview.context_items.push(ContextPreviewItem {
496 kind: "file".to_string(),
497 label: "src/main.rs".to_string(),
498 detail: Some("included".to_string()),
499 included: true,
500 removable: false,
501 selected: false,
502 });
503 preview.context_items.push(ContextPreviewItem {
504 kind: "missing".to_string(),
505 label: "nope.txt".to_string(),
506 detail: Some("not found".to_string()),
507 included: false,
508 removable: false,
509 selected: false,
510 });
511 let rows = render_to_string(&preview, 64);
512 assert!(rows[0].contains("Context for next send"));
513 assert!(rows[1].contains("[file] src/main.rs"));
514 assert!(rows[2].contains("[missing] nope.txt"));
515 }
516
517 #[test]
518 fn selected_removable_attachment_renders_delete_hint() {
519 let mut preview = PendingInputPreview::new();
520 preview.context_items.push(ContextPreviewItem {
521 kind: "image".to_string(),
522 label: "/tmp/pasted.png".to_string(),
523 detail: Some("attached media".to_string()),
524 included: true,
525 removable: true,
526 selected: true,
527 });
528
529 let rows = render_to_string(&preview, 96);
530
531 assert!(
532 rows.iter()
533 .any(|row| row.contains("Backspace/Delete removes"))
534 );
535 assert!(rows.iter().any(|row| row.contains("▸")));
536 }
537
538 #[test]
539 fn pending_steer_renders_without_queue_edit_hint() {
540 let mut preview = PendingInputPreview::new();
541 preview.pending_steers.push("Please continue.".to_string());
542 let rows = render_to_string(&preview, 80);
543 assert!(
544 rows.iter().any(|r| r.contains("Pending inputs")),
545 "missing pending input header: {rows:?}"
546 );
547 assert!(
548 !rows.iter().any(|r| r.contains("Esc")),
549 "unexpected Esc hint: {rows:?}"
550 );
551 assert!(
552 !rows.iter().any(|r| r.contains("edit last queued")),
553 "unexpected edit hint in pending-steer-only view: {rows:?}"
554 );
555 }
556
557 #[test]
558 fn all_pending_inputs_render_as_one_list() {
559 let mut preview = PendingInputPreview::new();
560 preview.pending_steers.push("steer".to_string());
561 preview.rejected_steers.push("rejected".to_string());
562 preview.queued_messages.push("queued".to_string());
563 let rows = render_to_string(&preview, 60);
564 assert!(rows[0].contains("Pending inputs"));
565 assert_eq!(
566 rows.iter().filter(|r| r.contains("Pending inputs")).count(),
567 1
568 );
569 assert!(rows.iter().any(|r| r.contains("steer")));
570 assert!(rows.iter().any(|r| r.contains("rejected")));
571 assert!(rows.iter().any(|r| r.contains("queued")));
572 assert!(rows.iter().any(|r| r.contains("↑")));
573 assert!(rows.iter().any(|r| r.contains("Enter send now")));
574 }
575
576 #[test]
577 fn pending_input_rows_label_each_delivery_mode() {
578 let mut preview = PendingInputPreview::new();
579 preview.pending_steers.push("steer".to_string());
580 preview.rejected_steers.push("rejected".to_string());
581 preview.queued_messages.push("queued".to_string());
582 preview.editing_queued_message = Some("editing".to_string());
583
584 let rows = render_to_string(&preview, 80);
585
586 assert!(
587 rows.iter()
588 .any(|row| row.contains("Live steer pending: steer")),
589 "missing pending-steer label: {rows:?}"
590 );
591 assert!(
592 rows.iter()
593 .any(|row| row.contains("Rejected live steer: rejected")),
594 "missing rejected-steer label: {rows:?}"
595 );
596 assert!(
597 rows.iter()
598 .any(|row| row.contains("Queued follow-up #1: queued")),
599 "missing queued-follow-up label: {rows:?}"
600 );
601 assert!(
602 rows.iter()
603 .any(|row| row.contains("Editing queued follow-up: editing")),
604 "missing queued-edit label: {rows:?}"
605 );
606 }
607
608 #[test]
609 fn queued_only_preview_truncates_instead_of_hiding_controls() {
610 let mut preview = PendingInputPreview::new();
611 preview
612 .queued_messages
613 .push("alpha beta gamma delta epsilon zeta".to_string());
614
615 let rows = render_to_string(&preview, 34);
616
617 assert_eq!(rows.len(), 2, "got rows: {rows:?}");
618 assert!(rows[0].contains("Queued #1: alpha"));
619 assert!(rows[0].contains('…'));
620 assert!(rows[1].contains("Enter send now"));
621 }
622
623 #[test]
624 fn multiline_queued_message_collapses_to_one_truncated_summary() {
625 let mut preview = PendingInputPreview::new();
626 preview
627 .queued_messages
628 .push("line1\nline2\nline3\nline4\nline5\nline6\nline7".to_string());
629 let rows = render_to_string(&preview, 40);
630 assert_eq!(rows.len(), 2, "got rows: {rows:?}");
631 assert!(rows[0].contains("Queued #1: line1 line2"));
632 assert!(rows[0].contains('…'));
633 assert!(rows[1].contains("Enter send now"));
634 assert!(rows[1].contains("↑ edit"));
635 }
636
637 #[test]
638 fn long_url_does_not_explode_into_ellipsis_rows() {
639 let mut preview = PendingInputPreview::new();
640 preview.queued_messages.push(
641 "example.test/api/v1/projects/alpha/releases/2026-02-17/build/1234567890/artifacts/x"
642 .to_string(),
643 );
644 let rows = render_to_string(&preview, 36);
645 assert_eq!(rows.len(), 2, "got rows: {rows:?}");
646 assert!(rows[0].contains("Queued #1:"));
647 assert!(rows[1].contains("Enter send now"));
648 }
649
650 #[test]
651 fn narrow_width_renders_nothing() {
652 let mut preview = PendingInputPreview::new();
653 preview.queued_messages.push("hi".to_string());
654 assert_eq!(preview.desired_height(2), 0);
655 }
656 }
657
657 lines RUST