Merge remote-tracking branch 'origin/main' into themes
ci / web (push) Successful in 1m27s
ci / rust (push) Successful in 5m4s
ci / flake (push) Successful in 6m57s

This commit is contained in:
Miguel Palhas
2026-08-25 18:46:35 +01:00
9 changed files with 193 additions and 7 deletions
+9
View File
@@ -31,6 +31,10 @@ pub enum Action {
FocusSidebar,
FocusPane(Direction),
// Session scrollback
ScrollPanePageUp,
ScrollPanePageDown,
// Sidebar highlight movement
SidebarNext,
SidebarPrev,
@@ -78,6 +82,8 @@ impl Action {
format!("{direction:?}").to_lowercase()
)
}
Action::ScrollPanePageUp => "Scroll session up one page".into(),
Action::ScrollPanePageDown => "Scroll session down one page".into(),
Action::SidebarNext => "Select next sidebar item".into(),
Action::SidebarPrev => "Select previous sidebar item".into(),
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
@@ -102,6 +108,8 @@ impl Action {
"EnterInsert" => return Ok(Action::EnterInsert),
"LeaveInsert" => return Ok(Action::LeaveInsert),
"FocusSidebar" => return Ok(Action::FocusSidebar),
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
"SidebarNext" => return Ok(Action::SidebarNext),
"SidebarPrev" => return Ok(Action::SidebarPrev),
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
@@ -221,6 +229,7 @@ mod tests {
Action::EnterInsert,
Action::Quit,
Action::NewDraftPane,
Action::ScrollPanePageDown,
Action::FocusPane(Direction::Right),
] {
let s = a.to_string();
+156 -1
View File
@@ -207,6 +207,11 @@ impl App {
return Ok(false);
}
if let Some(effect) = self.handle_pane_scroll(&key) {
self.perform(effect.into_iter().collect()).await;
return Ok(false);
}
let known = self.cfg.keymap.known_bindings(self.state.mode);
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
@@ -370,6 +375,10 @@ impl App {
// either in the sidebar or back in the pane.
self.state.focus = Focus::Pane;
}
Action::ScrollPanePageUp => self.scroll_active_pane(self.pane_page_rows() as isize),
Action::ScrollPanePageDown => {
self.scroll_active_pane(-(self.pane_page_rows() as isize))
}
Action::SidebarNext => {
let order = visible_sidebar_order(
&self.catalog.snapshot(),
@@ -501,11 +510,79 @@ impl App {
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_session()?;
self.emulators.get(sid)?;
let handle = self.emulators.get(sid)?;
let bytes = encode_key(key)?;
handle
.screen
.lock()
.expect("vt100 parser mutex")
.set_scrollback(0);
Some(Effect::Forward { id: sid, bytes })
}
fn pane_page_rows(&self) -> usize {
self.region
.pane
.map_or(1, |area| usize::from(area.height.saturating_sub(1).max(1)))
}
/// Positive rows move into older history; negative rows return toward the
/// live bottom. vt100 clamps both ends to the retained buffer.
fn scroll_active_pane(&mut self, rows: isize) {
let Some(sid) = self.state.active_session() else {
return;
};
let Some(handle) = self.emulators.get(sid) else {
return;
};
let mut parser = handle.screen.lock().expect("vt100 parser mutex");
let current = parser.screen().scrollback();
let next = if rows >= 0 {
current.saturating_add(rows as usize)
} else {
current.saturating_sub(rows.unsigned_abs())
};
parser.set_scrollback(next);
}
/// Wheel input belongs to the terminal surface in every mode. Normal
/// shells move through Maestro's retained history; full-screen programs
/// that enabled terminal mouse reporting receive a real wheel event and
/// scroll their own alternate-screen content.
fn handle_pane_scroll(&mut self, key: &InputKey) -> Option<Option<Effect>> {
let InputKey::Mouse {
kind,
region: Region::Pane,
col,
row,
} = key
else {
return None;
};
let lines = match kind {
MouseKind::ScrollUp => 3,
MouseKind::ScrollDown => -3,
_ => return None,
};
let sid = self.state.active_session()?;
let handle = self.emulators.get(sid)?;
let pane = self.region.pane?;
let parser = handle.screen.lock().expect("vt100 parser mutex");
if parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None {
let bytes = encode_mouse_wheel(
*kind,
col.saturating_sub(pane.x).saturating_add(1),
row.saturating_sub(pane.y).saturating_add(1),
parser.screen().mouse_protocol_encoding(),
);
drop(parser);
return Some(Some(Effect::Forward { id: sid, bytes }));
}
drop(parser);
self.scroll_active_pane(lines);
Some(None)
}
/// Feed one keystroke into the active tab's draft form. Returns the spawn
/// effect when the user accepted a valid form; an invalid one names the
/// missing field inside the form (#64) instead of posting.
@@ -1016,6 +1093,8 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
InputKey::Left => Some(b"\x1b[D".to_vec()),
InputKey::Home => Some(b"\x1b[H".to_vec()),
InputKey::End => Some(b"\x1b[F".to_vec()),
InputKey::PageUp => Some(b"\x1b[5~".to_vec()),
InputKey::PageDown => Some(b"\x1b[6~".to_vec()),
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
InputKey::Paste(text) => {
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
@@ -1031,6 +1110,39 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
}
}
fn encode_mouse_wheel(
kind: MouseKind,
col: u16,
row: u16,
encoding: vt100::MouseProtocolEncoding,
) -> Vec<u8> {
let button = match kind {
MouseKind::ScrollUp => 64,
MouseKind::ScrollDown => 65,
_ => return Vec::new(),
};
match encoding {
vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{col};{row}M").into_bytes(),
vt100::MouseProtocolEncoding::Default => vec![
0x1b,
b'[',
b'M',
(button + 32) as u8,
col.min(223).saturating_add(32) as u8,
row.min(223).saturating_add(32) as u8,
],
vt100::MouseProtocolEncoding::Utf8 => {
let mut out = b"\x1b[M".to_vec();
for value in [button + 32, u32::from(col) + 32, u32::from(row) + 32] {
let ch = char::from_u32(value).expect("mouse coordinate is a valid codepoint");
let mut bytes = [0; 4];
out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes());
}
out
}
}
}
/// Build the lines shown by the help overlay from the live keymap, so the
/// user always sees the bindings that are actually active (defaults + their
/// own `[tui.keymap.*]` overrides).
@@ -1825,6 +1937,49 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
#[test]
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::AtomicU8;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
let sid = Uuid::from_u128(91);
let mut parser = vt100::Parser::new(2, 20, 20);
parser.process(b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
let screen = Arc::new(Mutex::new(parser));
let handle = SessionHandle {
tx: mpsc::channel(1).0,
screen: Arc::clone(&screen),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
};
let mut app = make_app();
app.state.active_tab_mut().pane = Some(PaneRef::Session(sid));
app.emulators.insert(sid, handle);
app.scroll_active_pane(3);
assert!(screen.lock().unwrap().screen().scrollback() > 0);
assert!(app.forward_to_session(&InputKey::Enter).is_some());
assert_eq!(screen.lock().unwrap().screen().scrollback(), 0);
}
#[test]
fn mouse_wheel_encoding_supports_sgr_terminals() {
assert_eq!(
encode_mouse_wheel(MouseKind::ScrollUp, 7, 4, vt100::MouseProtocolEncoding::Sgr,),
b"\x1b[<64;7;4M"
);
assert_eq!(
encode_mouse_wheel(
MouseKind::ScrollDown,
7,
4,
vt100::MouseProtocolEncoding::Sgr,
),
b"\x1b[<65;7;4M"
);
}
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
/// paste into an attached session vanished once bracketed paste was
/// enabled. It also has to keep its DECSET 2004 markers — without them a
+3
View File
@@ -56,6 +56,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("w", "ShowWorktrees"),
// Focus
("ctrl-w h", "FocusSidebar"),
// Session scrollback
("pageup", "ScrollPanePageUp"),
("pagedown", "ScrollPanePageDown"),
// Chrome
("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"),
+10
View File
@@ -36,6 +36,8 @@ pub enum InputKey {
Right,
Home,
End,
PageUp,
PageDown,
Delete,
/// Bracketed paste: crossterm delivers the whole payload as one event, so
/// text fields can insert it as a single operation.
@@ -135,6 +137,8 @@ impl InputKey {
InputKey::Right => "right".into(),
InputKey::Home => "home".into(),
InputKey::End => "end".into(),
InputKey::PageUp => "pageup".into(),
InputKey::PageDown => "pagedown".into(),
InputKey::Delete => "delete".into(),
// Pastes are not bindable; the canonical form exists only to
// satisfy exhaustiveness and never round-trips through `parse`.
@@ -204,6 +208,8 @@ impl InputKey {
"right" => return Ok(InputKey::Right),
"home" => return Ok(InputKey::Home),
"end" => return Ok(InputKey::End),
"pageup" => return Ok(InputKey::PageUp),
"pagedown" => return Ok(InputKey::PageDown),
"delete" => return Ok(InputKey::Delete),
_ => {}
}
@@ -278,6 +284,8 @@ where
KeyCode::Right => InputKey::Right,
KeyCode::Home => InputKey::Home,
KeyCode::End => InputKey::End,
KeyCode::PageUp => InputKey::PageUp,
KeyCode::PageDown => InputKey::PageDown,
KeyCode::Delete => InputKey::Delete,
_ => return None,
})
@@ -614,6 +622,8 @@ mod tests {
"right",
"home",
"end",
"pageup",
"pagedown",
"delete",
] {
let k = InputKey::parse(s).unwrap();
+1 -1
View File
@@ -74,7 +74,7 @@ impl Pane {
// Place the terminal cursor at vt100's cursor position when we own
// focus and the cursor is on-screen. Terminals blink it on their own.
if self.focused && !screen.hide_cursor() {
if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() {
let (cr, cc) = screen.cursor_position();
if cr < rows && cc < cols {
f.set_cursor_position((area.x + cc, area.y + cr));
+5 -1
View File
@@ -23,6 +23,10 @@ use maestro_protocol::ClientControl;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
/// Match the browser terminal's retained history without letting long-running
/// sessions grow the client without bound.
pub const SCROLLBACK_ROWS: usize = 5_000;
/// Input the dispatcher (or the resize tracker) pushes to a session task.
#[derive(Debug, Clone)]
pub enum SessionInput {
@@ -103,7 +107,7 @@ impl SessionTask {
/// up with `SessionInput::Resize` on every frame whose allocation changes.
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
let parser_bg = parser.clone();
+2
View File
@@ -145,6 +145,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
"?" = "ShowHelp"
"ctrl-w h" = "FocusSidebar"
"enter" = "AttachHighlighted"
"pageup" = "ScrollPanePageUp"
"pagedown" = "ScrollPanePageDown"
[tui.keymap.insert]
# (leader is added automatically as LeaveInsert; only override here when you
+2 -1
View File
@@ -123,7 +123,8 @@ export function createAttachConnection(
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
// scrolling *its* content (the only scroll that exists on the alt screen).
// • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
// gesture stream; only the terminal surface opts out of native page panning.
{
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
+5 -3
View File
@@ -1142,9 +1142,11 @@ textarea {
.attach-body .xterm .xterm-viewport {
overflow-y: auto;
scrollbar-width: none;
/* We drive scrollback from touchmove (see attach.ts); claim vertical pans so
the browser doesn't fight the handler or trigger gestures mid-scroll. */
touch-action: pan-y;
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
the gesture to the browser, which may cancel the event stream before the
terminal sees it; `none` keeps a drag inside the terminal under our
control while the rest of the page retains native scrolling. */
touch-action: none;
}
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {