返回 CodeWhale
legacy_fixture.rs
根目录 / crates / tui / src / tui / widgets / pending_input_preview / legacy_fixture.rs
1 // Verbatim production prefix of Engine pending_input_preview.rs, captured 2026-10-02.
2 // Test-only detached parity fixture; remove this header to recover original bytes.
3 //! Pending-input preview widget for the composer area.
4 //!
5 //! Renders queued and in-turn follow-ups above the composer when a turn is
6 //! in flight, so typed input doesn't disappear silently. The backing state
7 //! still distinguishes queue vs send-now origins, but the UI renders one
8 //! coherent pending-input list.
9 //!
10 //! Empty state renders zero rows so the composer doesn't gain wasted height
11 //! when there's nothing to show.
12 //!
13 //! Wired into `ui.rs::render` between the chat area and the composer; the user
14 //! can see when typed input has been captured for later delivery.
15
16 use ratatui::buffer::Buffer;
17 use ratatui::layout::Rect;
18 use ratatui::style::{Modifier, Style};
19 use ratatui::text::{Line, Span};
20 use ratatui::widgets::{Paragraph, Widget};
21
22 use crate::tui::menu_style;
23 use crate::tui::widgets::Renderable;
24 use codewhale_localization::{Locale, MessageId, tr};
25 use codewhale_palette as palette;
26
27 /// Per-item line cap before we collapse the rest into a `…` overflow row.
28 const PREVIEW_LINE_LIMIT: usize = 3;
29
30 /// Description of the keybinding the hint line at the bottom should advertise
31 /// for the "edit last queued message" action.
32 #[derive(Debug, Clone)]
33 pub struct EditBinding {
34 pub label: &'static str,
35 }
36
37 impl EditBinding {
38 pub const UP: EditBinding = EditBinding { label: "↑" };
39 }
40
41 /// Widget showing pending input while a turn is in progress.
42 #[derive(Debug, Clone)]
43 pub struct PendingInputPreview {
44 pub locale: Locale,
45 pub context_items: Vec<ContextPreviewItem>,
46 pub pending_steers: Vec<String>,
47 pub queued_messages: Vec<String>,
48 pub editing_queued_message: Option<String>,
49 pub edit_binding: EditBinding,
50 /// "Approval needed in {agent} — /agents", one row per child agent
51 /// waiting on the person whose card is not on top (approvals C1).
52 pub pending_approvals: Vec<String>,
53 }
54
55 /// Compact pre-send context row shown above the composer. `included=false`
56 /// marks unconfirmed, missing, or skipped context distinctly from files/media
57 /// already known to be sent or inlined.
58 #[derive(Debug, Clone, PartialEq, Eq)]
59 pub struct ContextPreviewItem {
60 pub kind: String,
61 pub label: String,
62 pub detail: Option<String>,
63 pub included: bool,
64 pub removable: bool,
65 pub selected: bool,
66 }
67
68 impl PendingInputPreview {
69 pub fn new() -> Self {
70 Self {
71 locale: Locale::En,
72 context_items: Vec::new(),
73 pending_steers: Vec::new(),
74 queued_messages: Vec::new(),
75 editing_queued_message: None,
76 edit_binding: EditBinding::UP,
77 pending_approvals: Vec::new(),
78 }
79 }
80
81 fn has_pending_inputs(&self) -> bool {
82 !self.pending_steers.is_empty()
83 || !self.queued_messages.is_empty()
84 || self.editing_queued_message.is_some()
85 }
86
87 fn is_queued_only(&self) -> bool {
88 self.context_items.is_empty()
89 && self.pending_steers.is_empty()
90 && self.editing_queued_message.is_none()
91 && !self.queued_messages.is_empty()
92 }
93
94 /// Build the (possibly empty) ordered line list this widget would render
95 /// at `width`. Pulled out so `desired_height` can ask the same renderer
96 /// without duplicating wrapping logic.
97 fn lines(&self, width: u16) -> Vec<Line<'static>> {
98 if width < 4 {
99 return Vec::new();
100 }
101 // A child agent waiting on the person outranks queued input: it is
102 // work that has stopped until someone answers.
103 let mut lines: Vec<Line<'static>> = self
104 .pending_approvals
105 .iter()
106 .map(|row| {
107 Line::from(Span::styled(
108 codewhale_localization::truncate_to_width(row, usize::from(width)),
109 Style::default().fg(palette::STATUS_WARNING),
110 ))
111 })
112 .collect();
113 lines.extend(self.input_lines(width));
114 lines
115 }
116
117 fn input_lines(&self, width: u16) -> Vec<Line<'static>> {
118 if self.context_items.is_empty() && !self.has_pending_inputs() {
119 return Vec::new();
120 }
121
122 let dim = Style::default()
123 .fg(palette::TEXT_DIM)
124 .add_modifier(Modifier::DIM);
125 let dim_italic = dim.add_modifier(Modifier::ITALIC);
126
127 let mut lines: Vec<Line<'static>> = Vec::new();
128
129 // The common queued-only state must remain actionable at the release
130 // floor. A compact summary avoids spending scarce rows on a section
131 // heading and two separate command choruses.
132 if self.is_queued_only() {
133 let count = self.queued_messages.len();
134 let prefix = if count == 1 {
135 tr(self.locale, MessageId::PendingQueuedOnePrefix).into_owned()
136 } else {
137 tr(self.locale, MessageId::PendingQueuedManyPrefix)
138 .replace("{count}", &count.to_string())
139 };
140 let next = self.queued_messages[0].replace('\n', " ");
141 let summary = codewhale_localization::truncate_to_width(
142 &format!("{prefix}{next}"),
143 usize::from(width),
144 );
145 let controls = codewhale_localization::truncate_to_width(
146 &tr(self.locale, MessageId::PendingSendNowDropControls)
147 .replace("{key}", self.edit_binding.label),
148 usize::from(width),
149 );
150 lines.push(Line::from(Span::styled(summary, dim_italic)));
151 lines.push(Line::from(Span::styled(controls, dim)));
152 return lines;
153 }
154
155 if !self.context_items.is_empty() {
156 push_section_header(
157 &mut lines,
158 Line::from(vec![
159 Span::raw("• "),
160 Span::raw(tr(self.locale, MessageId::PendingContextHeader).into_owned()),
161 ]),
162 );
163 for item in &self.context_items {
164 push_context_item(&mut lines, item, width);
165 }
166 }
167
168 if self.has_pending_inputs() {
169 if !lines.is_empty() {
170 lines.push(Line::from(""));
171 }
172 push_section_header(
173 &mut lines,
174 Line::from(vec![
175 Span::raw("• "),
176 Span::raw(tr(self.locale, MessageId::PendingInputsHeader).into_owned()),
177 ]),
178 );
179 let sending_prefix =
180 tr(self.locale, MessageId::PendingSendingIntoTurnPrefix).into_owned();
181 let sending_indent = continuation_indent(&sending_prefix);
182 for steer in &self.pending_steers {
183 push_truncated_item(
184 &mut lines,
185 steer,
186 width,
187 dim,
188 &sending_prefix,
189 &sending_indent,
190 );
191 }
192 if let Some(draft) = self.editing_queued_message.as_deref() {
193 let editing_prefix =
194 tr(self.locale, MessageId::PendingEditingFollowUpPrefix).into_owned();
195 let editing_indent = continuation_indent(&editing_prefix);
196 push_truncated_item(
197 &mut lines,
198 draft,
199 width,
200 dim_italic,
201 &editing_prefix,
202 &editing_indent,
203 );
204 lines.push(Line::from(vec![Span::styled(
205 tr(self.locale, MessageId::PendingEscRestore).into_owned(),
206 dim,
207 )]));
208 }
209 for (idx, message) in self.queued_messages.iter().enumerate() {
210 let row_number = idx + 1;
211 let queued_prefix = tr(self.locale, MessageId::PendingQueuedFollowUpPrefix)
212 .replace("{number}", &row_number.to_string());
213 let queued_message_indent = continuation_indent(&queued_prefix);
214 push_truncated_item(
215 &mut lines,
216 message,
217 width,
218 dim_italic,
219 &queued_prefix,
220 &queued_message_indent,
221 );
222 }
223 if !self.queued_messages.is_empty() {
224 lines.push(Line::from(vec![Span::styled(
225 tr(self.locale, MessageId::PendingSendNowControls)
226 .replace("{key}", self.edit_binding.label),
227 dim,
228 )]));
229 }
230 }
231
232 lines
233 }
234 }
235
236 impl Default for PendingInputPreview {
237 fn default() -> Self {
238 Self::new()
239 }
240 }
241
242 impl Renderable for PendingInputPreview {
243 fn render(&self, area: Rect, buf: &mut Buffer) {
244 if area.is_empty() {
245 return;
246 }
247 let mut lines = self.lines(area.width);
248 if lines.is_empty() {
249 return;
250 }
251 // If the rest of a 40x12 layout leaves one preview row, preserve the
252 // direct action rather than a non-actionable message summary.
253 if self.is_queued_only()
254 && self.pending_approvals.is_empty()
255 && area.height == 1
256 && lines.len() == 2
257 {
258 lines.remove(0);
259 }
260 Paragraph::new(lines).render(area, buf);
261 }
262
263 fn desired_height(&self, width: u16) -> u16 {
264 let lines = self.lines(width);
265 u16::try_from(lines.len()).unwrap_or(u16::MAX)
266 }
267 }
268
269 fn continuation_indent(prefix: &str) -> String {
270 " ".repeat(display_width(prefix))
271 }
272
273 fn push_section_header(lines: &mut Vec<Line<'static>>, header: Line<'static>) {
274 lines.push(header);
275 }
276
277 fn push_context_item(lines: &mut Vec<Line<'static>>, item: &ContextPreviewItem, width: u16) {
278 let status_style = if item.selected {
279 menu_style::selected_row_style()
280 } else if item.included {
281 Style::default().fg(palette::TEXT_MUTED)
282 } else {
283 Style::default().fg(palette::STATUS_WARNING)
284 };
285 let label_style = if item.selected {
286 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
287 } else if item.included {
288 Style::default().fg(palette::TEXT_PRIMARY)
289 } else {
290 Style::default().fg(palette::TEXT_MUTED)
291 };
292 let detail = item
293 .detail
294 .as_deref()
295 .filter(|detail| !detail.trim().is_empty())
296 .map(|detail| format!(" · {detail}"))
297 .unwrap_or_default();
298 let action = if item.selected {
299 " · Backspace/Delete removes"
300 } else if item.removable {
301 " · removable"
302 } else {
303 ""
304 };
305 let body = format!("[{}] {}{}{}", item.kind, item.label, detail, action);
306 let body_width = width.saturating_sub(4).max(1) as usize;
307 for (idx, segment) in wrap_to_width(&body, body_width).into_iter().enumerate() {
308 let prefix = if idx == 0 {
309 if item.selected { " ▸ " } else { " ↳ " }
310 } else {
311 " "
312 };
313 lines.push(Line::from(vec![
314 Span::styled(prefix.to_string(), status_style),
315 Span::styled(segment, label_style),
316 ]));
317 }
318 }
319
320 /// Render a single bucket item with `↳` prefix, truncating to
321 /// [`PREVIEW_LINE_LIMIT`] visible rows. Multi-line input wraps at the given
322 /// column budget and the continuation rows get the `subsequent_indent` so
323 /// the prefix and the body stay column-aligned.
324 fn push_truncated_item(
325 lines: &mut Vec<Line<'static>>,
326 raw: &str,
327 width: u16,
328 style: Style,
329 prefix: &str,
330 subsequent_indent: &str,
331 ) {
332 let body_width = width.saturating_sub(display_width(prefix) as u16) as usize;
333 let body_width = body_width.max(1);
334
335 let mut produced: Vec<String> = Vec::new();
336 for (idx, paragraph) in raw.split('\n').enumerate() {
337 let wrapped = wrap_to_width(paragraph, body_width);
338 for (j, segment) in wrapped.into_iter().enumerate() {
339 let row = if idx == 0 && j == 0 {
340 format!("{prefix}{segment}")
341 } else {
342 format!("{subsequent_indent}{segment}")
343 };
344 produced.push(row);
345 if produced.len() > PREVIEW_LINE_LIMIT {
346 break;
347 }
348 }
349 if produced.len() > PREVIEW_LINE_LIMIT {
350 break;
351 }
352 }
353
354 let truncated = produced.len() > PREVIEW_LINE_LIMIT;
355 for (i, row) in produced.into_iter().enumerate() {
356 if i >= PREVIEW_LINE_LIMIT {
357 break;
358 }
359 lines.push(Line::from(Span::styled(row, style)));
360 }
361 if truncated {
362 lines.push(Line::from(Span::styled(
363 format!("{subsequent_indent}…"),
364 style,
365 )));
366 }
367 }
368
369 /// Naive word-aware wrap that respects unicode display widths. Matches the
370 /// behavior expected by snapshot tests in the codex source — long URL-like
371 /// tokens that exceed `width` are emitted on their own row instead of being
372 /// hard-broken mid-character.
373 fn wrap_to_width(text: &str, width: usize) -> Vec<String> {
374 if width == 0 || text.is_empty() {
375 return vec![text.to_string()];
376 }
377
378 let mut out: Vec<String> = Vec::new();
379 let mut current = String::new();
380 let mut current_width = 0usize;
381
382 for word in text.split_inclusive(' ') {
383 let word_width = display_width(word);
384 if current_width + word_width > width && !current.is_empty() {
385 out.push(std::mem::take(&mut current));
386 current_width = 0;
387 }
388 if word_width > width {
389 // Token longer than the budget: flush current, emit the word as
390 // its own row even though it overflows. Avoids the codex-issue
391 // of a long URL fanning out into N junk-ellipsis rows.
392 if !current.is_empty() {
393 out.push(std::mem::take(&mut current));
394 current_width = 0;
395 }
396 out.push(word.trim_end().to_string());
397 continue;
398 }
399 current.push_str(word);
400 current_width += word_width;
401 }
402 if !current.is_empty() {
403 out.push(current);
404 }
405 out
406 }
407
408 // Delegates to the canonical width contract (`ui_text::text_display_width`):
409 // tabs are 4 columns and control chars occupy one, matching what the renderer
410 // draws. The old local copy used `unwrap_or(0)` and ignored tabs, so preview
411 // word-wrap disagreed with the real layout on those inputs (#3924).
412 fn display_width(s: &str) -> usize {
413 crate::tui::ui_text::text_display_width(s)
414 }
415
416 // End frozen production counterpart. This module is included only in tests.
417
417 lines RUST