Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Every task runs in its own pseudo-terminal, emulated with `vt100`. The same scre

### Input fidelity

Attached input follows the child's terminal state. Modified Enter is sent as `ESC CR` when reported, and paste uses bracketed-paste markers only when enabled by the child. The client captures the mouse only for attached children using a mouse protocol; elsewhere, native selection remains available and the wheel uses alternate scroll for full-screen children. Details are in [`docs/commands.md`](docs/commands.md).
Attached input follows the child's terminal state. Modified Enter is sent as `ESC CR` when reported, and paste uses bracketed-paste markers only when enabled by the child. Mouse-protocol children receive mouse events, and full-screen children use alternate scroll. Tasks retain 2,000 lines of scrollback: for inline children, wheel-up over output enters scrollback; `Shift+PageUp` also enters it, paging keys navigate, and `Esc` or typing returns to live output. Details are in [`docs/commands.md`](docs/commands.md).

### Jobs outlive the UI

Expand Down
6 changes: 5 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ Three inputs are richer than a keypress, and each is routed by state rather than

- Shift+Enter and Alt+Enter are sent as `ESC CR`, which is distinct from plain Enter. Shift requires a terminal that reports modified keys; terminals that do not report it send plain `CR`.
- Paste travels as one message. Bracketed-paste-aware children receive paste markers with embedded terminators removed; other children receive line endings as `CR`. Fleetcom text fields strip control characters.
- The client captures the mouse only while the attached child requests a mouse protocol, then forwards clicks, drags, releases, and wheel events in the negotiated encoding. In that mode, use the terminal's selection override chord. Otherwise, native click-drag selection remains available; the wheel scrolls full-screen children and moves dashboard selection. It is disabled for attached inline children so arrow keys are not sent to their standard input.
- Mouse-protocol children receive clicks, drags, releases, and wheel events in the negotiated encoding. Full-screen children without a mouse protocol use alternate scroll. For inline children without a mouse protocol, wheel-up enters Fleetcom's scrollback view. When Fleetcom captures the mouse (for mouse-protocol children, inline children, or scrollback), terminal selection requires the terminal's selection-override modifier. Otherwise, drag selects normally.

#### Scrollback

Tasks retain 2,000 lines of scrollback. While attached to an inline child, wheel-up over its output enters scrollback; `Shift+PageUp` also enters it (`Ctrl+PageUp` and `Alt+PageUp` work when Shift is intercepted). The status bar shows `[scroll ↑N]`. The wheel scrolls, `PageUp`/`PageDown` move by pages, `↑`/`↓` by lines, and `Home` jumps to the oldest row. `Esc`, `Enter`, `q`, `End`, or reaching the bottom returns to live output. Typing also returns to live and forwards the key. Detaching or switching tasks resets the view.

#### Destroy is Shift-gated

Expand Down
198 changes: 180 additions & 18 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crossterm::event::{
use crossterm::{execute, style::Print};

use crate::path;
use crate::protocol::{Command, Event, MouseBtn, MouseKind, ScreenView, TaskView};
use crate::protocol::{Command, Event, MouseBtn, MouseKind, ScreenView, ScrollAction, TaskView};
use crate::session;
use crate::supervisor::Supervisor;
use crate::task::Lifecycle;
Expand Down Expand Up @@ -154,17 +154,24 @@ pub struct App {
mouse_captured: bool,
/// The alternate-scroll state last applied to the terminal.
alt_scroll: bool,
/// Whether the attached task is displaying scrollback.
view_scroll: bool,
}

/// Return `(mouse_capture, alt_scroll)` for the attached child's screen.
/// Without an attached screen, preserve native selection and enable wheel
/// navigation through alternate scroll.
fn desired_input_modes(attached: Option<&ScreenView>) -> (bool, bool) {
/// Scrollback and inline children capture the mouse; mouse-aware children
/// receive events, while full-screen children use alternate scroll.
fn desired_input_modes(attached: Option<&ScreenView>, view_scroll: bool) -> (bool, bool) {
if view_scroll {
return (true, true);
}
match attached {
// Capture and forward mouse events requested by the child.
Some(s) if s.wants_mouse => (true, true),
// Scroll full-screen children; disable wheel-generated arrows inline.
Some(s) => (false, s.alt_screen),
// Scroll full-screen children through alternate-scroll arrows.
Some(s) if s.alt_screen => (false, true),
// Capture wheel-up to enter scrollback for inline children.
Some(_) => (true, true),
None => (false, true),
}
}
Expand Down Expand Up @@ -283,6 +290,7 @@ impl App {
should_quit: false,
mouse_captured: false,
alt_scroll: true,
view_scroll: false,
exit_intent: ExitIntent::Disconnect,
}
}
Expand Down Expand Up @@ -460,7 +468,15 @@ impl App {
// The handshake is handled before the transport is created.
Event::HelloOk => {}
Event::Tasks(v) => self.views = v,
Event::Screen(s) => self.focused_screen = Some(s),
Event::Screen(s) => {
// Exit only after a nonzero offset returns to live, so a
// pre-entry screen update cannot immediately exit the view.
let prev = self.focused_screen.as_ref().map_or(0, |p| p.scrollback);
if self.view_scroll && prev > 0 && s.scrollback == 0 {
self.view_scroll = false;
}
self.focused_screen = Some(s);
}
Event::Status(s) => self.status = Some(s),
}
}
Expand Down Expand Up @@ -861,6 +877,8 @@ impl App {
if detach {
self.mode = Mode::Dashboard;
self.focused_id = None;
// The watch change resets the task viewport.
self.view_scroll = false;
// Repaint from scratch next tick; wipe the child's screen now.
use crossterm::{
cursor::MoveTo,
Expand All @@ -870,6 +888,41 @@ impl App {
let _ = execute!(out, Clear(ClearType::All), MoveTo(0, 0));
return Ok(());
}
// Keep one row of overlap between pages.
let page = self.pane_rows().saturating_sub(1).max(1);
if self.view_scroll {
// Scrollback navigation is not forwarded to the child.
match k.code {
KeyCode::PageUp => self.send_scrollback(ScrollAction::Up(page)),
KeyCode::PageDown => self.send_scrollback(ScrollAction::Down(page)),
KeyCode::Up => self.send_scrollback(ScrollAction::Up(1)),
KeyCode::Down => self.send_scrollback(ScrollAction::Down(1)),
KeyCode::Home => self.send_scrollback(ScrollAction::Top),
KeyCode::End | KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
self.view_scroll = false;
self.send_scrollback(ScrollAction::Live);
}
// Other input returns to live and is forwarded immediately.
_ => {
self.view_scroll = false;
if let Some(id) = self.focused_id
&& let Some(bytes) = key_to_bytes(k.code, k.modifiers)
{
self.transport.send(Command::Input { id, bytes });
}
}
}
return Ok(());
}
// Ctrl/Alt provide alternatives when the terminal intercepts Shift.
if k.code == KeyCode::PageUp
&& k.modifiers
.intersects(KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT)
{
self.view_scroll = true;
self.send_scrollback(ScrollAction::Up(page));
return Ok(());
}
if let Some(id) = self.focused_id
&& let Some(bytes) = key_to_bytes(k.code, k.modifiers)
{
Expand Down Expand Up @@ -938,7 +991,27 @@ impl App {
_ => {}
},
Mode::Attached => {
// The wheel navigates scrollback instead of the child.
if self.view_scroll {
match kind {
MouseKind::WheelUp => self.send_scrollback(ScrollAction::Up(3)),
MouseKind::WheelDown => self.send_scrollback(ScrollAction::Down(3)),
_ => {}
}
return;
}
if let Some(id) = self.focused_id {
// Wheel-up enters scrollback for inline children that do
// not receive mouse events.
let inline = matches!(
self.screen_for(id),
Some(s) if !s.wants_mouse && !s.alt_screen
);
if inline && kind == MouseKind::WheelUp {
self.view_scroll = true;
self.send_scrollback(ScrollAction::Up(3));
return;
}
// Keep the pointer coordinate within the child pane: the
// bottom row is fleetcom's status bar, not the child's.
let row = m.row.min(self.pane_rows().saturating_sub(1));
Expand All @@ -950,13 +1023,21 @@ impl App {
}
}

/// Move the attached task's scrollback viewport.
fn send_scrollback(&mut self, action: ScrollAction) {
if let Some(id) = self.focused_id {
self.transport.send(Command::Scrollback { id, action });
}
}

/// Apply input-mode changes for the current focus.
fn sync_input_modes(&mut self, out: &mut Stdout) -> io::Result<()> {
let attached = match self.mode {
Mode::Attached => self.focused_id.and_then(|id| self.screen_for(id)),
_ => None,
};
let (capture, alt_scroll) = desired_input_modes(attached);
let view = self.mode == Mode::Attached && self.view_scroll;
let (capture, alt_scroll) = desired_input_modes(attached, view);
if capture != self.mouse_captured {
if capture {
execute!(out, EnableMouseCapture)?;
Expand Down Expand Up @@ -984,6 +1065,7 @@ impl App {
// sent from the run loop next tick.
self.focused_id = Some(self.views[i].id);
self.mode = Mode::Attached;
self.view_scroll = false;
}
}

Expand Down Expand Up @@ -1495,10 +1577,9 @@ mod tests {
assert!(app.mode == Mode::Spawn, "paste must not submit");
}

/// Capture mouse events only for children that request them; disable
/// alternate scroll for attached inline children.
/// Select capture and alternate-scroll modes by screen type.
#[test]
fn capture_only_for_mouse_hungry_children() {
fn input_modes_match_screen_type() {
let screen = |wants_mouse, alt_screen| ScreenView {
id: 1,
lines: Vec::new(),
Expand All @@ -1507,25 +1588,106 @@ mod tests {
hide_cursor: false,
wants_mouse,
alt_screen,
scrollback: 0,
};
// No attached screen: keep native selection available.
assert_eq!(desired_input_modes(None), (false, true));
assert_eq!(desired_input_modes(None, false), (false, true));
// Mouse-aware child: capture.
assert_eq!(desired_input_modes(Some(&screen(true, true))), (true, true));
assert_eq!(
desired_input_modes(Some(&screen(true, false))),
desired_input_modes(Some(&screen(true, true)), false),
(true, true)
);
assert_eq!(
desired_input_modes(Some(&screen(true, false)), false),
(true, true)
);
// Full-screen child without mouse mode: alternate scroll.
assert_eq!(
desired_input_modes(Some(&screen(false, true))),
desired_input_modes(Some(&screen(false, true)), false),
(false, true)
);
// Inline child: wheel silenced, selection native.
// Inline child: capture wheel-up to enter scrollback.
assert_eq!(
desired_input_modes(Some(&screen(false, false)), false),
(true, true)
);
// The scroll view overrides everything: the wheel must scroll it.
assert_eq!(
desired_input_modes(Some(&screen(false, false))),
(false, false)
desired_input_modes(Some(&screen(false, false)), true),
(true, true)
);
assert_eq!(desired_input_modes(None, true), (true, true));
}

/// Wheel-up enters scrollback for inline children, but forwards for
/// mouse-aware children.
#[test]
fn wheel_up_enters_scroll_view_for_inline_children() {
let mut app = App::new_local(30, 100);
let dir = app.invocation_dir.clone();
app.spawn_in("sleep 5", dir);
app.pump();
app.resolve_selection();
app.attach();
let id = app.focused_id.expect("attached");
let screen = |wants_mouse| ScreenView {
id,
lines: Vec::new(),
formatted: Vec::new(),
cursor: (0, 0),
hide_cursor: false,
wants_mouse,
alt_screen: false,
scrollback: 0,
};
let wheel_up = MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};

// Inline child: wheel-up enters scrollback.
app.focused_screen = Some(screen(false));
app.on_mouse(wheel_up);
assert!(app.view_scroll, "wheel-up must open the scroll view");

// Mouse-aware child: wheel-up forwards instead.
app.view_scroll = false;
app.focused_screen = Some(screen(true));
app.on_mouse(wheel_up);
assert!(!app.view_scroll, "mouse-aware children keep their wheel");
}

/// Scrollback opens with modified PageUp and closes on Esc or typing.
#[test]
fn scroll_view_entry_and_exit() {
let mut app = App::new_local(30, 100);
let dir = app.invocation_dir.clone();
app.spawn_in("sleep 5", dir);
app.pump();
app.resolve_selection();
app.attach();
assert!(app.mode == Mode::Attached);
let mut out = io::stdout();

let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
app.on_key_attached(&mut out, KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT))
.unwrap();
assert!(app.view_scroll, "Shift+PageUp must enter the scroll view");
app.on_key_attached(&mut out, key(KeyCode::Esc)).unwrap();
assert!(!app.view_scroll, "Esc must return to live");

app.on_key_attached(&mut out, KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL))
.unwrap();
assert!(app.view_scroll, "Ctrl+PageUp is an entry fallback");
app.on_key_attached(&mut out, key(KeyCode::Char('x')))
.unwrap();
assert!(!app.view_scroll, "typing must snap back to live");

// Plain PageUp is forwarded to the child.
app.on_key_attached(&mut out, key(KeyCode::PageUp)).unwrap();
assert!(!app.view_scroll);
}

/// A wheel notch on the dashboard moves the selection like an arrow key.
Expand Down
Loading
Loading