返回 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 entire pager body to the system clipboard
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;
19
20 use crossterm::event::{
21 KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
22 };
23 use ratatui::{
24 buffer::Buffer,
25 layout::Rect,
26 style::{Color, Modifier, Style},
27 text::{Line, Span},
28 widgets::{Paragraph, Widget, Wrap},
29 };
30 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
31
32 use crate::tui::views::{
33 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, action_footer_lines,
34 render_modal_footer, render_panel_scroll_rail, render_underwater_surface,
35 };
36 use codewhale_palette as palette;
37
38 #[derive(Debug, Clone)]
39 struct PagerDestructiveAction {
40 key: char,
41 label: String,
42 confirm_label: String,
43 event: ViewEvent,
44 armed: bool,
45 }
46
47 /// One independently copyable document in a pager. Most pagers contain a
48 /// single page; Turn Inspector uses several so each turn stays isolated while
49 /// retaining the pager's existing scroll/search/copy behavior.
50 #[derive(Debug, Clone)]
51 pub(crate) struct PagerPage {
52 title: String,
53 lines: Vec<Line<'static>>,
54 plain_lines: Vec<String>,
55 export_markdown: Option<String>,
56 copy_text: Option<String>,
57 answer_text: Option<String>,
58 }
59
60 impl PagerPage {
61 pub(crate) fn from_text(title: impl Into<String>, text: &str, width: u16) -> Self {
62 // Pager bodies frequently carry tool output or worker transcripts.
63 // Keep the same terminal-injection boundary as the single-page path.
64 let mut sanitized = String::with_capacity(text.len());
65 crate::tui::osc8::strip_ansi_into(text, &mut sanitized);
66 let mut lines = Vec::new();
67 for raw in sanitized.lines() {
68 for wrapped in wrap_text(raw, width.max(1) as usize) {
69 lines.push(Line::from(Span::raw(wrapped)));
70 }
71 }
72 let plain_lines = lines.iter().map(line_to_string).collect();
73 Self {
74 title: title.into(),
75 lines,
76 plain_lines,
77 export_markdown: None,
78 copy_text: None,
79 answer_text: None,
80 }
81 }
82
83 pub(crate) fn with_export_markdown(mut self, markdown: impl Into<String>) -> Self {
84 self.export_markdown = Some(markdown.into());
85 self
86 }
87
88 pub(crate) fn with_copy_text(mut self, text: impl Into<String>) -> Self {
89 self.copy_text = Some(text.into());
90 self
91 }
92
93 /// Attach the clean final assistant answer that the `a` key copies to
94 /// the clipboard. Only surfaces that can produce an answer-only payload
95 /// (Turn Inspector pages, assistant detail pagers) set this; the pager
96 /// body itself stays free to render scaffolding around the answer.
97 pub(crate) fn with_copy_answer(mut self, text: impl Into<String>) -> Self {
98 self.answer_text = Some(text.into());
99 self
100 }
101 }
102
103 pub struct PagerView {
104 pages: Vec<PagerPage>,
105 page_index: usize,
106 scroll: usize,
107 search_input: String,
108 search_matches: Vec<usize>,
109 search_index: usize,
110 search_mode: bool,
111 pending_g: bool,
112 /// Cached visible content height from the last render. Used by paging
113 /// keys (Ctrl+D/U, Ctrl+F/B, Space, etc.) to compute scroll deltas
114 /// without access to the render area.
115 last_visible_height: Cell<usize>,
116 /// Optional inspector-owned destructive action. It requires two presses
117 /// (or key then Enter); Esc disarms before it closes the pager.
118 destructive_action: Option<PagerDestructiveAction>,
119 action_area: Cell<Option<Rect>>,
120 }
121
122 impl PagerView {
123 pub fn new(title: impl Into<String>, lines: Vec<Line<'static>>) -> Self {
124 let plain_lines = lines.iter().map(line_to_string).collect();
125 Self {
126 pages: vec![PagerPage {
127 title: title.into(),
128 lines,
129 plain_lines,
130 export_markdown: None,
131 copy_text: None,
132 answer_text: None,
133 }],
134 page_index: 0,
135 scroll: 0,
136 search_input: String::new(),
137 search_matches: Vec::new(),
138 search_index: 0,
139 search_mode: false,
140 pending_g: false,
141 last_visible_height: Cell::new(0),
142 destructive_action: None,
143 action_area: Cell::new(None),
144 }
145 }
146
147 /// Build an opt-in multi-page pager and open the requested page. Page
148 /// switching is deliberately unavailable to ordinary one-page pagers.
149 pub(crate) fn from_pages(pages: Vec<PagerPage>, initial_page: usize) -> Self {
150 assert!(!pages.is_empty(), "a pager needs at least one page");
151 let page_index = initial_page.min(pages.len().saturating_sub(1));
152 Self {
153 pages,
154 page_index,
155 scroll: 0,
156 search_input: String::new(),
157 search_matches: Vec::new(),
158 search_index: 0,
159 search_mode: false,
160 pending_g: false,
161 last_visible_height: Cell::new(0),
162 destructive_action: None,
163 action_area: Cell::new(None),
164 }
165 }
166
167 /// Attach a compact Markdown export (e.g. the #4108 turn handoff) that the
168 /// `e` key copies to the clipboard. Only the Turn Inspector pager sets this;
169 /// other pagers leave `e` inert.
170 #[cfg(test)]
171 pub fn with_export_markdown(mut self, markdown: impl Into<String>) -> Self {
172 self.current_page_mut().export_markdown = Some(markdown.into());
173 self
174 }
175
176 /// Preserve a source-faithful payload for `c` / `y` while the rendered
177 /// pager remains free to wrap content to its viewport.
178 pub fn with_copy_text(mut self, text: impl Into<String>) -> Self {
179 self.current_page_mut().copy_text = Some(text.into());
180 self
181 }
182
183 /// Attach the clean final assistant answer that the `a` key copies
184 /// (see [`PagerPage::with_copy_answer`]).
185 pub fn with_copy_answer(mut self, text: impl Into<String>) -> Self {
186 self.current_page_mut().answer_text = Some(text.into());
187 self
188 }
189
190 /// Attach a two-step destructive action to this pager. Work Graph
191 /// inspectors use this to keep Stop inside the detail surface while
192 /// reusing the existing command/agent cancellation events.
193 pub fn with_destructive_action(
194 mut self,
195 key: char,
196 label: impl Into<String>,
197 confirm_label: impl Into<String>,
198 event: ViewEvent,
199 ) -> Self {
200 self.destructive_action = Some(PagerDestructiveAction {
201 key,
202 label: label.into(),
203 confirm_label: confirm_label.into(),
204 event,
205 armed: false,
206 });
207 self
208 }
209
210 pub fn from_text(title: impl Into<String>, text: &str, width: u16) -> Self {
211 Self::from_pages(vec![PagerPage::from_text(title, text, width)], 0)
212 }
213
214 /// Reuse the inspector confirmation control for token-bound commands.
215 /// Copy exposes the exact command; confirmation dispatches it unchanged.
216 pub(crate) fn command_review(
217 title: impl Into<String>,
218 text: &str,
219 width: u16,
220 command: String,
221 locale: codewhale_localization::Locale,
222 ) -> Self {
223 let confirm = codewhale_localization::tr(
224 locale,
225 codewhale_localization::MessageId::PagerActionConfirm,
226 )
227 .into_owned();
228 Self::from_text(title, text, width)
229 .with_copy_text(command.clone())
230 .with_destructive_action(
231 'y',
232 confirm.clone(),
233 confirm,
234 ViewEvent::CommandPaletteSelected {
235 action: crate::tui::views::CommandPaletteAction::ExecuteCommand { command },
236 },
237 )
238 }
239
240 fn activate_destructive_action(&mut self) -> ViewAction {
241 self.pending_g = false;
242 let Some(action) = self.destructive_action.as_mut() else {
243 return ViewAction::None;
244 };
245 if action.armed {
246 ViewAction::EmitAndClose(action.event.clone())
247 } else {
248 action.armed = true;
249 ViewAction::None
250 }
251 }
252
253 fn current_page(&self) -> &PagerPage {
254 &self.pages[self.page_index]
255 }
256
257 fn current_page_mut(&mut self) -> &mut PagerPage {
258 &mut self.pages[self.page_index]
259 }
260
261 fn switch_page(&mut self, page_index: usize) {
262 let page_index = page_index.min(self.pages.len().saturating_sub(1));
263 if page_index == self.page_index {
264 return;
265 }
266 self.page_index = page_index;
267 self.scroll = 0;
268 self.search_input.clear();
269 self.search_matches.clear();
270 self.search_index = 0;
271 self.search_mode = false;
272 self.pending_g = false;
273 }
274
275 fn scroll_up(&mut self, amount: usize) {
276 self.scroll = self.scroll.saturating_sub(amount);
277 }
278
279 fn scroll_down(&mut self, amount: usize, max_scroll: usize) {
280 self.scroll = (self.scroll + amount).min(max_scroll);
281 }
282
283 fn scroll_to_top(&mut self) {
284 self.scroll = 0;
285 }
286
287 fn scroll_to_bottom(&mut self, max_scroll: usize) {
288 self.scroll = max_scroll;
289 }
290
291 /// Plain-text rendered body of the pager joined with `\n`. This reflects
292 /// width-based display wrapping. Clipboard events use this by default;
293 /// pagers with a source-faithful override use that payload instead.
294 pub fn body_text(&self) -> String {
295 self.current_page().plain_lines.join("\n")
296 }
297
298 fn clipboard_text(&self) -> String {
299 self.current_page()
300 .copy_text
301 .clone()
302 .unwrap_or_else(|| self.body_text())
303 }
304
305 /// The pager's title bar text. Used by tests to assert the raw-detail
306 /// pager is framed at leaf scope (#4105).
307 #[cfg(test)]
308 pub(crate) fn title(&self) -> &str {
309 &self.current_page().title
310 }
311
312 /// Return the page height (in lines) used for paging keys.
313 ///
314 /// Falls back to a small constant (10) before the first render so the
315 /// pager still responds to paging keys when invoked synthetically (e.g.
316 /// in unit tests). After the first render, the cached value reflects
317 /// the actual visible content area.
318 fn page_height(&self) -> usize {
319 let cached = self.last_visible_height.get();
320 if cached == 0 { 10 } else { cached }
321 }
322
323 /// Half a page, rounded up so a single press always moves at least one line.
324 fn half_page_height(&self) -> usize {
325 let page = self.page_height();
326 page.div_ceil(2).max(1)
327 }
328
329 fn max_scroll(&self) -> usize {
330 // Match the render-side clamp so G/End land at the visible bottom and
331 // k/Up immediately scroll back up by one line.
332 self.current_page()
333 .lines
334 .len()
335 .saturating_sub(self.page_height())
336 }
337
338 fn start_search(&mut self) {
339 self.search_mode = true;
340 self.search_input.clear();
341 self.search_matches.clear();
342 self.search_index = 0;
343 }
344
345 fn update_search_matches(&mut self) {
346 let query = self.search_input.trim();
347 if query.is_empty() {
348 self.search_matches.clear();
349 self.search_index = 0;
350 return;
351 }
352 let lower = query.to_ascii_lowercase();
353 self.search_matches = self
354 .current_page()
355 .plain_lines
356 .iter()
357 .enumerate()
358 .filter_map(|(idx, line)| {
359 if line.to_ascii_lowercase().contains(&lower) {
360 Some(idx)
361 } else {
362 None
363 }
364 })
365 .collect();
366 self.search_index = 0;
367 }
368
369 fn jump_to_match(&mut self) {
370 if let Some(&line) = self.search_matches.get(self.search_index) {
371 self.scroll = line;
372 }
373 }
374
375 fn next_match(&mut self) {
376 if self.search_matches.is_empty() {
377 return;
378 }
379 self.search_index = (self.search_index + 1) % self.search_matches.len();
380 self.jump_to_match();
381 }
382
383 fn prev_match(&mut self) {
384 if self.search_matches.is_empty() {
385 return;
386 }
387 if self.search_index == 0 {
388 self.search_index = self.search_matches.len().saturating_sub(1);
389 } else {
390 self.search_index = self.search_index.saturating_sub(1);
391 }
392 self.jump_to_match();
393 }
394 }
395
396 impl ModalView for PagerView {
397 fn kind(&self) -> ModalKind {
398 ModalKind::Pager
399 }
400
401 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
402 self
403 }
404
405 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
406 if self.search_mode {
407 match key.code {
408 KeyCode::Enter => {
409 self.search_mode = false;
410 self.update_search_matches();
411 self.jump_to_match();
412 return ViewAction::None;
413 }
414 KeyCode::Esc => {
415 // Bail out of search mode AND drop the current match list
416 // so the user gets back to the un-highlighted view —
417 // codex-style behavior. To resume from where they left
418 // off they re-enter `/` and re-type.
419 self.search_mode = false;
420 self.search_input.clear();
421 self.search_matches.clear();
422 self.search_index = 0;
423 return ViewAction::None;
424 }
425 KeyCode::Backspace => {
426 self.search_input.pop();
427 return ViewAction::None;
428 }
429 // Ctrl+H is the legacy ASCII backspace many terminals emit.
430 KeyCode::Char('h')
431 if key.modifiers.contains(KeyModifiers::CONTROL)
432 && !key.modifiers.contains(KeyModifiers::ALT) =>
433 {
434 self.search_input.pop();
435 return ViewAction::None;
436 }
437 KeyCode::Char(c) => {
438 self.search_input.push(c);
439 return ViewAction::None;
440 }
441 // All other keys (Up/Down, PageUp/PageDown, etc.) are captured
442 // in search mode so they don't fall through to the pager body.
443 _ => return ViewAction::None,
444 }
445 }
446
447 if let Some(action) = self.destructive_action.as_mut() {
448 if key.code == KeyCode::Esc && action.armed {
449 action.armed = false;
450 self.pending_g = false;
451 return ViewAction::None;
452 }
453 let matching_key =
454 matches!(key.code, KeyCode::Char(ch) if ch.eq_ignore_ascii_case(&action.key));
455 if matching_key || (key.code == KeyCode::Enter && action.armed) {
456 if key.kind != KeyEventKind::Press
457 || !key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
458 {
459 return ViewAction::None;
460 }
461 return self.activate_destructive_action();
462 }
463 }
464
465 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
466 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
467 let max_scroll = self.max_scroll();
468
469 // Ctrl+chord paging keys are matched first because their KeyCode
470 // also matches the bare `KeyCode::Char(c)` arms below.
471 if ctrl {
472 match key.code {
473 KeyCode::Char('d') | KeyCode::Char('D') => {
474 self.scroll_down(self.half_page_height(), max_scroll);
475 self.pending_g = false;
476 return ViewAction::None;
477 }
478 KeyCode::Char('u') | KeyCode::Char('U') => {
479 self.scroll_up(self.half_page_height());
480 self.pending_g = false;
481 return ViewAction::None;
482 }
483 KeyCode::Char('f') | KeyCode::Char('F') => {
484 self.scroll_down(self.page_height(), max_scroll);
485 self.pending_g = false;
486 return ViewAction::None;
487 }
488 KeyCode::Char('b') | KeyCode::Char('B') => {
489 self.scroll_up(self.page_height());
490 self.pending_g = false;
491 return ViewAction::None;
492 }
493 _ => {}
494 }
495 }
496
497 match key.code {
498 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
499 KeyCode::Left if self.pages.len() > 1 => {
500 self.switch_page(self.page_index.saturating_sub(1));
501 ViewAction::None
502 }
503 KeyCode::Right if self.pages.len() > 1 => {
504 self.switch_page(
505 self.page_index
506 .saturating_add(1)
507 .min(self.pages.len().saturating_sub(1)),
508 );
509 ViewAction::None
510 }
511 KeyCode::Up | KeyCode::Char('k') => {
512 self.scroll_up(1);
513 self.pending_g = false;
514 ViewAction::None
515 }
516 KeyCode::Down | KeyCode::Char('j') => {
517 self.scroll_down(1, max_scroll);
518 self.pending_g = false;
519 ViewAction::None
520 }
521 KeyCode::PageUp => {
522 self.scroll_up(self.page_height());
523 self.pending_g = false;
524 ViewAction::None
525 }
526 KeyCode::PageDown => {
527 self.scroll_down(self.page_height(), max_scroll);
528 self.pending_g = false;
529 ViewAction::None
530 }
531 // Vim convention: Space pages down, Shift+Space pages up. Match
532 // Shift+Space first so it is not absorbed by the bare ' ' arm.
533 KeyCode::Char(' ') if shift => {
534 self.scroll_up(self.page_height());
535 self.pending_g = false;
536 ViewAction::None
537 }
538 KeyCode::Char(' ') => {
539 self.scroll_down(self.page_height(), max_scroll);
540 self.pending_g = false;
541 ViewAction::None
542 }
543 KeyCode::Home => {
544 self.scroll_to_top();
545 self.pending_g = false;
546 ViewAction::None
547 }
548 KeyCode::End => {
549 self.scroll_to_bottom(max_scroll);
550 self.pending_g = false;
551 ViewAction::None
552 }
553 KeyCode::Char('g') => {
554 if self.pending_g {
555 self.scroll_to_top();
556 self.pending_g = false;
557 } else {
558 self.pending_g = true;
559 }
560 ViewAction::None
561 }
562 KeyCode::Char('G') => {
563 self.scroll_to_bottom(max_scroll);
564 self.pending_g = false;
565 ViewAction::None
566 }
567 KeyCode::Char('/') => {
568 self.start_search();
569 self.pending_g = false;
570 ViewAction::None
571 }
572 KeyCode::Char('n') => {
573 self.next_match();
574 self.pending_g = false;
575 ViewAction::None
576 }
577 KeyCode::Char('N') => {
578 self.prev_match();
579 self.pending_g = false;
580 ViewAction::None
581 }
582 // Copy the entire pager body to the clipboard. The pager
583 // intercepts mouse capture so terminal-native selection is
584 // disabled inside it; without this binding users with no
585 // out-of-band copy path would have no way to extract content
586 // they can see (#1354). Both `c` and `y` are wired so users
587 // landing from either OS-clipboard or vim convention find a
588 // working key.
589 KeyCode::Char('c') | KeyCode::Char('y') => {
590 self.pending_g = false;
591 ViewAction::Emit(ViewEvent::CopyToClipboard {
592 text: self.clipboard_text(),
593 label: "Pager content".to_string(),
594 })
595 }
596 // `e` exports the compact turn handoff (#4108) when this pager
597 // carries one — the Turn Inspector. Elsewhere the guard fails and
598 // `e` falls through to the inert arm below.
599 KeyCode::Char('e') | KeyCode::Char('E')
600 if self.current_page().export_markdown.is_some() =>
601 {
602 self.pending_g = false;
603 let text = self
604 .current_page()
605 .export_markdown
606 .clone()
607 .unwrap_or_default();
608 ViewAction::Emit(ViewEvent::CopyToClipboard {
609 text,
610 label: "Turn handoff".to_string(),
611 })
612 }
613 // `a` copies ONLY the final assistant answer — the clean
614 // answer-only payload attached by the Turn Inspector and the
615 // assistant detail pagers. Unlike `c`/`y` (rendered body) or `e`
616 // (whole-turn handoff markdown), this payload carries no
617 // reasoning, tool calls/results, runtime status, or transcript
618 // scaffolding. Elsewhere the guard fails and `a` is inert.
619 KeyCode::Char('a') if self.current_page().answer_text.is_some() => {
620 self.pending_g = false;
621 let text = self.current_page().answer_text.clone().unwrap_or_default();
622 ViewAction::Emit(ViewEvent::CopyToClipboard {
623 text,
624 label: "Answer".to_string(),
625 })
626 }
627 _ => ViewAction::None,
628 }
629 }
630
631 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
632 if mouse.kind == MouseEventKind::Down(MouseButton::Left)
633 && mouse.modifiers.is_empty()
634 && self
635 .action_area
636 .get()
637 .is_some_and(|area| area.contains((mouse.column, mouse.row).into()))
638 {
639 return self.activate_destructive_action();
640 }
641 match mouse.kind {
642 MouseEventKind::ScrollUp => {
643 self.scroll_up(3);
644 self.pending_g = false;
645 ViewAction::None
646 }
647 MouseEventKind::ScrollDown => {
648 self.scroll_down(3, self.max_scroll());
649 self.pending_g = false;
650 ViewAction::None
651 }
652 _ => ViewAction::None,
653 }
654 }
655
656 fn render(&self, area: Rect, buf: &mut Buffer) {
657 let page = self.current_page();
658 let title = if self.pages.len() > 1 {
659 format!(
660 "{} · {}/{} · ←/→",
661 page.title,
662 self.page_index + 1,
663 self.pages.len()
664 )
665 } else {
666 page.title.clone()
667 };
668 let inner = render_underwater_surface(area, buf, title);
669
670 // The wrapping action footer is anchored to the bottom of the inner
671 // area; the body fills the rows above it.
672 let mut hints = vec![
673 ActionHint::new("q/Esc", "close"),
674 ActionHint::new("j/k", "scroll"),
675 ActionHint::new("Space", "page"),
676 ActionHint::new("Ctrl+D/U", "half"),
677 ActionHint::new("g/G", "top/bottom"),
678 ActionHint::new("/", "search"),
679 ActionHint::new("c", "copy"),
680 ];
681 if page.export_markdown.is_some() {
682 hints.push(ActionHint::new("e", "copy handoff"));
683 }
684 if page.answer_text.is_some() {
685 hints.push(ActionHint::new("a", "copy answer"));
686 }
687 self.action_area.set(None);
688 if let Some(action) = self.destructive_action.as_ref() {
689 let key = if action.armed {
690 format!("{}/Enter", action.key)
691 } else {
692 action.key.to_string()
693 };
694 let label = if action.armed {
695 action.confirm_label.clone()
696 } else {
697 action.label.clone()
698 };
699 let action_width = key.width() + 2 + label.width();
700 hints.push(ActionHint::new(key, label));
701 let footer_lines = action_footer_lines(&hints, inner.width);
702 if footer_lines.len() <= usize::from(inner.height)
703 && inner.width > 0
704 && let Some(last) = footer_lines.last()
705 {
706 let offset = last
707 .width()
708 .saturating_sub(action_width)
709 .min(usize::from(inner.width));
710 self.action_area.set(Some(Rect::new(
711 inner.x.saturating_add(offset as u16),
712 inner.bottom().saturating_sub(1),
713 (action_width.min(usize::from(inner.width).saturating_sub(offset))) as u16,
714 1,
715 )));
716 }
717 }
718 let content = render_modal_footer(inner, buf, &hints);
719
720 // `content` already excludes the border, padding, and footer rows.
721 let mut visible_height = content.height as usize;
722 if self.search_mode {
723 // Reserve a row for the search prompt that gets pushed below.
724 visible_height = visible_height.saturating_sub(1);
725 } else if !self.search_matches.is_empty() {
726 // Reserve a row for the "match X/Y (n/N)" status; without this
727 // the status line gets clipped on small popup heights and the
728 // user can't see how many matches there are.
729 visible_height = visible_height.saturating_sub(1);
730 }
731 // Cache for paging keys; the value is treated as advisory and
732 // clamped at use-time.
733 self.last_visible_height.set(visible_height);
734 let max_scroll = page.lines.len().saturating_sub(visible_height);
735 let scroll = self.scroll.min(max_scroll);
736 let end = (scroll + visible_height).min(page.lines.len());
737 let mut visible_lines = if page.lines.is_empty() {
738 vec![Line::from("")]
739 } else {
740 page.lines[scroll..end].to_vec()
741 };
742
743 // Highlight matched lines while the search prompt is closed and the
744 // user is navigating with `n` / `N`. Other matches get a subtle
745 // background; the current match gets a louder one. Per-substring
746 // highlighting is deferred to a follow-up — preserving the pre-styled
747 // spans (assistant / system colors) through a substring re-style is
748 // a separate concern.
749 if !self.search_mode && !self.search_matches.is_empty() {
750 let current_match_line = self.search_matches.get(self.search_index).copied();
751 for (visible_idx, line) in visible_lines.iter_mut().enumerate() {
752 let absolute_idx = scroll + visible_idx;
753 if absolute_idx >= page.lines.len() {
754 break;
755 }
756 if !self.search_matches.contains(&absolute_idx) {
757 continue;
758 }
759 let is_current = current_match_line == Some(absolute_idx);
760 let bg = if is_current {
761 Color::Yellow
762 } else {
763 Color::DarkGray
764 };
765 let fg = if is_current {
766 Color::Black
767 } else {
768 Color::Yellow
769 };
770 let highlight = Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD);
771 for span in line.spans.iter_mut() {
772 span.style = highlight;
773 }
774 }
775 }
776
777 if self.search_mode {
778 let prompt = format!("/{}", self.search_input);
779 visible_lines.push(Line::from(Span::styled(
780 prompt,
781 Style::default()
782 .fg(palette::WHALE_ACTION)
783 .add_modifier(Modifier::BOLD),
784 )));
785 } else if !self.search_matches.is_empty() {
786 let status = format!(
787 "match {}/{} (n/N)",
788 self.search_index + 1,
789 self.search_matches.len()
790 );
791 visible_lines.push(Line::from(Span::styled(
792 status,
793 Style::default().fg(palette::TEXT_MUTED),
794 )));
795 }
796
797 let content =
798 render_panel_scroll_rail(content, buf, page.lines.len(), scroll, visible_height, true);
799 // Explicit base ink: the surface behind this body is always WHALE_BG,
800 // so spans without their own fg must not inherit a dark terminal
801 // default (light-profile terminals would render them as black-on-black).
802 // Ratatui paints the base first; styled spans patch over it.
803 let paragraph = Paragraph::new(visible_lines)
804 .wrap(Wrap { trim: false })
805 .style(Style::default().fg(palette::TEXT_PRIMARY));
806 paragraph.render(content, buf);
807 }
808 }
809
810 fn line_to_string(line: &Line<'static>) -> String {
811 line.spans
812 .iter()
813 .map(|span| span.content.to_string())
814 .collect::<String>()
815 }
816
817 fn wrap_text(text: &str, width: usize) -> Vec<String> {
818 if width == 0 {
819 return vec![text.to_string()];
820 }
821 let mut lines = Vec::new();
822 let mut current = String::new();
823 let mut current_width = 0usize;
824
825 for word in text.split_whitespace() {
826 let word_width = word.width();
827 if word_width > width {
828 if !current.is_empty() {
829 lines.push(std::mem::take(&mut current));
830 current_width = 0;
831 }
832 push_word_breaking_chars(word, width, &mut current, &mut current_width, &mut lines);
833 continue;
834 }
835 let additional = if current.is_empty() {
836 word_width
837 } else {
838 word_width + 1
839 };
840 if current_width + additional > width && !current.is_empty() {
841 lines.push(current);
842 current = word.to_string();
843 current_width = word_width;
844 } else {
845 if !current.is_empty() {
846 current.push(' ');
847 current_width += 1;
848 }
849 current.push_str(word);
850 current_width += word_width;
851 }
852 }
853
854 if current.is_empty() {
855 lines.push(String::new());
856 } else {
857 lines.push(current);
858 }
859
860 lines
861 }
862
863 fn push_word_breaking_chars(
864 word: &str,
865 width: usize,
866 current: &mut String,
867 current_width: &mut usize,
868 lines: &mut Vec<String>,
869 ) {
870 for ch in word.chars() {
871 let char_width = ch.width().unwrap_or(1);
872 if *current_width + char_width > width && *current_width > 0 {
873 lines.push(std::mem::take(current));
874 *current_width = 0;
875 }
876 current.push(ch);
877 *current_width += char_width;
878 }
879 }
880
881 #[cfg(test)]
882 mod tests {
883 use super::*;
884 use ratatui::text::Line;
885
886 fn make_pager(lines: usize) -> PagerView {
887 let lines: Vec<Line<'static>> = (0..lines)
888 .map(|i| Line::from(format!("line-{i:03}")))
889 .collect();
890 PagerView::new("T", lines)
891 }
892
893 fn key(code: KeyCode) -> KeyEvent {
894 KeyEvent::new(code, KeyModifiers::NONE)
895 }
896
897 fn key_mod(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
898 KeyEvent::new(code, mods)
899 }
900
901 fn ctrl(code: KeyCode) -> KeyEvent {
902 KeyEvent::new(code, KeyModifiers::CONTROL)
903 }
904
905 #[test]
906 fn destructive_action_requires_two_steps_and_escape_only_disarms() {
907 let mut pager = make_pager(2).with_destructive_action(
908 's',
909 "stop",
910 "confirm stop · Esc cancels",
911 ViewEvent::SidebarAgentCancel {
912 agent_id: "agent_1".to_string(),
913 },
914 );
915
916 assert!(matches!(
917 pager.handle_key(key(KeyCode::Char('s'))),
918 ViewAction::None
919 ));
920 assert!(matches!(
921 pager.handle_key(key(KeyCode::Esc)),
922 ViewAction::None
923 ));
924 assert!(matches!(
925 pager.handle_key(key(KeyCode::Esc)),
926 ViewAction::Close
927 ));
928
929 let _ = pager.handle_key(key(KeyCode::Char('s')));
930 assert!(matches!(
931 pager.handle_key(key(KeyCode::Enter)),
932 ViewAction::EmitAndClose(ViewEvent::SidebarAgentCancel { agent_id })
933 if agent_id == "agent_1"
934 ));
935 }
936
937 #[test]
938 fn command_review_confirms_the_pinned_command_with_keys_or_painted_mouse_control() {
939 let command = format!(
940 "/plugin trust fixture {}.{}",
941 "a".repeat(64),
942 "b".repeat(64)
943 );
944 for (width, height) in [(40, 12), (80, 24), (140, 40)] {
945 let mut pager = PagerView::command_review(
946 "Review fixture",
947 "Exact reviewed capabilities",
948 width - 2,
949 command.clone(),
950 codewhale_localization::Locale::En,
951 );
952 for modifiers in [
953 KeyModifiers::CONTROL,
954 KeyModifiers::ALT,
955 KeyModifiers::SUPER,
956 ] {
957 assert!(matches!(
958 pager.handle_key(KeyEvent::new(KeyCode::Char('y'), modifiers)),
959 ViewAction::None
960 ));
961 assert!(!pager.destructive_action.as_ref().unwrap().armed);
962 }
963 assert!(matches!(
964 pager.handle_key(KeyEvent::new_with_kind(
965 KeyCode::Char('y'),
966 KeyModifiers::NONE,
967 KeyEventKind::Repeat
968 )),
969 ViewAction::None
970 ));
971 assert!(!pager.destructive_action.as_ref().unwrap().armed);
972 let ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) =
973 pager.handle_key(key(KeyCode::Char('c')))
974 else {
975 panic!("copy exposes the pinned command");
976 };
977 assert_eq!(text, command);
978 let area = Rect::new(0, 0, width, height);
979 let mut buffer = Buffer::empty(area);
980 pager.render(area, &mut buffer);
981 let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
982 assert!(rendered.contains("Confirm"), "{rendered}");
983 assert!(
984 !rendered.contains("disable"),
985 "generic review must not describe an unrelated action"
986 );
987 let button = pager
988 .action_area
989 .get()
990 .expect("visible confirmation control");
991 assert!(button.width > 0 && button.bottom() <= height);
992 let click = |button: Rect| MouseEvent {
993 kind: MouseEventKind::Down(MouseButton::Left),
994 column: button.x,
995 row: button.y,
996 modifiers: KeyModifiers::NONE,
997 };
998 assert!(matches!(
999 pager.handle_mouse(click(button)),
1000 ViewAction::None
1001 ));
1002 assert!(pager.destructive_action.as_ref().unwrap().armed);
1003 assert!(matches!(
1004 pager.handle_key(KeyEvent::new_with_kind(
1005 KeyCode::Enter,
1006 KeyModifiers::NONE,
1007 KeyEventKind::Repeat
1008 )),
1009 ViewAction::None
1010 ));
1011 pager.render(area, &mut buffer);
1012 let ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
1013 action: crate::tui::views::CommandPaletteAction::ExecuteCommand { command: actual },
1014 }) = pager.handle_mouse(click(pager.action_area.get().unwrap()))
1015 else {
1016 panic!("second click confirms the reviewed command");
1017 };
1018 assert_eq!(actual, command);
1019 }
1020 }
1021
1022 /// Drive a render once so `last_visible_height` is populated and paging
1023 /// keys use a deterministic page size.
1024 fn prime_layout(view: &mut PagerView, height: u16) {
1025 let area = Rect::new(0, 0, 40, height);
1026 let mut buf = Buffer::empty(area);
1027 view.render(area, &mut buf);
1028 }
1029
1030 #[test]
1031 fn j_scrolls_down_one_line() {
1032 let mut p = make_pager(50);
1033 let _ = p.handle_key(key(KeyCode::Char('j')));
1034 assert_eq!(p.scroll, 1);
1035 }
1036
1037 #[test]
1038 fn k_scrolls_up_one_line() {
1039 let mut p = make_pager(50);
1040 p.scroll = 5;
1041 let _ = p.handle_key(key(KeyCode::Char('k')));
1042 assert_eq!(p.scroll, 4);
1043 }
1044
1045 #[test]
1046 fn gg_jumps_to_top() {
1047 let mut p = make_pager(50);
1048 p.scroll = 30;
1049 let _ = p.handle_key(key(KeyCode::Char('g')));
1050 assert!(p.pending_g, "first 'g' should arm pending_g");
1051 assert_eq!(p.scroll, 30, "first 'g' alone must not scroll");
1052 let _ = p.handle_key(key(KeyCode::Char('g')));
1053 assert_eq!(p.scroll, 0);
1054 assert!(!p.pending_g);
1055 }
1056
1057 #[test]
1058 fn home_jumps_to_top() {
1059 let mut p = make_pager(50);
1060 p.scroll = 30;
1061 let _ = p.handle_key(key(KeyCode::Home));
1062 assert_eq!(p.scroll, 0);
1063 }
1064
1065 #[test]
1066 fn shift_g_jumps_to_bottom() {
1067 let mut p = make_pager(50);
1068 let _ = p.handle_key(key(KeyCode::Char('G')));
1069 assert_eq!(p.scroll, p.max_scroll());
1070 }
1071
1072 #[test]
1073 fn end_jumps_to_bottom() {
1074 let mut p = make_pager(50);
1075 let _ = p.handle_key(key(KeyCode::End));
1076 assert_eq!(p.scroll, p.max_scroll());
1077 }
1078
1079 #[test]
1080 fn up_immediately_scrolls_after_shift_g_to_bottom() {
1081 let mut p = make_pager(50);
1082 prime_layout(&mut p, 22);
1083 let bottom = p.max_scroll();
1084
1085 let _ = p.handle_key(key(KeyCode::Char('G')));
1086 assert_eq!(p.scroll, bottom);
1087 let _ = p.handle_key(key(KeyCode::Up));
1088 assert_eq!(p.scroll, bottom - 1);
1089 let _ = p.handle_key(key(KeyCode::Char('k')));
1090 assert_eq!(p.scroll, bottom - 2);
1091 }
1092
1093 #[test]
1094 fn k_immediately_scrolls_after_end_to_bottom() {
1095 let mut p = make_pager(50);
1096 prime_layout(&mut p, 22);
1097 let bottom = p.max_scroll();
1098
1099 let _ = p.handle_key(key(KeyCode::End));
1100 assert_eq!(p.scroll, bottom);
1101 let _ = p.handle_key(key(KeyCode::Char('k')));
1102 assert_eq!(p.scroll, bottom - 1);
1103 }
1104
1105 #[test]
1106 fn ctrl_d_half_page_down() {
1107 let mut p = make_pager(200);
1108 prime_layout(&mut p, 22);
1109 let half = p.half_page_height();
1110 assert!(half >= 1, "half-page must move at least one line");
1111 let _ = p.handle_key(ctrl(KeyCode::Char('d')));
1112 assert_eq!(p.scroll, half);
1113 }
1114
1115 #[test]
1116 fn ctrl_u_half_page_up() {
1117 let mut p = make_pager(200);
1118 prime_layout(&mut p, 22);
1119 p.scroll = 50;
1120 let half = p.half_page_height();
1121 let _ = p.handle_key(ctrl(KeyCode::Char('u')));
1122 assert_eq!(p.scroll, 50 - half);
1123 }
1124
1125 #[test]
1126 fn ctrl_f_full_page_down() {
1127 let mut p = make_pager(200);
1128 prime_layout(&mut p, 22);
1129 let page = p.page_height();
1130 let _ = p.handle_key(ctrl(KeyCode::Char('f')));
1131 assert_eq!(p.scroll, page);
1132 }
1133
1134 #[test]
1135 fn ctrl_b_full_page_up() {
1136 let mut p = make_pager(200);
1137 prime_layout(&mut p, 22);
1138 p.scroll = 80;
1139 let page = p.page_height();
1140 let _ = p.handle_key(ctrl(KeyCode::Char('b')));
1141 assert_eq!(p.scroll, 80 - page);
1142 }
1143
1144 #[test]
1145 fn space_pages_down() {
1146 let mut p = make_pager(200);
1147 prime_layout(&mut p, 22);
1148 let page = p.page_height();
1149 let _ = p.handle_key(key(KeyCode::Char(' ')));
1150 assert_eq!(p.scroll, page);
1151 }
1152
1153 #[test]
1154 fn shift_space_pages_up() {
1155 let mut p = make_pager(200);
1156 prime_layout(&mut p, 22);
1157 p.scroll = 80;
1158 let page = p.page_height();
1159 let _ = p.handle_key(key_mod(KeyCode::Char(' '), KeyModifiers::SHIFT));
1160 assert_eq!(p.scroll, 80 - page);
1161 }
1162
1163 #[test]
1164 fn page_down_uses_cached_visible_height() {
1165 let mut p = make_pager(200);
1166 prime_layout(&mut p, 22);
1167 let page = p.page_height();
1168 let _ = p.handle_key(key(KeyCode::PageDown));
1169 assert_eq!(p.scroll, page);
1170 }
1171
1172 #[test]
1173 fn q_closes_pager() {
1174 let mut p = make_pager(10);
1175 let action = p.handle_key(key(KeyCode::Char('q')));
1176 assert!(matches!(action, ViewAction::Close));
1177 }
1178
1179 #[test]
1180 fn esc_closes_pager() {
1181 let mut p = make_pager(10);
1182 let action = p.handle_key(key(KeyCode::Esc));
1183 assert!(matches!(action, ViewAction::Close));
1184 }
1185
1186 #[test]
1187 fn multi_page_switch_resets_view_state_and_keeps_copy_export_page_scoped() {
1188 let first =
1189 PagerPage::from_text("Turns", "first displayed", 80).with_copy_text("FIRST-SOURCE");
1190 let latest = PagerPage::from_text("Turns", "latest displayed", 80)
1191 .with_copy_text("LATEST-SOURCE")
1192 .with_export_markdown("LATEST-HANDOFF");
1193 let mut pager = PagerView::from_pages(vec![first, latest], 1);
1194 pager.scroll = 4;
1195 pager.search_input = "latest".to_string();
1196 pager.search_matches = vec![0];
1197
1198 assert!(matches!(
1199 pager.handle_key(key(KeyCode::Left)),
1200 ViewAction::None
1201 ));
1202 assert_eq!(pager.body_text(), "first displayed");
1203 assert_eq!(pager.scroll, 0);
1204 assert!(pager.search_input.is_empty());
1205 assert!(pager.search_matches.is_empty());
1206 assert!(matches!(
1207 pager.handle_key(key(KeyCode::Char('c'))),
1208 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) if text == "FIRST-SOURCE"
1209 ));
1210 assert!(matches!(
1211 pager.handle_key(key(KeyCode::Char('e'))),
1212 ViewAction::None
1213 ));
1214
1215 let _ = pager.handle_key(key(KeyCode::Right));
1216 assert!(matches!(
1217 pager.handle_key(key(KeyCode::Char('e'))),
1218 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) if text == "LATEST-HANDOFF"
1219 ));
1220 }
1221
1222 #[test]
1223 fn g_does_not_consume_search_input() {
1224 // While in search mode, 'g' must be treated as a search character,
1225 // not as the half of a `gg` jump-to-top sequence.
1226 let mut p = make_pager(50);
1227 p.scroll = 10;
1228 let _ = p.handle_key(key(KeyCode::Char('/')));
1229 assert!(p.search_mode);
1230 let _ = p.handle_key(key(KeyCode::Char('g')));
1231 assert_eq!(p.search_input, "g");
1232 assert_eq!(p.scroll, 10);
1233 }
1234
1235 #[test]
1236 fn footer_hint_includes_new_bindings() {
1237 // The rendered pager must surface the new vim-style bindings to the
1238 // user. The footer is now a wrapping ActionHint row inside the modal
1239 // body (not the bottom border), so assert against the rendered buffer.
1240 let p = make_pager(5);
1241 let area = Rect::new(0, 0, 100, 16);
1242 let mut buf = Buffer::empty(area);
1243 p.render(area, &mut buf);
1244 let mut text = String::new();
1245 for y in 0..area.height {
1246 for x in 0..area.width {
1247 text.push_str(buf[(x, y)].symbol());
1248 }
1249 text.push('\n');
1250 }
1251 for needle in &[
1252 "j/k",
1253 "scroll",
1254 "g/G",
1255 "top/bottom",
1256 "Space",
1257 "page",
1258 "Ctrl+D/U",
1259 "half",
1260 "search",
1261 "copy",
1262 "q/Esc",
1263 "close",
1264 ] {
1265 assert!(text.contains(needle), "footer hint missing {needle:?}");
1266 }
1267 }
1268
1269 #[test]
1270 fn body_cells_carry_explicit_ink_on_the_dark_surface() {
1271 // The pager paints WHALE_BG behind the body, so a body span
1272 // without its own fg inherits the terminal default, black ink on
1273 // light-profile terminals, i.e. black-on-black. The base paragraph
1274 // style must pin every text cell to the body ink.
1275 let p = make_pager(3);
1276 let area = Rect::new(0, 0, 100, 16);
1277 let mut buf = Buffer::empty(area);
1278 p.render(area, &mut buf);
1279 let mut checked = 0;
1280 for y in 0..area.height {
1281 let mut row = String::new();
1282 for x in 0..area.width {
1283 row.push_str(buf[(x, y)].symbol());
1284 }
1285 if !row.contains("line-") {
1286 continue;
1287 }
1288 for x in 0..area.width {
1289 let cell = &buf[(x, y)];
1290 if cell.symbol().trim().is_empty() {
1291 continue;
1292 }
1293 assert_eq!(
1294 cell.style().fg,
1295 Some(palette::TEXT_PRIMARY),
1296 "body cell ({x}, {y}) must carry explicit body ink",
1297 );
1298 checked += 1;
1299 }
1300 }
1301 assert!(checked > 0, "expected body rows in the rendered pager");
1302 }
1303
1304 #[test]
1305 fn c_emits_copy_event_with_full_body() {
1306 // #1354: the pager intercepts mouse capture, so users have no way to
1307 // copy content out without an in-app key. Both `c` and `y` should
1308 // emit a CopyToClipboard event carrying the whole body so the host
1309 // dispatcher (in ui.rs) can write through `app.clipboard` and toast
1310 // a confirmation.
1311 let mut p = make_pager(3);
1312 let action = p.handle_key(key(KeyCode::Char('c')));
1313 match action {
1314 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
1315 assert_eq!(text, "line-000\nline-001\nline-002");
1316 assert_eq!(label, "Pager content");
1317 }
1318 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1319 }
1320 }
1321
1322 #[test]
1323 fn a_emits_copy_event_with_attached_answer_only() {
1324 // `a` copies the attached answer-only payload — never the rendered
1325 // body, which may carry scaffolding around the answer.
1326 let mut pager = PagerView::from_text("Turn Inspector", "[◆ · done] body", 40)
1327 .with_copy_answer("CLEAN-ANSWER");
1328 let action = pager.handle_key(key(KeyCode::Char('a')));
1329 match action {
1330 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
1331 assert_eq!(text, "CLEAN-ANSWER");
1332 assert_eq!(label, "Answer");
1333 }
1334 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1335 }
1336
1337 // Without an attached answer `a` stays inert; it must never fall
1338 // back to copying the rendered body.
1339 let mut plain = PagerView::from_text("T", "body", 40);
1340 assert!(matches!(
1341 plain.handle_key(key(KeyCode::Char('a'))),
1342 ViewAction::None
1343 ));
1344 }
1345
1346 #[test]
1347 fn copy_override_preserves_indentation_tabs_and_blank_lines() {
1348 let source = "Result:\n indented\n\twith-tab\n\nnext";
1349 let mut pager = PagerView::from_text("T", source, 12).with_copy_text(source);
1350
1351 let action = pager.handle_key(key(KeyCode::Char('c')));
1352 match action {
1353 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) => {
1354 assert_eq!(text, source);
1355 }
1356 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1357 }
1358 }
1359
1360 #[test]
1361 fn from_text_keeps_one_display_row_per_blank_source_line() {
1362 let pager = PagerView::from_text("T", "first\n\nthird", 80);
1363 assert_eq!(pager.body_text(), "first\n\nthird");
1364 }
1365
1366 #[test]
1367 fn from_text_strips_csi_mouse_and_osc_sequences() {
1368 // A worker transcript can carry captured terminal bytes (a child TUI's
1369 // mouse-tracking handshake, SGR color, OSC hyperlinks). Rendering them
1370 // raw emits the escapes to the user's terminal, which re-enables mouse
1371 // reporting after exit and leaves the shell executing fragments.
1372 let hostile = "── assistant ──\n\
1373 enabling \u{1b}[?1003h\u{1b}[?1006h mouse tracking\n\
1374 click bytes \u{1b}[<65;72;17M\u{1b}[<35;131;42M arrived\n\
1375 \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\
1376 trailing stray \u{1b}\u{7}bytes\u{1b}c done";
1377 let pager = PagerView::from_text("Agent transcript", hostile, 200);
1378 let body = pager.body_text();
1379 assert!(
1380 !body.contains('\u{1b}'),
1381 "no ESC byte may survive into the pager body: {body:?}"
1382 );
1383 assert!(
1384 !body.contains('\u{7}'),
1385 "no BEL byte may survive into the pager body: {body:?}"
1386 );
1387 for fragment in ["?1003h", "?1006h", "<65;72;17M", "<35;131;42M", "]8;;"] {
1388 assert!(
1389 !body.contains(fragment),
1390 "escape fragment {fragment:?} must be stripped, not painted: {body:?}"
1391 );
1392 }
1393 assert!(body.contains("red text"), "visible text survives: {body:?}");
1394 assert!(body.contains("OSC link"), "link label survives: {body:?}");
1395 assert!(body.contains("done"), "trailing text survives: {body:?}");
1396 }
1397
1398 #[test]
1399 fn from_text_sanitizes_jsonl_transcript_shaped_content() {
1400 // Regression for the sub-agent transcript pager corrupting the parent
1401 // terminal: content shaped like the raw artifact lines, with embedded
1402 // mouse/CSI sequences inside a tool result, must render inert.
1403 let transcript_like = concat!(
1404 "── assistant ──\n",
1405 "I'll run the build now.\n",
1406 "← tool result (call-1)\n",
1407 // JSON-escaped text is literal backslash-u bytes — inert, kept as-is.
1408 "{\"line\":\"\\u{1b}[<0;9;4Mprogress done\"}\n",
1409 // Raw event bytes are the dangerous form and must be stripped.
1410 "raw \u{1b}[<0;10;5M event bytes\u{1b}[2K after\n",
1411 );
1412 let pager = PagerView::from_text("Agent transcript", transcript_like, 200);
1413 let body = pager.body_text();
1414 assert!(!body.contains('\u{1b}'), "inert body required: {body:?}");
1415 assert!(
1416 !body.contains("<0;10;5M"),
1417 "mouse event fragment must not render: {body:?}"
1418 );
1419 assert!(
1420 body.contains("progress") && body.contains("after"),
1421 "readable content survives: {body:?}"
1422 );
1423 }
1424
1425 #[test]
1426 fn y_emits_copy_event_for_vim_users() {
1427 let mut p = make_pager(3);
1428 let action = p.handle_key(key(KeyCode::Char('y')));
1429 assert!(
1430 matches!(action, ViewAction::Emit(ViewEvent::CopyToClipboard { .. })),
1431 "y must emit a copy event for vim-yank parity"
1432 );
1433 }
1434
1435 #[test]
1436 fn e_exports_turn_handoff_when_attached() {
1437 // #4108: the Turn Inspector pager carries a compact Markdown handoff;
1438 // `e` copies that artifact (not the visible inspector body) to the
1439 // clipboard via the host dispatcher.
1440 let mut p = make_pager(3).with_export_markdown("# Turn handoff\n\n## Intent\ndo the thing");
1441 let action = p.handle_key(key(KeyCode::Char('e')));
1442 match action {
1443 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
1444 assert!(text.contains("# Turn handoff"), "handoff text: {text}");
1445 assert_eq!(label, "Turn handoff");
1446 }
1447 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1448 }
1449 }
1450
1451 #[test]
1452 fn e_is_inert_without_an_attached_handoff() {
1453 // Every other pager leaves `e` unbound so it never surprises the user.
1454 let mut p = make_pager(3);
1455 assert!(matches!(
1456 p.handle_key(key(KeyCode::Char('e'))),
1457 ViewAction::None
1458 ));
1459 }
1460
1461 #[test]
1462 fn copy_keys_inert_in_search_mode() {
1463 // Within `/`-search mode `c` and `y` must be treated as search
1464 // characters, not as a copy trigger — otherwise users typing a
1465 // query that contains either letter would lose their input.
1466 let mut p = make_pager(10);
1467 let _ = p.handle_key(key(KeyCode::Char('/')));
1468 assert!(p.search_mode);
1469 let action = p.handle_key(key(KeyCode::Char('c')));
1470 assert!(matches!(action, ViewAction::None));
1471 assert_eq!(p.search_input, "c");
1472 }
1473
1474 #[test]
1475 fn footer_hint_is_rendered_in_buffer() {
1476 let p = make_pager(5);
1477 let area = Rect::new(0, 0, 100, 10);
1478 let mut buf = Buffer::empty(area);
1479 p.render(area, &mut buf);
1480 // The footer is now anchored to the bottom of the modal body (above the
1481 // padding/border) rather than painted on the border, so scan the whole
1482 // frame for the action labels.
1483 let mut text = String::new();
1484 for y in 0..area.height {
1485 for x in 0..area.width {
1486 text.push_str(buf[(x, y)].symbol());
1487 }
1488 text.push('\n');
1489 }
1490 assert!(
1491 text.contains("close") || text.contains("scroll"),
1492 "expected footer hint in rendered pager, got:\n{text}"
1493 );
1494 }
1495
1496 /// `/` opens the search prompt; typing chars accumulates them; Enter
1497 /// commits and jumps to the first match. The matches index/count line
1498 /// must surface in the rendered buffer afterwards.
1499 #[test]
1500 fn search_finds_matches_and_renders_match_counter() {
1501 let mut p = make_pager(20);
1502 prime_layout(&mut p, 16);
1503
1504 // Open search.
1505 let _ = p.handle_key(key(KeyCode::Char('/')));
1506 // Type "5" to match line-005, line-015 (any line whose number contains
1507 // a 5 — make_pager produced "line-NNN" with three-digit indices).
1508 for ch in "5".chars() {
1509 let _ = p.handle_key(key(KeyCode::Char(ch)));
1510 }
1511 // Commit.
1512 let _ = p.handle_key(key(KeyCode::Enter));
1513
1514 // Render and look for the "match X/Y" status line.
1515 let area = Rect::new(0, 0, 60, 16);
1516 let mut buf = Buffer::empty(area);
1517 p.render(area, &mut buf);
1518 let mut full = String::new();
1519 for y in 0..area.height {
1520 for x in 0..area.width {
1521 full.push_str(buf[(x, y)].symbol());
1522 }
1523 full.push('\n');
1524 }
1525 assert!(
1526 full.contains("match 1/2") || full.contains("match 1/3"),
1527 "expected match counter; got buffer:\n{full}"
1528 );
1529 }
1530
1531 /// Esc while in search mode bails out AND clears the highlighted matches
1532 /// so the un-highlighted view returns. (Codex parity.)
1533 #[test]
1534 fn esc_in_search_mode_clears_matches() {
1535 let mut p = make_pager(20);
1536 prime_layout(&mut p, 16);
1537
1538 let _ = p.handle_key(key(KeyCode::Char('/')));
1539 let _ = p.handle_key(key(KeyCode::Char('5')));
1540 let _ = p.handle_key(key(KeyCode::Enter));
1541 assert!(!p.search_matches.is_empty());
1542
1543 // Re-enter search mode and Esc out — matches must clear.
1544 let _ = p.handle_key(key(KeyCode::Char('/')));
1545 let _ = p.handle_key(key(KeyCode::Esc));
1546 assert!(p.search_matches.is_empty());
1547 assert_eq!(p.search_input, "");
1548 assert!(!p.search_mode);
1549 }
1550
1551 /// `n` and `N` cycle forward and backward through matches, wrapping at
1552 /// the ends without panicking on out-of-bounds index.
1553 #[test]
1554 fn n_and_capital_n_cycle_matches_with_wrap() {
1555 let mut p = make_pager(50);
1556 prime_layout(&mut p, 16);
1557
1558 // Search "1" — matches every line whose printed index contains a 1.
1559 let _ = p.handle_key(key(KeyCode::Char('/')));
1560 let _ = p.handle_key(key(KeyCode::Char('1')));
1561 let _ = p.handle_key(key(KeyCode::Enter));
1562 let total = p.search_matches.len();
1563 assert!(total > 1, "test needs multiple matches, got {total}");
1564
1565 let start = p.search_index;
1566 let _ = p.handle_key(key(KeyCode::Char('n')));
1567 assert_eq!(p.search_index, (start + 1) % total);
1568 let _ = p.handle_key(key(KeyCode::Char('N')));
1569 assert_eq!(p.search_index, start);
1570
1571 // Wrap backwards from 0 → last.
1572 let _ = p.handle_key(key(KeyCode::Char('N')));
1573 assert_eq!(p.search_index, total - 1);
1574 let _ = p.handle_key(key(KeyCode::Char('n')));
1575 assert_eq!(p.search_index, 0);
1576 }
1577
1578 /// While search matches exist and the prompt is closed, the matched
1579 /// lines are visually distinguished in the rendered buffer by their
1580 /// background color. We sample directly across the matched-line text
1581 /// columns rather than the whole row width because Paragraph leaves
1582 /// the trailing-area cells at the default background.
1583 #[test]
1584 fn matched_lines_get_highlight_background() {
1585 let mut p = make_pager(20);
1586 prime_layout(&mut p, 16);
1587
1588 let _ = p.handle_key(key(KeyCode::Char('/')));
1589 let _ = p.handle_key(key(KeyCode::Char('5')));
1590 let _ = p.handle_key(key(KeyCode::Enter));
1591 assert!(!p.search_matches.is_empty());
1592
1593 let area = Rect::new(0, 0, 40, 16);
1594 let mut buf = Buffer::empty(area);
1595 p.render(area, &mut buf);
1596
1597 // Find the actual painted match: shared compact layout may move it.
1598 let row = (0..area.height)
1599 .find(|&y| {
1600 (0..area.width)
1601 .map(|x| buf[(x, y)].symbol())
1602 .collect::<String>()
1603 .contains("line-005")
1604 })
1605 .expect("matched text must be visible");
1606 let highlighted = (0..area.width)
1607 .filter(|&x| buf[(x, row)].style().bg == Some(Color::Yellow))
1608 .collect::<Vec<_>>();
1609 assert_eq!(highlighted.len(), "line-005".len());
1610 for x in highlighted {
1611 assert_eq!(buf[(x, row)].style().fg, Some(Color::Black));
1612 }
1613 }
1614
1615 #[test]
1616 fn mouse_scroll_up_scrolls_content() {
1617 let mut p = make_pager(50);
1618 p.scroll = 10;
1619 let action = p.handle_mouse(MouseEvent {
1620 kind: MouseEventKind::ScrollUp,
1621 column: 0,
1622 row: 0,
1623 modifiers: KeyModifiers::NONE,
1624 });
1625
1626 assert_eq!(p.scroll, 7);
1627 assert!(matches!(action, ViewAction::None));
1628 }
1629
1630 #[test]
1631 fn mouse_scroll_down_scrolls_content() {
1632 let mut p = make_pager(50);
1633 prime_layout(&mut p, 20);
1634 p.scroll = 10;
1635 let action = p.handle_mouse(MouseEvent {
1636 kind: MouseEventKind::ScrollDown,
1637 column: 0,
1638 row: 0,
1639 modifiers: KeyModifiers::NONE,
1640 });
1641
1642 assert_eq!(p.scroll, 13);
1643 assert!(matches!(action, ViewAction::None));
1644 }
1645
1646 #[test]
1647 fn mouse_scroll_down_clamps_to_pager_bottom() {
1648 let mut p = make_pager(50);
1649 prime_layout(&mut p, 20);
1650 let bottom = p.max_scroll();
1651
1652 for _ in 0..100 {
1653 let _ = p.handle_mouse(MouseEvent {
1654 kind: MouseEventKind::ScrollDown,
1655 column: 0,
1656 row: 0,
1657 modifiers: KeyModifiers::NONE,
1658 });
1659 }
1660
1661 assert_eq!(p.scroll, bottom);
1662 }
1663
1664 #[test]
1665 fn pager_is_usable_and_opaque_at_blocker_sizes() {
1666 use crate::tui::views::ViewStack;
1667
1668 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
1669 for (w, h) in BLOCKER_SIZES {
1670 let area = Rect::new(0, 0, w, h);
1671 let mut buf = Buffer::empty(area);
1672 for y in 0..h {
1673 for x in 0..w {
1674 buf[(x, y)].set_symbol("X");
1675 }
1676 }
1677 let mut stack = ViewStack::new();
1678 stack.push(make_pager(60));
1679 stack.render(area, &mut buf);
1680
1681 let rows: Vec<String> = (0..h)
1682 .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
1683 .collect();
1684 let text = rows.join("\n");
1685
1686 // Footer keeps every action.
1687 for label in [
1688 "close",
1689 "scroll",
1690 "page",
1691 "half",
1692 "top/bottom",
1693 "search",
1694 "copy",
1695 ] {
1696 assert!(text.contains(label), "{w}x{h}: footer missing '{label}'");
1697 }
1698
1699 // Composited frame is fully opaque.
1700 assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
1701 assert_eq!(
1702 buf[(w / 2, h / 2)].bg,
1703 palette::WHALE_BG,
1704 "{w}x{h}: modal interior must be opaque"
1705 );
1706
1707 // No horizontal overflow.
1708 for (y, row) in rows.iter().enumerate() {
1709 assert!(
1710 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1711 "{w}x{h}: row {y} overflows width: {row:?}"
1712 );
1713 }
1714 }
1715 }
1716
1717 #[test]
1718 fn wrap_text_breaks_overlong_cjk_runs() {
1719 let text = "这是一个非常长的中文字符串".repeat(10);
1720 let lines = wrap_text(&text, 16);
1721
1722 for line in &lines {
1723 assert!(line.width() <= 16, "line {line:?} exceeds width 16");
1724 }
1725
1726 assert_eq!(lines.join(""), text);
1727 }
1728 }
1729
1729 lines RUST