Compare commits

...
5 Commits
Author SHA1 Message Date
Miguel Palhas 7eeb43774a feat(maestro): clear AwaitingInput after 1s of focus
ci / web (push) Successful in 30s
ci / flake (push) Successful in 2m32s
ci / rust (push) Successful in 5m35s
Was 5s, which felt slow on a pane the operator is already looking at.
2026-09-03 08:23:36 +01:00
Miguel PalhasandClaude Opus 5 b36d571b21 perf(maestro): buffer TUI output and skip unchanged frames
ci / web (push) Successful in 32s
ci / flake (push) Successful in 2m19s
ci / rust (push) Successful in 5m26s
The TUI repainted on every 33ms tick whether or not anything had
changed — measured at ~725 bytes/s of escape sequences with nothing
attached and nothing happening. Over a remote terminal that is constant
traffic for no visible change.

Output was also unbuffered: `CrosstermBackend::new(std::io::stdout())`
issues one `write!` per cell run, 3551 of them for a 200x50 repaint,
each taking the global stdout lock. A BufWriter collapses a frame into
one flush (measured: 3551 writes -> 2).

Frames are now keyed on the catalog version, the active pane's screen
revision, an input epoch and the terminal size, and skipped when the key
is unchanged. Session cards carry an advancing duration, so a frame
still never stands for longer than a second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 08:11:17 +01:00
Miguel PalhasandClaude Opus 5 e4601e3554 fix(maestro): repin pnpmDeps hash for addon-webgl
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m37s
ci / rust (push) Successful in 5m47s
Adding @xterm/addon-webgl changed web/pnpm-lock.yaml, which the flake's
pnpmDeps.hash pins against, so `nix build .#webFrontend` failed offline
on the missing tarball and took the CI flake job with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 23:09:28 +01:00
Miguel PalhasandClaude Opus 5 86eeae8296 fix(maestro): give dev sessions the user's PATH
ci / web (push) Successful in 39s
ci / flake (push) Failing after 1m18s
ci / rust (push) Successful in 4m52s
The dev daemon ran with the toolchain PATH from `nix run .#dev`, whose
trailing `/usr/bin:/bin` is all but empty on NixOS. Sessions spawned
from the dev UI inherit that PATH, so every one came up unable to find
`claude`, `zsh`, or anything else installed in the user's profile.

Append the usual profile directories in the daemon's command, resolved
by the shell at run time. Build tools still come first; directories that
do not exist on a given host cost nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 22:15:28 +01:00
Miguel PalhasandClaude Opus 5 4bb732d9b9 perf(maestro): render terminals via WebGL
xterm's DOM renderer builds a node per cell, which is CPU-bound — the
wrong bottleneck on a phone, where an agent dumping output stutters
visibly on a Pixel 7 despite plenty of GPU headroom.

A WebGL context is scarce and revocable, so it follows the mounted view
rather than the connection: acquired in onMount, released in onUnmount.
ConnectionPool keeps MAX_PARKED connections alive behind the visible
ones, which would otherwise exceed the browser's context cap (~16 on
desktop Chrome, fewer on Android) and bring a parked terminal back
blank. Context loss falls back to the DOM renderer and retries against a
shared two-loss budget; a device that keeps dropping contexts stops
being asked. A device pixel ratio change rebuilds the glyph atlas, which
is otherwise left blurry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 22:06:11 +01:00
13 changed files with 467 additions and 17 deletions
+2 -2
View File
@@ -69,7 +69,7 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
/// How long a session's pane must be continuously focused (a client's pane, /// How long a session's pane must be continuously focused (a client's pane,
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the /// not a sidebar highlight) before an `AwaitingInput` glow clears: the
/// operator has looked, so the rail stops asking for attention. /// operator has looked, so the rail stops asking for attention.
pub const FOCUS_CLEAR_HOLD: Duration = Duration::from_secs(5); pub const FOCUS_CLEAR_HOLD: Duration = Duration::from_secs(1);
/// Downgrade `raw` from `AwaitingInput` to `Idle` once the pane has been /// Downgrade `raw` from `AwaitingInput` to `Idle` once the pane has been
/// focused for at least [`FOCUS_CLEAR_HOLD`]. /// focused for at least [`FOCUS_CLEAR_HOLD`].
@@ -161,7 +161,7 @@ mod tests {
fn awaiting_input_glows_until_focus_hold_is_reached() { fn awaiting_input_glows_until_focus_hold_is_reached() {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
let mut suppressed = false; let mut suppressed = false;
let since = now - (FOCUS_CLEAR_HOLD - Duration::from_secs(1)); let since = now - (FOCUS_CLEAR_HOLD - Duration::from_millis(200));
assert_eq!( assert_eq!(
apply_focus_suppression( apply_focus_suppression(
ActivityState::AwaitingInput, ActivityState::AwaitingInput,
+179 -9
View File
@@ -11,7 +11,6 @@
//! `dispatch` / the overlay routing. //! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::io::Stdout;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
@@ -19,7 +18,6 @@ use chrono::Utc;
use crossterm::event::{Event as CtEvent, EventStream}; use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt; use futures_util::StreamExt;
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch}; use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use uuid::Uuid; use uuid::Uuid;
@@ -89,8 +87,36 @@ pub struct App {
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle, /// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
/// mouse) is covered without each one remembering to report it. /// mouse) is covered without each one remembering to report it.
focused_session: Option<Uuid>, focused_session: Option<Uuid>,
/// Fingerprint of the last frame drawn. The loop compares a freshly
/// computed key against this and skips the repaint when they match — an
/// idle TUI used to emit a full diff 30 times a second forever, which over
/// a remote terminal is constant traffic for no change on screen.
last_frame: Option<FrameKey>,
/// When that frame went out, for the periodic refresh below.
last_drawn_at: std::time::Instant,
/// Bumped by every path that mutates `AppState` outside the catalog and
/// the attached pane: keys, mouse, draft results, link transitions. Those
/// mutations are too diffuse to fingerprint individually, so the loop
/// treats "an input was processed" as "assume the frame is stale".
epoch: u64,
} }
/// Everything a frame is built from that is cheap to sample every tick.
/// Equal keys mean an identical frame, so the repaint can be skipped.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FrameKey {
catalog: u64,
epoch: u64,
/// Active pane's session, its screen revision, and its link state.
pane: Option<(Uuid, u64, crate::session_task::SessionState)>,
area: ratatui::layout::Size,
}
/// Session cards carry a duration that advances on its own (`docs/SIDEBAR.md`),
/// so a frame is never allowed to stand for longer than this even when nothing
/// else changed.
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
impl std::fmt::Debug for App { impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App") f.debug_struct("App")
@@ -170,13 +196,16 @@ impl App {
last_sent_size: HashMap::new(), last_sent_size: HashMap::new(),
clicks: ClickTracker::default(), clicks: ClickTracker::default(),
focused_session: None, focused_session: None,
last_frame: None,
last_drawn_at: std::time::Instant::now(),
epoch: 0,
} }
} }
/// Drive the loop until the user quits. Consumes self. /// Drive the loop until the user quits. Consumes self.
pub async fn run( pub async fn run<B: ratatui::backend::Backend>(
mut self, mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>, terminal: &mut Terminal<B>,
) -> Result<(), TuiError> { ) -> Result<(), TuiError> {
if let Ok(info) = self.daemon.daemon_info().await { if let Ok(info) = self.daemon.daemon_info().await {
self.home_dir = info.home_dir; self.home_dir = info.home_dir;
@@ -184,7 +213,7 @@ impl App {
let mut events = EventStream::new(); let mut events = EventStream::new();
loop { loop {
self.sync_focus().await; self.sync_focus().await;
self.redraw(terminal)?; self.redraw_if_stale(terminal)?;
tokio::select! { tokio::select! {
Some(Ok(ev)) = events.next() => { Some(Ok(ev)) = events.next() => {
@@ -193,17 +222,22 @@ impl App {
} }
} }
Some(update) = self.draft_rx.recv() => { Some(update) = self.draft_rx.recv() => {
self.epoch = self.epoch.wrapping_add(1);
self.apply_draft_update(update).await; self.apply_draft_update(update).await;
} }
_ = self.catalog.changed() => { _ = self.catalog.changed() => {
// Render on next loop iteration. // Picked up by the frame key's catalog version.
} }
_ = self.link.changed() => { _ = self.link.changed() => {
// The status indicator and the restart overlay both read // The status indicator and the restart overlay both read
// the link; redraw on the next loop iteration. // the link; neither is fingerprinted, so mark stale.
self.epoch = self.epoch.wrapping_add(1);
} }
_ = tokio::time::sleep(Duration::from_millis(33)) => { _ = tokio::time::sleep(Duration::from_millis(33)) => {
let effects = self.poll_due_probes_at(std::time::Instant::now()); let effects = self.poll_due_probes_at(std::time::Instant::now());
if !effects.is_empty() {
self.epoch = self.epoch.wrapping_add(1);
}
self.perform(effects).await; self.perform(effects).await;
} }
} }
@@ -217,6 +251,10 @@ impl App {
return Ok(false); return Ok(false);
}; };
let key = self.clicks.promote(key, std::time::Instant::now()); let key = self.clicks.promote(key, std::time::Instant::now());
// Any key can move the cursor, open an overlay or edit a text buffer.
// Rather than fingerprint each of those, treat a processed input as
// proof the frame is stale.
self.epoch = self.epoch.wrapping_add(1);
// The daemon is gone: everything a key could do goes through it, so // The daemon is gone: everything a key could do goes through it, so
// only the quit binding still resolves until it answers again. // only the quit binding still resolves until it answers again.
@@ -959,9 +997,46 @@ impl App {
self.focused_session = wanted; self.focused_session = wanted;
} }
fn redraw( /// Sample everything a frame depends on. Cheap: two atomic loads, an
/// `RwLock` read of a `u64`, and the backend's size.
fn frame_key(&self, area: ratatui::layout::Size) -> FrameKey {
let pane = self.state.active_session().and_then(|sid| {
self.emulators
.get(sid)
.map(|h| (sid, h.revision(), h.state()))
});
FrameKey {
catalog: self.catalog.version(),
epoch: self.epoch,
pane,
area,
}
}
/// Repaint only when the frame key moved, or the clock refresh is due.
fn redraw_if_stale<B: ratatui::backend::Backend>(
&mut self, &mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>, terminal: &mut Terminal<B>,
) -> Result<(), TuiError> {
// Polled rather than driven off `Event::Resize`: `Terminal::draw`'s own
// autoresize only runs on a draw, and a skipped draw must still notice
// the terminal changed shape.
let area = terminal
.size()
.map_err(|e| TuiError::Terminal(e.to_string()))?;
let key = self.frame_key(area);
if self.last_frame == Some(key) && self.last_drawn_at.elapsed() < CLOCK_REFRESH {
return Ok(());
}
self.redraw(terminal)?;
self.last_frame = Some(key);
self.last_drawn_at = std::time::Instant::now();
Ok(())
}
fn redraw<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut Terminal<B>,
) -> Result<(), TuiError> { ) -> Result<(), TuiError> {
let snap = self.catalog.snapshot(); let snap = self.catalog.snapshot();
let attached: HashSet<Uuid> = self let attached: HashSet<Uuid> = self
@@ -1257,6 +1332,100 @@ mod tests {
) )
} }
/// Counts how many times the loop actually repainted, by watching the
/// `TestBackend` buffer's generation via `draw` calls.
fn drew(app: &mut App, terminal: &mut Terminal<ratatui::backend::TestBackend>) -> bool {
let before = app.last_drawn_at;
app.redraw_if_stale(terminal).unwrap();
app.last_drawn_at != before
}
#[test]
fn unchanged_frame_is_not_repainted() {
let mut app = make_app();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
assert!(drew(&mut app, &mut terminal), "first frame must paint");
assert!(
!drew(&mut app, &mut terminal),
"nothing changed; the repaint should be skipped"
);
}
#[test]
fn a_processed_key_makes_the_frame_stale() {
let mut app = make_app();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
drew(&mut app, &mut terminal);
app.epoch = app.epoch.wrapping_add(1);
assert!(drew(&mut app, &mut terminal));
}
#[test]
fn a_catalog_event_makes_the_frame_stale() {
let mut app = make_app();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
drew(&mut app, &mut terminal);
app.catalog
.apply_event(maestro_protocol::CatalogEvent::Snapshot {
sessions: Vec::new(),
});
assert!(drew(&mut app, &mut terminal));
}
#[test]
fn a_resize_makes_the_frame_stale() {
let mut app = make_app();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
drew(&mut app, &mut terminal);
terminal.backend_mut().resize(120, 40);
assert!(drew(&mut app, &mut terminal));
}
#[test]
fn pane_output_makes_the_frame_stale() {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
let mut app = make_app();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
let sid = Uuid::from_u128(7);
let revision = Arc::new(AtomicU64::new(0));
app.emulators.insert(
sid,
SessionHandle {
tx: tokio::sync::mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: revision.clone(),
},
);
app.state.attach_to_focused(sid);
drew(&mut app, &mut terminal);
assert!(!drew(&mut app, &mut terminal), "idle pane must not repaint");
revision.fetch_add(1, std::sync::atomic::Ordering::Release);
assert!(drew(&mut app, &mut terminal), "new PTY bytes must repaint");
}
#[test]
fn a_stale_clock_repaints_even_with_no_events() {
let mut app = make_app();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
drew(&mut app, &mut terminal);
assert!(!drew(&mut app, &mut terminal));
// Session cards show an advancing duration, so a frame may not stand
// for longer than CLOCK_REFRESH regardless of the key.
app.last_drawn_at = std::time::Instant::now() - CLOCK_REFRESH;
assert!(drew(&mut app, &mut terminal));
}
#[test] #[test]
fn enter_insert_then_leave() { fn enter_insert_then_leave() {
let mut app = make_app(); let mut app = make_app();
@@ -2091,6 +2260,7 @@ mod tests {
tx: mpsc::channel(1).0, tx: mpsc::channel(1).0,
screen: Arc::clone(&screen), screen: Arc::clone(&screen),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)), state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}; };
let mut app = make_app(); let mut app = make_app();
app.state.active_tab_mut().pane = Some(sid); app.state.active_tab_mut().pane = Some(sid);
+10
View File
@@ -87,6 +87,16 @@ impl CatalogStore {
} }
/// Cheap O(sessions) read of current state. /// Cheap O(sessions) read of current state.
/// The version counter alone, without cloning the session list. The render
/// loop reads this every tick to decide whether the sidebar is stale.
pub fn version(&self) -> u64 {
self.inner
.state
.read()
.expect("catalog rwlock poisoned")
.version
}
pub fn snapshot(&self) -> CatalogSnapshot { pub fn snapshot(&self) -> CatalogSnapshot {
let s = self.inner.state.read().expect("catalog rwlock poisoned"); let s = self.inner.state.read().expect("catalog rwlock poisoned");
let mut sessions: Vec<Session> = s.sessions.values().cloned().collect(); let mut sessions: Vec<Session> = s.sessions.values().cloned().collect();
+1
View File
@@ -168,6 +168,7 @@ mod tests {
tx: mpsc::channel(1).0, tx: mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)), state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}; };
emulators.insert(id, handle); emulators.insert(id, handle);
assert!(emulators.get(id).is_some()); assert!(emulators.get(id).is_some());
+12 -1
View File
@@ -24,6 +24,7 @@ pub mod state;
pub mod terminal; pub mod terminal;
pub mod text_input; pub mod text_input;
use std::io::BufWriter;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
@@ -121,13 +122,23 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
let _guard = terminal::TerminalGuard::enter().map_err(|e| match e { let _guard = terminal::TerminalGuard::enter().map_err(|e| match e {
terminal::TerminalError::Io(io) => TuiError::Terminal(io.to_string()), terminal::TerminalError::Io(io) => TuiError::Terminal(io.to_string()),
})?; })?;
let backend = CrosstermBackend::new(std::io::stdout()); // Buffered: ratatui issues one `write!` per cell run — thousands per
// frame — and an unbuffered `Stdout` takes the global stdout lock on every
// one of them. A single BufWriter collapses a frame into one flush.
let backend = CrosstermBackend::new(BufWriter::with_capacity(
FRAME_BUFFER_BYTES,
std::io::stdout(),
));
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?; let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
let app = App::new(tui_cfg, catalog, daemon, link); let app = App::new(tui_cfg, catalog, daemon, link);
app.run(&mut terminal).await app.run(&mut terminal).await
} }
/// One full 200x50 repaint is ~5 KB of escape sequences; 64 KB leaves room for
/// a large terminal without ever flushing mid-frame.
const FRAME_BUFFER_BYTES: usize = 64 * 1024;
/// Startup probe budget, applied to connect *and* the whole request: the user is /// Startup probe budget, applied to connect *and* the whole request: the user is
/// staring at a terminal, so an unreachable daemon must fail in under a second. /// staring at a terminal, so an unreachable daemon must fail in under a second.
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500); const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);
+35 -1
View File
@@ -14,7 +14,7 @@
//! parser is **not** reset on reconnect — the last-known frame stays //! parser is **not** reset on reconnect — the last-known frame stays
//! visible until new bytes arrive, so the user doesn't see a blank. //! visible until new bytes arrive, so the user doesn't see a blank.
use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@@ -56,11 +56,15 @@ pub enum SessionState {
/// here. /// here.
/// - `state` — `Arc<AtomicU8>`: live status of the WS, queryable without /// - `state` — `Arc<AtomicU8>`: live status of the WS, queryable without
/// holding the screen lock. /// holding the screen lock.
/// - `revision` — `Arc<AtomicU64>`: bumped every time the parser's screen is
/// mutated. The render loop compares it against the last drawn frame's so an
/// idle pane costs a `load` instead of a repaint.
#[derive(Clone)] #[derive(Clone)]
pub struct SessionHandle { pub struct SessionHandle {
pub tx: mpsc::Sender<SessionInput>, pub tx: mpsc::Sender<SessionInput>,
pub screen: Arc<Mutex<vt100::Parser>>, pub screen: Arc<Mutex<vt100::Parser>>,
pub(crate) state: Arc<AtomicU8>, pub(crate) state: Arc<AtomicU8>,
pub(crate) revision: Arc<AtomicU64>,
} }
impl std::fmt::Debug for SessionHandle { impl std::fmt::Debug for SessionHandle {
@@ -81,6 +85,12 @@ impl SessionHandle {
} }
} }
/// Monotonic counter of screen mutations. Only equality across two reads
/// is meaningful — the loop uses it to decide whether a frame is stale.
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
/// Stop the underlying task. Used when the pane closes and the user has /// Stop the underlying task. Used when the pane closes and the user has
/// no other tabs pointing at this session. /// no other tabs pointing at this session.
pub fn end(&self) { pub fn end(&self) {
@@ -109,9 +119,11 @@ impl SessionTask {
let (tx, mut rx) = mpsc::channel::<SessionInput>(64); let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS))); let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8)); let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
let revision = Arc::new(AtomicU64::new(0));
let parser_bg = parser.clone(); let parser_bg = parser.clone();
let state_bg = state.clone(); let state_bg = state.clone();
let revision_bg = revision.clone();
tokio::spawn(async move { tokio::spawn(async move {
let url = ws_url; let url = ws_url;
@@ -151,6 +163,7 @@ impl SessionTask {
} }
SessionInput::Resize { cols, rows } => { SessionInput::Resize { cols, rows } => {
parser_bg.lock().expect("vt100 parser mutex").set_size(rows, cols); parser_bg.lock().expect("vt100 parser mutex").set_size(rows, cols);
revision_bg.fetch_add(1, Ordering::Release);
let frame = serde_json::to_string(&ClientControl::Resize { let frame = serde_json::to_string(&ClientControl::Resize {
cols, cols,
rows, rows,
@@ -166,6 +179,7 @@ impl SessionTask {
match frame { match frame {
Some(Ok(Message::Binary(bytes))) => { Some(Ok(Message::Binary(bytes))) => {
parser_bg.lock().expect("vt100 parser mutex").process(&bytes); parser_bg.lock().expect("vt100 parser mutex").process(&bytes);
revision_bg.fetch_add(1, Ordering::Release);
} }
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
// Ping/Pong control frames travel as text JSON. Reply to Ping. // Ping/Pong control frames travel as text JSON. Reply to Ping.
@@ -202,6 +216,7 @@ impl SessionTask {
tx, tx,
screen: parser, screen: parser,
state, state,
revision,
} }
} }
} }
@@ -228,6 +243,7 @@ mod tests {
tx: mpsc::channel(1).0, tx: mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Connecting as u8)), state: Arc::new(AtomicU8::new(SessionState::Connecting as u8)),
revision: Arc::new(AtomicU64::new(0)),
}; };
assert_eq!(h.state(), SessionState::Connecting); assert_eq!(h.state(), SessionState::Connecting);
} }
@@ -238,8 +254,26 @@ mod tests {
tx: mpsc::channel(1).0, tx: mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)), state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
}; };
h.end(); h.end();
assert_eq!(h.state(), SessionState::Ended); assert_eq!(h.state(), SessionState::Ended);
} }
#[test]
fn revision_starts_at_zero_and_tracks_screen_mutations() {
let revision = Arc::new(AtomicU64::new(0));
let h = SessionHandle {
tx: mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: revision.clone(),
};
assert_eq!(h.revision(), 0);
// Stands in for the WS pump: process bytes, then bump.
h.screen.lock().unwrap().process(b"hello");
revision.fetch_add(1, Ordering::Release);
assert_eq!(h.revision(), 1);
}
} }
+1 -1
View File
@@ -234,7 +234,7 @@ the agent, and answering 204 would hide it.
A session's pane — the pane a client's own attach view is showing, never a A session's pane — the pane a client's own attach view is showing, never a
sidebar highlight — clears its `AwaitingInput` glow once it has been sidebar highlight — clears its `AwaitingInput` glow once it has been
**continuously focused for 5 seconds**: the operator has looked, so the rail **continuously focused for 1 second**: the operator has looked, so the rail
stops asking for attention and the card reads as `Idle` instead. This is a stops asking for attention and the card reads as `Idle` instead. This is a
real state change, not a client-side paint-over — the daemon computes it, so real state change, not a client-side paint-over — the daemon computes it, so
every client watching the session (web, TUI, a second pane on the same every client watching the session (web, TUI, a second pane on the same
+13 -3
View File
@@ -148,7 +148,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) { pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src; inherit (finalAttrs) pname version src;
fetcherVersion = 3; fetcherVersion = 3;
hash = "sha256-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0="; hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
}; };
buildPhase = '' buildPhase = ''
@@ -218,8 +218,8 @@
settings = { settings = {
# `nix run .#dev` works from a bare shell, not only from inside # `nix run .#dev` works from a bare shell, not only from inside
# `nix develop`, so the toolchain is put on PATH here rather than # `nix develop`, so the toolchain is put on PATH here rather than
# inherited. `/usr/bin:/bin` stays last for anything else a # inherited. This covers the build and Vite; the daemon widens it
# spawned session reaches for. # further for the sessions it spawns (see `daemon.command`).
environment = [ environment = [
"PATH=${lib.makeBinPath devTools}:/usr/bin:/bin" "PATH=${lib.makeBinPath devTools}:/usr/bin:/bin"
# Not :7070: that is the daemon's own default, and the dev # Not :7070: that is the daemon's own default, and the dev
@@ -241,7 +241,17 @@
# `environment`. A tmux socket of its own keeps dev sessions # `environment`. A tmux socket of its own keeps dev sessions
# out of — and safe from — the tmux server belonging to # out of — and safe from — the tmux server belonging to
# whatever daemon the developer actually runs. # whatever daemon the developer actually runs.
#
# A spawned session inherits the daemon's PATH, and the
# toolchain PATH above holds build tools only — on NixOS the
# trailing `/usr/bin:/bin` is all but empty, so every session
# opened from the dev UI came up unable to find `claude`,
# `zsh` or anything else the developer actually has. Append
# the usual user profile directories so dev sessions behave
# like real ones. Resolved by the shell at run time; the ones
# that do not exist on a given host cost nothing.
command = '' command = ''
PATH="$PATH:$HOME/.nix-profile/bin:/etc/profiles/per-user/$(id -un)/bin:/run/current-system/sw/bin:/usr/local/bin" \
MAESTRO_DAEMON_TMUX_TMPDIR="$PWD/.dev/tmux" \ MAESTRO_DAEMON_TMUX_TMPDIR="$PWD/.dev/tmux" \
RUST_LOG="''${RUST_LOG:-maestro=debug,info}" \ RUST_LOG="''${RUST_LOG:-maestro=debug,info}" \
target/debug/maestro daemon target/debug/maestro daemon
+1
View File
@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0" "@xterm/xterm": "^5.5.0"
}, },
"devDependencies": { "devDependencies": {
+8
View File
@@ -14,6 +14,9 @@ importers:
'@xterm/addon-fit': '@xterm/addon-fit':
specifier: ^0.10.0 specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0) version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/addon-webgl':
specifier: ^0.19.0
version: 0.19.0
'@xterm/xterm': '@xterm/xterm':
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.5.0 version: 5.5.0
@@ -348,6 +351,9 @@ packages:
peerDependencies: peerDependencies:
'@xterm/xterm': ^5.0.0 '@xterm/xterm': ^5.0.0
'@xterm/addon-webgl@0.19.0':
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
'@xterm/xterm@5.5.0': '@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
@@ -772,6 +778,8 @@ snapshots:
dependencies: dependencies:
'@xterm/xterm': 5.5.0 '@xterm/xterm': 5.5.0
'@xterm/addon-webgl@0.19.0': {}
'@xterm/xterm@5.5.0': {} '@xterm/xterm@5.5.0': {}
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
+77
View File
@@ -1,4 +1,5 @@
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
@@ -10,6 +11,11 @@ import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js"; import { requestToggleDrawer } from "./drawer.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js"; import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
/** /**
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket. * A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
@@ -135,6 +141,72 @@ export function createAttachConnection(
term.loadAddon(fit); term.loadAddon(fit);
term.open(body); term.open(body);
// GPU rendering is scoped to the mounted view, not the connection: acquired
// in onMount, released in onUnmount. See webglRenderer.ts.
let webgl: WebglAddon | null = null;
let dprMedia: MediaQueryList | null = null;
// The glyph atlas is rasterised for one device pixel ratio. Dragging the
// window to a display with a different one (or an Android browser-zoom
// change) leaves every cell blurry until the atlas is rebuilt.
const onDprChange = () => {
try {
term.clearTextureAtlas();
} catch {
// ignore — nothing to rebuild if the renderer already went away
}
watchDpr();
};
function watchDpr(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
try {
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
dprMedia?.addEventListener("change", onDprChange);
} catch {
// ignore — a browser without `resolution` media queries just keeps the
// atlas it has, which is the pre-WebGL behaviour anyway
}
}
function releaseWebgl(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
if (!webgl) return;
const addon = webgl;
webgl = null;
try {
addon.dispose(); // xterm falls back to the DOM renderer
} catch {
// ignore — a lost context can throw on the way out
}
}
function acquireWebgl(): void {
if (webgl || disposed || !webglPolicy.enabled()) return;
try {
const addon = new WebglAddon();
// Context loss is routine on Android — backgrounding the tab is enough.
// Drop back to the DOM renderer, then retry only while the shared budget
// allows and this view is still the one on screen.
addon.onContextLoss(() => {
webglPolicy.noteContextLoss();
releaseWebgl();
if (mounted) acquireWebgl();
});
term.loadAddon(addon);
webgl = addon;
watchDpr();
} catch {
// Context creation failed outright (no GPU, or the browser is already at
// its context cap). Count it like a loss so we stop asking, and stay on
// the DOM renderer.
webglPolicy.noteContextLoss();
webgl = null;
}
}
// Plain Escape hands the keyboard back to the session list instead of the // Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event. // shell/app. `false` tells xterm to skip its own handling of this event.
term.attachCustomKeyEventHandler((e) => { term.attachCustomKeyEventHandler((e) => {
@@ -402,6 +474,7 @@ export function createAttachConnection(
el, el,
onMount() { onMount() {
mounted = true; mounted = true;
acquireWebgl();
// Defer a tick so the element has its content-pane dimensions before fit. // Defer a tick so the element has its content-pane dimensions before fit.
requestAnimationFrame(() => { requestAnimationFrame(() => {
tryFit(); tryFit();
@@ -411,6 +484,9 @@ export function createAttachConnection(
}, },
onUnmount() { onUnmount() {
mounted = false; mounted = false;
// Parked views hand their GPU context back: the browser's cap is small
// (smaller still on Android) and MAX_PARKED connections outlive the pane.
releaseWebgl();
}, },
focusTerminal() { focusTerminal() {
term.focus(); term.focus();
@@ -427,6 +503,7 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize); visual?.removeEventListener("resize", onResize);
dataDisp.dispose(); dataDisp.dispose();
resizeDisp.dispose(); resizeDisp.dispose();
releaseWebgl();
try { try {
ws.close(); ws.close();
} catch { } catch {
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
import { MAX_CONTEXT_LOSSES, WebglPolicy } from "./webglRenderer.js";
describe("WebglPolicy", () => {
it("is enabled when the browser supports WebGL2", () => {
expect(new WebglPolicy(() => true).enabled()).toBe(true);
});
it("is disabled when the browser has no WebGL2", () => {
expect(new WebglPolicy(() => false).enabled()).toBe(false);
});
it("probes only once, however often it is asked", () => {
const probe = vi.fn(() => true);
const policy = new WebglPolicy(probe);
policy.enabled();
policy.enabled();
policy.enabled();
expect(probe).toHaveBeenCalledTimes(1);
});
it("tolerates a loss below the budget — a backgrounded tab is ordinary", () => {
const policy = new WebglPolicy(() => true);
policy.noteContextLoss();
expect(policy.enabled()).toBe(true);
});
it("gives up for good once losses reach the budget", () => {
const policy = new WebglPolicy(() => true);
for (let i = 0; i < MAX_CONTEXT_LOSSES; i++) policy.noteContextLoss();
expect(policy.enabled()).toBe(false);
expect(policy.contextLosses()).toBe(MAX_CONTEXT_LOSSES);
});
it("stays disabled past the budget, and never bothers probing", () => {
const probe = vi.fn(() => true);
const policy = new WebglPolicy(probe);
for (let i = 0; i < MAX_CONTEXT_LOSSES + 3; i++) policy.noteContextLoss();
expect(policy.enabled()).toBe(false);
expect(probe).not.toHaveBeenCalled();
});
it("treats a throwing probe as unsupported", () => {
const policy = new WebglPolicy(() => {
throw new Error("no gl for you");
});
expect(() => policy.enabled()).not.toThrow();
expect(policy.enabled()).toBe(false);
});
it("reports no WebGL2 under the test DOM, which has no GPU", () => {
expect(new WebglPolicy().enabled()).toBe(false);
});
});
+73
View File
@@ -0,0 +1,73 @@
// WebGL renderer policy for attached terminals. Split out of attach.ts so it
// can be tested without a DOM or an xterm instance (same reasoning as
// reconnect.ts).
//
// xterm's default renderer builds DOM nodes per cell, which is CPU-bound —
// exactly the wrong bottleneck on a phone, where a full-screen repaint from an
// agent dumping output visibly stutters. The WebGL addon moves glyph drawing to
// a GPU texture atlas instead.
//
// The cost is that a WebGL context is a scarce, revocable resource:
//
// * Browsers cap live contexts (~16 on desktop Chrome, fewer on Android) and
// silently kill the oldest past the cap. `ConnectionPool` keeps MAX_PARKED
// parked connections alive on top of the mounted ones, so handing every
// connection its own context would blow that budget on its own and bring a
// parked terminal back blank.
// * Android drops contexts as routine housekeeping — backgrounding the tab,
// locking the screen, memory pressure.
//
// So the context follows the *view*, not the connection: acquired on mount,
// released on park. Losses count against a shared budget, because a device that
// keeps dropping contexts is telling us it cannot sustain one.
/** Context losses tolerated before the page gives up and stays on the DOM
* renderer. One loss is ordinary (a tab switch); repeated losses mean the
* device is thrashing, and re-acquiring only makes it worse. */
export const MAX_CONTEXT_LOSSES = 2;
/** Can this browser hand out a WebGL2 context at all? */
export function probeWebgl2(): boolean {
return document.createElement("canvas").getContext("webgl2") !== null;
}
/**
* Whether an attached terminal should try the WebGL renderer right now.
*
* Shared by every connection on the page: the context budget and the "this
* device cannot cope" signal are both global, not per-terminal.
*/
export class WebglPolicy {
private supported: boolean | null = null;
private losses = 0;
constructor(private readonly probe: () => boolean = probeWebgl2) {}
/** True while WebGL is both available and still worth attempting. The probe
* runs at most once — support does not change under us, and creating
* throwaway contexts to re-ask would itself consume the budget. */
enabled(): boolean {
if (this.losses >= MAX_CONTEXT_LOSSES) return false;
if (this.supported === null) {
try {
this.supported = this.probe();
} catch {
// A browser that throws rather than returning null has no usable
// context either way.
this.supported = false;
}
}
return this.supported;
}
/** Record a lost context. Past the budget, `enabled()` stays false for the
* life of the page. */
noteContextLoss(): void {
this.losses += 1;
}
/** Losses recorded so far — for the debug HUD and tests. */
contextLosses(): number {
return this.losses;
}
}