返回 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 //! - `q` / Esc — close pager
15
16 use std::cell::Cell;
17
18 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
19 use ratatui::{
20 buffer::Buffer,
21 layout::Rect,
22 style::{Color, Modifier, Style},
23 text::{Line, Span},
24 widgets::{Paragraph, Widget, Wrap},
25 };
26 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
27
28 use crate::palette;
29 use crate::tui::views::{
30 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
31 render_panel_scroll_rail, render_underwater_surface,
32 };
33
34 #[derive(Debug, Clone)]
35 struct PagerDestructiveAction {
36 key: char,
37 label: String,
38 confirm_label: String,
39 event: ViewEvent,
40 armed: bool,
41 }
42
43 pub struct PagerView {
44 title: String,
45 lines: Vec<Line<'static>>,
46 plain_lines: Vec<String>,
47 scroll: usize,
48 search_input: String,
49 search_matches: Vec<usize>,
50 search_index: usize,
51 search_mode: bool,
52 pending_g: bool,
53 /// Cached visible content height from the last render. Used by paging
54 /// keys (Ctrl+D/U, Ctrl+F/B, Space, etc.) to compute scroll deltas
55 /// without access to the render area.
56 last_visible_height: Cell<usize>,
57 /// Optional compact Markdown artifact surfaced by the `e` key. Set for the
58 /// Turn Inspector pager (#4108) so `e` copies a pasteable turn handoff;
59 /// `None` for every other pager, where `e` stays inert.
60 export_markdown: Option<String>,
61 /// Optional source-faithful clipboard payload. Display wrapping is a view
62 /// concern and may insert line breaks or normalize whitespace; Turn
63 /// Inspector copy must retain the assembled text exactly (#4482).
64 copy_text: Option<String>,
65 /// Optional inspector-owned destructive action. It requires two presses
66 /// (or key then Enter); Esc disarms before it closes the pager.
67 destructive_action: Option<PagerDestructiveAction>,
68 }
69
70 impl PagerView {
71 pub fn new(title: impl Into<String>, lines: Vec<Line<'static>>) -> Self {
72 let plain_lines = lines.iter().map(line_to_string).collect();
73 Self {
74 title: title.into(),
75 lines,
76 plain_lines,
77 scroll: 0,
78 search_input: String::new(),
79 search_matches: Vec::new(),
80 search_index: 0,
81 search_mode: false,
82 pending_g: false,
83 last_visible_height: Cell::new(0),
84 export_markdown: None,
85 copy_text: None,
86 destructive_action: None,
87 }
88 }
89
90 /// Attach a compact Markdown export (e.g. the #4108 turn handoff) that the
91 /// `e` key copies to the clipboard. Only the Turn Inspector pager sets this;
92 /// other pagers leave `e` inert.
93 pub fn with_export_markdown(mut self, markdown: impl Into<String>) -> Self {
94 self.export_markdown = Some(markdown.into());
95 self
96 }
97
98 /// Preserve a source-faithful payload for `c` / `y` while the rendered
99 /// pager remains free to wrap content to its viewport.
100 pub fn with_copy_text(mut self, text: impl Into<String>) -> Self {
101 self.copy_text = Some(text.into());
102 self
103 }
104
105 /// Attach a two-step destructive action to this pager. Work Graph
106 /// inspectors use this to keep Stop inside the detail surface while
107 /// reusing the existing command/agent cancellation events.
108 pub fn with_destructive_action(
109 mut self,
110 key: char,
111 label: impl Into<String>,
112 confirm_label: impl Into<String>,
113 event: ViewEvent,
114 ) -> Self {
115 self.destructive_action = Some(PagerDestructiveAction {
116 key,
117 label: label.into(),
118 confirm_label: confirm_label.into(),
119 event,
120 armed: false,
121 });
122 self
123 }
124
125 pub fn from_text(title: impl Into<String>, text: &str, width: u16) -> Self {
126 // Pager bodies frequently carry tool output or worker transcripts
127 // (e.g. the sub-agent chat pager re-reads the raw JSONL artifact from
128 // disk). Any CSI/OSC bytes in that content are emitted verbatim by
129 // the terminal backend, which can re-enable mouse tracking or leave
130 // the user's shell executing fragments after the TUI exits. Strip
131 // every escape sequence and stray control byte at this chokepoint so
132 // no pager surface can inject terminal state.
133 let mut sanitized = String::with_capacity(text.len());
134 crate::tui::osc8::strip_ansi_into(text, &mut sanitized);
135 let mut lines = Vec::new();
136 for raw in sanitized.lines() {
137 for wrapped in wrap_text(raw, width.max(1) as usize) {
138 lines.push(Line::from(Span::raw(wrapped)));
139 }
140 }
141 Self::new(title, lines)
142 }
143
144 fn scroll_up(&mut self, amount: usize) {
145 self.scroll = self.scroll.saturating_sub(amount);
146 }
147
148 fn scroll_down(&mut self, amount: usize, max_scroll: usize) {
149 self.scroll = (self.scroll + amount).min(max_scroll);
150 }
151
152 fn scroll_to_top(&mut self) {
153 self.scroll = 0;
154 }
155
156 fn scroll_to_bottom(&mut self, max_scroll: usize) {
157 self.scroll = max_scroll;
158 }
159
160 /// Plain-text rendered body of the pager joined with `\n`. This reflects
161 /// width-based display wrapping. Clipboard events use this by default;
162 /// pagers with a source-faithful override use that payload instead.
163 pub fn body_text(&self) -> String {
164 self.plain_lines.join("\n")
165 }
166
167 fn clipboard_text(&self) -> String {
168 self.copy_text.clone().unwrap_or_else(|| self.body_text())
169 }
170
171 /// The pager's title bar text. Used by tests to assert the raw-detail
172 /// pager is framed at leaf scope (#4105).
173 #[cfg(test)]
174 pub(crate) fn title(&self) -> &str {
175 &self.title
176 }
177
178 /// Return the page height (in lines) used for paging keys.
179 ///
180 /// Falls back to a small constant (10) before the first render so the
181 /// pager still responds to paging keys when invoked synthetically (e.g.
182 /// in unit tests). After the first render, the cached value reflects
183 /// the actual visible content area.
184 fn page_height(&self) -> usize {
185 let cached = self.last_visible_height.get();
186 if cached == 0 { 10 } else { cached }
187 }
188
189 /// Half a page, rounded up so a single press always moves at least one line.
190 fn half_page_height(&self) -> usize {
191 let page = self.page_height();
192 page.div_ceil(2).max(1)
193 }
194
195 fn max_scroll(&self) -> usize {
196 // Match the render-side clamp so G/End land at the visible bottom and
197 // k/Up immediately scroll back up by one line.
198 self.lines.len().saturating_sub(self.page_height())
199 }
200
201 fn start_search(&mut self) {
202 self.search_mode = true;
203 self.search_input.clear();
204 self.search_matches.clear();
205 self.search_index = 0;
206 }
207
208 fn update_search_matches(&mut self) {
209 let query = self.search_input.trim();
210 if query.is_empty() {
211 self.search_matches.clear();
212 self.search_index = 0;
213 return;
214 }
215 let lower = query.to_ascii_lowercase();
216 self.search_matches = self
217 .plain_lines
218 .iter()
219 .enumerate()
220 .filter_map(|(idx, line)| {
221 if line.to_ascii_lowercase().contains(&lower) {
222 Some(idx)
223 } else {
224 None
225 }
226 })
227 .collect();
228 self.search_index = 0;
229 }
230
231 fn jump_to_match(&mut self) {
232 if let Some(&line) = self.search_matches.get(self.search_index) {
233 self.scroll = line;
234 }
235 }
236
237 fn next_match(&mut self) {
238 if self.search_matches.is_empty() {
239 return;
240 }
241 self.search_index = (self.search_index + 1) % self.search_matches.len();
242 self.jump_to_match();
243 }
244
245 fn prev_match(&mut self) {
246 if self.search_matches.is_empty() {
247 return;
248 }
249 if self.search_index == 0 {
250 self.search_index = self.search_matches.len().saturating_sub(1);
251 } else {
252 self.search_index = self.search_index.saturating_sub(1);
253 }
254 self.jump_to_match();
255 }
256 }
257
258 impl ModalView for PagerView {
259 fn kind(&self) -> ModalKind {
260 ModalKind::Pager
261 }
262
263 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
264 self
265 }
266
267 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
268 if self.search_mode {
269 match key.code {
270 KeyCode::Enter => {
271 self.search_mode = false;
272 self.update_search_matches();
273 self.jump_to_match();
274 return ViewAction::None;
275 }
276 KeyCode::Esc => {
277 // Bail out of search mode AND drop the current match list
278 // so the user gets back to the un-highlighted view —
279 // codex-style behavior. To resume from where they left
280 // off they re-enter `/` and re-type.
281 self.search_mode = false;
282 self.search_input.clear();
283 self.search_matches.clear();
284 self.search_index = 0;
285 return ViewAction::None;
286 }
287 KeyCode::Backspace => {
288 self.search_input.pop();
289 return ViewAction::None;
290 }
291 // Ctrl+H is the legacy ASCII backspace many terminals emit.
292 KeyCode::Char('h')
293 if key.modifiers.contains(KeyModifiers::CONTROL)
294 && !key.modifiers.contains(KeyModifiers::ALT) =>
295 {
296 self.search_input.pop();
297 return ViewAction::None;
298 }
299 KeyCode::Char(c) => {
300 self.search_input.push(c);
301 return ViewAction::None;
302 }
303 // All other keys (Up/Down, PageUp/PageDown, etc.) are captured
304 // in search mode so they don't fall through to the pager body.
305 _ => return ViewAction::None,
306 }
307 }
308
309 if let Some(action) = self.destructive_action.as_mut() {
310 if key.code == KeyCode::Esc && action.armed {
311 action.armed = false;
312 self.pending_g = false;
313 return ViewAction::None;
314 }
315 let matching_key =
316 matches!(key.code, KeyCode::Char(ch) if ch.eq_ignore_ascii_case(&action.key));
317 if matching_key || (key.code == KeyCode::Enter && action.armed) {
318 self.pending_g = false;
319 if action.armed {
320 return ViewAction::EmitAndClose(action.event.clone());
321 }
322 action.armed = true;
323 return ViewAction::None;
324 }
325 }
326
327 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
328 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
329 let max_scroll = self.max_scroll();
330
331 // Ctrl+chord paging keys are matched first because their KeyCode
332 // also matches the bare `KeyCode::Char(c)` arms below.
333 if ctrl {
334 match key.code {
335 KeyCode::Char('d') | KeyCode::Char('D') => {
336 self.scroll_down(self.half_page_height(), max_scroll);
337 self.pending_g = false;
338 return ViewAction::None;
339 }
340 KeyCode::Char('u') | KeyCode::Char('U') => {
341 self.scroll_up(self.half_page_height());
342 self.pending_g = false;
343 return ViewAction::None;
344 }
345 KeyCode::Char('f') | KeyCode::Char('F') => {
346 self.scroll_down(self.page_height(), max_scroll);
347 self.pending_g = false;
348 return ViewAction::None;
349 }
350 KeyCode::Char('b') | KeyCode::Char('B') => {
351 self.scroll_up(self.page_height());
352 self.pending_g = false;
353 return ViewAction::None;
354 }
355 _ => {}
356 }
357 }
358
359 match key.code {
360 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
361 KeyCode::Up | KeyCode::Char('k') => {
362 self.scroll_up(1);
363 self.pending_g = false;
364 ViewAction::None
365 }
366 KeyCode::Down | KeyCode::Char('j') => {
367 self.scroll_down(1, max_scroll);
368 self.pending_g = false;
369 ViewAction::None
370 }
371 KeyCode::PageUp => {
372 self.scroll_up(self.page_height());
373 self.pending_g = false;
374 ViewAction::None
375 }
376 KeyCode::PageDown => {
377 self.scroll_down(self.page_height(), max_scroll);
378 self.pending_g = false;
379 ViewAction::None
380 }
381 // Vim convention: Space pages down, Shift+Space pages up. Match
382 // Shift+Space first so it is not absorbed by the bare ' ' arm.
383 KeyCode::Char(' ') if shift => {
384 self.scroll_up(self.page_height());
385 self.pending_g = false;
386 ViewAction::None
387 }
388 KeyCode::Char(' ') => {
389 self.scroll_down(self.page_height(), max_scroll);
390 self.pending_g = false;
391 ViewAction::None
392 }
393 KeyCode::Home => {
394 self.scroll_to_top();
395 self.pending_g = false;
396 ViewAction::None
397 }
398 KeyCode::End => {
399 self.scroll_to_bottom(max_scroll);
400 self.pending_g = false;
401 ViewAction::None
402 }
403 KeyCode::Char('g') => {
404 if self.pending_g {
405 self.scroll_to_top();
406 self.pending_g = false;
407 } else {
408 self.pending_g = true;
409 }
410 ViewAction::None
411 }
412 KeyCode::Char('G') => {
413 self.scroll_to_bottom(max_scroll);
414 self.pending_g = false;
415 ViewAction::None
416 }
417 KeyCode::Char('/') => {
418 self.start_search();
419 self.pending_g = false;
420 ViewAction::None
421 }
422 KeyCode::Char('n') => {
423 self.next_match();
424 self.pending_g = false;
425 ViewAction::None
426 }
427 KeyCode::Char('N') => {
428 self.prev_match();
429 self.pending_g = false;
430 ViewAction::None
431 }
432 // Copy the entire pager body to the clipboard. The pager
433 // intercepts mouse capture so terminal-native selection is
434 // disabled inside it; without this binding users with no
435 // out-of-band copy path would have no way to extract content
436 // they can see (#1354). Both `c` and `y` are wired so users
437 // landing from either OS-clipboard or vim convention find a
438 // working key.
439 KeyCode::Char('c') | KeyCode::Char('y') => {
440 self.pending_g = false;
441 ViewAction::Emit(ViewEvent::CopyToClipboard {
442 text: self.clipboard_text(),
443 label: "Pager content".to_string(),
444 })
445 }
446 // `e` exports the compact turn handoff (#4108) when this pager
447 // carries one — the Turn Inspector. Elsewhere the guard fails and
448 // `e` falls through to the inert arm below.
449 KeyCode::Char('e') | KeyCode::Char('E') if self.export_markdown.is_some() => {
450 self.pending_g = false;
451 let text = self.export_markdown.clone().unwrap_or_default();
452 ViewAction::Emit(ViewEvent::CopyToClipboard {
453 text,
454 label: "Turn handoff".to_string(),
455 })
456 }
457 _ => ViewAction::None,
458 }
459 }
460
461 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
462 match mouse.kind {
463 MouseEventKind::ScrollUp => {
464 self.scroll_up(3);
465 self.pending_g = false;
466 ViewAction::None
467 }
468 MouseEventKind::ScrollDown => {
469 self.scroll_down(3, self.max_scroll());
470 self.pending_g = false;
471 ViewAction::None
472 }
473 _ => ViewAction::None,
474 }
475 }
476
477 fn render(&self, area: Rect, buf: &mut Buffer) {
478 let inner = render_underwater_surface(area, buf, self.title.clone());
479
480 // The wrapping action footer is anchored to the bottom of the inner
481 // area; the body fills the rows above it.
482 let mut hints = vec![
483 ActionHint::new("q/Esc", "close"),
484 ActionHint::new("j/k", "scroll"),
485 ActionHint::new("Space", "page"),
486 ActionHint::new("Ctrl+D/U", "half"),
487 ActionHint::new("g/G", "top/bottom"),
488 ActionHint::new("/", "search"),
489 ActionHint::new("c", "copy"),
490 ];
491 if self.export_markdown.is_some() {
492 hints.push(ActionHint::new("e", "copy handoff"));
493 }
494 if let Some(action) = self.destructive_action.as_ref() {
495 let key = action.key.to_string();
496 let label = if action.armed {
497 action.confirm_label.clone()
498 } else {
499 action.label.clone()
500 };
501 hints.push(ActionHint::new(key, label));
502 }
503 let content = render_modal_footer(inner, buf, &hints);
504
505 // `content` already excludes the border, padding, and footer rows.
506 let mut visible_height = content.height as usize;
507 if self.search_mode {
508 // Reserve a row for the search prompt that gets pushed below.
509 visible_height = visible_height.saturating_sub(1);
510 } else if !self.search_matches.is_empty() {
511 // Reserve a row for the "match X/Y (n/N)" status; without this
512 // the status line gets clipped on small popup heights and the
513 // user can't see how many matches there are.
514 visible_height = visible_height.saturating_sub(1);
515 }
516 // Cache for paging keys; the value is treated as advisory and
517 // clamped at use-time.
518 self.last_visible_height.set(visible_height);
519 let max_scroll = self.lines.len().saturating_sub(visible_height);
520 let scroll = self.scroll.min(max_scroll);
521 let end = (scroll + visible_height).min(self.lines.len());
522 let mut visible_lines = if self.lines.is_empty() {
523 vec![Line::from("")]
524 } else {
525 self.lines[scroll..end].to_vec()
526 };
527
528 // Highlight matched lines while the search prompt is closed and the
529 // user is navigating with `n` / `N`. Other matches get a subtle
530 // background; the current match gets a louder one. Per-substring
531 // highlighting is deferred to a follow-up — preserving the pre-styled
532 // spans (assistant / system colors) through a substring re-style is
533 // a separate concern.
534 if !self.search_mode && !self.search_matches.is_empty() {
535 let current_match_line = self.search_matches.get(self.search_index).copied();
536 for (visible_idx, line) in visible_lines.iter_mut().enumerate() {
537 let absolute_idx = scroll + visible_idx;
538 if absolute_idx >= self.lines.len() {
539 break;
540 }
541 if !self.search_matches.contains(&absolute_idx) {
542 continue;
543 }
544 let is_current = current_match_line == Some(absolute_idx);
545 let bg = if is_current {
546 Color::Yellow
547 } else {
548 Color::DarkGray
549 };
550 let fg = if is_current {
551 Color::Reset
552 } else {
553 Color::Yellow
554 };
555 let highlight = Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD);
556 for span in line.spans.iter_mut() {
557 span.style = highlight;
558 }
559 }
560 }
561
562 if self.search_mode {
563 let prompt = format!("/{}", self.search_input);
564 visible_lines.push(Line::from(Span::styled(
565 prompt,
566 Style::default()
567 .fg(palette::WHALE_INFO)
568 .add_modifier(Modifier::BOLD),
569 )));
570 } else if !self.search_matches.is_empty() {
571 let status = format!(
572 "match {}/{} (n/N)",
573 self.search_index + 1,
574 self.search_matches.len()
575 );
576 visible_lines.push(Line::from(Span::styled(
577 status,
578 Style::default().fg(palette::TEXT_MUTED),
579 )));
580 }
581
582 let content =
583 render_panel_scroll_rail(content, buf, self.lines.len(), scroll, visible_height, true);
584 let paragraph = Paragraph::new(visible_lines).wrap(Wrap { trim: false });
585 paragraph.render(content, buf);
586 }
587 }
588
589 fn line_to_string(line: &Line<'static>) -> String {
590 line.spans
591 .iter()
592 .map(|span| span.content.to_string())
593 .collect::<String>()
594 }
595
596 fn wrap_text(text: &str, width: usize) -> Vec<String> {
597 if width == 0 {
598 return vec![text.to_string()];
599 }
600 let mut lines = Vec::new();
601 let mut current = String::new();
602 let mut current_width = 0usize;
603
604 for word in text.split_whitespace() {
605 let word_width = word.width();
606 if word_width > width {
607 if !current.is_empty() {
608 lines.push(std::mem::take(&mut current));
609 current_width = 0;
610 }
611 push_word_breaking_chars(word, width, &mut current, &mut current_width, &mut lines);
612 continue;
613 }
614 let additional = if current.is_empty() {
615 word_width
616 } else {
617 word_width + 1
618 };
619 if current_width + additional > width && !current.is_empty() {
620 lines.push(current);
621 current = word.to_string();
622 current_width = word_width;
623 } else {
624 if !current.is_empty() {
625 current.push(' ');
626 current_width += 1;
627 }
628 current.push_str(word);
629 current_width += word_width;
630 }
631 }
632
633 if current.is_empty() {
634 lines.push(String::new());
635 } else {
636 lines.push(current);
637 }
638
639 lines
640 }
641
642 fn push_word_breaking_chars(
643 word: &str,
644 width: usize,
645 current: &mut String,
646 current_width: &mut usize,
647 lines: &mut Vec<String>,
648 ) {
649 for ch in word.chars() {
650 let char_width = ch.width().unwrap_or(1);
651 if *current_width + char_width > width && *current_width > 0 {
652 lines.push(std::mem::take(current));
653 *current_width = 0;
654 }
655 current.push(ch);
656 *current_width += char_width;
657 }
658 }
659
660 #[cfg(test)]
661 mod tests {
662 use super::*;
663 use ratatui::text::Line;
664
665 fn make_pager(lines: usize) -> PagerView {
666 let lines: Vec<Line<'static>> = (0..lines)
667 .map(|i| Line::from(format!("line-{i:03}")))
668 .collect();
669 PagerView::new("T", lines)
670 }
671
672 fn key(code: KeyCode) -> KeyEvent {
673 KeyEvent::new(code, KeyModifiers::NONE)
674 }
675
676 fn key_mod(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
677 KeyEvent::new(code, mods)
678 }
679
680 fn ctrl(code: KeyCode) -> KeyEvent {
681 KeyEvent::new(code, KeyModifiers::CONTROL)
682 }
683
684 #[test]
685 fn destructive_action_requires_two_steps_and_escape_only_disarms() {
686 let mut pager = make_pager(2).with_destructive_action(
687 's',
688 "stop",
689 "confirm stop · Esc cancels",
690 ViewEvent::SidebarAgentCancel {
691 agent_id: "agent_1".to_string(),
692 },
693 );
694
695 assert!(matches!(
696 pager.handle_key(key(KeyCode::Char('s'))),
697 ViewAction::None
698 ));
699 assert!(matches!(
700 pager.handle_key(key(KeyCode::Esc)),
701 ViewAction::None
702 ));
703 assert!(matches!(
704 pager.handle_key(key(KeyCode::Esc)),
705 ViewAction::Close
706 ));
707
708 let _ = pager.handle_key(key(KeyCode::Char('s')));
709 assert!(matches!(
710 pager.handle_key(key(KeyCode::Enter)),
711 ViewAction::EmitAndClose(ViewEvent::SidebarAgentCancel { agent_id })
712 if agent_id == "agent_1"
713 ));
714 }
715
716 /// Drive a render once so `last_visible_height` is populated and paging
717 /// keys use a deterministic page size.
718 fn prime_layout(view: &mut PagerView, height: u16) {
719 let area = Rect::new(0, 0, 40, height);
720 let mut buf = Buffer::empty(area);
721 view.render(area, &mut buf);
722 }
723
724 #[test]
725 fn j_scrolls_down_one_line() {
726 let mut p = make_pager(50);
727 let _ = p.handle_key(key(KeyCode::Char('j')));
728 assert_eq!(p.scroll, 1);
729 }
730
731 #[test]
732 fn k_scrolls_up_one_line() {
733 let mut p = make_pager(50);
734 p.scroll = 5;
735 let _ = p.handle_key(key(KeyCode::Char('k')));
736 assert_eq!(p.scroll, 4);
737 }
738
739 #[test]
740 fn gg_jumps_to_top() {
741 let mut p = make_pager(50);
742 p.scroll = 30;
743 let _ = p.handle_key(key(KeyCode::Char('g')));
744 assert!(p.pending_g, "first 'g' should arm pending_g");
745 assert_eq!(p.scroll, 30, "first 'g' alone must not scroll");
746 let _ = p.handle_key(key(KeyCode::Char('g')));
747 assert_eq!(p.scroll, 0);
748 assert!(!p.pending_g);
749 }
750
751 #[test]
752 fn home_jumps_to_top() {
753 let mut p = make_pager(50);
754 p.scroll = 30;
755 let _ = p.handle_key(key(KeyCode::Home));
756 assert_eq!(p.scroll, 0);
757 }
758
759 #[test]
760 fn shift_g_jumps_to_bottom() {
761 let mut p = make_pager(50);
762 let _ = p.handle_key(key(KeyCode::Char('G')));
763 assert_eq!(p.scroll, p.max_scroll());
764 }
765
766 #[test]
767 fn end_jumps_to_bottom() {
768 let mut p = make_pager(50);
769 let _ = p.handle_key(key(KeyCode::End));
770 assert_eq!(p.scroll, p.max_scroll());
771 }
772
773 #[test]
774 fn up_immediately_scrolls_after_shift_g_to_bottom() {
775 let mut p = make_pager(50);
776 prime_layout(&mut p, 22);
777 let bottom = p.max_scroll();
778
779 let _ = p.handle_key(key(KeyCode::Char('G')));
780 assert_eq!(p.scroll, bottom);
781 let _ = p.handle_key(key(KeyCode::Up));
782 assert_eq!(p.scroll, bottom - 1);
783 let _ = p.handle_key(key(KeyCode::Char('k')));
784 assert_eq!(p.scroll, bottom - 2);
785 }
786
787 #[test]
788 fn k_immediately_scrolls_after_end_to_bottom() {
789 let mut p = make_pager(50);
790 prime_layout(&mut p, 22);
791 let bottom = p.max_scroll();
792
793 let _ = p.handle_key(key(KeyCode::End));
794 assert_eq!(p.scroll, bottom);
795 let _ = p.handle_key(key(KeyCode::Char('k')));
796 assert_eq!(p.scroll, bottom - 1);
797 }
798
799 #[test]
800 fn ctrl_d_half_page_down() {
801 let mut p = make_pager(200);
802 prime_layout(&mut p, 22);
803 let half = p.half_page_height();
804 assert!(half >= 1, "half-page must move at least one line");
805 let _ = p.handle_key(ctrl(KeyCode::Char('d')));
806 assert_eq!(p.scroll, half);
807 }
808
809 #[test]
810 fn ctrl_u_half_page_up() {
811 let mut p = make_pager(200);
812 prime_layout(&mut p, 22);
813 p.scroll = 50;
814 let half = p.half_page_height();
815 let _ = p.handle_key(ctrl(KeyCode::Char('u')));
816 assert_eq!(p.scroll, 50 - half);
817 }
818
819 #[test]
820 fn ctrl_f_full_page_down() {
821 let mut p = make_pager(200);
822 prime_layout(&mut p, 22);
823 let page = p.page_height();
824 let _ = p.handle_key(ctrl(KeyCode::Char('f')));
825 assert_eq!(p.scroll, page);
826 }
827
828 #[test]
829 fn ctrl_b_full_page_up() {
830 let mut p = make_pager(200);
831 prime_layout(&mut p, 22);
832 p.scroll = 80;
833 let page = p.page_height();
834 let _ = p.handle_key(ctrl(KeyCode::Char('b')));
835 assert_eq!(p.scroll, 80 - page);
836 }
837
838 #[test]
839 fn space_pages_down() {
840 let mut p = make_pager(200);
841 prime_layout(&mut p, 22);
842 let page = p.page_height();
843 let _ = p.handle_key(key(KeyCode::Char(' ')));
844 assert_eq!(p.scroll, page);
845 }
846
847 #[test]
848 fn shift_space_pages_up() {
849 let mut p = make_pager(200);
850 prime_layout(&mut p, 22);
851 p.scroll = 80;
852 let page = p.page_height();
853 let _ = p.handle_key(key_mod(KeyCode::Char(' '), KeyModifiers::SHIFT));
854 assert_eq!(p.scroll, 80 - page);
855 }
856
857 #[test]
858 fn page_down_uses_cached_visible_height() {
859 let mut p = make_pager(200);
860 prime_layout(&mut p, 22);
861 let page = p.page_height();
862 let _ = p.handle_key(key(KeyCode::PageDown));
863 assert_eq!(p.scroll, page);
864 }
865
866 #[test]
867 fn q_closes_pager() {
868 let mut p = make_pager(10);
869 let action = p.handle_key(key(KeyCode::Char('q')));
870 assert!(matches!(action, ViewAction::Close));
871 }
872
873 #[test]
874 fn esc_closes_pager() {
875 let mut p = make_pager(10);
876 let action = p.handle_key(key(KeyCode::Esc));
877 assert!(matches!(action, ViewAction::Close));
878 }
879
880 #[test]
881 fn g_does_not_consume_search_input() {
882 // While in search mode, 'g' must be treated as a search character,
883 // not as the half of a `gg` jump-to-top sequence.
884 let mut p = make_pager(50);
885 p.scroll = 10;
886 let _ = p.handle_key(key(KeyCode::Char('/')));
887 assert!(p.search_mode);
888 let _ = p.handle_key(key(KeyCode::Char('g')));
889 assert_eq!(p.search_input, "g");
890 assert_eq!(p.scroll, 10);
891 }
892
893 #[test]
894 fn footer_hint_includes_new_bindings() {
895 // The rendered pager must surface the new vim-style bindings to the
896 // user. The footer is now a wrapping ActionHint row inside the modal
897 // body (not the bottom border), so assert against the rendered buffer.
898 let p = make_pager(5);
899 let area = Rect::new(0, 0, 100, 16);
900 let mut buf = Buffer::empty(area);
901 p.render(area, &mut buf);
902 let mut text = String::new();
903 for y in 0..area.height {
904 for x in 0..area.width {
905 text.push_str(buf[(x, y)].symbol());
906 }
907 text.push('\n');
908 }
909 for needle in &[
910 "j/k",
911 "scroll",
912 "g/G",
913 "top/bottom",
914 "Space",
915 "page",
916 "Ctrl+D/U",
917 "half",
918 "search",
919 "copy",
920 "q/Esc",
921 "close",
922 ] {
923 assert!(text.contains(needle), "footer hint missing {needle:?}");
924 }
925 }
926
927 #[test]
928 fn c_emits_copy_event_with_full_body() {
929 // #1354: the pager intercepts mouse capture, so users have no way to
930 // copy content out without an in-app key. Both `c` and `y` should
931 // emit a CopyToClipboard event carrying the whole body so the host
932 // dispatcher (in ui.rs) can write through `app.clipboard` and toast
933 // a confirmation.
934 let mut p = make_pager(3);
935 let action = p.handle_key(key(KeyCode::Char('c')));
936 match action {
937 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
938 assert_eq!(text, "line-000\nline-001\nline-002");
939 assert_eq!(label, "Pager content");
940 }
941 other => panic!("expected CopyToClipboard emit, got {other:?}"),
942 }
943 }
944
945 #[test]
946 fn copy_override_preserves_indentation_tabs_and_blank_lines() {
947 let source = "Result:\n indented\n\twith-tab\n\nnext";
948 let mut pager = PagerView::from_text("T", source, 12).with_copy_text(source);
949
950 let action = pager.handle_key(key(KeyCode::Char('c')));
951 match action {
952 ViewAction::Emit(ViewEvent::CopyToClipboard { text, .. }) => {
953 assert_eq!(text, source);
954 }
955 other => panic!("expected CopyToClipboard emit, got {other:?}"),
956 }
957 }
958
959 #[test]
960 fn from_text_keeps_one_display_row_per_blank_source_line() {
961 let pager = PagerView::from_text("T", "first\n\nthird", 80);
962 assert_eq!(pager.body_text(), "first\n\nthird");
963 }
964
965 #[test]
966 fn from_text_strips_csi_mouse_and_osc_sequences() {
967 // A worker transcript can carry captured terminal bytes (a child TUI's
968 // mouse-tracking handshake, SGR color, OSC hyperlinks). Rendering them
969 // raw emits the escapes to the user's terminal, which re-enables mouse
970 // reporting after exit and leaves the shell executing fragments.
971 let hostile = "── assistant ──\n\
972 enabling \u{1b}[?1003h\u{1b}[?1006h mouse tracking\n\
973 click bytes \u{1b}[<65;72;17M\u{1b}[<35;131;42M arrived\n\
974 \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\
975 trailing stray \u{1b}\u{7}bytes\u{1b}c done";
976 let pager = PagerView::from_text("Agent transcript", hostile, 200);
977 let body = pager.body_text();
978 assert!(
979 !body.contains('\u{1b}'),
980 "no ESC byte may survive into the pager body: {body:?}"
981 );
982 assert!(
983 !body.contains('\u{7}'),
984 "no BEL byte may survive into the pager body: {body:?}"
985 );
986 for fragment in ["?1003h", "?1006h", "<65;72;17M", "<35;131;42M", "]8;;"] {
987 assert!(
988 !body.contains(fragment),
989 "escape fragment {fragment:?} must be stripped, not painted: {body:?}"
990 );
991 }
992 assert!(body.contains("red text"), "visible text survives: {body:?}");
993 assert!(body.contains("OSC link"), "link label survives: {body:?}");
994 assert!(body.contains("done"), "trailing text survives: {body:?}");
995 }
996
997 #[test]
998 fn from_text_sanitizes_jsonl_transcript_shaped_content() {
999 // Regression for the sub-agent transcript pager corrupting the parent
1000 // terminal: content shaped like the raw artifact lines, with embedded
1001 // mouse/CSI sequences inside a tool result, must render inert.
1002 let transcript_like = concat!(
1003 "── assistant ──\n",
1004 "I'll run the build now.\n",
1005 "← tool result (call-1)\n",
1006 // JSON-escaped text is literal backslash-u bytes — inert, kept as-is.
1007 "{\"line\":\"\\u{1b}[<0;9;4Mprogress done\"}\n",
1008 // Raw event bytes are the dangerous form and must be stripped.
1009 "raw \u{1b}[<0;10;5M event bytes\u{1b}[2K after\n",
1010 );
1011 let pager = PagerView::from_text("Agent transcript", transcript_like, 200);
1012 let body = pager.body_text();
1013 assert!(!body.contains('\u{1b}'), "inert body required: {body:?}");
1014 assert!(
1015 !body.contains("<0;10;5M"),
1016 "mouse event fragment must not render: {body:?}"
1017 );
1018 assert!(
1019 body.contains("progress") && body.contains("after"),
1020 "readable content survives: {body:?}"
1021 );
1022 }
1023
1024 #[test]
1025 fn y_emits_copy_event_for_vim_users() {
1026 let mut p = make_pager(3);
1027 let action = p.handle_key(key(KeyCode::Char('y')));
1028 assert!(
1029 matches!(action, ViewAction::Emit(ViewEvent::CopyToClipboard { .. })),
1030 "y must emit a copy event for vim-yank parity"
1031 );
1032 }
1033
1034 #[test]
1035 fn e_exports_turn_handoff_when_attached() {
1036 // #4108: the Turn Inspector pager carries a compact Markdown handoff;
1037 // `e` copies that artifact (not the visible inspector body) to the
1038 // clipboard via the host dispatcher.
1039 let mut p = make_pager(3).with_export_markdown("# Turn handoff\n\n## Intent\ndo the thing");
1040 let action = p.handle_key(key(KeyCode::Char('e')));
1041 match action {
1042 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
1043 assert!(text.contains("# Turn handoff"), "handoff text: {text}");
1044 assert_eq!(label, "Turn handoff");
1045 }
1046 other => panic!("expected CopyToClipboard emit, got {other:?}"),
1047 }
1048 }
1049
1050 #[test]
1051 fn e_is_inert_without_an_attached_handoff() {
1052 // Every other pager leaves `e` unbound so it never surprises the user.
1053 let mut p = make_pager(3);
1054 assert!(matches!(
1055 p.handle_key(key(KeyCode::Char('e'))),
1056 ViewAction::None
1057 ));
1058 }
1059
1060 #[test]
1061 fn copy_keys_inert_in_search_mode() {
1062 // Within `/`-search mode `c` and `y` must be treated as search
1063 // characters, not as a copy trigger — otherwise users typing a
1064 // query that contains either letter would lose their input.
1065 let mut p = make_pager(10);
1066 let _ = p.handle_key(key(KeyCode::Char('/')));
1067 assert!(p.search_mode);
1068 let action = p.handle_key(key(KeyCode::Char('c')));
1069 assert!(matches!(action, ViewAction::None));
1070 assert_eq!(p.search_input, "c");
1071 }
1072
1073 #[test]
1074 fn footer_hint_is_rendered_in_buffer() {
1075 let p = make_pager(5);
1076 let area = Rect::new(0, 0, 100, 10);
1077 let mut buf = Buffer::empty(area);
1078 p.render(area, &mut buf);
1079 // The footer is now anchored to the bottom of the modal body (above the
1080 // padding/border) rather than painted on the border, so scan the whole
1081 // frame for the action labels.
1082 let mut text = String::new();
1083 for y in 0..area.height {
1084 for x in 0..area.width {
1085 text.push_str(buf[(x, y)].symbol());
1086 }
1087 text.push('\n');
1088 }
1089 assert!(
1090 text.contains("close") || text.contains("scroll"),
1091 "expected footer hint in rendered pager, got:\n{text}"
1092 );
1093 }
1094
1095 /// `/` opens the search prompt; typing chars accumulates them; Enter
1096 /// commits and jumps to the first match. The matches index/count line
1097 /// must surface in the rendered buffer afterwards.
1098 #[test]
1099 fn search_finds_matches_and_renders_match_counter() {
1100 let mut p = make_pager(20);
1101 prime_layout(&mut p, 16);
1102
1103 // Open search.
1104 let _ = p.handle_key(key(KeyCode::Char('/')));
1105 // Type "5" to match line-005, line-015 (any line whose number contains
1106 // a 5 — make_pager produced "line-NNN" with three-digit indices).
1107 for ch in "5".chars() {
1108 let _ = p.handle_key(key(KeyCode::Char(ch)));
1109 }
1110 // Commit.
1111 let _ = p.handle_key(key(KeyCode::Enter));
1112
1113 // Render and look for the "match X/Y" status line.
1114 let area = Rect::new(0, 0, 60, 16);
1115 let mut buf = Buffer::empty(area);
1116 p.render(area, &mut buf);
1117 let mut full = String::new();
1118 for y in 0..area.height {
1119 for x in 0..area.width {
1120 full.push_str(buf[(x, y)].symbol());
1121 }
1122 full.push('\n');
1123 }
1124 assert!(
1125 full.contains("match 1/2") || full.contains("match 1/3"),
1126 "expected match counter; got buffer:\n{full}"
1127 );
1128 }
1129
1130 /// Esc while in search mode bails out AND clears the highlighted matches
1131 /// so the un-highlighted view returns. (Codex parity.)
1132 #[test]
1133 fn esc_in_search_mode_clears_matches() {
1134 let mut p = make_pager(20);
1135 prime_layout(&mut p, 16);
1136
1137 let _ = p.handle_key(key(KeyCode::Char('/')));
1138 let _ = p.handle_key(key(KeyCode::Char('5')));
1139 let _ = p.handle_key(key(KeyCode::Enter));
1140 assert!(!p.search_matches.is_empty());
1141
1142 // Re-enter search mode and Esc out — matches must clear.
1143 let _ = p.handle_key(key(KeyCode::Char('/')));
1144 let _ = p.handle_key(key(KeyCode::Esc));
1145 assert!(p.search_matches.is_empty());
1146 assert_eq!(p.search_input, "");
1147 assert!(!p.search_mode);
1148 }
1149
1150 /// `n` and `N` cycle forward and backward through matches, wrapping at
1151 /// the ends without panicking on out-of-bounds index.
1152 #[test]
1153 fn n_and_capital_n_cycle_matches_with_wrap() {
1154 let mut p = make_pager(50);
1155 prime_layout(&mut p, 16);
1156
1157 // Search "1" — matches every line whose printed index contains a 1.
1158 let _ = p.handle_key(key(KeyCode::Char('/')));
1159 let _ = p.handle_key(key(KeyCode::Char('1')));
1160 let _ = p.handle_key(key(KeyCode::Enter));
1161 let total = p.search_matches.len();
1162 assert!(total > 1, "test needs multiple matches, got {total}");
1163
1164 let start = p.search_index;
1165 let _ = p.handle_key(key(KeyCode::Char('n')));
1166 assert_eq!(p.search_index, (start + 1) % total);
1167 let _ = p.handle_key(key(KeyCode::Char('N')));
1168 assert_eq!(p.search_index, start);
1169
1170 // Wrap backwards from 0 → last.
1171 let _ = p.handle_key(key(KeyCode::Char('N')));
1172 assert_eq!(p.search_index, total - 1);
1173 let _ = p.handle_key(key(KeyCode::Char('n')));
1174 assert_eq!(p.search_index, 0);
1175 }
1176
1177 /// While search matches exist and the prompt is closed, the matched
1178 /// lines are visually distinguished in the rendered buffer by their
1179 /// background color. We sample directly across the matched-line text
1180 /// columns rather than the whole row width because Paragraph leaves
1181 /// the trailing-area cells at the default style.
1182 #[test]
1183 fn matched_lines_get_highlight_background() {
1184 let mut p = make_pager(20);
1185 prime_layout(&mut p, 16);
1186
1187 let _ = p.handle_key(key(KeyCode::Char('/')));
1188 let _ = p.handle_key(key(KeyCode::Char('5')));
1189 let _ = p.handle_key(key(KeyCode::Enter));
1190 assert!(!p.search_matches.is_empty());
1191
1192 let area = Rect::new(0, 0, 40, 16);
1193 let mut buf = Buffer::empty(area);
1194 p.render(area, &mut buf);
1195
1196 // Text starts at popup_area.x + block_border_left + padding_left
1197 // = 1 + 1 + 1 = 3. The fixture text is "line-NNN" (8 chars) so we
1198 // sample 3..11. The current-match row is the top of the visible
1199 // window because `jump_to_match` set scroll = match_line.
1200 let popup_top_y = 1 /* outer popup */ + 1 /* block top border */ + 1 /* padding top */;
1201 let mut found_highlight = false;
1202 for x in 3..11 {
1203 let bg = buf[(x, popup_top_y)].style().bg;
1204 if matches!(bg, Some(Color::Yellow) | Some(Color::DarkGray)) {
1205 found_highlight = true;
1206 break;
1207 }
1208 }
1209 assert!(
1210 found_highlight,
1211 "expected a Yellow/DarkGray highlight cell on the matched-line text columns"
1212 );
1213 }
1214
1215 #[test]
1216 fn mouse_scroll_up_scrolls_content() {
1217 let mut p = make_pager(50);
1218 p.scroll = 10;
1219 let action = p.handle_mouse(MouseEvent {
1220 kind: MouseEventKind::ScrollUp,
1221 column: 0,
1222 row: 0,
1223 modifiers: KeyModifiers::NONE,
1224 });
1225
1226 assert_eq!(p.scroll, 7);
1227 assert!(matches!(action, ViewAction::None));
1228 }
1229
1230 #[test]
1231 fn mouse_scroll_down_scrolls_content() {
1232 let mut p = make_pager(50);
1233 prime_layout(&mut p, 20);
1234 p.scroll = 10;
1235 let action = p.handle_mouse(MouseEvent {
1236 kind: MouseEventKind::ScrollDown,
1237 column: 0,
1238 row: 0,
1239 modifiers: KeyModifiers::NONE,
1240 });
1241
1242 assert_eq!(p.scroll, 13);
1243 assert!(matches!(action, ViewAction::None));
1244 }
1245
1246 #[test]
1247 fn mouse_scroll_down_clamps_to_pager_bottom() {
1248 let mut p = make_pager(50);
1249 prime_layout(&mut p, 20);
1250 let bottom = p.max_scroll();
1251
1252 for _ in 0..100 {
1253 let _ = p.handle_mouse(MouseEvent {
1254 kind: MouseEventKind::ScrollDown,
1255 column: 0,
1256 row: 0,
1257 modifiers: KeyModifiers::NONE,
1258 });
1259 }
1260
1261 assert_eq!(p.scroll, bottom);
1262 }
1263
1264 #[test]
1265 fn pager_is_usable_and_opaque_at_blocker_sizes() {
1266 use crate::tui::views::ViewStack;
1267
1268 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
1269 for (w, h) in BLOCKER_SIZES {
1270 let area = Rect::new(0, 0, w, h);
1271 let mut buf = Buffer::empty(area);
1272 for y in 0..h {
1273 for x in 0..w {
1274 buf[(x, y)].set_symbol("X");
1275 }
1276 }
1277 let mut stack = ViewStack::new();
1278 stack.push(make_pager(60));
1279 stack.render(area, &mut buf);
1280
1281 let rows: Vec<String> = (0..h)
1282 .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
1283 .collect();
1284 let text = rows.join("\n");
1285
1286 // Footer keeps every action.
1287 for label in [
1288 "close",
1289 "scroll",
1290 "page",
1291 "half",
1292 "top/bottom",
1293 "search",
1294 "copy",
1295 ] {
1296 assert!(text.contains(label), "{w}x{h}: footer missing '{label}'");
1297 }
1298
1299 // Composited frame is fully opaque.
1300 assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
1301 assert_eq!(
1302 buf[(w / 2, h / 2)].bg,
1303 palette::WHALE_BG,
1304 "{w}x{h}: modal interior must be opaque"
1305 );
1306
1307 // No horizontal overflow.
1308 for (y, row) in rows.iter().enumerate() {
1309 assert!(
1310 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1311 "{w}x{h}: row {y} overflows width: {row:?}"
1312 );
1313 }
1314 }
1315 }
1316
1317 #[test]
1318 fn wrap_text_breaks_overlong_cjk_runs() {
1319 let text = "这是一个非常长的中文字符串".repeat(10);
1320 let lines = wrap_text(&text, 16);
1321
1322 for line in &lines {
1323 assert!(line.width() <= 16, "line {line:?} exceeds width 16");
1324 }
1325
1326 assert_eq!(lines.join(""), text);
1327 }
1328 }
1329
1329 lines RUST