diff --git a/Cargo.lock b/Cargo.lock index 88c8a2f..0302f25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,9 +37,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arrayvec" @@ -159,7 +159,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", ] [[package]] @@ -210,9 +210,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -243,9 +243,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "getrandom" @@ -287,9 +287,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" @@ -449,18 +449,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -482,7 +482,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -632,6 +632,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -643,11 +654,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -658,18 +669,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 56d6f7b..732cb32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["Christopher Sardegna "] categories = ["command-line-interface", "command-line-utilities"] -description = "A fleet-view supervisor for arbitrary shell commands." +description = "A fleet-view supervisor for concurrent shell commands." edition = "2024" exclude = [".github", "docs", "tests", ".gitignore", ".gitattributes"] keywords = ["cli", "tui", "pty", "supervisor", "process"] diff --git a/README.md b/README.md index c9512ad..322645a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ### Fleet view -See every task’s state and latest output from one dashboard. +Monitor every task's state and live output from one dashboard: test results, dev servers, and which agent is waiting on you. ![`fleetcom` fleet view](docs/img/home.png) @@ -22,6 +22,10 @@ Press `Enter` to take control of a task, then `Ctrl-\` to return to the dashboar ![`fleetcom` attach](docs/img/attach.png) +### Resume agent sessions + +Start `claude`, `codex`, or `grok` normally. When you rerun the task or reload a saved session, `fleetcom` resumes the same conversation automatically. + ## Operational model Running several long-lived commands is pesky once they span terminal panes or need to survive a disconnect. `fleetcom`: @@ -35,7 +39,7 @@ Running several long-lived commands is pesky once they span terminal panes or ne ## Documentation -The [`docs/`](docs/README.md) directory covers configuration, on-disk state, session files, commands, and a complete first run. +The [`docs/`](docs/README.md) directory covers configuration, on-disk state, session files, resuming supported agent sessions, commands, and a complete first run. ## Installation diff --git a/docs/README.md b/docs/README.md index aa7a54d..b86fd45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,9 +7,10 @@ - [Commands](commands.md): every key and launch flag, including the routing mechanics - [How it works](how-it-works.md): the PTY emulation, input routing, and activity grouping - [Sessions](sessions.md): the task recipe format and where it lives -- [Agent session resume](../src/harness/agent-resume.md): when `fleetcom` can save resumable `claude`, `codex`, and `grok` commands +- [Agent session resume](agent-resume.md): how `fleetcom` captures and resumes supported `claude`, `codex`, and `grok` sessions - [Storage paths](#storage-paths): the socket, the lock, and the session paths - [First-run walkthrough](#first-run-walkthrough): a first run, start to finish +- [Security](#security): the trust boundary, on-disk state, and what is not protected - [Operational constraints](#operational-constraints): process and protocol boundaries ## Installation from source @@ -136,6 +137,44 @@ The attached status bar shows both: `[attached] api tests · cargo watch -x test `w`, a name, and `Enter` save the fleet as a [session](sessions.md). `q` then disconnects while the daemon and both tasks continue running. A subsequent `fleetcom` invocation reconstructs the dashboard from the daemon's current task state. `Q` or `fleetcom --kill` stops the tasks (`TERM`, then `KILL` after a two-second grace period) and exits the daemon. +## Security + +`fleetcom` runs entirely as your user. It neither raises nor drops privileges. Access control comes from filesystem permissions rather than authentication: the socket is mode `0600` inside a mode-`0700` directory, and the daemon performs no peer check. Any process running as your user can therefore connect, spawn commands, and read task output. That is the trust boundary. + +### What lands on disk + +| Path | Mode | Contents | +| -- | -- | -- | +| [runtime directory](#runtime-directory-socket--lock) | `0700` | the socket, lock, daemon log, and any capture roots resolved beneath it | +| `/default.sock` | `0600` | the client↔daemon socket | +| `/daemon.lock` | `0666 & ~umask` when new; otherwise unchanged | the owning daemon's PID, trustworthy only while its `flock` is held | +| `/daemon.log` | `0666 & ~umask` when new; otherwise unchanged | stderr from the autostarted daemon | +| [session directory](#config-directory-sessions) | `0700` | saved recipes | +| `/.json` | `0600` | directories, commands, groups, display names | +| `/recovery/` | `0700` | [automatic snapshots](sessions.md#recovery) | +| `/recovery/.json` | `0600` | one automatic session recipe | +| `/-/` | `0700` | [agent hook and notifier assets plus per-run capture payloads](agent-resume.md#capture-state-and-isolation) | + +Saves are atomic: `fleetcom` writes a mode-`0600` temporary file in the destination directory, syncs it, then renames it over the target. This does not expose a partial or world-readable recipe. New session and recovery directories use mode `0700`; each save also removes group and other permissions from the destination directory. + +### The runtime directory must be trustworthy + +`fleetcom` validates the runtime directory before trusting its contents. The path must be a real directory owned by the current user; symlinks and directories owned by another user are rejected. Group or other write access is fatal because another user could already have planted entries. Any remaining group or other permissions are removed in place. + +### What is not protected + +Recipes persist full command lines, which can embed secrets. A token passed as an argument is written to its session file and to every recovery snapshot that captures the task. + +`fleetcom` does not persist the client environment. Each client sends its environment and working directory during the connection handshake, and the daemon retains that launch context in memory. Session and recovery files store only directories, commands, group assignments, and display names. + +### Captured IDs cross a shell boundary + +Agent resume writes a captured conversation ID into a command run through `$SHELL -c`, so validation is a security boundary. Accepted IDs contain only lowercase hexadecimal in the `8-4-4-4-12` UUID shape. Hook payloads, terminal scrapes, filesystem correlation, and the command builder all apply that check. Instrumentation applies only to a bare program word or its canonical resume form, never arbitrary shell text. [Agent session resume](agent-resume.md#validation-boundary) documents both boundaries. + +### Copied text leaves through the terminal + +When `fleetcom` copies a selection or forwards an attached task's clipboard store, it sends the text to the host terminal as an OSC 52 escape sequence. The sequence also crosses intermediaries such as SSH connections and terminal multiplexers. + ## Operational constraints ### The fleet dies with the daemon diff --git a/src/harness/agent-resume.md b/docs/agent-resume.md similarity index 77% rename from src/harness/agent-resume.md rename to docs/agent-resume.md index b68cd28..56a408c 100644 --- a/src/harness/agent-resume.md +++ b/docs/agent-resume.md @@ -1,8 +1,19 @@ # Agent session resume -Session files preserve launch commands, not application state. That boundary is problematic for agent CLIs because relaunching a bare `claude`, `codex`, or `grok` command starts another conversation. +Session files preserve launch commands, not process state. Relaunching a bare `claude`, `codex`, or `grok` command ordinarily starts another conversation. For accepted commands, `fleetcom` captures a validated conversation ID when available and builds a canonical resume command when saving a session or rerunning a finished task (`r`). -To preserve that conversation, `fleetcom` captures a validated ID and builds the resume command used by session save or rerun (`r`). Instrumentation changes only the string executed through `$SHELL -c`; direct spawns and session loads still display the requested command. Rerun displays the generated resume command because that command becomes the task's new launch recipe. +## Workflow + +Start a supported agent without flags: + +1. Press `n` and run `claude`, `codex`, or `grok`. The task appears in the dashboard under the command you typed. Instrumentation changes only the string executed through `$SHELL -c`, so a direct spawn still displays the requested command. +2. Work in it. `Enter` attaches; `Ctrl-\` returns to the dashboard. Depending on the agent, `fleetcom` pins an ID at launch and may update it from a hook or notifier while the task runs or from terminal output after it exits. +3. Press `w`, enter a session name, and press `Enter`. If the earlier sources produced no ID, the save also checks the agent's on-disk session store. A captured bare command becomes its canonical resume form, such as `claude --resume ''`. +4. Run `fleetcom `, or press `o` in the dashboard, to start new processes from the saved commands. A stored resume command reopens its captured conversation. + +On a finished agent task, `r` uses the captured launch, hook, notifier, or exit ID without performing save-time filesystem correlation. The replacement keeps the task's ID, tag, group, and name. After a successful rewrite, the row shows the resume command because it has become the task's launch recipe; a [saved session](sessions.md) records the same string. + +Capture is best-effort and narrow by design. A command carrying a prompt, extra flags, or shell syntax stays opaque and saves verbatim. An accepted command with no available ID also saves unchanged. In both cases, loading the recipe reruns the original command. ## Accepted command boundary @@ -88,7 +99,7 @@ Every captured value eventually enters a shell command, which makes validation t ## Extending capture -Each tool implements the `Harness` trait in [`mod.rs`](mod.rs). The methods keep detection, evidence collection, and command construction separate: +Each tool implements the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs). The methods keep detection, evidence collection, and command construction separate: - `detect` classifies the accepted command shapes. - `instrument` returns spawn-time arguments, environment entries, and an optional pinned ID. diff --git a/docs/sessions.md b/docs/sessions.md index 1051854..6d8f4a4 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -87,4 +87,4 @@ Recovery files carry the same caveat as saved recipes: they persist full command - Load in-app: `o`, pick from the list, `Enter`. - Load at launch: `fleetcom `. -Loading always spawns new processes from the stored commands. Existing tasks remain daemon state and never enter the session file. [Agent session resume](../src/harness/agent-resume.md) documents when supported agent commands can preserve their conversations across that relaunch. +Loading always spawns new processes from the stored commands. Existing tasks remain daemon state and never enter the session file. [Agent session resume](agent-resume.md) documents when supported agent commands can preserve their conversations across that relaunch. diff --git a/src/app.rs b/src/app.rs index a6336d4..0ed90a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -49,6 +49,26 @@ const _: () = assert!( /// How long an ephemeral notice remains visible. const NOTICE_TTL: Duration = Duration::from_secs(5); +/// Minimum interval between non-forced repaints outside attached mode. +const PAINT_MIN: Duration = Duration::from_millis(33); + +/// Maximum time the run loop blocks before checking for termination. +const WAIT_MAX: Duration = Duration::from_millis(100); + +/// Return whether the current pass may paint. +fn paint_due(attached: bool, forced: bool, since_paint: Duration) -> bool { + attached || forced || since_paint >= PAINT_MIN +} + +/// Return the next repaint or termination-check timeout. +fn wait_for_paint(due: bool, since_paint: Duration) -> Duration { + if due { + WAIT_MAX + } else { + PAINT_MIN.saturating_sub(since_paint).min(WAIT_MAX) + } +} + /// Priority of an ephemeral notice. Active warnings take precedence over info. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NoticeLevel { @@ -179,6 +199,10 @@ pub struct App { /// Bytes of the last painted frame; the renderer skips the write when the /// next frame is identical. pub last_frame: Vec, + /// Time of the last frame write, or `None` until the first write. + last_paint: Option, + /// Whether the next pass bypasses `PAINT_MIN`. + force_paint: bool, /// Directory `fleetcom` was launched from: base for relative `@` paths and /// the "default" section that sorts first in "by dir" mode. pub invocation_dir: PathBuf, @@ -375,6 +399,8 @@ impl App { rows, cols, last_frame: Vec::new(), + last_paint: None, + force_paint: false, invocation_dir, invocation_label, dir_input: EditBuffer::default(), @@ -763,18 +789,31 @@ impl App { self.selection = None; } - // Emit accepted clipboard stores before painting the next frame. + // Flush clipboard output and synchronize terminal input modes. self.flush_clipboard(out)?; self.sync_input_modes(out)?; - ui::render(out, self)?; - // Wake for input or core events; the timeout observes termination. - let _ = self.wait_rx.recv_timeout(Duration::from_millis(100)); + let now = Instant::now(); + // Treat an absent prior paint as one full interval elapsed. + let since_paint = self.last_paint.map_or(PAINT_MIN, |t| now.duration_since(t)); + let due = paint_due(self.mode == Mode::Attached, self.force_paint, since_paint); + if due { + // Start a new interval only when the frame is written. + if ui::render(out, self)? { + self.last_paint = Some(now); + } + self.force_paint = false; + } + + // Wait for input, a core event, or the next repaint deadline. + let _ = self.wait_rx.recv_timeout(wait_for_paint(due, since_paint)); while self.wait_rx.try_recv().is_ok() {} // coalesce wake tokens // Handle every buffered key/resize in one pass: coalesces a paste and // shaves the last keystroke's echo (no render between chars). while let Ok(ev) = self.input_rx.try_recv() { + // Terminal events make the next pass bypass `PAINT_MIN`. + self.force_paint = true; match ev { // Accept Repeat too, so a held key still forwards when attached. CtEvent::Key(k) diff --git a/src/app_tests.rs b/src/app_tests.rs index 424a254..57f1c10 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -2701,3 +2701,90 @@ fn wants_mouse_child_keeps_the_left_button() { assert_eq!(got, b"\x1b[<0;3;2M\x1b[<32;6;2M\x1b[<0;6;2m".to_vec()); let _ = std::fs::remove_dir_all(&dir); } + +// Frame emission: overlay modes composite by overdraw, so the emulator must +// never see a frame's dashboard layer without its overlay. + +/// Every emitted frame is one synchronized update. +#[test] +fn frame_is_wrapped_in_one_synchronized_update() { + 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(); + for (label, mode) in [ + ("dashboard", Mode::Dashboard), + ("peek", Mode::Peek), + ("attached", Mode::Attached), + ] { + app.mode = mode; + app.focused_id = app.selected_id; + app.last_frame.clear(); // force the write; only changed frames emit + let mut out = Vec::new(); + crate::ui::render(&mut out, &mut app).unwrap(); + assert!(!out.is_empty(), "{label} must paint"); + assert!( + out.starts_with(b"\x1b[?2026h") && out.ends_with(b"\x1b[?2026l"), + "the {label} frame must open and close one synchronized update" + ); + assert_eq!( + out.windows(8).filter(|w| *w == b"\x1b[?2026h").count(), + 1, + "{label} must not nest updates" + ); + } +} + +/// The update markers are constant, so an unchanged frame still writes nothing. +#[test] +fn unchanged_frame_emits_nothing() { + 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.mode = Mode::Peek; + let mut first = Vec::new(); + assert!( + crate::ui::render(&mut first, &mut app).unwrap(), + "the first frame paints" + ); + assert!(!first.is_empty()); + let mut second = Vec::new(); + // An unchanged frame reports that no bytes were written. + assert!( + !crate::ui::render(&mut second, &mut app).unwrap(), + "an identical frame reports no paint" + ); + assert!(second.is_empty(), "an identical frame is a no-op"); +} + +// Repaint timing. + +/// Core-driven repaints wait for the remainder of `PAINT_MIN`. +#[test] +fn repaint_floor_gates_core_driven_frames() { + assert!(!paint_due(false, false, Duration::from_millis(10))); + assert_eq!( + wait_for_paint(false, Duration::from_millis(10)), + Duration::from_millis(23) + ); + assert!(paint_due(false, false, PAINT_MIN)); + assert_eq!(wait_for_paint(true, PAINT_MIN), WAIT_MAX); +} + +/// Terminal input and attached mode permit immediate repainting. +#[test] +fn input_and_attached_echo_bypass_the_repaint_floor() { + assert!( + paint_due(false, true, Duration::ZERO), + "a handled event paints" + ); + assert!( + paint_due(true, false, Duration::ZERO), + "attached echo paints" + ); + // Before the floor elapses, wait for its remaining duration. + assert_eq!(wait_for_paint(false, Duration::ZERO), PAINT_MIN); +} diff --git a/src/task_tests.rs b/src/task_tests.rs index 503b16f..4a7610a 100644 --- a/src/task_tests.rs +++ b/src/task_tests.rs @@ -372,46 +372,40 @@ fn input_hints_track_child_modes() { t.terminate(); } -/// Holding the grid lock after process exit blocks reader EOF, which must -/// also block exit-hint scraping. +/// The scrape waits on two criteria: the child must have exited and the PTY +/// reader must have stopped; a live reader may still hold bytes that +/// have not reached the grid. #[test] fn scrape_exit_hint_waits_for_reader_eof() { const ID: &str = "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d"; - let dir = temp("task_scrape"); - let flag = dir.join("flag"); - let cmd = format!( - "until [ -e '{f}' ]; do sleep 0.05; done; \ - printf 'Resume this session with:\\nclaude --resume {ID}\\n'", - f = flag.display() - ); + let cmd = format!("printf 'Resume this session with:\\nclaude --resume {ID}\\n'"); let mut t = Task::spawn(20, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); t.harness = Some(&crate::harness::Claude); - - // Hold the grid before output so the reader cannot process bytes or - // observe EOF. - let parser = Arc::clone(&t.parser); - let guard = parser.lock(); - std::fs::write(&flag, b"").unwrap(); - // The process can exit while its hint remains blocked in the reader. - // The long deadline bounds failure without constraining loaded CI. assert!( wait_until(Duration::from_secs(60), || { t.poll_exit().unwrap(); - t.finished.is_some() + t.output_complete() }), "child never exited" ); + + // Reopen the reader gate: a thread outliving the child's exit stands in + // for a reader still working through bytes the grid has not seen. + let (release, parked) = channel::<()>(); + t.handle = Some(thread::spawn(move || { + let _ = parked.recv(); + })); t.scrape_exit_hint(); assert_eq!(t.scraped_id, None, "the scrape must wait for reader EOF"); - // Release the reader so it can parse the hint and reach EOF. - drop(guard); - wait_until(Duration::from_secs(60), || { - t.scrape_exit_hint(); - t.scraped_id.is_some() - }); + // Dropping the sender ends the stand-in: the reader reached EOF. + drop(release); + assert!( + wait_until(Duration::from_secs(60), || t.reader_done()), + "the stand-in reader never stopped" + ); + t.scrape_exit_hint(); assert_eq!(t.scraped_id.as_deref(), Some(ID)); - let _ = std::fs::remove_dir_all(&dir); } /// A child that dies with a `?2026` frame still open leaves its hint diff --git a/src/ui.rs b/src/ui.rs index f0fbf59..37e38f1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,13 +1,15 @@ //! Terminal rendering from the client's task and screen snapshots. Frames are -//! buffered and written only when they differ from the previous frame. +//! buffered, wrapped in one synchronized update, and written only when they +//! differ from the previous frame. -use std::io::{self, Stdout, Write}; +use std::io::{self, Write}; use std::time::Duration; use crossterm::{ cursor::{Hide, MoveTo, Show}, queue, style::{Attribute, Print, SetAttribute}, + terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}, }; use unicode_width::UnicodeWidthStr; @@ -20,8 +22,11 @@ use crate::{ selection::Selection, }; -pub fn render(out: &mut Stdout, app: &mut App) -> io::Result<()> { +/// Paint the current mode's frame, returning whether bytes were written. +pub fn render(out: &mut impl Write, app: &mut App) -> io::Result { let mut buf: Vec = Vec::with_capacity(app.cols as usize * app.rows as usize * 3 + 128); + // DECSET 2026 around the whole frame + queue!(buf, BeginSynchronizedUpdate)?; match app.mode { Mode::Attached => render_attached(&mut buf, app)?, Mode::Peek => { @@ -45,14 +50,16 @@ pub fn render(out: &mut Stdout, app: &mut App) -> io::Result<()> { render_dashboard(&mut buf, app)? } } + queue!(buf, EndSynchronizedUpdate)?; // Repaint only on change: a stable frame (idle tasks, no input) is a no-op, // so there is nothing to flicker and nothing to burn CPU on. - if buf != app.last_frame { - out.write_all(&buf)?; - out.flush()?; - app.last_frame = buf; + if buf == app.last_frame { + return Ok(false); } - Ok(()) + out.write_all(&buf)?; + out.flush()?; + app.last_frame = buf; + Ok(true) } fn put(out: &mut impl Write, y: u16, s: &str, cols: usize) -> io::Result<()> {