返回 DeepSeek-TUI-2026
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 use unicode_width::UnicodeWidthChar;
21
22 use crate::palette;
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
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 context_items: Vec<ContextPreviewItem>,
43 pub pending_steers: Vec<String>,
44 pub rejected_steers: Vec<String>,
45 pub queued_messages: Vec<String>,
46 pub edit_binding: EditBinding,
47 }
48
49 /// Compact pre-send context row shown above the composer. `included=false`
50 /// marks missing/skipped context distinctly from files/media that will be
51 /// sent or inlined.
52 #[derive(Debug, Clone, PartialEq, Eq)]
53 pub struct ContextPreviewItem {
54 pub kind: String,
55 pub label: String,
56 pub detail: Option<String>,
57 pub included: bool,
58 pub removable: bool,
59 pub selected: bool,
60 }
61
62 impl PendingInputPreview {
63 pub fn new() -> Self {
64 Self {
65 context_items: Vec::new(),
66 pending_steers: Vec::new(),
67 rejected_steers: Vec::new(),
68 queued_messages: Vec::new(),
69 edit_binding: EditBinding::UP,
70 }
71 }
72
73 fn has_pending_inputs(&self) -> bool {
74 !self.pending_steers.is_empty()
75 || !self.rejected_steers.is_empty()
76 || !self.queued_messages.is_empty()
77 }
78
79 /// Build the (possibly empty) ordered line list this widget would render
80 /// at `width`. Pulled out so `desired_height` can ask the same renderer
81 /// without duplicating wrapping logic.
82 fn lines(&self, width: u16) -> Vec<Line<'static>> {
83 if (self.context_items.is_empty() && !self.has_pending_inputs()) || width < 4 {
84 return Vec::new();
85 }
86
87 let dim = Style::default()
88 .fg(palette::TEXT_DIM)
89 .add_modifier(Modifier::DIM);
90 let dim_italic = dim.add_modifier(Modifier::ITALIC);
91
92 let mut lines: Vec<Line<'static>> = Vec::new();
93
94 if !self.context_items.is_empty() {
95 push_section_header(
96 &mut lines,
97 Line::from(vec![Span::raw("• "), Span::raw("Context for next send")]),
98 );
99 for item in &self.context_items {
100 push_context_item(&mut lines, item, width);
101 }
102 }
103
104 if self.has_pending_inputs() {
105 if !lines.is_empty() {
106 lines.push(Line::from(""));
107 }
108 push_section_header(
109 &mut lines,
110 Line::from(vec![Span::raw("• "), Span::raw("Pending inputs")]),
111 );
112 for steer in &self.pending_steers {
113 push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " ");
114 }
115 for steer in &self.rejected_steers {
116 push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " ");
117 }
118 for message in &self.queued_messages {
119 push_truncated_item(&mut lines, message, width, dim_italic, " ↳ ", " ");
120 }
121 if !self.queued_messages.is_empty() {
122 lines.push(Line::from(vec![Span::styled(
123 format!(" {} edit last queued message", self.edit_binding.label),
124 dim,
125 )]));
126 }
127 }
128
129 lines
130 }
131 }
132
133 impl Default for PendingInputPreview {
134 fn default() -> Self {
135 Self::new()
136 }
137 }
138
139 impl Renderable for PendingInputPreview {
140 fn render(&self, area: Rect, buf: &mut Buffer) {
141 if area.is_empty() {
142 return;
143 }
144 let lines = self.lines(area.width);
145 if lines.is_empty() {
146 return;
147 }
148 Paragraph::new(lines).render(area, buf);
149 }
150
151 fn desired_height(&self, width: u16) -> u16 {
152 let lines = self.lines(width);
153 u16::try_from(lines.len()).unwrap_or(u16::MAX)
154 }
155 }
156
157 fn push_section_header(lines: &mut Vec<Line<'static>>, header: Line<'static>) {
158 lines.push(header);
159 }
160
161 fn push_context_item(lines: &mut Vec<Line<'static>>, item: &ContextPreviewItem, width: u16) {
162 let status_style = if item.selected {
163 Style::default()
164 .fg(palette::SELECTION_TEXT)
165 .bg(palette::SELECTION_BG)
166 .add_modifier(Modifier::BOLD)
167 } else if item.included {
168 Style::default().fg(palette::TEXT_MUTED)
169 } else {
170 Style::default().fg(palette::STATUS_WARNING)
171 };
172 let label_style = if item.selected {
173 Style::default()
174 .fg(palette::SELECTION_TEXT)
175 .bg(palette::SELECTION_BG)
176 } else if item.included {
177 Style::default().fg(palette::TEXT_PRIMARY)
178 } else {
179 Style::default().fg(palette::TEXT_MUTED)
180 };
181 let detail = item
182 .detail
183 .as_deref()
184 .filter(|detail| !detail.trim().is_empty())
185 .map(|detail| format!(" · {detail}"))
186 .unwrap_or_default();
187 let action = if item.selected {
188 " · Backspace/Delete removes"
189 } else if item.removable {
190 " · removable"
191 } else {
192 ""
193 };
194 let body = format!("[{}] {}{}{}", item.kind, item.label, detail, action);
195 let body_width = width.saturating_sub(4).max(1) as usize;
196 for (idx, segment) in wrap_to_width(&body, body_width).into_iter().enumerate() {
197 let prefix = if idx == 0 {
198 if item.selected { " ▸ " } else { " ↳ " }
199 } else {
200 " "
201 };
202 lines.push(Line::from(vec![
203 Span::styled(prefix.to_string(), status_style),
204 Span::styled(segment, label_style),
205 ]));
206 }
207 }
208
209 /// Render a single bucket item with `↳` prefix, truncating to
210 /// [`PREVIEW_LINE_LIMIT`] visible rows. Multi-line input wraps at the given
211 /// column budget and the continuation rows get the `subsequent_indent` so
212 /// the prefix and the body stay column-aligned.
213 fn push_truncated_item(
214 lines: &mut Vec<Line<'static>>,
215 raw: &str,
216 width: u16,
217 style: Style,
218 prefix: &str,
219 subsequent_indent: &str,
220 ) {
221 let body_width = width.saturating_sub(display_width(prefix) as u16) as usize;
222 let body_width = body_width.max(1);
223
224 let mut produced: Vec<String> = Vec::new();
225 for (idx, paragraph) in raw.split('\n').enumerate() {
226 let wrapped = wrap_to_width(paragraph, body_width);
227 for (j, segment) in wrapped.into_iter().enumerate() {
228 let row = if idx == 0 && j == 0 {
229 format!("{prefix}{segment}")
230 } else {
231 format!("{subsequent_indent}{segment}")
232 };
233 produced.push(row);
234 if produced.len() > PREVIEW_LINE_LIMIT {
235 break;
236 }
237 }
238 if produced.len() > PREVIEW_LINE_LIMIT {
239 break;
240 }
241 }
242
243 let truncated = produced.len() > PREVIEW_LINE_LIMIT;
244 for (i, row) in produced.into_iter().enumerate() {
245 if i >= PREVIEW_LINE_LIMIT {
246 break;
247 }
248 lines.push(Line::from(Span::styled(row, style)));
249 }
250 if truncated {
251 lines.push(Line::from(Span::styled(
252 format!("{subsequent_indent}…"),
253 style,
254 )));
255 }
256 }
257
258 /// Naive word-aware wrap that respects unicode display widths. Matches the
259 /// behavior expected by snapshot tests in the codex source — long URL-like
260 /// tokens that exceed `width` are emitted on their own row instead of being
261 /// hard-broken mid-character.
262 fn wrap_to_width(text: &str, width: usize) -> Vec<String> {
263 if width == 0 || text.is_empty() {
264 return vec![text.to_string()];
265 }
266
267 let mut out: Vec<String> = Vec::new();
268 let mut current = String::new();
269 let mut current_width = 0usize;
270
271 for word in text.split_inclusive(' ') {
272 let word_width = display_width(word);
273 if current_width + word_width > width && !current.is_empty() {
274 out.push(std::mem::take(&mut current));
275 current_width = 0;
276 }
277 if word_width > width {
278 // Token longer than the budget: flush current, emit the word as
279 // its own row even though it overflows. Avoids the codex-issue
280 // of a long URL fanning out into N junk-ellipsis rows.
281 if !current.is_empty() {
282 out.push(std::mem::take(&mut current));
283 current_width = 0;
284 }
285 out.push(word.trim_end().to_string());
286 continue;
287 }
288 current.push_str(word);
289 current_width += word_width;
290 }
291 if !current.is_empty() {
292 out.push(current);
293 }
294 out
295 }
296
297 fn display_width(s: &str) -> usize {
298 s.chars()
299 .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
300 .sum()
301 }
302
303 #[cfg(test)]
304 mod tests {
305 use super::*;
306
307 fn render_to_string(widget: &PendingInputPreview, width: u16) -> Vec<String> {
308 let height = widget.desired_height(width);
309 if height == 0 {
310 return Vec::new();
311 }
312 let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
313 widget.render(Rect::new(0, 0, width, height), &mut buf);
314 (0..height)
315 .map(|y| {
316 (0..width)
317 .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
318 .collect::<String>()
319 .trim_end()
320 .to_string()
321 })
322 .collect()
323 }
324
325 #[test]
326 fn empty_widget_has_zero_height() {
327 let preview = PendingInputPreview::new();
328 assert_eq!(preview.desired_height(40), 0);
329 }
330
331 #[test]
332 fn single_queued_message_renders_header_item_and_hint() {
333 let mut preview = PendingInputPreview::new();
334 preview.queued_messages.push("Hello, world!".to_string());
335 let rows = render_to_string(&preview, 40);
336 // Expect: header line, message line, hint line.
337 assert_eq!(rows.len(), 3, "got rows: {rows:?}");
338 assert!(rows[0].contains("Pending inputs"));
339 assert!(rows[1].contains("Hello, world!"));
340 assert!(rows[2].contains("edit last queued message"));
341 }
342
343 #[test]
344 fn context_items_render_before_queue_buckets() {
345 let mut preview = PendingInputPreview::new();
346 preview.context_items.push(ContextPreviewItem {
347 kind: "file".to_string(),
348 label: "src/main.rs".to_string(),
349 detail: Some("included".to_string()),
350 included: true,
351 removable: false,
352 selected: false,
353 });
354 preview.context_items.push(ContextPreviewItem {
355 kind: "missing".to_string(),
356 label: "nope.txt".to_string(),
357 detail: Some("not found".to_string()),
358 included: false,
359 removable: false,
360 selected: false,
361 });
362 let rows = render_to_string(&preview, 64);
363 assert!(rows[0].contains("Context for next send"));
364 assert!(rows[1].contains("[file] src/main.rs"));
365 assert!(rows[2].contains("[missing] nope.txt"));
366 }
367
368 #[test]
369 fn selected_removable_attachment_renders_delete_hint() {
370 let mut preview = PendingInputPreview::new();
371 preview.context_items.push(ContextPreviewItem {
372 kind: "image".to_string(),
373 label: "/tmp/pasted.png".to_string(),
374 detail: Some("attached media".to_string()),
375 included: true,
376 removable: true,
377 selected: true,
378 });
379
380 let rows = render_to_string(&preview, 96);
381
382 assert!(
383 rows.iter()
384 .any(|row| row.contains("Backspace/Delete removes"))
385 );
386 assert!(rows.iter().any(|row| row.contains("▸")));
387 }
388
389 #[test]
390 fn pending_steer_renders_without_queue_edit_hint() {
391 let mut preview = PendingInputPreview::new();
392 preview.pending_steers.push("Please continue.".to_string());
393 let rows = render_to_string(&preview, 80);
394 assert!(
395 rows.iter().any(|r| r.contains("Pending inputs")),
396 "missing pending input header: {rows:?}"
397 );
398 assert!(
399 !rows.iter().any(|r| r.contains("Esc")),
400 "unexpected Esc hint: {rows:?}"
401 );
402 assert!(
403 !rows.iter().any(|r| r.contains("edit last queued message")),
404 "unexpected edit hint in pending-steer-only view: {rows:?}"
405 );
406 }
407
408 #[test]
409 fn all_pending_inputs_render_as_one_list() {
410 let mut preview = PendingInputPreview::new();
411 preview.pending_steers.push("steer".to_string());
412 preview.rejected_steers.push("rejected".to_string());
413 preview.queued_messages.push("queued".to_string());
414 let rows = render_to_string(&preview, 60);
415 assert!(rows[0].contains("Pending inputs"));
416 assert_eq!(
417 rows.iter().filter(|r| r.contains("Pending inputs")).count(),
418 1
419 );
420 assert!(rows.iter().any(|r| r.contains("steer")));
421 assert!(rows.iter().any(|r| r.contains("rejected")));
422 assert!(rows.iter().any(|r| r.contains("queued")));
423 assert!(rows.iter().any(|r| r.contains("↑")));
424 }
425
426 #[test]
427 fn message_truncates_to_three_visible_lines() {
428 let mut preview = PendingInputPreview::new();
429 preview
430 .queued_messages
431 .push("line1\nline2\nline3\nline4\nline5".to_string());
432 let rows = render_to_string(&preview, 40);
433 // Header + 3 visible lines + ellipsis row + hint = 6 rows.
434 assert_eq!(rows.len(), 6, "got rows: {rows:?}");
435 assert!(rows[0].contains("Pending inputs"));
436 assert!(rows[1].contains("line1"));
437 assert!(rows[2].contains("line2"));
438 assert!(rows[3].contains("line3"));
439 assert!(rows[4].contains("…"));
440 assert!(rows[5].contains("edit last queued message"));
441 }
442
443 #[test]
444 fn long_url_does_not_explode_into_ellipsis_rows() {
445 let mut preview = PendingInputPreview::new();
446 preview.queued_messages.push(
447 "example.test/api/v1/projects/alpha/releases/2026-02-17/build/1234567890/artifacts/x"
448 .to_string(),
449 );
450 let rows = render_to_string(&preview, 36);
451 // Header + URL row + hint = 3 rows; the URL must NOT cause a chain of
452 // wrapped-ellipsis rows.
453 assert_eq!(rows.len(), 3, "got rows: {rows:?}");
454 assert!(!rows.iter().any(|r| r.contains("…")));
455 }
456
457 #[test]
458 fn narrow_width_renders_nothing() {
459 let mut preview = PendingInputPreview::new();
460 preview.queued_messages.push("hi".to_string());
461 assert_eq!(preview.desired_height(2), 0);
462 }
463 }
464
464 lines RUST