Honor pager bindings for transcript page keys (#50396)

## Why

Transcript scrolling hardcoded `PageUp` and `PageDown` to full-page movement, ignoring customized pager bindings.

## What changed

Resolve unmodified `PageUp` and `PageDown` through the pager's scroll bindings so they can scroll by a full page, half a page, or one row, or remain unbound. Preserve the one-row overlap for full-page scrolling and leave pager typing shortcuts unhandled by the transcript.

## Testing

Add a regression test and snapshot covering default, half-page, single-row, and unbound configurations. Verify that `PageDown` reverses `PageUp` scrolling and that `j` and `Ctrl+u` remain unhandled.

GitOrigin-RevId: 945e99d7370f84f326ab71037be2924dddada32c
This commit is contained in:
Benjamin Carlsson
2026-10-02 18:29:19 +00:00
committed by copyberry
parent f88a6efe43
commit d61c7a824f
3 changed files with 132 additions and 11 deletions
@@ -0,0 +1,43 @@
---
source: tui/src/transcript_view_tests.rs
expression: "frames.join(\"\\n\\n\")"
---
default
row 16
row 17
row 18
row 19
row 20
row 21
row 22
row 23
half page
row 19
row 20
row 21
row 22
row 23
row 24
row 25
row 26
single row
row 22
row 23
row 24
row 25
row 26
row 27
row 28
row 29
unbound
row 23
row 24
row 25
row 26
row 27
row 28
row 29
row 30
+18 -11
View File
@@ -343,17 +343,24 @@ impl TranscriptView {
) -> Option<ViewAction> {
let jump = JumpTarget::from_key(key);
match (key.code, key.modifiers) {
(KeyCode::PageUp, KeyModifiers::NONE) => {
self.scroll(
cells,
-(self.area.height.saturating_sub(/*rhs*/ 1).max(/*other*/ 1) as isize),
);
}
(KeyCode::PageDown, KeyModifiers::NONE) => {
self.scroll(
cells,
self.area.height.saturating_sub(/*rhs*/ 1).max(/*other*/ 1) as isize,
);
(KeyCode::PageUp | KeyCode::PageDown, KeyModifiers::NONE) => {
// Share page-key configuration without taking the pager's typing shortcuts.
// Full pages retain the main view's existing one-row overlap.
let keymap = &self.disclosure.keymap.pager;
let height = self.area.height.max(/*other*/ 1) as isize;
let page = (height - 1).max(/*other*/ 1);
let half = (height + 1) / 2;
let delta = [
(&keymap.scroll_up, -1),
(&keymap.scroll_down, 1),
(&keymap.page_up, -page),
(&keymap.page_down, page),
(&keymap.half_page_up, -half),
(&keymap.half_page_down, half),
]
.into_iter()
.find_map(|(bindings, delta)| bindings.is_pressed(key).then_some(delta))?;
self.scroll(cells, delta);
}
(KeyCode::Esc, KeyModifiers::NONE) if self.can_return_to_latest() => {
self.jump_to_latest();
+71
View File
@@ -63,6 +63,77 @@ pub(super) fn text(buffer: &Buffer) -> String {
.join("\n")
}
#[test]
fn page_keys_follow_pager_scroll_bindings() {
let cells = [cell(
(1..=30)
.map(|row| format!("row {row}\n"))
.collect::<String>(),
)];
let mut frames = Vec::new();
for (label, pager, rows) in [
("default", serde_json::json!({}), 7),
(
"half page",
serde_json::json!({
"page_up": [], "page_down": [],
"half_page_up": "page-up", "half_page_down": "page-down"
}),
4,
),
(
"single row",
serde_json::json!({
"page_up": [], "page_down": [],
"scroll_up": "page-up", "scroll_down": "page-down"
}),
1,
),
(
"unbound",
serde_json::json!({"page_up": [], "page_down": []}),
0,
),
] {
let config = serde_json::from_value(serde_json::json!({"pager": pager})).unwrap();
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).unwrap();
let mut view = TranscriptView::default();
view.set_keymap_bindings(&keymap);
let pre_scroll = render(&mut view, &cells, /*width*/ 20, /*height*/ 8);
assert_eq!(
view.handle_key(KeyCode::PageUp.into(), &cells).is_some(),
rows != 0
);
let scrolled = render(&mut view, &cells, /*width*/ 20, /*height*/ 8);
let mut expected = TranscriptView::default();
render(&mut expected, &cells, /*width*/ 20, /*height*/ 8);
expected.scroll(&cells, -rows);
assert_eq!(
scrolled,
render(&mut expected, &cells, /*width*/ 20, /*height*/ 8)
);
frames.push(format!("{label}\n{}", text(&scrolled)));
assert_eq!(
view.handle_key(KeyCode::PageDown.into(), &cells).is_some(),
rows != 0
);
assert_eq!(
render(&mut view, &cells, /*width*/ 20, /*height*/ 8),
pre_scroll
);
for key in [
KeyCode::Char('j').into(),
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
] {
assert!(view.handle_key(key, &cells).is_none());
}
}
insta::assert_snapshot!(
"page_keys_follow_pager_scroll_bindings",
frames.join("\n\n")
);
}
#[test]
fn terminal_output_disclosure_follows_live_history_and_keymap() {
use crate::exec_cell::CommandOutput;