返回 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 codewhale_ratatui::{
15 Paint, PendingCard, PendingCardContext, PendingCardStyles, PendingCardWords,
16 };
17 use ratatui::buffer::Buffer;
18 use ratatui::layout::Rect;
19 use ratatui::style::{Modifier, Style};
20
21 use crate::tui::menu_style;
22 use crate::tui::widgets::Renderable;
23 use codewhale_localization::{Locale, MessageId, tr};
24 use codewhale_palette as palette;
25
26 /// Description of the keybinding the hint line at the bottom should advertise
27 /// for the "edit last queued message" action.
28 #[derive(Debug, Clone)]
29 pub struct EditBinding {
30 pub label: &'static str,
31 }
32
33 impl EditBinding {
34 pub const UP: EditBinding = EditBinding { label: "↑" };
35 }
36
37 /// Widget showing pending input while a turn is in progress.
38 #[derive(Debug, Clone)]
39 pub struct PendingInputPreview {
40 pub locale: Locale,
41 pub context_items: Vec<ContextPreviewItem>,
42 pub pending_steers: Vec<String>,
43 pub queued_messages: Vec<String>,
44 pub editing_queued_message: Option<String>,
45 pub edit_binding: EditBinding,
46 /// "Approval needed in {agent} — /agents", one row per child agent
47 /// waiting on the person whose card is not on top (approvals C1).
48 pub pending_approvals: Vec<String>,
49 }
50
51 /// Compact pre-send context row shown above the composer. `included=false`
52 /// marks unconfirmed, missing, or skipped context distinctly from files/media
53 /// already known to be sent or inlined.
54 #[derive(Debug, Clone, PartialEq, Eq)]
55 pub struct ContextPreviewItem {
56 pub kind: String,
57 pub label: String,
58 pub detail: Option<String>,
59 pub included: bool,
60 pub removable: bool,
61 pub selected: bool,
62 }
63
64 impl PendingInputPreview {
65 pub fn new() -> Self {
66 Self {
67 locale: Locale::En,
68 context_items: Vec::new(),
69 pending_steers: Vec::new(),
70 queued_messages: Vec::new(),
71 editing_queued_message: None,
72 edit_binding: EditBinding::UP,
73 pending_approvals: Vec::new(),
74 }
75 }
76
77 fn kit(&self) -> PendingCard<'_> {
78 let mut card = PendingCard::new(PendingCardWords {
79 context_header: tr(self.locale, MessageId::PendingContextHeader),
80 inputs_header: tr(self.locale, MessageId::PendingInputsHeader),
81 sending_prefix: tr(self.locale, MessageId::PendingSendingIntoTurnPrefix),
82 editing_prefix: tr(self.locale, MessageId::PendingEditingFollowUpPrefix),
83 editing_restore: tr(self.locale, MessageId::PendingEscRestore),
84 queued_prefix: tr(self.locale, MessageId::PendingQueuedFollowUpPrefix),
85 queued_one_prefix: tr(self.locale, MessageId::PendingQueuedOnePrefix),
86 queued_many_prefix: tr(self.locale, MessageId::PendingQueuedManyPrefix),
87 queued_controls: tr(self.locale, MessageId::PendingSendNowControls)
88 .replace("{key}", self.edit_binding.label)
89 .into(),
90 compact_controls: tr(self.locale, MessageId::PendingSendNowDropControls)
91 .replace("{key}", self.edit_binding.label)
92 .into(),
93 // These are the existing context suffixes; their copy has not
94 // acquired new localization authority in this rendering slice.
95 removable: "removable".into(),
96 selected_remove: "Backspace/Delete removes".into(),
97 });
98 card.context = self
99 .context_items
100 .iter()
101 .map(|item| PendingCardContext {
102 kind: item.kind.as_str().into(),
103 label: item.label.as_str().into(),
104 detail: item.detail.as_deref().map(Into::into),
105 included: item.included,
106 removable: item.removable,
107 selected: item.selected,
108 })
109 .collect();
110 card.sending = self
111 .pending_steers
112 .iter()
113 .map(|value| value.as_str().into())
114 .collect();
115 card.queued = self
116 .queued_messages
117 .iter()
118 .map(|value| value.as_str().into())
119 .collect();
120 card.editing = self.editing_queued_message.as_deref().map(Into::into);
121 card.priority_rows = self
122 .pending_approvals
123 .iter()
124 .map(|value| value.as_str().into())
125 .collect();
126 card.styles = Some(PendingCardStyles {
127 input: Style::default()
128 .fg(palette::TEXT_DIM)
129 .add_modifier(Modifier::DIM),
130 warning: Style::default().fg(palette::STATUS_WARNING),
131 context_muted: Style::default().fg(palette::TEXT_MUTED),
132 context_label: Style::default().fg(palette::TEXT_PRIMARY),
133 selected: menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT),
134 });
135 card
136 }
137 }
138
139 impl Default for PendingInputPreview {
140 fn default() -> Self {
141 Self::new()
142 }
143 }
144
145 impl Renderable for PendingInputPreview {
146 fn render(&self, area: Rect, buf: &mut Buffer) {
147 // The existing backend owns terminal punctuation/color projection.
148 // This pure card receives exact host styles and authors one row plan.
149 self.kit()
150 .paint(area, buf, &crate::tui::infoline::source_theme(false));
151 }
152 fn desired_height(&self, width: u16) -> u16 {
153 self.kit()
154 .height(width, &crate::tui::infoline::source_theme(false))
155 }
156 }
157
158 #[cfg(test)]
159 #[path = "pending_input_preview/legacy_fixture.rs"]
160 mod legacy_fixture;
161
162 #[cfg(test)]
163 mod tests {
164 use super::*;
165
166 fn render_to_string(widget: &PendingInputPreview, width: u16) -> Vec<String> {
167 let height = widget.desired_height(width);
168 if height == 0 {
169 return Vec::new();
170 }
171 let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
172 widget.render(Rect::new(0, 0, width, height), &mut buf);
173 (0..height)
174 .map(|y| {
175 (0..width)
176 .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
177 .collect::<String>()
178 .trim_end()
179 .to_string()
180 })
181 .collect()
182 }
183
184 fn render_in_area(widget: &PendingInputPreview, width: u16, height: u16) -> Vec<String> {
185 let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
186 widget.render(Rect::new(0, 0, width, height), &mut buf);
187 (0..height)
188 .map(|y| {
189 (0..width)
190 .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
191 .collect::<String>()
192 .trim_end()
193 .to_string()
194 })
195 .collect()
196 }
197
198 #[test]
199 fn empty_widget_has_zero_height() {
200 let preview = PendingInputPreview::new();
201 assert_eq!(preview.desired_height(40), 0);
202 }
203
204 #[test]
205 fn single_queued_message_renders_header_item_and_hint() {
206 let mut preview = PendingInputPreview::new();
207 preview.queued_messages.push("Hello, world!".to_string());
208 let rows = render_to_string(&preview, 40);
209 assert_eq!(rows.len(), 2, "got rows: {rows:?}");
210 assert!(rows[0].contains("Queued #1: Hello, world!"));
211 assert!(rows[1].contains("Enter send now"));
212 assert!(rows[1].contains("↑ edit"));
213 assert!(rows[1].contains("/queue drop 1"));
214 }
215
216 #[test]
217 fn compact_queue_keeps_send_control_in_one_two_and_three_row_areas() {
218 let mut preview = PendingInputPreview::new();
219 preview
220 .queued_messages
221 .push("ship the compact fix".to_string());
222
223 for (width, height) in [(40, 1), (40, 2), (60, 3)] {
224 let rows = render_in_area(&preview, width, height);
225 assert!(
226 rows.iter().any(|row| row.contains("Enter send now")),
227 "send control clipped at {width}x{height}: {rows:?}"
228 );
229 }
230 }
231
232 #[test]
233 fn editing_queued_message_renders_explicit_state_and_restore_hint() {
234 let mut preview = PendingInputPreview::new();
235 preview.editing_queued_message = Some("revise before sending".to_string());
236
237 let rows = render_to_string(&preview, 80);
238
239 assert!(rows[0].contains("Pending inputs"));
240 assert!(
241 rows.iter()
242 .any(|row| row.contains("Editing follow-up: revise before sending")),
243 "missing editing label: {rows:?}"
244 );
245 assert!(
246 rows.iter()
247 .any(|row| row.contains("Esc restores the queued follow-up")),
248 "missing restore hint: {rows:?}"
249 );
250 assert!(
251 !rows.iter().any(|row| row.contains("edit last queued")),
252 "editing mode should not also advertise opening a queued edit: {rows:?}"
253 );
254 }
255
256 #[test]
257 fn context_items_render_before_queue_buckets() {
258 let mut preview = PendingInputPreview::new();
259 preview.context_items.push(ContextPreviewItem {
260 kind: "file".to_string(),
261 label: "src/main.rs".to_string(),
262 detail: Some("included".to_string()),
263 included: true,
264 removable: false,
265 selected: false,
266 });
267 preview.context_items.push(ContextPreviewItem {
268 kind: "missing".to_string(),
269 label: "nope.txt".to_string(),
270 detail: Some("not found".to_string()),
271 included: false,
272 removable: false,
273 selected: false,
274 });
275 let rows = render_to_string(&preview, 64);
276 assert!(rows[0].contains("Context for next send"));
277 assert!(rows[1].contains("[file] src/main.rs"));
278 assert!(rows[2].contains("[missing] nope.txt"));
279 }
280
281 #[test]
282 fn selected_removable_attachment_renders_delete_hint() {
283 let mut preview = PendingInputPreview::new();
284 preview.context_items.push(ContextPreviewItem {
285 kind: "image".to_string(),
286 label: "/tmp/pasted.png".to_string(),
287 detail: Some("attached media".to_string()),
288 included: true,
289 removable: true,
290 selected: true,
291 });
292
293 let rows = render_to_string(&preview, 96);
294
295 assert!(
296 rows.iter()
297 .any(|row| row.contains("Backspace/Delete removes"))
298 );
299 assert!(rows.iter().any(|row| row.contains("▸")));
300 }
301
302 #[test]
303 fn pending_steer_renders_without_queue_edit_hint() {
304 let mut preview = PendingInputPreview::new();
305 preview.pending_steers.push("Please continue.".to_string());
306 let rows = render_to_string(&preview, 80);
307 assert!(
308 rows.iter().any(|r| r.contains("Pending inputs")),
309 "missing pending input header: {rows:?}"
310 );
311 assert!(
312 !rows.iter().any(|r| r.contains("Esc")),
313 "unexpected Esc hint: {rows:?}"
314 );
315 assert!(
316 !rows.iter().any(|r| r.contains("edit last queued")),
317 "unexpected edit hint in pending-steer-only view: {rows:?}"
318 );
319 }
320
321 #[test]
322 fn all_pending_inputs_render_as_one_list() {
323 let mut preview = PendingInputPreview::new();
324 preview.pending_steers.push("steer".to_string());
325 preview.queued_messages.push("queued".to_string());
326 let rows = render_to_string(&preview, 60);
327 assert!(rows[0].contains("Pending inputs"));
328 assert_eq!(
329 rows.iter().filter(|r| r.contains("Pending inputs")).count(),
330 1
331 );
332 assert!(rows.iter().any(|r| r.contains("steer")));
333 assert!(rows.iter().any(|r| r.contains("queued")));
334 assert!(rows.iter().any(|r| r.contains("↑")));
335 assert!(rows.iter().any(|r| r.contains("Enter send now")));
336 }
337
338 #[test]
339 fn pending_input_copy_does_not_teach_steer() {
340 let mut preview = PendingInputPreview::new();
341 preview.pending_steers.push("please continue".to_string());
342 preview.queued_messages.push("next".to_string());
343 let joined = render_to_string(&preview, 80)
344 .join("\n")
345 .to_ascii_lowercase();
346 assert!(
347 !joined.contains("steer"),
348 "pending-input copy leaked internal vocabulary: {joined}"
349 );
350 assert!(joined.contains("sending into this turn"));
351 assert!(joined.contains("queued follow-up"));
352 }
353
354 #[test]
355 fn pending_input_rows_label_each_delivery_mode() {
356 let mut preview = PendingInputPreview::new();
357 preview.pending_steers.push("steer".to_string());
358 preview.queued_messages.push("queued".to_string());
359 preview.editing_queued_message = Some("editing".to_string());
360
361 let rows = render_to_string(&preview, 80);
362
363 assert!(
364 rows.iter()
365 .any(|row| row.contains("Sending into this turn: steer")),
366 "missing pending send-now label: {rows:?}"
367 );
368 assert!(
369 rows.iter()
370 .any(|row| row.contains("Queued follow-up #1: queued")),
371 "missing queued-follow-up label: {rows:?}"
372 );
373 assert!(
374 rows.iter()
375 .any(|row| row.contains("Editing follow-up: editing")),
376 "missing queued-edit label: {rows:?}"
377 );
378 }
379
380 #[test]
381 fn queued_only_preview_truncates_instead_of_hiding_controls() {
382 let mut preview = PendingInputPreview::new();
383 preview
384 .queued_messages
385 .push("alpha beta gamma delta epsilon zeta".to_string());
386
387 let rows = render_to_string(&preview, 34);
388
389 assert_eq!(rows.len(), 2, "got rows: {rows:?}");
390 assert!(rows[0].contains("Queued #1: alpha"));
391 assert!(rows[0].contains('…'));
392 assert!(rows[1].contains("Enter send now"));
393 }
394
395 #[test]
396 fn multiline_queued_message_collapses_to_one_truncated_summary() {
397 let mut preview = PendingInputPreview::new();
398 preview
399 .queued_messages
400 .push("line1\nline2\nline3\nline4\nline5\nline6\nline7".to_string());
401 let rows = render_to_string(&preview, 40);
402 assert_eq!(rows.len(), 2, "got rows: {rows:?}");
403 assert!(rows[0].contains("Queued #1: line1 line2"));
404 assert!(rows[0].contains('…'));
405 assert!(rows[1].contains("Enter send now"));
406 assert!(rows[1].contains("↑ edit"));
407 }
408
409 #[test]
410 fn long_url_does_not_explode_into_ellipsis_rows() {
411 let mut preview = PendingInputPreview::new();
412 preview.queued_messages.push(
413 "example.test/api/v1/projects/alpha/releases/2026-02-17/build/1234567890/artifacts/x"
414 .to_string(),
415 );
416 let rows = render_to_string(&preview, 36);
417 assert_eq!(rows.len(), 2, "got rows: {rows:?}");
418 assert!(rows[0].contains("Queued #1:"));
419 assert!(rows[1].contains("Enter send now"));
420 }
421
422 #[test]
423 fn narrow_width_renders_nothing() {
424 let mut preview = PendingInputPreview::new();
425 preview.queued_messages.push("hi".to_string());
426 assert_eq!(preview.desired_height(2), 0);
427 }
428 // Append inside pending_input_preview.rs's existing tests module.
429 // This frozen production counterpart measures both cell contents and styles;
430 // it does not restate the new kit's implementation as its own expectation.
431 fn legacy_counterpart(preview: &PendingInputPreview) -> legacy_fixture::PendingInputPreview {
432 legacy_fixture::PendingInputPreview {
433 locale: preview.locale,
434 context_items: preview
435 .context_items
436 .iter()
437 .map(|item| legacy_fixture::ContextPreviewItem {
438 kind: item.kind.clone(),
439 label: item.label.clone(),
440 detail: item.detail.clone(),
441 included: item.included,
442 removable: item.removable,
443 selected: item.selected,
444 })
445 .collect(),
446 pending_steers: preview.pending_steers.clone(),
447 queued_messages: preview.queued_messages.clone(),
448 editing_queued_message: preview.editing_queued_message.clone(),
449 edit_binding: legacy_fixture::EditBinding {
450 label: preview.edit_binding.label,
451 },
452 pending_approvals: preview.pending_approvals.clone(),
453 }
454 }
455
456 fn guarded_preview_buffer(area: Rect) -> Buffer {
457 // A nonzero buffer origin catches accidental use of local coordinates.
458 // A guard around the whole requested rectangle catches stray painting;
459 // cells inside are initially empty with a caller-owned background
460 // and modifier, including wide-character continuation cells.
461 let canvas = Rect::new(2, 3, area.width + 12, area.height + 8);
462 let mut buffer = Buffer::empty(canvas);
463 for y in canvas.y..canvas.bottom() {
464 for x in canvas.x..canvas.right() {
465 buffer[(x, y)].set_style(
466 Style::default()
467 .fg(ratatui::style::Color::Rgb(13, 41, 67))
468 .bg(ratatui::style::Color::Rgb(19, 47, 73))
469 .add_modifier(Modifier::UNDERLINED),
470 );
471 if x < area.x || x >= area.right() || y < area.y || y >= area.bottom() {
472 buffer[(x, y)].set_symbol("~");
473 }
474 }
475 }
476 buffer
477 }
478
479 fn assert_preview_matches_legacy(preview: &PendingInputPreview, case: &str) {
480 let legacy = legacy_counterpart(preview);
481 for width in [0, 2, 4, 12, 40, 80] {
482 assert_eq!(
483 preview.desired_height(width),
484 legacy.desired_height(width),
485 "height case={case} locale={:?} width={width}",
486 preview.locale,
487 );
488 for height in [0, 1, 2, 3, 12] {
489 let area = Rect::new(7, 6, width, height);
490 let mut actual = guarded_preview_buffer(area);
491 let mut expected = actual.clone();
492 preview.render(area, &mut actual);
493 legacy.render(area, &mut expected);
494 assert_eq!(
495 actual, expected,
496 "buffer case={case} locale={:?} area={area:?}",
497 preview.locale,
498 );
499 }
500 }
501 }
502
503 fn parity_context(included: bool, removable: bool, selected: bool) -> ContextPreviewItem {
504 ContextPreviewItem {
505 kind: "file 文件".to_string(),
506 label: "資料/cafe\u{0301}-①-1\u{20e3}-👩‍💻.txt".to_string(),
507 detail: Some("CJK 你好 and cafe\u{0301} ① 1\u{20e3} attachment".to_string()),
508 included,
509 removable,
510 selected,
511 }
512 }
513
514 #[test]
515 fn kit_pending_preview_matches_frozen_native_in_every_shipped_locale_and_geometry() {
516 let long_url = "https://example.test/api/v1/projects/資料/releases/2026-10-02/build/1234567890/artifacts/abcdefghijklmnopqrstuvwxyz";
517 for &locale in Locale::shipped() {
518 let mut empty = PendingInputPreview::new();
519 empty.locale = locale;
520 let mut queued = empty.clone();
521 queued
522 .queued_messages
523 .push(format!("你好 cafe\u{0301} {long_url}"));
524 let mut queued_many = queued.clone();
525 queued_many
526 .queued_messages
527 .push("second follow-up".to_string());
528 let mut mixed = queued_many.clone();
529 mixed.context_items.push(parity_context(true, true, false));
530 mixed.context_items.push(parity_context(false, false, true));
531 mixed.pending_steers.push(format!(
532 "你好 cafe\u{0301} ① 1\u{20e3} 👩‍💻 first paragraph\nsecond paragraph {long_url}\nthird\nfourth"
533 ));
534 mixed.editing_queued_message =
535 Some("revise 你好 cafe\u{0301}\nnext paragraph".to_string());
536 mixed.edit_binding = EditBinding { label: "Alt+↑" };
537 let mut context_only = empty.clone();
538 context_only
539 .context_items
540 .push(parity_context(false, true, true));
541 context_only
542 .context_items
543 .push(parity_context(true, false, false));
544 let mut priority_only = empty.clone();
545 priority_only.pending_approvals = vec![
546 "Approval needed in 資料/cafe\u{0301} — /agents".to_string(),
547 "Another child needs input — /agents".to_string(),
548 ];
549 let mut child_and_queued = queued.clone();
550 child_and_queued.pending_approvals = priority_only.pending_approvals.clone();
551 for (case, preview) in [
552 ("empty", empty),
553 ("queued-only", queued),
554 ("queued-many", queued_many),
555 ("mixed", mixed),
556 ("context-only", context_only),
557 ("priority-only", priority_only),
558 ("child-and-queued", child_and_queued),
559 ] {
560 assert_preview_matches_legacy(&preview, case);
561 }
562 }
563 }
564
565 #[test]
566 fn kit_pending_context_flags_remain_independent_of_each_other() {
567 for &locale in Locale::shipped() {
568 for included in [false, true] {
569 for removable in [false, true] {
570 for selected in [false, true] {
571 let mut preview = PendingInputPreview::new();
572 preview.locale = locale;
573 preview
574 .context_items
575 .push(parity_context(included, removable, selected));
576 assert_preview_matches_legacy(
577 &preview,
578 &format!(
579 "included={included} removable={removable} selected={selected}"
580 ),
581 );
582 }
583 }
584 }
585 }
586 }
587
588 #[test]
589 fn kit_pending_control_and_bidi_sanitization_precedes_measurement_and_paint() {
590 // Sanitization is an intentional safety change, so this uses explicit
591 // sanitized user text rather than comparing against the old renderer.
592 for &locale in Locale::shipped() {
593 let mut safe = PendingInputPreview::new();
594 safe.locale = locale;
595 safe.context_items.push(ContextPreviewItem {
596 kind: "ABCD".to_string(),
597 label: "ABCD".to_string(),
598 detail: Some("ABCD".to_string()),
599 included: false,
600 removable: true,
601 selected: true,
602 });
603 safe.pending_steers.push("ABCD".to_string());
604 safe.queued_messages.push("ABCD".to_string());
605 safe.editing_queued_message = Some("ABCD".to_string());
606 safe.pending_approvals.push("ABCD".to_string());
607 let mut unsafe_text = safe.clone();
608 let value = "A\u{202e}B\u{2066}C\u{001b}\0\t\rD".to_string();
609 unsafe_text.context_items[0].kind = value.clone();
610 unsafe_text.context_items[0].label = value.clone();
611 unsafe_text.context_items[0].detail = Some(value.clone());
612 unsafe_text.pending_steers[0] = value.clone();
613 unsafe_text.queued_messages[0] = value.clone();
614 unsafe_text.editing_queued_message = Some(value.clone());
615 unsafe_text.pending_approvals[0] = value;
616 for width in [4, 12, 40, 80] {
617 assert_eq!(
618 unsafe_text.desired_height(width),
619 safe.desired_height(width)
620 );
621 let area = Rect::new(7, 6, width, 12);
622 let mut actual = guarded_preview_buffer(area);
623 let mut expected = actual.clone();
624 unsafe_text.render(area, &mut actual);
625 safe.render(area, &mut expected);
626 assert_eq!(
627 actual, expected,
628 "sanitization locale={locale:?} width={width}"
629 );
630 }
631 }
632 }
633
634 #[test]
635 fn kit_pending_multiline_body_keeps_three_rows_and_one_overflow_marker() {
636 for &locale in Locale::shipped() {
637 let mut preview = PendingInputPreview::new();
638 preview.locale = locale;
639 preview
640 .pending_steers
641 .push("one\ntwo\nthree\nfour\nfive\nsix\nseven".to_string());
642 assert_eq!(preview.desired_height(160), 5, "locale={locale:?}");
643 let rows = render_to_string(&preview, 160);
644 assert_eq!(rows.len(), 5);
645 assert!(rows[1].ends_with("one"));
646 assert!(rows[2].ends_with("two"));
647 assert!(rows[3].ends_with("three"));
648 assert_eq!(rows[4].trim(), "…");
649 assert!(!rows.iter().any(|row| row.contains("four")));
650 }
651 }
652 }
653
653 lines RUST