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