Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
521f1df
refactor: introduce the Emulator seam around the vt100 backend
ReagentX Jul 13, 2026
f87ff9d
test: add PTY capture corpus for emulator golden suites
ReagentX Jul 13, 2026
95d418f
feat: add the alacritty grid→ANSI serializer behind a round-trip oracle
ReagentX Jul 13, 2026
390263a
test: add cross-backend golden suites (compatibility + semantic)
ReagentX Jul 13, 2026
f29f7dd
test: adjudicate golden-suite findings; add minimal retention fixture
ReagentX Jul 13, 2026
2cf89b2
feat: swap the production emulator backend to alacritty_terminal
ReagentX Jul 13, 2026
7572dcd
test: pin resize survival of region-scrolled history
ReagentX Jul 13, 2026
d328fec
refactor: retire vt100 to a dev-dependency; wheel arrows honor DECSET…
ReagentX Jul 13, 2026
4890e30
fix: correct punctuation in comments and documentation across multipl…
ReagentX Jul 13, 2026
84ba83b
refactor: update comments and documentation for clarity across multip…
ReagentX Jul 13, 2026
9ca724d
fix: honor DECSET 1007 end-to-end through the attached client
ReagentX Jul 13, 2026
2125bc4
fix: guarantee worst-case screen frames fit the wire, skip what cannot
ReagentX Jul 13, 2026
1b370f4
docs: correct stale backend references; record review dispositions
ReagentX Jul 13, 2026
d74c3d1
Refactor documentation and improve code comments
ReagentX Jul 13, 2026
dad95f5
fix: clarify comments in tests regarding geometry constraints and fra…
ReagentX Jul 13, 2026
f67a8ca
fix: improve documentation and clarify behavior regarding DECSET 1007…
ReagentX Jul 13, 2026
cc69265
feat: implement zero-width character management and scanning in Alacr…
ReagentX Jul 13, 2026
fc1d983
refactor: remove vt100 dependency; improve mouse event encoding and t…
ReagentX Jul 13, 2026
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
270 changes: 247 additions & 23 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ repository = "https://github.com/ReagentX/fleetcom"
version = "0.0.0"

[dependencies]
# Disable the unused serde integration.
alacritty_terminal = { version = "=0.26.0", default-features = false }
base64 = "=0.22.1"
crossterm = "=0.29.0"
dirs = "=6.0.0"
Expand All @@ -21,7 +23,6 @@ portable-pty = "=0.9.0"
rustix = { version = "=1.1.4", features = ["process"] }
# Match crossterm's `signal-hook` requirement to avoid duplicate versions.
signal-hook = { version = "0.3", default-features = false }
vt100 = "=0.16.2"

[profile.release]
# Enable link-time optimization.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ The first ordinary invocation starts the daemon when necessary. `--daemon` is an

### One PTY per command

Every task runs in its own pseudo-terminal, emulated with `vt100`. The same screen grid powers the dashboard preview, the peek overlay, and full attached rendering. A mid-run `vim` or `htop` therefore renders from the same terminal state as any other task. Backgrounding changes client focus; it does not notify the child.
Every task runs in its own pseudo-terminal, emulated with `alacritty_terminal`. Cursor-position and device-attribute queries are answered, `?2026` synchronized updates render as whole frames, and history reflows on resize. The same screen grid powers the dashboard preview, the peek overlay, and full attached rendering. A mid-run `vim` or `htop` therefore renders from the same terminal state as any other task. Backgrounding changes client focus; it does not notify the child.

### 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. 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).
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. Full-screen children use alternate scroll only while DECSET 1007 is enabled; otherwise wheel events are suppressed. 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
114 changes: 102 additions & 12 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,18 +174,19 @@ pub struct App {
view_scroll: bool,
}

/// Return `(mouse_capture, alt_scroll)` for the attached child's screen.
/// Scrollback and inline children capture the mouse; mouse-aware children
/// receive events, while full-screen children use alternate scroll.
/// Return `(mouse_capture, alt_scroll)` for the attached view. Scrollback,
/// inline views, and children that disable alternate scroll capture the mouse.
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 through alternate-scroll arrows.
Some(s) if s.alt_screen => (false, true),
// Let the terminal convert wheel events to arrow keys.
Some(s) if s.alt_screen && s.alt_scroll => (false, true),
// Capture wheel events when the child disables alternate scroll.
Some(s) if s.alt_screen => (true, true),
// Capture wheel-up to enter scrollback for inline children.
Some(_) => (true, true),
None => (false, true),
Expand Down Expand Up @@ -1701,40 +1702,46 @@ mod tests {
/// Select capture and alternate-scroll modes by screen type.
#[test]
fn input_modes_match_screen_type() {
let screen = |wants_mouse, alt_screen| ScreenView {
let screen = |wants_mouse, alt_screen, alt_scroll| ScreenView {
id: 1,
lines: Vec::new(),
formatted: Vec::new(),
cursor: (0, 0),
hide_cursor: false,
wants_mouse,
alt_screen,
alt_scroll,
scrollback: 0,
};
// No attached screen: keep native selection available.
assert_eq!(desired_input_modes(None, false), (false, true));
// Mouse-aware child: capture.
assert_eq!(
desired_input_modes(Some(&screen(true, true)), false),
desired_input_modes(Some(&screen(true, true, true)), false),
(true, true)
);
assert_eq!(
desired_input_modes(Some(&screen(true, false)), false),
desired_input_modes(Some(&screen(true, false, false)), false),
(true, true)
);
// Full-screen child without mouse mode: alternate scroll.
// Full-screen child with alternate scroll enabled.
assert_eq!(
desired_input_modes(Some(&screen(false, true)), false),
desired_input_modes(Some(&screen(false, true, true)), false),
(false, true)
);
// Full-screen child with alternate scroll disabled.
assert_eq!(
desired_input_modes(Some(&screen(false, true, false)), false),
(true, true)
);
// Inline child: capture wheel-up to enter scrollback.
assert_eq!(
desired_input_modes(Some(&screen(false, false)), false),
desired_input_modes(Some(&screen(false, false, false)), false),
(true, true)
);
// The scroll view overrides everything: the wheel must scroll it.
assert_eq!(
desired_input_modes(Some(&screen(false, false)), true),
desired_input_modes(Some(&screen(false, false, false)), true),
(true, true)
);
assert_eq!(desired_input_modes(None, true), (true, true));
Expand All @@ -1759,6 +1766,7 @@ mod tests {
hide_cursor: false,
wants_mouse,
alt_screen: false,
alt_scroll: false,
scrollback: 0,
};
let wheel_up = MouseEvent {
Expand All @@ -1780,6 +1788,88 @@ mod tests {
assert!(!app.view_scroll, "mouse-aware children keep their wheel");
}

/// Attached wheel input follows the child's DECSET 1007 state.
#[test]
fn attached_wheel_honors_the_childs_1007_veto() {
use std::time::Instant;
let dir = std::env::temp_dir().join(format!("fleetcom_app_1007_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();

let wheel_up = MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};
// Send one wheel notch and return the first `take` bytes read by the child.
let run = |veto: bool, take: usize, out: PathBuf| -> Vec<u8> {
let mut app = App::new_local(30, 100);
let cwd = app.invocation_dir.clone();
let modes = if veto {
"\\033[?1049h\\033[?1007l"
} else {
"\\033[?1049h"
};
// Noncanonical input lets `head` read arrow sequences without a newline.
let cmd = format!(
"stty -icanon -echo min 1 time 0; printf '{modes}'; head -c {take} > {}",
out.display()
);
app.spawn_in(&cmd, cwd);
app.pump();
app.resolve_selection();
app.attach();
let id = app.focused_id.expect("attached");
app.set_watch(Some(id));
// Wait for the child's terminal modes to reach the client.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
app.pump();
if matches!(
app.screen_for(id),
Some(s) if s.alt_screen && s.alt_scroll != veto
) {
break;
}
assert!(
Instant::now() < deadline,
"gate state (veto: {veto}) never reached the client"
);
std::thread::sleep(Duration::from_millis(10));
}
// Disabled alternate scroll captures the wheel; enabled does not.
assert_eq!(desired_input_modes(app.screen_for(id), false), (veto, true));
app.on_mouse(wheel_up);
if veto {
// The sentinel follows the wheel on the writer queue.
app.transport.send(Command::Input {
id,
bytes: b"zzz".to_vec(),
});
}
let deadline = Instant::now() + Duration::from_secs(5);
let mut got = Vec::new();
while Instant::now() < deadline {
got = std::fs::read(&out).unwrap_or_default();
if got.len() >= take {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
got
};

// Disabled alternate scroll suppresses the wheel bytes.
assert_eq!(run(true, 3, dir.join("veto")), b"zzz".to_vec());
// Enabled alternate scroll emits three arrows per notch.
assert_eq!(
run(false, 9, dir.join("dflt")),
b"\x1b[A\x1b[A\x1b[A".to_vec()
);
let _ = std::fs::remove_dir_all(&dir);
}

/// Scrollback opens with modified PageUp and closes on Esc or typing.
#[test]
fn scroll_view_entry_and_exit() {
Expand Down
4 changes: 2 additions & 2 deletions src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ use crate::{
pub enum Wake {
/// A client request to apply.
Cmd(Command),
/// A task wrote output; its `vt100` screen advanced (the reader thread has
/// already fed the parser), so the loop should tick to ship it.
/// A task wrote output; its emulator screen advanced (the reader thread
/// has already fed the parser), so the loop should tick to ship it.
Output,
/// The command source ended: the client's socket hit EOF. Distinct from a
/// `Shutdown` command: the jobs keep running, only this connection is done.
Expand Down
53 changes: 45 additions & 8 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ use nix::{

use crate::{
core::{LoopExit, Wake, run_loop},
frame::{read_frame, write_frame},
frame::{MAX_FRAME, read_frame, write_frame},
protocol::{
Command, Event, LaunchContext, PROTOCOL_VERSION, decode_command, decode_event,
decode_hello, encode_command, encode_event, encode_hello,
Expand Down Expand Up @@ -531,6 +531,16 @@ fn handshake(stream: &mut UnixStream) -> Result<LaunchContext, String> {
}
}

/// Encode and write one event frame. Oversized payloads are skipped without
/// disconnecting the client; other write failures return `false`.
fn send_event(write: &mut impl Write, ev: &Event) -> bool {
let (kind, payload) = encode_event(ev);
if payload.len() > MAX_FRAME as usize {
return true;
}
write_frame(write, kind, &payload).is_ok()
}

/// Serve one client to completion. The hello handshake runs first (version
/// check, launch context); then a reader thread turns inbound frames into
/// `Wake::Cmd`s on the channel the core loop waits on; task output arrives on the
Expand All @@ -540,9 +550,8 @@ fn handshake(stream: &mut UnixStream) -> Result<LaunchContext, String> {
/// client is attached.
///
/// Panics while serving end the connection without terminating the daemon.
/// Supervisor state is best-effort afterwards (`apply` is not transactional);
/// a panic mid-render at worst garbles one task's grid until its next repaint
/// (`task::grid` recovers the poisoned lock rather than blanking the screen).
/// Supervisor updates are not transactional, and grid locks remain usable
/// after a panic, so subsequent work may observe partial updates.
fn serve_client(sup: &mut Supervisor, stream: UnixStream, stop: &AtomicBool) -> ServeOutcome {
let mut stream = stream;
match handshake(&mut stream) {
Expand Down Expand Up @@ -592,10 +601,7 @@ fn serve_client(sup: &mut Supervisor, stream: UnixStream, stop: &AtomicBool) ->
// write may block; a timeout surfaces as an error below and drops the client.
let _ = write.set_write_timeout(Some(Duration::from_secs(5)));
let outcome = catch_unwind(AssertUnwindSafe(|| {
run_loop(sup, &wake_rx, stop, |ev| {
let (kind, payload) = encode_event(ev);
write_frame(&mut write, kind, &payload).is_ok()
})
run_loop(sup, &wake_rx, stop, |ev| send_event(&mut write, ev))
}));
// Cleanup sits *after* the catch so every exit (return or panic) passes
// through it: a stale waker points task reader threads at a dead channel,
Expand Down Expand Up @@ -650,6 +656,37 @@ mod tests {
let _ = fs::remove_dir_all(&base);
}

/// Oversized events are skipped without preventing subsequent writes.
#[test]
fn oversized_event_is_skipped_not_fatal() {
use crate::protocol::ScreenView;
let oversized = Event::Screen(ScreenView {
id: 1,
lines: Vec::new(),
formatted: vec![b'x'; MAX_FRAME as usize + 1],
cursor: (0, 0),
hide_cursor: false,
wants_mouse: false,
alt_screen: false,
alt_scroll: false,
scrollback: 0,
});
let mut buf: Vec<u8> = Vec::new();
assert!(
send_event(&mut buf, &oversized),
"an oversized event must not read as a dead client"
);
assert!(buf.is_empty(), "no partial frame may reach the stream");

assert!(send_event(&mut buf, &Event::Status("ok".into())));
let (kind, payload) = read_frame(&mut io::Cursor::new(&buf)).unwrap();
assert_eq!(
decode_event(kind, &payload),
Some(Event::Status("ok".into())),
"ordinary events still flow after a skip"
);
}

/// The retry whitelist: fd exhaustion, interruption, and an aborted peer
/// are survivable; a permanent listener failure is not.
#[test]
Expand Down
Loading
Loading