返回 CodeWhale
pager.rs
根目录 / crates / tui / src / tui / pager.rs
1 //! Full-screen pager overlay for long outputs.
2 //!
3 //! Vim-style key bindings (mirroring the codex pager_overlay):
4 //! - `j` / Down — scroll down one line
5 //! - `k` / Up — scroll up one line
6 //! - `g g` / Home — jump to top
7 //! - `G` / End — jump to bottom
8 //! - `Ctrl+D` — half-page down
9 //! - `Ctrl+U` — half-page up
10 //! - `Ctrl+F` / PageDown / Space — full page down
11 //! - `Ctrl+B` / PageUp / Shift+Space — full page up
12 //! - `/` — start search; `n` / `N` — next / previous match
13 //! - `c` / `y` — copy the pager's source text (or its attached copy payload)
14 //! - `a` — copy the attached final assistant answer (answer-carrying pagers)
15 //! - `e` — copy the attached turn handoff markdown (Turn Inspector only)
16 //! - `q` / Esc — close pager
17
18 use std::cell::{Cell, RefCell};
19 use std::ops::Range;
20
21 use crossterm::event::{
22 KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
23 };
24 use ratatui::{
25 buffer::Buffer,
26 layout::Rect,
27 style::{Color, Modifier, Style},
28 text::{Line, Span},
29 widgets::{Paragraph, Widget, Wrap},
30 };
31 use unicode_segmentation::UnicodeSegmentation;
32 use unicode_width::UnicodeWidthStr;
33
34 use crate::tui::ui_text::grapheme_display_width;
35 use crate::tui::views::{
36 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, action_footer_lines,
37 render_modal_footer, render_panel_scroll_rail, render_underwater_surface,
38 };
39 use codewhale_palette as palette;
40
41 #[derive(Debug, Clone)]
42 struct PagerDestructiveAction {
43 key: char,
44 label: String,
45 confirm_label: String,
46 event: ViewEvent,
47 armed: bool,
48 }
49
50 /// One independently copyable document in a pager. Most pagers contain a
51 /// single page; Turn Inspector uses several so each turn stays isolated while
52 /// retaining the pager's existing scroll/search/copy behavior.
53 #[derive(Debug, Clone)]
54 pub(crate) struct PagerPage {
55 title: String,
56 /// What the page shows. Its display rows are wrapped from this to the
57 /// width the body really gets at render time, so one row is one scroll
58 /// step whatever the terminal width.
59 source: PagerSource,
60 /// Width to lay the page out at before its first render (`0`: unwrapped).
61 first_width: usize,
62 rows: RefCell<PagerRows>,
63 export_markdown: Option<String>,
64 copy_text: Option<String>,
65 answer_text: Option<String>,
66 }
67
68 #[derive(Debug, Clone)]
69 enum PagerSource {
70 /// Sanitized text; `c` / `y` copy it verbatim when no explicit copy
71 /// payload is attached.
72 Text(String),
73 /// Pre-styled lines, wrapped with their styles intact.
74 Lines(Vec<Line<'static>>),
75 }
76
77 /// The display rows of one page and the column width they were wrapped to.
78 #[derive(Debug, Clone, Default)]
79 struct PagerRows {
80 /// `None` until the page is first laid out.
81 width: Option<usize>,
82 plain: Vec<String>,
83 /// Styled rows of a styled-line page; text pages paint `plain`.
84 styled: Vec<Line<'static>>,
85 /// The source line each row was wrapped from, so a re-wrap can keep the
86 /// same line at the top of the view.
87 origin: Vec<usize>,
88 }
89
90 impl PagerRows {
91 fn wrap_text(source: &str, width: usize) -> Self {
92 let mut rows = Self {
93 width: Some(width),
94 ..Self::default()
95 };
96 for (line_index, raw) in source.lines().enumerate() {
97 for row in wrap_text(raw, width) {
98 rows.plain.push(row);
99 rows.origin.push(line_index);
100 }
101 }
102 rows
103 }
104
105 fn wrap_lines(lines: &[Line<'static>], width: usize) -> Self {
106 let mut rows = Self {
107 width: Some(width),
108 ..Self::default()
109 };
110 for (line_index, line) in lines.iter().enumerate() {
111 let cells = display_cells(
112 line.spans
113 .iter()
114 .enumerate()
115 .map(|(span, piece)| (piece.content.as_ref(), span)),
116 );
117 for range in wrap_ranges(&cells, width) {
118 let mut spans: Vec<Span<'static>> = Vec::new();
119 let mut run = String::new();
120 let mut run_span = None;
121 for &(ch, span) in &cells[range] {
122 if run_span.is_some_and(|current| current != span) {
123 let style = line.spans[run_span.unwrap_or(span)].style;
124 spans.push(Span::styled(std::mem::take(&mut run), style));
125 }
126 run_span = Some(span);
127 run.push(ch);
128 }
129 if let Some(span) = run_span {
130 spans.push(Span::styled(run, line.spans[span].style));
131 }
132 let mut row = Line::from(spans).style(line.style);
133 row.alignment = line.alignment;
134 rows.plain.push(line_to_string(&row));
135 rows.styled.push(row);
136 rows.origin.push(line_index);
137 }
138 }
139 rows
140 }
141 }
142
143 impl PagerPage {
144 pub(crate) fn from_text(title: impl Into<String>, text: &str, width: u16) -> Self {
145 // Pager bodies frequently carry tool output or worker transcripts.
146 // Keep the same terminal-injection boundary as the single-page path.
147 let mut sanitized = String::with_capacity(text.len());
148 crate::tui::osc8::strip_ansi_into(text, &mut sanitized);
149 Self::new(title, PagerSource::Text(sanitized), usize::from(width))
150 }
151
152 fn from_lines(title: impl Into<String>, lines: Vec<Line<'static>>) -> Self {
153 Self::new(title, PagerSource::Lines(lines), 0)
154 }
155
156 fn new(title: impl Into<String>, source: PagerSource, first_width: usize) -> Self {
157 Self {
158 title: title.into(),
159 source,
160 first_width,
161 rows: RefCell::default(),
162 export_markdown: None,
163 copy_text: None,
164 answer_text: None,
165 }
166 }
167
168 /// Wrap the page to `width` columns (`0`: unwrapped) unless it already
169 /// is; the paragraph then never wraps a row again, so one row is one
170 /// scroll step. Returns whether the rows changed.
171 fn layout(&self, width: usize) -> bool {
172 if self.rows.borrow().width == Some(width) {
173 return false;
174 }
175 *self.rows.borrow_mut() = match &self.source {
176 PagerSource::Text(source) => PagerRows::wrap_text(source, width),
177 PagerSource::Lines(lines) => PagerRows::wrap_lines(lines, width),
178 };
179 true
180 }
181
182 /// The display rows, laid out at the caller's width if no render has
183 /// fixed the real one yet.
184 fn rows(&self) -> std::cell::Ref<'_, PagerRows> {
185 if self.rows.borrow().width.is_none() {
186 self.layout(self.first_width);
187 }
188 self.rows.borrow()
189 }
190
191 fn row_count(&self) -> usize {
192 self.rows().plain.len()
193 }
194
195 /// What `c` / `y` copy without an explicit payload: the source, never the
196 /// display wrapping.
197 fn source_text(&self) -> String {
198 match &self.source {
199 PagerSource::Text(source) => source.clone(),
200 PagerSource::Lines(lines) => lines
201 .iter()
202 .map(line_to_string)
203 .collect::<Vec<_>>()
204 .join("\n"),
205 }
206 }
207
208 pub(crate) fn with_export_markdown(mut self, markdown: impl Into<String>) -> Self {
209 self.export_markdown = Some(markdown.into());
210 self
211 }
212
213 pub(crate) fn with_copy_text(mut self, text: impl Into<String>) -> Self {
214 self.copy_text = Some(text.into());
215 self
216 }
217
218 /// Attach the clean final assistant answer that the `a` key copies to
219 /// the clipboard. Only surfaces that can produce an answer-only payload
220 /// (Turn Inspector pages, assistant detail pagers) set this; the pager
221 /// body itself stays free to render scaffolding around the answer.
222 pub(crate) fn with_copy_answer(mut self, text: impl Into<String>) -> Self {
223 self.answer_text = Some(text.into());
224 self
225 }
226 }
227
228 pub struct PagerView {
229 pages: Vec<PagerPage>,
230 page_index: usize,
231 /// First visible row. A cell because a resize re-wraps the rows during
232 /// render, which remaps it to keep the same source line on top.
233 scroll: Cell<usize>,
234 search_input: String,
235 /// Rows matching the search, ascending; recomputed when a re-wrap moves
236 /// the rows under them.
237 search_matches: RefCell<Vec<usize>>,
238 search_index: Cell<usize>,
239 search_mode: bool,
240 pending_g: bool,
241 /// Cached visible content height from the last render. Used by paging
242 /// keys (Ctrl+D/U, Ctrl+F/B, Space, etc.) to compute scroll deltas
243 /// without access to the render area.
244 last_visible_height: Cell<usize>,
245 /// Optional inspector-owned destructive action. It requires two presses
246 /// (or key then Enter); Esc disarms before it closes the pager.
247 destructive_action: Option<PagerDestructiveAction>,
248 action_area: Cell<Option<Rect>>,
249 }
250
251 impl PagerView {
252 pub fn new(title: impl Into<String>, lines: Vec<Line<'static>>) -> Self {
253 Self::from_pages(vec![PagerPage::from_lines(title, lines)], 0)
254 }
255
256 /// Build an opt-in multi-page pager and open the requested page. Page
257 /// switching is deliberately unavailable to ordinary one-page pagers.
258 pub(crate) fn from_pages(pages: Vec<PagerPage>, initial_page: usize) -> Self {
259 assert!(!pages.is_empty(), "a pager needs at least one page");
260 let page_index = initial_page.min(pages.len().saturating_sub(1));
261 Self {
262 pages,
263 page_index,
264 scroll: Cell::new(0),
265 search_input: String::new(),
266 search_matches: RefCell::default(),
267 search_index: Cell::new(0),
268 search_mode: false,
269 pending_g: false,
270 last_visible_height: Cell::new(0),
271 destructive_action: None,
272 action_area: Cell::new(None),
273 }
274 }
275
276 /// Attach a compact Markdown export (e.g. the #4108 turn handoff) that the
277 /// `e` key copies to the clipboard. Only the Turn Inspector pager sets this;
278 /// other pagers leave `e` inert.
279 #[cfg(test)]
280 pub fn with_export_markdown(mut self, markdown: impl Into<String>) -> Self {
281 self.current_page_mut().export_markdown = Some(markdown.into());
282 self
283 }
284
285 /// Preserve a source-faithful payload for `c` / `y` while the rendered
286 /// pager remains free to wrap content to its viewport.
287 pub fn with_copy_text(mut self, text: impl Into<String>) -> Self {
288 self.current_page_mut().copy_text = Some(text.into());
289 self
290 }
291
292 /// Attach the clean final assistant answer that the `a` key copies
293 /// (see [`PagerPage::with_copy_answer`]).
294 pub fn with_copy_answer(mut self, text: impl Into<String>) -> Self {
295 self.current_page_mut().answer_text = Some(text.into());
296 self
297 }
298
299 /// Attach a two-step destructive action to this pager. Work Graph
300 /// inspectors use this to keep Stop inside the detail surface while
301 /// reusing the existing command/agent cancellation events.
302 pub fn with_destructive_action(
303 mut self,
304 key: char,
305 label: impl Into<String>,
306 confirm_label: impl Into<String>,
307 event: ViewEvent,
308 ) -> Self {
309 self.destructive_action = Some(PagerDestructiveAction {
310 key,
311 label: label.into(),
312 confirm_label: confirm_label.into(),
313 event,
314 armed: false,
315 });
316 self
317 }
318
319 pub fn from_text(title: impl Into<String>, text: &str, width: u16) -> Self {
320 Self::from_pages(vec![PagerPage::from_text(title, text, width)], 0)
321 }
322
323 /// Reuse the inspector confirmation control for token-bound commands.
324 /// Copy exposes the exact command; confirmation dispatches it unchanged.
325 pub(crate) fn command_review(
326 title: impl Into<String>,
327 text: &str,
328 width: u16,
329 command: String,
330 locale: codewhale_localization::Locale,
331 ) -> Self {
332 let confirm = codewhale_localization::tr(
333 locale,
334 codewhale_localization::MessageId::PagerActionConfirm,
335 )
336 .into_owned();
337 Self::from_text(title, text, width)
338 .with_copy_text(command.clone())
339 .with_destructive_action(
340 'y',
341 confirm.clone(),
342 confirm,
343 ViewEvent::CommandPaletteSelected {
344 action: crate::tui::views::CommandPaletteAction::ExecuteCommand { command },
345 },
346 )
347 }
348
349 fn activate_destructive_action(&mut self) -> ViewAction {
350 self.pending_g = false;
351 let Some(action) = self.destructive_action.as_mut() else {
352 return ViewAction::None;
353 };
354 if action.armed {
355 ViewAction::EmitAndClose(action.event.clone())
356 } else {
357 action.armed = true;
358 ViewAction::None
359 }
360 }
361
362 fn current_page(&self) -> &PagerPage {
363 &self.pages[self.page_index]
364 }
365
366 fn current_page_mut(&mut self) -> &mut PagerPage {
367 &mut self.pages[self.page_index]
368 }
369
370 fn switch_page(&mut self, page_index: usize) {
371 let page_index = page_index.min(self.pages.len().saturating_sub(1));
372 if page_index == self.page_index {
373 return;
374 }
375 self.page_index = page_index;
376 self.scroll.set(0);
377 self.search_input.clear();
378 self.search_matches.get_mut().clear();
379 self.search_index.set(0);
380 self.search_mode = false;
381 self.pending_g = false;
382 }
383
384 fn scroll_up(&mut self, amount: usize) {
385 self.scroll.set(self.scroll.get().saturating_sub(amount));
386 }
387
388 fn scroll_down(&mut self, amount: usize, max_scroll: usize) {
389 self.scroll
390 .set(self.scroll.get().saturating_add(amount).min(max_scroll));
391 }
392
393 fn scroll_to_top(&mut self) {
394 self.scroll.set(0);
395 }
396
397 fn scroll_to_bottom(&mut self, max_scroll: usize) {
398 self.scroll.set(max_scroll);
399 }
400
401 /// Plain-text rendered body of the pager joined with `\n`. This reflects
402 /// width-based display wrapping, so it is not what the clipboard gets:
403 /// an explicit copy payload wins, then the page's own source.
404 #[cfg(test)]
405 pub fn body_text(&self) -> String {
406 self.current_page().rows().plain.join("\n")
407 }
408
409 fn clipboard_text(&self) -> String {
410 let page = self.current_page();
411 page.copy_text.clone().unwrap_or_else(|| page.source_text())
412 }
413
414 /// The pager's title bar text. Used by tests to assert the raw-detail
415 /// pager is framed at leaf scope (#4105).
416 #[cfg(test)]
417 pub(crate) fn title(&self) -> &str {
418 &self.current_page().title
419 }
420
421 /// Return the page height (in lines) used for paging keys.
422 ///
423 /// Falls back to a small constant (10) before the first render so the
424 /// pager still responds to paging keys when invoked synthetically (e.g.
425 /// in unit tests). After the first render, the cached value reflects
426 /// the actual visible content area.
427 fn page_height(&self) -> usize {
428 let cached = self.last_visible_height.get();
429 if cached == 0 { 10 } else { cached }
430 }
431
432 /// Half a page, rounded up so a single press always moves at least one line.
433 fn half_page_height(&self) -> usize {
434 let page = self.page_height();
435 page.div_ceil(2).max(1)
436 }
437
438 fn max_scroll(&self) -> usize {
439 // Match the render-side clamp so G/End land at the visible bottom and
440 // k/Up immediately scroll back up by one line.
441 self.current_page()
442 .row_count()
443 .saturating_sub(self.page_height())
444 }
445
446 fn start_search(&mut self) {
447 self.search_mode = true;
448 self.search_input.clear();
449 self.search_matches.get_mut().clear();
450 self.search_index.set(0);
451 }
452
453 fn update_search_matches(&mut self) {
454 let matches = find_matches(&self.current_page().rows().plain, &self.search_input);
455 *self.search_matches.get_mut() = matches;
456 self.search_index.set(0);
457 }
458
459 /// Wrap the current page to `width`. When that re-wraps it (a resize),
460 /// keep the same source line at the top of the view and the same match
461 /// selected, since the old row indices no longer name the same rows.
462 fn layout(&self, width: usize) {
463 let page = self.current_page();
464 let (top, current_match) = {
465 let rows = page.rows.borrow();
466 let origin = |row: usize| rows.origin.get(row).copied();
467 let current = self
468 .search_matches
469 .borrow()
470 .get(self.search_index.get())
471 .copied();
472 (origin(self.scroll.get()), current.and_then(origin))
473 };
474 if !page.layout(width) {
475 return;
476 }
477 let rows = page.rows.borrow();
478 if let Some(top) = top {
479 self.scroll
480 .set(rows.origin.partition_point(|&line| line < top));
481 }
482 if !self.search_input.trim().is_empty() {
483 let matches = find_matches(&rows.plain, &self.search_input);
484 let index = current_match
485 .and_then(|line| matches.iter().position(|&row| rows.origin[row] >= line))
486 .unwrap_or(0);
487 *self.search_matches.borrow_mut() = matches;
488 self.search_index.set(index);
489 }
490 }
491
492 fn jump_to_match(&mut self) {
493 if let Some(&row) = self.search_matches.get_mut().get(self.search_index.get()) {
494 self.scroll.set(row);
495 }
496 }
497
498 fn next_match(&mut self) {
499 let total = self.search_matches.get_mut().len();
500 if total == 0 {
501 return;
502 }
503 self.search_index.set((self.search_index.get() + 1) % total);
504 self.jump_to_match();
505 }
506
507 fn prev_match(&mut self) {
508 let total = self.search_matches.get_mut().len();
509 if total == 0 {
510 return;
511 }
512 let index = self.search_index.get();
513 self.search_index
514 .set(if index == 0 { total - 1 } else { index - 1 });
515 self.jump_to_match();
516 }
517 }
518
519 impl ModalView for PagerView {
520 fn kind(&self) -> ModalKind {
521 ModalKind::Pager
522 }
523
524 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
525 self
526 }
527
528 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
529 if self.search_mode {
530 match key.code {
531 KeyCode::Enter => {
532 self.search_mode = false;
533 self.update_search_matches();
534 self.jump_to_match();
535 return ViewAction::None;
536 }
537 KeyCode::Esc => {
538 // Bail out of search mode AND drop the current match list
539 // so the user gets back to the un-highlighted view —
540 // codex-style behavior. To resume from where they left
541 // off they re-enter `/` and re-type.
542 self.search_mode = false;
543 self.search_input.clear();
544 self.search_matches.get_mut().clear();
545 self.search_index.set(0);
546 return ViewAction::None;
547 }
548 KeyCode::Backspace => {
549 self.search_input.pop();
550 return ViewAction::None;
551 }
552 // Ctrl+H is the legacy ASCII backspace many terminals emit.
553 KeyCode::Char('h')
554 if key.modifiers.contains(KeyModifiers::CONTROL)
555 && !key.modifiers.contains(KeyModifiers::ALT) =>
556 {
557 self.search_input.pop();
558 return ViewAction::None;
559 }
560 KeyCode::Char(c) => {
561 self.search_input.push(c);
562 return ViewAction::None;
563 }
564 // All other keys (Up/Down, PageUp/PageDown, etc.) are captured
565 // in search mode so they don't fall through to the pager body.
566 _ => return ViewAction::None,
567 }
568 }
569
570 if let Some(action) = self.destructive_action.as_mut() {
571 if key.code == KeyCode::Esc && action.armed {
572 action.armed = false;
573 self.pending_g = false;
574 return ViewAction::None;
575 }
576 let matching_key =
577 matches!(key.code, KeyCode::Char(ch) if ch.eq_ignore_ascii_case(&action.key));
578 if matching_key || (key.code == KeyCode::Enter && action.armed) {
579 if key.kind != KeyEventKind::Press
580 || !key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
581 {
582 return ViewAction::None;
583 }
584 return self.activate_destructive_action();
585 }
586 }
587
588 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
589 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
590 let max_scroll = self.max_scroll();
591
592 // Ctrl+chord paging keys are matched first because their KeyCode
593 // also matches the bare `KeyCode::Char(c)` arms below.
594 if ctrl {
595 match key.code {
596 KeyCode::Char('d') | KeyCode::Char('D') => {
597 self.scroll_down(self.half_page_height(), max_scroll);
598 self.pending_g = false;
599 return ViewAction::None;
600 }
601 KeyCode::Char('u') | KeyCode::Char('U') => {
602 self.scroll_up(self.half_page_height());
603 self.pending_g = false;
604 return ViewAction::None;
605 }
606 KeyCode::Char('f') | KeyCode::Char('F') => {
607 self.scroll_down(self.page_height(), max_scroll);
608 self.pending_g = false;
609 return ViewAction::None;
610 }
611 KeyCode::Char('b') | KeyCode::Char('B') => {
612 self.scroll_up(self.page_height());
613 self.pending_g = false;
614 return ViewAction::None;
615 }
616 _ => {}
617 }
618 }
619
620 match key.code {
621 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
622 KeyCode::Left if self.pages.len() > 1 => {
623 self.switch_page(self.page_index.saturating_sub(1));
624 ViewAction::None
625 }
626 KeyCode::Right if self.pages.len() > 1 => {
627 self.switch_page(
628 self.page_index
629 .saturating_add(1)
630 .min(self.pages.len().saturating_sub(1)),
631 );
632 ViewAction::None
633 }
634 KeyCode::Up | KeyCode::Char('k') => {
635 self.scroll_up(1);
636 self.pending_g = false;
637 ViewAction::None
638 }
639 KeyCode::Down | KeyCode::Char('j') => {
640 self.scroll_down(1, max_scroll);
641 self.pending_g = false;
642 ViewAction::None
643 }
644 KeyCode::PageUp => {
645 self.scroll_up(self.page_height());
646 self.pending_g = false;
647 ViewAction::None
648 }
649 KeyCode::PageDown => {
650 self.scroll_down(self.page_height(), max_scroll);
651 self.pending_g = false;
652 ViewAction::None
653 }
654 // Vim convention: Space pages down, Shift+Space pages up. Match
655 // Shift+Space first so it is not absorbed by the bare ' ' arm.
656 KeyCode::Char(' ') if shift => {
657 self.scroll_up(self.page_height());
658 self.pending_g = false;
659 ViewAction::None
660 }
661 KeyCode::Char(' ') => {
662 self.scroll_down(self.page_height(), max_scroll);
663 self.pending_g = false;
664 ViewAction::None
665 }
666 KeyCode::Home => {
667 self.scroll_to_top();
668 self.pending_g = false;
669 ViewAction::None
670 }
671 KeyCode::End => {
672 self.scroll_to_bottom(max_scroll);
673 self.pending_g = false;
674 ViewAction::None
675 }
676 KeyCode::Char('g') => {
677 if self.pending_g {
678 self.scroll_to_top();
679 self.pending_g = false;
680 } else {
681 self.pending_g = true;
682 }
683 ViewAction::None
684 }
685 KeyCode::Char('G') => {
686 self.scroll_to_bottom(max_scroll);
687 self.pending_g = false;
688 ViewAction::None
689 }
690 KeyCode::Char('/') => {
691 self.start_search();
692 self.pending_g = false;
693 ViewAction::None
694 }
695 KeyCode::Char('n') => {
696 self.next_match();
697 self.pending_g = false;
698 ViewAction::None
699 }
700 KeyCode::Char('N') => {
701 self.prev_match();
702 self.pending_g = false;
703 ViewAction::None
704 }
705 // Copy the entire pager body to the clipboard. The pager
706 // intercepts mouse capture so terminal-native selection is
707 // disabled inside it; without this binding users with no
708 // out-of-band copy path would have no way to extract content
709 // they can see (#1354). Both `c` and `y` are wired so users
710 // landing from either OS-clipboard or vim convention find a
711 // working key.
712 KeyCode::Char('c') | KeyCode::Char('y') => {
713 self.pending_g = false;
714 ViewAction::Emit(ViewEvent::CopyToClipboard {
715 text: self.clipboard_text(),
716 label: "Pager content".to_string(),
717 })
718 }
719 // `e` exports the compact turn handoff (#4108) when this pager
720 // carries one — the Turn Inspector. Elsewhere the guard fails and
721 // `e` falls through to the inert arm below.
722 KeyCode::Char('e') | KeyCode::Char('E')
723 if self.current_page().export_markdown.is_some() =>
724 {
725 self.pending_g = false;
726 let text = self
727 .current_page()
728 .export_markdown
729 .clone()
730 .unwrap_or_default();
731 ViewAction::Emit(ViewEvent::CopyToClipboard {
732 text,
733 label: "Turn handoff".to_string(),
734 })
735 }
736 // `a` copies ONLY the final assistant answer — the clean
737 // answer-only payload attached by the Turn Inspector and the
738 // assistant detail pagers. Unlike `c`/`y` (rendered body) or `e`
739 // (whole-turn handoff markdown), this payload carries no
740 // reasoning, tool calls/results, runtime status, or transcript
741 // scaffolding. Elsewhere the guard fails and `a` is inert.
742 KeyCode::Char('a') if self.current_page().answer_text.is_some() => {
743 self.pending_g = false;
744 let text = self.current_page().answer_text.clone().unwrap_or_default();
745 ViewAction::Emit(ViewEvent::CopyToClipboard {
746 text,
747 label: "Answer".to_string(),
748 })
749 }
750 _ => ViewAction::None,
751 }
752 }
753
754 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
755 if mouse.kind == MouseEventKind::Down(MouseButton::Left)
756 && mouse.modifiers.is_empty()
757 && self
758 .action_area
759 .get()
760 .is_some_and(|area| area.contains((mouse.column, mouse.row).into()))
761 {
762 return self.activate_destructive_action();
763 }
764 match mouse.kind {
765 MouseEventKind::ScrollUp => {
766 self.scroll_up(3);
767 self.pending_g = false;
768 ViewAction::None
769 }
770 MouseEventKind::ScrollDown => {
771 self.scroll_down(3, self.max_scroll());
772 self.pending_g = false;
773 ViewAction::None
774 }
775 _ => ViewAction::None,
776 }
777 }
778
779 fn render(&self, area: Rect, buf: &mut Buffer) {
780 let page = self.current_page();
781 let title = if self.pages.len() > 1 {
782 format!(
783 "{} · {}/{} · ←/→",
784 page.title,
785 self.page_index + 1,
786 self.pages.len()
787 )
788 } else {
789 page.title.clone()
790 };
791 let inner = render_underwater_surface(area, buf, title);
792
793 // The wrapping action footer is anchored to the bottom of the inner
794 // area; the body fills the rows above it.
795 let mut hints = vec![
796 ActionHint::new("q/Esc", "close"),
797 ActionHint::new("j/k", "scroll"),
798 ActionHint::new("Space", "page"),
799 ActionHint::new("Ctrl+D/U", "half"),
800 ActionHint::new("g/G", "top/bottom"),
801 ActionHint::new("/", "search"),
802 ActionHint::new("c", "copy"),
803 ];
804 if page.export_markdown.is_some() {
805 hints.push(ActionHint::new("e", "copy handoff"));
806 }
807 if page.answer_text.is_some() {
808 hints.push(ActionHint::new("a", "copy answer"));
809 }
810 self.action_area.set(None);
811 if let Some(action) = self.destructive_action.as_ref() {
812 let key = if action.armed {
813 format!("{}/Enter", action.key)
814 } else {
815 action.key.to_string()
816 };
817 let label = if action.armed {
818 action.confirm_label.clone()
819 } else {
820 action.label.clone()
821 };
822 let action_width = key.width() + 2 + label.width();
823 hints.push(ActionHint::new(key, label));
824 let footer_lines = action_footer_lines(&hints, inner.width);
825 if footer_lines.len() <= usize::from(inner.height)
826 && inner.width > 0
827 && let Some(last) = footer_lines.last()
828 {
829 let offset = last
830 .width()
831 .saturating_sub(action_width)
832 .min(usize::from(inner.width));
833 self.action_area.set(Some(Rect::new(
834 inner.x.saturating_add(offset as u16),
835 inner.bottom().saturating_sub(1),
836 (action_width.min(usize::from(inner.width).saturating_sub(offset))) as u16,
837 1,
838 )));
839 }
840 }
841 let content = render_modal_footer(inner, buf, &hints);
842
843 // Re-wrap before reserving the search-status row: resizing can make
844 // the first match appear or the last match disappear. Hold a column
845 // for the scroll rail so it cannot cause another paragraph wrap.
846 let body_width = if content.width >= 2 {
847 content.width - 1
848 } else {
849 content.width
850 };
851 self.layout(usize::from(body_width));
852
853 // `content` already excludes the border, padding, and footer rows.
854 let mut visible_height = content.height as usize;
855 if self.search_mode {
856 // Reserve a row for the search prompt that gets pushed below.
857 visible_height = visible_height.saturating_sub(1);
858 } else if !self.search_matches.borrow().is_empty() {
859 // Reserve a row for the "match X/Y (n/N)" status; without this
860 // the status line gets clipped on small popup heights and the
861 // user can't see how many matches there are.
862 visible_height = visible_height.saturating_sub(1);
863 }
864 // Cache for paging keys; the value is treated as advisory and
865 // clamped at use-time.
866 self.last_visible_height.set(visible_height);
867 let rows = page.rows.borrow();
868 let row_count = rows.plain.len();
869 let max_scroll = row_count.saturating_sub(visible_height);
870 let scroll = self.scroll.get().min(max_scroll);
871 let end = (scroll + visible_height).min(row_count);
872 let mut visible_lines: Vec<Line<'static>> = if row_count == 0 {
873 vec![Line::from("")]
874 } else if rows.styled.is_empty() {
875 rows.plain[scroll..end]
876 .iter()
877 .map(|row| Line::from(row.clone()))
878 .collect()
879 } else {
880 rows.styled[scroll..end].to_vec()
881 };
882
883 // Highlight matched lines while the search prompt is closed and the
884 // user is navigating with `n` / `N`. Other matches get a subtle
885 // background; the current match gets a louder one. Per-substring
886 // highlighting is deferred to a follow-up — preserving the pre-styled
887 // spans (assistant / system colors) through a substring re-style is
888 // a separate concern.
889 let search_matches = self.search_matches.borrow();
890 if !self.search_mode && !search_matches.is_empty() {
891 let current_match_line = search_matches.get(self.search_index.get()).copied();
892 for (visible_idx, line) in visible_lines.iter_mut().enumerate() {
893 let absolute_idx = scroll + visible_idx;
894 if absolute_idx >= row_count {
895 break;
896 }
897 // `search_matches` is built in ascending line order.
898 if search_matches.binary_search(&absolute_idx).is_err() {
899 continue;
900 }
901 let is_current = current_match_line == Some(absolute_idx);
902 let bg = if is_current {
903 Color::Yellow
904 } else {
905 Color::DarkGray
906 };
907 let fg = if is_current {
908 Color::Black
909 } else {
910 Color::Yellow
911 };
912 let highlight = Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD);
913 for span in line.spans.iter_mut() {
914 span.style = highlight;
915 }
916 }
917 }
918
919 if self.search_mode {
920 let prompt = format!("/{}", self.search_input);
921 visible_lines.push(Line::from(Span::styled(
922 prompt,
923 Style::default()
924 .fg(palette::WHALE_ACTION)
925 .add_modifier(Modifier::BOLD),
926 )));
927 } else if !search_matches.is_empty() {
928 let status = format!(
929 "match {}/{} (n/N)",
930 self.search_index.get() + 1,
931 search_matches.len()
932 );
933 visible_lines.push(Line::from(Span::styled(
934 status,
935 Style::default().fg(palette::TEXT_MUTED),
936 )));
937 }
938
939 let content =
940 render_panel_scroll_rail(content, buf, row_count, scroll, visible_height, true);
941 // Explicit base ink: the surface behind this body is always WHALE_BG,
942 // so spans without their own fg must not inherit a dark terminal
943 // default (light-profile terminals would render them as black-on-black).
944 // Ratatui paints the base first; styled spans patch over it.
945 let paragraph = Paragraph::new(visible_lines)
946 .wrap(Wrap { trim: false })
947 .style(Style::default().fg(palette::TEXT_PRIMARY));
948 paragraph.render(content, buf);
949 }
950 }
951
952 fn line_to_string(line: &Line<'static>) -> String {
953 line.spans
954 .iter()
955 .map(|span| span.content.to_string())
956 .collect::<String>()
957 }
958
959 /// Rows containing `query` (trimmed, ASCII case-insensitive), ascending.
960 fn find_matches(rows: &[String], query: &str) -> Vec<usize> {
961 let query = query.trim().to_ascii_lowercase();
962 if query.is_empty() {
963 return Vec::new();
964 }
965 rows.iter()
966 .enumerate()
967 .filter_map(|(index, row)| row.to_ascii_lowercase().contains(&query).then_some(index))
968 .collect()
969 }
970
971 /// Columns between tab stops, the width the rest of the TUI gives a tab
972 /// (`ui_text::char_display_width`).
973 const TAB_STOP: usize = 4;
974
975 /// The characters one source line paints, each tagged with the piece (span)
976 /// it came from. Tabs advance to the next tab stop, so tab-aligned columns
977 /// stay aligned; a carriage return, form feed or other control character or
978 /// line/paragraph separator reads as the space it separates; bidirectional
979 /// format characters, which would reorder the painted row, are dropped. The
980 /// source itself is kept for copying.
981 fn display_cells<'a>(pieces: impl Iterator<Item = (&'a str, usize)>) -> Vec<(char, usize)> {
982 let mut cells = Vec::new();
983 let mut column = 0;
984 for (text, tag) in pieces {
985 let display: String = text
986 .chars()
987 .filter(|&ch| !crate::core::events::is_bidi_format_control(ch))
988 .map(|ch| {
989 if ch != '\t' && (ch.is_control() || matches!(ch, '\u{2028}' | '\u{2029}')) {
990 ' '
991 } else {
992 ch
993 }
994 })
995 .collect();
996 for grapheme in display.graphemes(true) {
997 if grapheme == "\t" {
998 let advance = TAB_STOP - column % TAB_STOP;
999 cells.extend(std::iter::repeat_n((' ', tag), advance));
1000 column += advance;
1001 } else {
1002 cells.extend(grapheme.chars().map(|ch| (ch, tag)));
1003 column += grapheme_display_width(grapheme);
1004 }
1005 }
1006 }
1007 cells
1008 }
1009
1010 /// Wrap one source line to `width` columns (`0`: unwrapped) without
1011 /// collapsing whitespace: indentation, aligned columns and repeated spaces
1012 /// survive, and only the whitespace at a soft break is consumed.
1013 fn wrap_text(text: &str, width: usize) -> Vec<String> {
1014 let cells = display_cells(std::iter::once((text, 0)));
1015 wrap_ranges(&cells, width)
1016 .into_iter()
1017 .map(|range| cells[range].iter().map(|&(ch, _)| ch).collect())
1018 .collect()
1019 }
1020
1021 /// The cell ranges of each wrapped row; see [`wrap_text`].
1022 fn wrap_ranges(cells: &[(char, usize)], width: usize) -> Vec<Range<usize>> {
1023 if width == 0 {
1024 return std::iter::once(0..cells.len()).collect();
1025 }
1026 // Keep character ranges for style reconstruction, but measure and break
1027 // only at grapheme boundaries. Summing scalar widths splits joined emoji
1028 // even when their painted width fits on one row.
1029 let text: String = cells.iter().map(|&(ch, _)| ch).collect();
1030 let mut character = 0;
1031 let graphemes: Vec<_> = text
1032 .graphemes(true)
1033 .map(|grapheme| {
1034 let start = character;
1035 character += grapheme.chars().count();
1036 (
1037 start,
1038 grapheme_display_width(grapheme),
1039 grapheme.chars().all(char::is_whitespace),
1040 )
1041 })
1042 .collect();
1043 let cell_width = |index: usize| graphemes[index].1;
1044 let mut rows = Vec::new();
1045 let mut current = 0..0;
1046 let mut current_width = 0usize;
1047 // Whether `current` holds more than indentation. A soft break is only
1048 // taken after real content; an over-wide indent or word is split instead.
1049 let mut has_content = false;
1050
1051 let mut run_start = 0;
1052 while run_start < graphemes.len() {
1053 let is_space = graphemes[run_start].2;
1054 let run_end = graphemes[run_start..]
1055 .iter()
1056 .position(|&(_, _, space)| space != is_space)
1057 .map_or(graphemes.len(), |offset| run_start + offset);
1058 let run = run_start..run_end;
1059 run_start = run_end;
1060 let run_width: usize = run.clone().map(cell_width).sum();
1061 if current_width + run_width <= width {
1062 current.end = run.end;
1063 current_width += run_width;
1064 has_content |= !is_space;
1065 continue;
1066 }
1067 if has_content {
1068 let mut kept = current.end;
1069 while kept > current.start && graphemes[kept - 1].2 {
1070 kept -= 1;
1071 }
1072 rows.push(current.start..kept);
1073 current_width = 0;
1074 has_content = false;
1075 current = run.end..run.end;
1076 if is_space {
1077 continue;
1078 }
1079 current = run.clone();
1080 if run_width <= width {
1081 current_width = run_width;
1082 has_content = true;
1083 continue;
1084 }
1085 current.end = run.start;
1086 }
1087 // Split an over-wide word (or indent) between whole graphemes.
1088 for index in run {
1089 let char_width = cell_width(index);
1090 if current_width + char_width > width && current_width > 0 {
1091 rows.push(current.clone());
1092 current = index..index;
1093 current_width = 0;
1094 }
1095 current.end = index + 1;
1096 current_width += char_width;
1097 }
1098 has_content |= !is_space;
1099 }
1100
1101 rows.push(current);
1102 let boundary = |index: usize| graphemes.get(index).map_or(cells.len(), |cell| cell.0);
1103 rows.into_iter()
1104 .map(|range| boundary(range.start)..boundary(range.end))
1105 .collect()
1106 }
1107
1108 #[cfg(test)]
1109 mod tests {
1110 use super::*;
1111 use ratatui::text::Line;
1112
1113 fn make_pager(lines: usize) -> PagerView {
1114 let lines: Vec<Line<'static>> = (0..lines)
1115 .map(|i| Line::from(format!("line-{i:03}")))
1116 .collect();
1117 PagerView::new("T", lines)
1118 }
1119
1120 fn key(code: KeyCode) -> KeyEvent {
1121 KeyEvent::new(code, KeyModifiers::NONE)
1122 }
1123
1124 fn key_mod(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
1125 KeyEvent::new(code, mods)
1126 }
1127
1128 fn ctrl(code: KeyCode) -> KeyEvent {
1129 KeyEvent::new(code, KeyModifiers::CONTROL)
1130 }
1131
1132 #[test]
1133 fn destructive_action_requires_two_steps_and_escape_only_disarms() {
1134 let mut pager = make_pager(2).with_destructive_action(
1135 's',
1136 "stop",
1137 "confirm stop · Esc cancels",
1138 ViewEvent::SidebarAgentCancel {
1139 agent_id: "agent_1".to_string(),
1140 },
1141 );
1142
1143 assert!(matches!(
1144 pager.handle_key(key(KeyCode::Char('s'))),
1145 ViewAction::None
1146 ));
1147 assert!(matches!(
1148 pager.handle_key(key(KeyCode::Esc)),
1149 ViewAction::None
1150 ));
1151 assert!(matches!(
1152 pager.handle_key(key(KeyCode::Esc)),
1153 ViewAction::Close
1154 ));
1155
1156 let _ = pager.handle_key(key(KeyCode::Char('s')));
1157 assert!(matches!(
1158 pager.handle_key(key(KeyCode::Enter)),
1159 ViewAction::EmitAndClose(ViewEvent::SidebarAgentCancel { agent_id })
1160 if agent_id == "agent_1"
1161 ));
1162 }
1163
1164 #[test]
1165 fn command_review_confirms_the_pinned_command_with_keys_or_painted_mouse_control() {
1166 let command = format!(
1167 "/plugin trust fixture {}.{}",
1168 "a".repeat(64),
1169 "b".repeat(64)
1170 );
1171 for (width, height) in [(40, 12), (80, 24), (140, 40)] {
1172 let mut pager = PagerView::command_review(
1173 "Review fixture",
1174 "Exact reviewed capabilities",
1175 width - 2,
1176 command.clone(),
1177 codewhale_localization::Locale::En,
1178 );
1179 for modifiers in [
1180 KeyModifiers::CONTROL,
1181 KeyModifiers::ALT,
1182 KeyModifiers::SUPER,
1183 ] {
1184 assert!(matches!(
1185 pager.handle_key(KeyEvent::new(KeyCode::Char('y'), modifiers)),
1186 ViewAction::None
1187 ));
1188 assert!(!pager.destructive_action.as_ref().unwrap().armed);
1189 }
1190 assert!(matches!(
1191 pager.handle_key(KeyEvent::new_with_kind(
1192 KeyCode::Char('y'),
1193 KeyModifiers::NONE,
1194 KeyEventKind::Repeat
1195 )),
1196 ViewAction::None
1197 ));
1198 assert!(!pager.destructive_action.as_ref().unwrap().armed);
1199 let ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) =
1200 pager.handle_key(key(KeyCode::Char('c')))
1201 else {
1202 panic!("copy exposes the pinned command");
1203 };
1204 assert_eq!(text, command);
1205 let area = Rect::new(0, 0, width, height);
1206 let mut buffer = Buffer::empty(area);
1207 pager.render(area, &mut buffer);
1208 let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
1209 assert!(rendered.contains("Confirm"), "{rendered}");
1210 assert!(
1211 !rendered.contains("disable"),
1212 "generic review must not describe an unrelated action"
1213 );
1214 let button = pager
1215 .action_area
1216 .get()
1217 .expect("visible confirmation control");
1218 assert!(button.width > 0 && button.bottom() <= height);
1219 let click = |button: Rect| MouseEvent {
1220 kind: MouseEventKind::Down(MouseButton::Left),
1221 column: button.x,
1222 row: button.y,
1223 modifiers: KeyModifiers::NONE,
1224 };
1225 assert!(matches!(
1226 pager.handle_mouse(click(button)),
1227 ViewAction::None
1228 ));
1229 assert!(pager.destructive_action.as_ref().unwrap().armed);
1230 assert!(matches!(
1231 pager.handle_key(KeyEvent::new_with_kind(
1232 KeyCode::Enter,
1233 KeyModifiers::NONE,
1234 KeyEventKind::Repeat
1235 )),
1236 ViewAction::None
1237 ));
1238 pager.render(area, &mut buffer);
1239 let ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
1240 action: crate::tui::views::CommandPaletteAction::ExecuteCommand { command: actual },
1241 }) = pager.handle_mouse(click(pager.action_area.get().unwrap()))
1242 else {
1243 panic!("second click confirms the reviewed command");
1244 };
1245 assert_eq!(actual, command);
1246 }
1247 }
1248
1249 /// Drive a render once so `last_visible_height` is populated and paging
1250 /// keys use a deterministic page size.
1251 fn prime_layout(view: &mut PagerView, height: u16) {
1252 let area = Rect::new(0, 0, 40, height);
1253 let mut buf = Buffer::empty(area);
1254 view.render(area, &mut buf);
1255 }
1256
1257 #[test]
1258 fn j_scrolls_down_one_line() {
1259 let mut p = make_pager(50);
1260 let _ = p.handle_key(key(KeyCode::Char('j')));
1261 assert_eq!(p.scroll.get(), 1);
1262 }
1263
1264 #[test]
1265 fn k_scrolls_up_one_line() {
1266 let mut p = make_pager(50);
1267 p.scroll.set(5);
1268 let _ = p.handle_key(key(KeyCode::Char('k')));
1269 assert_eq!(p.scroll.get(), 4);
1270 }
1271
1272 #[test]
1273 fn gg_jumps_to_top() {
1274 let mut p = make_pager(50);
1275 p.scroll.set(30);
1276 let _ = p.handle_key(key(KeyCode::Char('g')));
1277 assert!(p.pending_g, "first 'g' should arm pending_g");
1278 assert_eq!(p.scroll.get(), 30, "first 'g' alone must not scroll");
1279 let _ = p.handle_key(key(KeyCode::Char('g')));
1280 assert_eq!(p.scroll.get(), 0);
1281 assert!(!p.pending_g);
1282 }
1283
1284 #[test]
1285 fn home_jumps_to_top() {
1286 let mut p = make_pager(50);
1287 p.scroll.set(30);
1288 let _ = p.handle_key(key(KeyCode::Home));
1289 assert_eq!(p.scroll.get(), 0);
1290 }
1291
1292 #[test]
1293 fn shift_g_jumps_to_bottom() {
1294 let mut p = make_pager(50);
1295 let _ = p.handle_key(key(KeyCode::Char('G')));
1296 assert_eq!(p.scroll.get(), p.max_scroll());
1297 }
1298
1299 #[test]
1300 fn end_jumps_to_bottom() {
1301 let mut p = make_pager(50);
1302 let _ = p.handle_key(key(KeyCode::End));
1303 assert_eq!(p.scroll.get(), p.max_scroll());
1304 }
1305
1306 #[test]
1307 fn up_immediately_scrolls_after_shift_g_to_bottom() {
1308 let mut p = make_pager(50);
1309 prime_layout(&mut p, 22);
1310 let bottom = p.max_scroll();
1311
1312 let _ = p.handle_key(key(KeyCode::Char('G')));
1313 assert_eq!(p.scroll.get(), bottom);
1314 let _ = p.handle_key(key(KeyCode::Up));
1315 assert_eq!(p.scroll.get(), bottom - 1);
1316 let _ = p.handle_key(key(KeyCode::Char('k')));
1317 assert_eq!(p.scroll.get(), bottom - 2);
1318 }
1319
1320 #[test]
1321 fn k_immediately_scrolls_after_end_to_bottom() {
1322 let mut p = make_pager(50);
1323 prime_layout(&mut p, 22);
1324 let bottom = p.max_scroll();
1325
1326 let _ = p.handle_key(key(KeyCode::End));
1327 assert_eq!(p.scroll.get(), bottom);
1328 let _ = p.handle_key(key(KeyCode::Char('k')));
1329 assert_eq!(p.scroll.get(), bottom - 1);
1330 }
1331
1332 #[test]
1333 fn ctrl_d_half_page_down() {
1334 let mut p = make_pager(200);
1335 prime_layout(&mut p, 22);
1336 let half = p.half_page_height();
1337 assert!(half >= 1, "half-page must move at least one line");
1338 let _ = p.handle_key(ctrl(KeyCode::Char('d')));
1339 assert_eq!(p.scroll.get(), half);
1340 }
1341
1342 #[test]
1343 fn ctrl_u_half_page_up() {
1344 let mut p = make_pager(200);
1345 prime_layout(&mut p, 22);
1346 p.scroll.set(50);
1347 let half = p.half_page_height();
1348 let _ = p.handle_key(ctrl(KeyCode::Char('u')));
1349 assert_eq!(p.scroll.get(), 50 - half);
1350 }
1351
1352 #[test]
1353 fn ctrl_f_full_page_down() {
1354 let mut p = make_pager(200);
1355 prime_layout(&mut p, 22);
1356 let page = p.page_height();
1357 let _ = p.handle_key(ctrl(KeyCode::Char('f')));
1358 assert_eq!(p.scroll.get(), page);
1359 }
1360
1361 #[test]
1362 fn ctrl_b_full_page_up() {
1363 let mut p = make_pager(200);
1364 prime_layout(&mut p, 22);
1365 p.scroll.set(80);
1366 let page = p.page_height();
1367 let _ = p.handle_key(ctrl(KeyCode::Char('b')));
1368 assert_eq!(p.scroll.get(), 80 - page);
1369 }
1370
1371 #[test]
1372 fn space_pages_down() {
1373 let mut p = make_pager(200);
1374 prime_layout(&mut p, 22);
1375 let page = p.page_height();
1376 let _ = p.handle_key(key(KeyCode::Char(' ')));
1377 assert_eq!(p.scroll.get(), page);
1378 }
1379
1380 #[test]
1381 fn shift_space_pages_up() {
1382 let mut p = make_pager(200);
1383 prime_layout(&mut p, 22);
1384 p.scroll.set(80);
1385 let page = p.page_height();
1386 let _ = p.handle_key(key_mod(KeyCode::Char(' '), KeyModifiers::SHIFT));
1387 assert_eq!(p.scroll.get(), 80 - page);
1388 }
1389
1390 #[test]
1391 fn page_down_uses_cached_visible_height() {
1392 let mut p = make_pager(200);
1393 prime_layout(&mut p, 22);
1394 let page = p.page_height();
1395 let _ = p.handle_key(key(KeyCode::PageDown));
1396 assert_eq!(p.scroll.get(), page);
1397 }
1398
1399 #[test]
1400 fn q_closes_pager() {
1401 let mut p = make_pager(10);
1402 let action = p.handle_key(key(KeyCode::Char('q')));
1403 assert!(matches!(action, ViewAction::Close));
1404 }
1405
1406 #[test]
1407 fn esc_closes_pager() {
1408 let mut p = make_pager(10);
1409 let action = p.handle_key(key(KeyCode::Esc));
1410 assert!(matches!(action, ViewAction::Close));
1411 }
1412
1413 #[test]
1414 fn multi_page_switch_resets_view_state_and_keeps_copy_export_page_scoped() {
1415 let first =
1416 PagerPage::from_text("Turns", "first displayed", 80).with_copy_text("FIRST-SOURCE");
1417 let latest = PagerPage::from_text("Turns", "latest displayed", 80)
1418 .with_copy_text("LATEST-SOURCE")
1419 .with_export_markdown("LATEST-HANDOFF");
1420 let mut pager = PagerView::from_pages(vec![first, latest], 1);
1421 pager.scroll.set(4);
1422 pager.search_input = "latest".to_string();
1423 *pager.search_matches.get_mut() = vec![0];
1424
1425 assert!(matches!(
1426 pager.handle_key(key(KeyCode::Left)),
1427 ViewAction::None
1428 ));
1429 assert_eq!(pager.body_text(), "first displayed");
1430 assert_eq!(pager.scroll.get(), 0);
1431 assert!(pager.search_input.is_empty());
1432 assert!(pager.search_matches.borrow().is_empty());
1433 assert!(matches!(
1434 pager.handle_key(key(KeyCode::Char('c'))),
1435 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) if text == "FIRST-SOURCE"
1436 ));
1437 assert!(matches!(
1438 pager.handle_key(key(KeyCode::Char('e'))),
1439 ViewAction::None
1440 ));
1441
1442 let _ = pager.handle_key(key(KeyCode::Right));
1443 assert!(matches!(
1444 pager.handle_key(key(KeyCode::Char('e'))),
1445 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) if text == "LATEST-HANDOFF"
1446 ));
1447 }
1448
1449 #[test]
1450 fn g_does_not_consume_search_input() {
1451 // While in search mode, 'g' must be treated as a search character,
1452 // not as the half of a `gg` jump-to-top sequence.
1453 let mut p = make_pager(50);
1454 p.scroll.set(10);
1455 let _ = p.handle_key(key(KeyCode::Char('/')));
1456 assert!(p.search_mode);
1457 let _ = p.handle_key(key(KeyCode::Char('g')));
1458 assert_eq!(p.search_input, "g");
1459 assert_eq!(p.scroll.get(), 10);
1460 }
1461
1462 #[test]
1463 fn footer_hint_includes_new_bindings() {
1464 // The rendered pager must surface the new vim-style bindings to the
1465 // user. The footer is now a wrapping ActionHint row inside the modal
1466 // body (not the bottom border), so assert against the rendered buffer.
1467 let p = make_pager(5);
1468 let area = Rect::new(0, 0, 100, 16);
1469 let mut buf = Buffer::empty(area);
1470 p.render(area, &mut buf);
1471 let mut text = String::new();
1472 for y in 0..area.height {
1473 for x in 0..area.width {
1474 text.push_str(buf[(x, y)].symbol());
1475 }
1476 text.push('\n');
1477 }
1478 for needle in &[
1479 "j/k",
1480 "scroll",
1481 "g/G",
1482 "top/bottom",
1483 "Space",
1484 "page",
1485 "Ctrl+D/U",
1486 "half",
1487 "search",
1488 "copy",
1489 "q/Esc",
1490 "close",
1491 ] {
1492 assert!(text.contains(needle), "footer hint missing {needle:?}");
1493 }
1494 }
1495
1496 #[test]
1497 fn body_cells_carry_explicit_ink_on_the_dark_surface() {
1498 // The pager paints WHALE_BG behind the body, so a body span
1499 // without its own fg inherits the terminal default, black ink on
1500 // light-profile terminals, i.e. black-on-black. The base paragraph
1501 // style must pin every text cell to the body ink.
1502 let p = make_pager(3);
1503 let area = Rect::new(0, 0, 100, 16);
1504 let mut buf = Buffer::empty(area);
1505 p.render(area, &mut buf);
1506 let mut checked = 0;
1507 for y in 0..area.height {
1508 let mut row = String::new();
1509 for x in 0..area.width {
1510 row.push_str(buf[(x, y)].symbol());
1511 }
1512 if !row.contains("line-") {
1513 continue;
1514 }
1515 for x in 0..area.width {
1516 let cell = &buf[(x, y)];
1517 if cell.symbol().trim().is_empty() {
1518 continue;
1519 }
1520 assert_eq!(
1521 cell.style().fg,
1522 Some(palette::TEXT_PRIMARY),
1523 "body cell ({x}, {y}) must carry explicit body ink",
1524 );
1525 checked += 1;
1526 }
1527 }
1528 assert!(checked > 0, "expected body rows in the rendered pager");
1529 }
1530
1531 #[test]
1532 fn c_emits_copy_event_with_full_body() {
1533 // #1354: the pager intercepts mouse capture, so users have no way to
1534 // copy content out without an in-app key. Both `c` and `y` should
1535 // emit a CopyToClipboard event carrying the whole body so the host
1536 // dispatcher (in ui.rs) can write through `app.clipboard` and toast
1537 // a confirmation.
1538 let mut p = make_pager(3);
1539 let action = p.handle_key(key(KeyCode::Char('c')));
1540 match action {
1541 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
1542 assert_eq!(text, "line-000\nline-001\nline-002");
1543 assert_eq!(label, "Pager content");
1544 }
1545 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1546 }
1547 }
1548
1549 #[test]
1550 fn a_emits_copy_event_with_attached_answer_only() {
1551 // `a` copies the attached answer-only payload — never the rendered
1552 // body, which may carry scaffolding around the answer.
1553 let mut pager = PagerView::from_text("Turn Inspector", "[◆ · done] body", 40)
1554 .with_copy_answer("CLEAN-ANSWER");
1555 let action = pager.handle_key(key(KeyCode::Char('a')));
1556 match action {
1557 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
1558 assert_eq!(text, "CLEAN-ANSWER");
1559 assert_eq!(label, "Answer");
1560 }
1561 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1562 }
1563
1564 // Without an attached answer `a` stays inert; it must never fall
1565 // back to copying the rendered body.
1566 let mut plain = PagerView::from_text("T", "body", 40);
1567 assert!(matches!(
1568 plain.handle_key(key(KeyCode::Char('a'))),
1569 ViewAction::None
1570 ));
1571 }
1572
1573 #[test]
1574 fn copy_override_preserves_indentation_tabs_and_blank_lines() {
1575 let source = "Result:\n indented\n\twith-tab\n\nnext";
1576 let mut pager = PagerView::from_text("T", source, 12).with_copy_text(source);
1577
1578 let action = pager.handle_key(key(KeyCode::Char('c')));
1579 match action {
1580 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) => {
1581 assert_eq!(text, source);
1582 }
1583 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1584 }
1585 }
1586
1587 #[test]
1588 fn from_text_keeps_one_display_row_per_blank_source_line() {
1589 let pager = PagerView::from_text("T", "first\n\nthird", 80);
1590 assert_eq!(pager.body_text(), "first\n\nthird");
1591 }
1592
1593 #[test]
1594 fn from_text_strips_csi_mouse_and_osc_sequences() {
1595 // A worker transcript can carry captured terminal bytes (a child TUI's
1596 // mouse-tracking handshake, SGR color, OSC hyperlinks). Rendering them
1597 // raw emits the escapes to the user's terminal, which re-enables mouse
1598 // reporting after exit and leaves the shell executing fragments.
1599 let hostile = "── assistant ──\n\
1600 enabling \u{1b}[?1003h\u{1b}[?1006h mouse tracking\n\
1601 click bytes \u{1b}[<65;72;17M\u{1b}[<35;131;42M arrived\n\
1602 \u{1b}[31mred text\u{1b}[0m and an \u{1b}]8;;https://example.com\u{7}OSC link\u{1b}]8;;\u{1b}\\\n\
1603 trailing stray \u{1b}\u{7}bytes\u{1b}c done";
1604 let pager = PagerView::from_text("Agent transcript", hostile, 200);
1605 let body = pager.body_text();
1606 assert!(
1607 !body.contains('\u{1b}'),
1608 "no ESC byte may survive into the pager body: {body:?}"
1609 );
1610 assert!(
1611 !body.contains('\u{7}'),
1612 "no BEL byte may survive into the pager body: {body:?}"
1613 );
1614 for fragment in ["?1003h", "?1006h", "<65;72;17M", "<35;131;42M", "]8;;"] {
1615 assert!(
1616 !body.contains(fragment),
1617 "escape fragment {fragment:?} must be stripped, not painted: {body:?}"
1618 );
1619 }
1620 assert!(body.contains("red text"), "visible text survives: {body:?}");
1621 assert!(body.contains("OSC link"), "link label survives: {body:?}");
1622 assert!(body.contains("done"), "trailing text survives: {body:?}");
1623 }
1624
1625 #[test]
1626 fn from_text_sanitizes_jsonl_transcript_shaped_content() {
1627 // Regression for the sub-agent transcript pager corrupting the parent
1628 // terminal: content shaped like the raw artifact lines, with embedded
1629 // mouse/CSI sequences inside a tool result, must render inert.
1630 let transcript_like = concat!(
1631 "── assistant ──\n",
1632 "I'll run the build now.\n",
1633 "← tool result (call-1)\n",
1634 // JSON-escaped text is literal backslash-u bytes — inert, kept as-is.
1635 "{\"line\":\"\\u{1b}[<0;9;4Mprogress done\"}\n",
1636 // Raw event bytes are the dangerous form and must be stripped.
1637 "raw \u{1b}[<0;10;5M event bytes\u{1b}[2K after\n",
1638 );
1639 let pager = PagerView::from_text("Agent transcript", transcript_like, 200);
1640 let body = pager.body_text();
1641 assert!(!body.contains('\u{1b}'), "inert body required: {body:?}");
1642 assert!(
1643 !body.contains("<0;10;5M"),
1644 "mouse event fragment must not render: {body:?}"
1645 );
1646 assert!(
1647 body.contains("progress") && body.contains("after"),
1648 "readable content survives: {body:?}"
1649 );
1650 }
1651
1652 #[test]
1653 fn y_emits_copy_event_for_vim_users() {
1654 let mut p = make_pager(3);
1655 let action = p.handle_key(key(KeyCode::Char('y')));
1656 assert!(
1657 matches!(action, ViewAction::Emit(ViewEvent::CopyToClipboard { .. })),
1658 "y must emit a copy event for vim-yank parity"
1659 );
1660 }
1661
1662 #[test]
1663 fn e_exports_turn_handoff_when_attached() {
1664 // #4108: the Turn Inspector pager carries a compact Markdown handoff;
1665 // `e` copies that artifact (not the visible inspector body) to the
1666 // clipboard via the host dispatcher.
1667 let mut p = make_pager(3).with_export_markdown("# Turn handoff\n\n## Intent\ndo the thing");
1668 let action = p.handle_key(key(KeyCode::Char('e')));
1669 match action {
1670 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
1671 assert!(text.contains("# Turn handoff"), "handoff text: {text}");
1672 assert_eq!(label, "Turn handoff");
1673 }
1674 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1675 }
1676 }
1677
1678 #[test]
1679 fn e_is_inert_without_an_attached_handoff() {
1680 // Every other pager leaves `e` unbound so it never surprises the user.
1681 let mut p = make_pager(3);
1682 assert!(matches!(
1683 p.handle_key(key(KeyCode::Char('e'))),
1684 ViewAction::None
1685 ));
1686 }
1687
1688 #[test]
1689 fn copy_keys_inert_in_search_mode() {
1690 // Within `/`-search mode `c` and `y` must be treated as search
1691 // characters, not as a copy trigger — otherwise users typing a
1692 // query that contains either letter would lose their input.
1693 let mut p = make_pager(10);
1694 let _ = p.handle_key(key(KeyCode::Char('/')));
1695 assert!(p.search_mode);
1696 let action = p.handle_key(key(KeyCode::Char('c')));
1697 assert!(matches!(action, ViewAction::None));
1698 assert_eq!(p.search_input, "c");
1699 }
1700
1701 #[test]
1702 fn footer_hint_is_rendered_in_buffer() {
1703 let p = make_pager(5);
1704 let area = Rect::new(0, 0, 100, 10);
1705 let mut buf = Buffer::empty(area);
1706 p.render(area, &mut buf);
1707 // The footer is now anchored to the bottom of the modal body (above the
1708 // padding/border) rather than painted on the border, so scan the whole
1709 // frame for the action labels.
1710 let mut text = String::new();
1711 for y in 0..area.height {
1712 for x in 0..area.width {
1713 text.push_str(buf[(x, y)].symbol());
1714 }
1715 text.push('\n');
1716 }
1717 assert!(
1718 text.contains("close") || text.contains("scroll"),
1719 "expected footer hint in rendered pager, got:\n{text}"
1720 );
1721 }
1722
1723 /// `/` opens the search prompt; typing chars accumulates them; Enter
1724 /// commits and jumps to the first match. The matches index/count line
1725 /// must surface in the rendered buffer afterwards.
1726 #[test]
1727 fn search_finds_matches_and_renders_match_counter() {
1728 let mut p = make_pager(20);
1729 prime_layout(&mut p, 16);
1730
1731 // Open search.
1732 let _ = p.handle_key(key(KeyCode::Char('/')));
1733 // Type "5" to match line-005, line-015 (any line whose number contains
1734 // a 5 — make_pager produced "line-NNN" with three-digit indices).
1735 for ch in "5".chars() {
1736 let _ = p.handle_key(key(KeyCode::Char(ch)));
1737 }
1738 // Commit.
1739 let _ = p.handle_key(key(KeyCode::Enter));
1740
1741 // Render and look for the "match X/Y" status line.
1742 let area = Rect::new(0, 0, 60, 16);
1743 let mut buf = Buffer::empty(area);
1744 p.render(area, &mut buf);
1745 let mut full = String::new();
1746 for y in 0..area.height {
1747 for x in 0..area.width {
1748 full.push_str(buf[(x, y)].symbol());
1749 }
1750 full.push('\n');
1751 }
1752 assert!(
1753 full.contains("match 1/2") || full.contains("match 1/3"),
1754 "expected match counter; got buffer:\n{full}"
1755 );
1756 }
1757
1758 /// Esc while in search mode bails out AND clears the highlighted matches
1759 /// so the un-highlighted view returns. (Codex parity.)
1760 #[test]
1761 fn esc_in_search_mode_clears_matches() {
1762 let mut p = make_pager(20);
1763 prime_layout(&mut p, 16);
1764
1765 let _ = p.handle_key(key(KeyCode::Char('/')));
1766 let _ = p.handle_key(key(KeyCode::Char('5')));
1767 let _ = p.handle_key(key(KeyCode::Enter));
1768 assert!(!p.search_matches.borrow().is_empty());
1769
1770 // Re-enter search mode and Esc out — matches must clear.
1771 let _ = p.handle_key(key(KeyCode::Char('/')));
1772 let _ = p.handle_key(key(KeyCode::Esc));
1773 assert!(p.search_matches.borrow().is_empty());
1774 assert_eq!(p.search_input, "");
1775 assert!(!p.search_mode);
1776 }
1777
1778 /// `n` and `N` cycle forward and backward through matches, wrapping at
1779 /// the ends without panicking on out-of-bounds index.
1780 #[test]
1781 fn n_and_capital_n_cycle_matches_with_wrap() {
1782 let mut p = make_pager(50);
1783 prime_layout(&mut p, 16);
1784
1785 // Search "1" — matches every line whose printed index contains a 1.
1786 let _ = p.handle_key(key(KeyCode::Char('/')));
1787 let _ = p.handle_key(key(KeyCode::Char('1')));
1788 let _ = p.handle_key(key(KeyCode::Enter));
1789 let total = p.search_matches.borrow().len();
1790 assert!(total > 1, "test needs multiple matches, got {total}");
1791
1792 let start = p.search_index.get();
1793 let _ = p.handle_key(key(KeyCode::Char('n')));
1794 assert_eq!(p.search_index.get(), (start + 1) % total);
1795 let _ = p.handle_key(key(KeyCode::Char('N')));
1796 assert_eq!(p.search_index.get(), start);
1797
1798 // Wrap backwards from 0 → last.
1799 let _ = p.handle_key(key(KeyCode::Char('N')));
1800 assert_eq!(p.search_index.get(), total - 1);
1801 let _ = p.handle_key(key(KeyCode::Char('n')));
1802 assert_eq!(p.search_index.get(), 0);
1803 }
1804
1805 /// While search matches exist and the prompt is closed, the matched
1806 /// lines are visually distinguished in the rendered buffer by their
1807 /// background color. We sample directly across the matched-line text
1808 /// columns rather than the whole row width because Paragraph leaves
1809 /// the trailing-area cells at the default background.
1810 #[test]
1811 fn matched_lines_get_highlight_background() {
1812 let mut p = make_pager(20);
1813 prime_layout(&mut p, 16);
1814
1815 let _ = p.handle_key(key(KeyCode::Char('/')));
1816 let _ = p.handle_key(key(KeyCode::Char('5')));
1817 let _ = p.handle_key(key(KeyCode::Enter));
1818 assert!(!p.search_matches.borrow().is_empty());
1819
1820 let area = Rect::new(0, 0, 40, 16);
1821 let mut buf = Buffer::empty(area);
1822 p.render(area, &mut buf);
1823
1824 // Find the actual painted match: shared compact layout may move it.
1825 let row = (0..area.height)
1826 .find(|&y| {
1827 (0..area.width)
1828 .map(|x| buf[(x, y)].symbol())
1829 .collect::<String>()
1830 .contains("line-005")
1831 })
1832 .expect("matched text must be visible");
1833 let highlighted = (0..area.width)
1834 .filter(|&x| buf[(x, row)].style().bg == Some(Color::Yellow))
1835 .collect::<Vec<_>>();
1836 assert_eq!(highlighted.len(), "line-005".len());
1837 for x in highlighted {
1838 assert_eq!(buf[(x, row)].style().fg, Some(Color::Black));
1839 }
1840 }
1841
1842 #[test]
1843 fn mouse_scroll_up_scrolls_content() {
1844 let mut p = make_pager(50);
1845 p.scroll.set(10);
1846 let action = p.handle_mouse(MouseEvent {
1847 kind: MouseEventKind::ScrollUp,
1848 column: 0,
1849 row: 0,
1850 modifiers: KeyModifiers::NONE,
1851 });
1852
1853 assert_eq!(p.scroll.get(), 7);
1854 assert!(matches!(action, ViewAction::None));
1855 }
1856
1857 #[test]
1858 fn mouse_scroll_down_scrolls_content() {
1859 let mut p = make_pager(50);
1860 prime_layout(&mut p, 20);
1861 p.scroll.set(10);
1862 let action = p.handle_mouse(MouseEvent {
1863 kind: MouseEventKind::ScrollDown,
1864 column: 0,
1865 row: 0,
1866 modifiers: KeyModifiers::NONE,
1867 });
1868
1869 assert_eq!(p.scroll.get(), 13);
1870 assert!(matches!(action, ViewAction::None));
1871 }
1872
1873 #[test]
1874 fn mouse_scroll_down_clamps_to_pager_bottom() {
1875 let mut p = make_pager(50);
1876 prime_layout(&mut p, 20);
1877 let bottom = p.max_scroll();
1878
1879 for _ in 0..100 {
1880 let _ = p.handle_mouse(MouseEvent {
1881 kind: MouseEventKind::ScrollDown,
1882 column: 0,
1883 row: 0,
1884 modifiers: KeyModifiers::NONE,
1885 });
1886 }
1887
1888 assert_eq!(p.scroll.get(), bottom);
1889 }
1890
1891 #[test]
1892 fn pager_is_usable_and_opaque_at_blocker_sizes() {
1893 use crate::tui::views::ViewStack;
1894
1895 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
1896 for (w, h) in BLOCKER_SIZES {
1897 let area = Rect::new(0, 0, w, h);
1898 let mut buf = Buffer::empty(area);
1899 for y in 0..h {
1900 for x in 0..w {
1901 buf[(x, y)].set_symbol("X");
1902 }
1903 }
1904 let mut stack = ViewStack::new();
1905 stack.push(make_pager(60));
1906 stack.render(area, &mut buf);
1907
1908 let rows: Vec<String> = (0..h)
1909 .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
1910 .collect();
1911 let text = rows.join("\n");
1912
1913 // Footer keeps every action.
1914 for label in [
1915 "close",
1916 "scroll",
1917 "page",
1918 "half",
1919 "top/bottom",
1920 "search",
1921 "copy",
1922 ] {
1923 assert!(text.contains(label), "{w}x{h}: footer missing '{label}'");
1924 }
1925
1926 // Composited frame is fully opaque.
1927 assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
1928 assert_eq!(
1929 buf[(w / 2, h / 2)].bg,
1930 palette::WHALE_BG,
1931 "{w}x{h}: modal interior must be opaque"
1932 );
1933
1934 // No horizontal overflow.
1935 for (y, row) in rows.iter().enumerate() {
1936 assert!(
1937 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1938 "{w}x{h}: row {y} overflows width: {row:?}"
1939 );
1940 }
1941 }
1942 }
1943
1944 #[test]
1945 fn wrap_text_keeps_indentation_columns_and_repeated_spaces() {
1946 assert_eq!(wrap_text(" return x", 40), [" return x"]);
1947 assert_eq!(wrap_text("\tif ok:", 40), [" if ok:"]);
1948 assert_eq!(wrap_text("name size", 40), ["name size"]);
1949 assert_eq!(wrap_text("10%\r20%\u{b}x\u{2028}y", 40), ["10% 20% x y"]);
1950 // Only the whitespace at a soft break is consumed.
1951 assert_eq!(wrap_text(" alpha beta", 9), [" alpha", "beta"]);
1952 // An indent wider than the row still keeps every column it can.
1953 assert_eq!(wrap_text(" word", 4), [" ", " wo", "rd"]);
1954 }
1955
1956 #[test]
1957 fn text_pager_displays_and_copies_code_with_its_indentation() {
1958 let source = "def f(x):\n if x:\n\treturn x\n\n| a | b |";
1959 let mut pager = PagerView::from_text("Selection", source, 80);
1960
1961 assert_eq!(
1962 pager.body_text(),
1963 "def f(x):\n if x:\n return x\n\n| a | b |"
1964 );
1965 match pager.handle_key(key(KeyCode::Char('y'))) {
1966 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) => {
1967 assert_eq!(text, source, "copy must carry the source, not the display");
1968 }
1969 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1970 }
1971 }
1972
1973 #[test]
1974 fn text_pager_rewraps_to_its_body_so_end_reaches_the_last_line() {
1975 // Callers wrap to the transcript width minus two; the pager body is
1976 // narrower (margins, padding, scroll rail). Rows that fit the caller's
1977 // width but not the body used to wrap again in the paragraph, which
1978 // the scroll arithmetic never counted.
1979 let mut text: Vec<String> = (0..39)
1980 .map(|i| format!("row {i:02} {}", "word ".repeat(14).trim_end()))
1981 .collect();
1982 text.push(format!(
1983 "row 39 {} END-MARKER",
1984 "word ".repeat(12).trim_end()
1985 ));
1986 let text = text.join("\n");
1987 let (width, height) = (80u16, 24u16);
1988 let mut pager = PagerView::from_text("Message", &text, width - 2);
1989 let area = Rect::new(0, 0, width, height);
1990 let mut buf = Buffer::empty(area);
1991 pager.render(area, &mut buf);
1992
1993 let _ = pager.handle_key(key(KeyCode::End));
1994 let mut buf = Buffer::empty(area);
1995 pager.render(area, &mut buf);
1996 let screen: String = (0..height)
1997 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
1998 .collect::<Vec<_>>()
1999 .join("\n");
2000 assert!(
2001 screen.contains("END-MARKER"),
2002 "End must reveal the last line:\n{screen}"
2003 );
2004 }
2005
2006 fn render_screen(pager: &PagerView, width: u16, height: u16) -> String {
2007 let area = Rect::new(0, 0, width, height);
2008 let mut buf = Buffer::empty(area);
2009 pager.render(area, &mut buf);
2010 (0..height)
2011 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
2012 .collect::<Vec<_>>()
2013 .join("\n")
2014 }
2015
2016 #[test]
2017 fn styled_pager_wraps_to_its_body_so_end_reaches_the_last_line() {
2018 // A styled row wider than the body used to be wrapped by the
2019 // paragraph but counted as one row, so End stopped short of the tail.
2020 let mut lines: Vec<Line<'static>> = (0..39)
2021 .map(|i| {
2022 Line::from(vec![
2023 Span::styled(format!("row {i:02} "), Style::default().fg(Color::Cyan)),
2024 Span::raw("word ".repeat(20)),
2025 ])
2026 })
2027 .collect();
2028 lines.push(Line::from(format!(
2029 "row 39 {}END-MARKER",
2030 "word ".repeat(20)
2031 )));
2032 let mut pager = PagerView::new("Models", lines);
2033 let (width, height) = (80u16, 24u16);
2034 render_screen(&pager, width, height);
2035
2036 let _ = pager.handle_key(key(KeyCode::End));
2037 let screen = render_screen(&pager, width, height);
2038 assert!(
2039 screen.contains("END-MARKER"),
2040 "End must reveal the last line:\n{screen}"
2041 );
2042 // Wrapping keeps each piece's style and copies the unwrapped source.
2043 let rows = pager.current_page().rows();
2044 assert_eq!(rows.styled[0].spans[0].style.fg, Some(Color::Cyan));
2045 assert!(
2046 rows.plain.len() > 40,
2047 "rows must be wrapped: {}",
2048 rows.plain.len()
2049 );
2050 drop(rows);
2051 match pager.handle_key(key(KeyCode::Char('c'))) {
2052 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) => {
2053 assert_eq!(text.lines().count(), 40);
2054 }
2055 other => panic!("expected CopyToClipboard emit, got {other:?}"),
2056 }
2057 }
2058
2059 #[test]
2060 fn wrap_text_advances_tabs_to_stops_and_drops_bidi_format_chars() {
2061 assert_eq!(wrap_text("a\tb", 40), ["a b"]);
2062 assert_eq!(wrap_text("abcd\tb", 40), ["abcd b"]);
2063 assert_eq!(wrap_text("会\tb", 40), ["会 b"]);
2064 assert_eq!(wrap_text("left\u{202E}right\u{2066}", 40), ["leftright"]);
2065 assert_eq!(wrap_text("👩‍💻\tx", 40), ["👩‍💻 x"]);
2066 }
2067
2068 #[test]
2069 fn pager_wraps_whole_graphemes_and_preserves_their_styles() {
2070 for grapheme in ["👩‍💻", "👍🏽", "🇨🇳", "1\u{fe0f}\u{20e3}"] {
2071 assert_eq!(wrap_text(grapheme, 2), [grapheme]);
2072 let source = grapheme.repeat(2);
2073 assert_eq!(wrap_text(&source, 2), [grapheme, grapheme]);
2074 let style = Style::default().fg(Color::Cyan);
2075 let rows = PagerRows::wrap_lines(&[Line::from(Span::styled(source, style))], 2);
2076 assert_eq!(rows.plain, [grapheme, grapheme]);
2077 assert!(
2078 rows.styled
2079 .iter()
2080 .all(|row| row.spans.len() == 1 && row.spans[0].style == style)
2081 );
2082 }
2083 assert_eq!(wrap_text("a\u{301}b", 1), ["a\u{301}", "b"]);
2084 }
2085
2086 #[test]
2087 fn resize_recomputes_zero_matches_before_reserving_the_status_row() {
2088 let query = "needle".repeat(10);
2089 let text = format!("{query}\n{}", "hay\n".repeat(40));
2090 let mut pager = PagerView::from_text("Output", &text, 38);
2091 let no_search = PagerView::from_text("Output", &text, 38);
2092 render_screen(&pager, 40, 24);
2093 let _ = pager.handle_key(key(KeyCode::Char('/')));
2094 for ch in query.chars() {
2095 let _ = pager.handle_key(key(KeyCode::Char(ch)));
2096 }
2097 let _ = pager.handle_key(key(KeyCode::Enter));
2098 assert!(pager.search_matches.borrow().is_empty());
2099
2100 let wide = render_screen(&pager, 100, 24);
2101 render_screen(&no_search, 100, 24);
2102 assert!(wide.contains("match 1/1"), "{wide}");
2103 assert_eq!(pager.search_matches.borrow().len(), 1);
2104 assert_eq!(pager.page_height() + 1, no_search.page_height());
2105
2106 let narrow = render_screen(&pager, 40, 24);
2107 render_screen(&no_search, 40, 24);
2108 assert!(!narrow.contains("match 1/1"), "{narrow}");
2109 assert!(pager.search_matches.borrow().is_empty());
2110 assert_eq!(pager.page_height(), no_search.page_height());
2111 }
2112
2113 #[test]
2114 fn resize_keeps_the_top_line_and_the_current_match() {
2115 let text = (0..60)
2116 .map(|i| {
2117 let tag = if i == 40 { "needle" } else { "hay" };
2118 format!("row {i:02} {tag} {}", "word ".repeat(24).trim_end())
2119 })
2120 .collect::<Vec<_>>()
2121 .join("\n");
2122 let mut pager = PagerView::from_text("Output", &text, 98);
2123 render_screen(&pager, 100, 24);
2124 let _ = pager.handle_key(key(KeyCode::Char('/')));
2125 for ch in "needle".chars() {
2126 let _ = pager.handle_key(key(KeyCode::Char(ch)));
2127 }
2128 let _ = pager.handle_key(key(KeyCode::Enter));
2129
2130 // Narrowing re-wraps every line into more rows.
2131 let screen = render_screen(&pager, 60, 24);
2132 let first_body_row = screen
2133 .lines()
2134 .find(|row| row.contains("row "))
2135 .unwrap_or_default();
2136 assert!(
2137 first_body_row.contains("row 40 needle"),
2138 "the line on top must stay on top after a resize:\n{screen}"
2139 );
2140 assert!(screen.contains("match 1/1"), "{screen}");
2141 let rows = pager.current_page().rows();
2142 let matches = pager.search_matches.borrow();
2143 assert_eq!(matches.len(), 1);
2144 assert!(rows.plain[matches[pager.search_index.get()]].contains("needle"));
2145 }
2146
2147 #[test]
2148 fn wrap_text_breaks_overlong_cjk_runs() {
2149 let text = "这是一个非常长的中文字符串".repeat(10);
2150 let lines = wrap_text(&text, 16);
2151
2152 for line in &lines {
2153 assert!(line.width() <= 16, "line {line:?} exceeds width 16");
2154 }
2155
2156 assert_eq!(lines.join(""), text);
2157 }
2158 }
2159
2159 lines RUST