Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eeb43774a | ||
|
|
b36d571b21 | ||
|
|
e4601e3554 | ||
|
|
86eeae8296 | ||
|
|
4bb732d9b9 | ||
|
|
f669ebbddd |
@@ -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,
|
||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||
/// 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
|
||||
/// focused for at least [`FOCUS_CLEAR_HOLD`].
|
||||
@@ -161,7 +161,7 @@ mod tests {
|
||||
fn awaiting_input_glows_until_focus_hold_is_reached() {
|
||||
let now = std::time::Instant::now();
|
||||
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!(
|
||||
apply_focus_suppression(
|
||||
ActivityState::AwaitingInput,
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
//! `dispatch` / the overlay routing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Stdout;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -19,7 +18,6 @@ use chrono::Utc;
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
use futures_util::StreamExt;
|
||||
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -89,8 +87,36 @@ pub struct App {
|
||||
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
|
||||
/// mouse) is covered without each one remembering to report it.
|
||||
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 {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("App")
|
||||
@@ -170,13 +196,16 @@ impl App {
|
||||
last_sent_size: HashMap::new(),
|
||||
clicks: ClickTracker::default(),
|
||||
focused_session: None,
|
||||
last_frame: None,
|
||||
last_drawn_at: std::time::Instant::now(),
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the loop until the user quits. Consumes self.
|
||||
pub async fn run(
|
||||
pub async fn run<B: ratatui::backend::Backend>(
|
||||
mut self,
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
terminal: &mut Terminal<B>,
|
||||
) -> Result<(), TuiError> {
|
||||
if let Ok(info) = self.daemon.daemon_info().await {
|
||||
self.home_dir = info.home_dir;
|
||||
@@ -184,7 +213,7 @@ impl App {
|
||||
let mut events = EventStream::new();
|
||||
loop {
|
||||
self.sync_focus().await;
|
||||
self.redraw(terminal)?;
|
||||
self.redraw_if_stale(terminal)?;
|
||||
|
||||
tokio::select! {
|
||||
Some(Ok(ev)) = events.next() => {
|
||||
@@ -193,17 +222,22 @@ impl App {
|
||||
}
|
||||
}
|
||||
Some(update) = self.draft_rx.recv() => {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
self.apply_draft_update(update).await;
|
||||
}
|
||||
_ = self.catalog.changed() => {
|
||||
// Render on next loop iteration.
|
||||
// Picked up by the frame key's catalog version.
|
||||
}
|
||||
_ = self.link.changed() => {
|
||||
// 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)) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -217,6 +251,10 @@ impl App {
|
||||
return Ok(false);
|
||||
};
|
||||
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
|
||||
// only the quit binding still resolves until it answers again.
|
||||
@@ -959,9 +997,46 @@ impl App {
|
||||
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,
|
||||
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> {
|
||||
let snap = self.catalog.snapshot();
|
||||
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]
|
||||
fn enter_insert_then_leave() {
|
||||
let mut app = make_app();
|
||||
@@ -2091,6 +2260,7 @@ mod tests {
|
||||
tx: mpsc::channel(1).0,
|
||||
screen: Arc::clone(&screen),
|
||||
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||
revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
let mut app = make_app();
|
||||
app.state.active_tab_mut().pane = Some(sid);
|
||||
|
||||
@@ -87,6 +87,16 @@ impl CatalogStore {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let s = self.inner.state.read().expect("catalog rwlock poisoned");
|
||||
let mut sessions: Vec<Session> = s.sessions.values().cloned().collect();
|
||||
|
||||
@@ -168,6 +168,7 @@ mod tests {
|
||||
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: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
emulators.insert(id, handle);
|
||||
assert!(emulators.get(id).is_some());
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod state;
|
||||
pub mod terminal;
|
||||
pub mod text_input;
|
||||
|
||||
use std::io::BufWriter;
|
||||
use std::path::PathBuf;
|
||||
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 {
|
||||
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 app = App::new(tui_cfg, catalog, daemon, link);
|
||||
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
|
||||
/// staring at a terminal, so an unreachable daemon must fail in under a second.
|
||||
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! parser is **not** reset on reconnect — the last-known frame stays
|
||||
//! 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::time::Duration;
|
||||
|
||||
@@ -56,11 +56,15 @@ pub enum SessionState {
|
||||
/// here.
|
||||
/// - `state` — `Arc<AtomicU8>`: live status of the WS, queryable without
|
||||
/// 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)]
|
||||
pub struct SessionHandle {
|
||||
pub tx: mpsc::Sender<SessionInput>,
|
||||
pub screen: Arc<Mutex<vt100::Parser>>,
|
||||
pub(crate) state: Arc<AtomicU8>,
|
||||
pub(crate) revision: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
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
|
||||
/// no other tabs pointing at this session.
|
||||
pub fn end(&self) {
|
||||
@@ -109,9 +119,11 @@ impl SessionTask {
|
||||
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
|
||||
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
||||
let revision = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let parser_bg = parser.clone();
|
||||
let state_bg = state.clone();
|
||||
let revision_bg = revision.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let url = ws_url;
|
||||
@@ -151,6 +163,7 @@ impl SessionTask {
|
||||
}
|
||||
SessionInput::Resize { cols, rows } => {
|
||||
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 {
|
||||
cols,
|
||||
rows,
|
||||
@@ -166,6 +179,7 @@ impl SessionTask {
|
||||
match frame {
|
||||
Some(Ok(Message::Binary(bytes))) => {
|
||||
parser_bg.lock().expect("vt100 parser mutex").process(&bytes);
|
||||
revision_bg.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
// Ping/Pong control frames travel as text JSON. Reply to Ping.
|
||||
@@ -202,6 +216,7 @@ impl SessionTask {
|
||||
tx,
|
||||
screen: parser,
|
||||
state,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +243,7 @@ mod tests {
|
||||
tx: mpsc::channel(1).0,
|
||||
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
||||
state: Arc::new(AtomicU8::new(SessionState::Connecting as u8)),
|
||||
revision: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
assert_eq!(h.state(), SessionState::Connecting);
|
||||
}
|
||||
@@ -238,8 +254,26 @@ mod tests {
|
||||
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: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
h.end();
|
||||
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
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0=";
|
||||
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
@@ -218,8 +218,8 @@
|
||||
settings = {
|
||||
# `nix run .#dev` works from a bare shell, not only from inside
|
||||
# `nix develop`, so the toolchain is put on PATH here rather than
|
||||
# inherited. `/usr/bin:/bin` stays last for anything else a
|
||||
# spawned session reaches for.
|
||||
# inherited. This covers the build and Vite; the daemon widens it
|
||||
# further for the sessions it spawns (see `daemon.command`).
|
||||
environment = [
|
||||
"PATH=${lib.makeBinPath devTools}:/usr/bin:/bin"
|
||||
# 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
|
||||
# out of — and safe from — the tmux server belonging to
|
||||
# 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 = ''
|
||||
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" \
|
||||
RUST_LOG="''${RUST_LOG:-maestro=debug,info}" \
|
||||
target/debug/maestro daemon
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+8
@@ -14,6 +14,9 @@ importers:
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/addon-webgl':
|
||||
specifier: ^0.19.0
|
||||
version: 0.19.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
@@ -348,6 +351,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
|
||||
'@xterm/addon-webgl@0.19.0':
|
||||
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
|
||||
|
||||
'@xterm/xterm@5.5.0':
|
||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||
|
||||
@@ -772,6 +778,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
'@xterm/addon-webgl@0.19.0': {}
|
||||
|
||||
'@xterm/xterm@5.5.0': {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
@@ -10,6 +11,11 @@ import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.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.
|
||||
@@ -135,6 +141,72 @@ export function createAttachConnection(
|
||||
term.loadAddon(fit);
|
||||
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
|
||||
// shell/app. `false` tells xterm to skip its own handling of this event.
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
@@ -402,6 +474,7 @@ export function createAttachConnection(
|
||||
el,
|
||||
onMount() {
|
||||
mounted = true;
|
||||
acquireWebgl();
|
||||
// Defer a tick so the element has its content-pane dimensions before fit.
|
||||
requestAnimationFrame(() => {
|
||||
tryFit();
|
||||
@@ -411,6 +484,9 @@ export function createAttachConnection(
|
||||
},
|
||||
onUnmount() {
|
||||
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() {
|
||||
term.focus();
|
||||
@@ -427,6 +503,7 @@ export function createAttachConnection(
|
||||
visual?.removeEventListener("resize", onResize);
|
||||
dataDisp.dispose();
|
||||
resizeDisp.dispose();
|
||||
releaseWebgl();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
|
||||
@@ -2023,6 +2023,51 @@ input.leaf-rename:focus {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Mobile: the spawn form is the tallest dialog we have, so it takes the whole
|
||||
screen and lays out as one flex column — header, scrolling body, actions
|
||||
pinned to the bottom edge. Below the fold is where the Submit button used to
|
||||
hide. */
|
||||
@media (max-width: 599px) {
|
||||
.modal.spawn-modal {
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.spawn-modal .spawn-form {
|
||||
flex: 1 1 auto;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* One scroll box, not four: the pickers' own caps stack scrollable regions
|
||||
inside a screen that is already short. Let them grow and let the form
|
||||
scroll. */
|
||||
.spawn-modal .picker,
|
||||
.spawn-modal .picker.projects,
|
||||
.spawn-modal .picker.wt-choices {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* Rides the bottom of the scroll box: reachable at any scroll position, and
|
||||
`margin-top: auto` keeps it at the screen's foot when the form is short. */
|
||||
.spawn-modal .form-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: auto;
|
||||
padding: var(--sp-2) 0 calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--surface);
|
||||
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.spawn-modal .form-actions .btn {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
|
||||
.spawn-form {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user