Compare commits

..
15 Commits
Author SHA1 Message Date
Miguel Palhas 71e0e8d0ce Merge branch 'main' of https://git.naps.pt/naps62/maestro into oboe
ci / web (push) Successful in 1m17s
ci / flake (push) Successful in 3m22s
ci / rust (push) Successful in 6m58s
2026-09-03 11:12:02 +01:00
Miguel Palhas 582a7ca888 Merge remote-tracking branch 'origin/main' into oboe
# Conflicts:
#	web/src/catalog.ts
2026-09-03 11:11:40 +01:00
Miguel Palhas d8a5d95db4 Merge remote-tracking branch 'origin/main' into quintet
ci / rust (push) Failing after 7s
ci / web (push) Successful in 32s
ci / flake (push) Successful in 2m2s
2026-09-03 11:08:32 +01:00
Miguel Palhas 51002fece8 docs(maestro): correct the tracker described in CLAUDE.md
Issues moved to Gitea; the file still named a Linear project and team.
Adds the label taxonomy and the milestone/blitz workflow while there.
2026-09-03 11:06:22 +01:00
Miguel Palhas 2319b4e47b fix(maestro): keep the keyboard in the list while previewing
Two mounts were taking focus out of the sidebar mid-walk: the render that
put the multi-view on screen before the preview applied, and the render
that restores the previous pane when a preview ends. Both now mount
without focusing, so j/k keeps walking. Also marks the preview with a
badge on the canvas and drops the close button from its tab.
2026-09-03 10:52:43 +01:00
Miguel Palhas 6635cff46e feat(maestro): preview the highlighted session in the TUI pane
The sidebar highlight now shows its session in the pane after a 120ms
debounce, attaching if nothing already is, without the tab taking it on
and without reporting focus to the daemon. Preview-only attaches stay
warm behind the current one, bounded at three.

docs/PREVIEW.md.
2026-09-03 10:46:45 +01:00
Miguel Palhas f6b2cf3ab4 feat(maestro): preview the highlighted session in the web pane
Moving the sidebar highlight (keyboard or hover) now shows that session in
the content pane without opening it: its own tab comes forward, or a
throwaway tab carries it. The layout, the saved layout and the daemon's
focus report are all left untouched, and the keyboard stays in the list.

Contract in docs/PREVIEW.md.
2026-09-03 10:42:53 +01:00
Miguel Palhas 05e7293af2 Merge remote-tracking branch 'origin/main' into accordion
ci / web (push) Successful in 32s
ci / flake (push) Successful in 2m45s
ci / rust (push) Successful in 6m3s
2026-09-03 10:34:09 +01:00
Miguel PalhasandClaude Opus 5 b1527f5fc1 fix(maestro): align TUI and web shortcuts
Spawn was `s`/`S` in the TUI and `n`/`N` in the web client; the TUI now
uses `n`/`N` too. The web sidebar gains `c`, `r` and `x` — collapse,
rename, kill — which the TUI already bound.

docs/SIDEBAR.md now carries the shared table, so a new binding has one
place to land on both clients.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 08:25:35 +01:00
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
Miguel Palhas f669ebbddd fix(maestro): full-screen spawn dialog on mobile
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m8s
ci / rust (push) Successful in 6m20s
The dialog capped at 92vh and scrolled its whole body, so the Spawn button
sat below the fold on a phone. Below 600px it now fills the viewport, the
pickers drop their own scroll caps, and the actions row sticks to the bottom
edge.
2026-09-02 21:53:26 +01:00
28 changed files with 3210 additions and 73 deletions
+13 -3
View File
@@ -36,10 +36,16 @@ optional.
## Where the work is
Linear project **Maestro** under team **Naps62**.
Gitea issues on [`naps62/maestro`](https://git.naps.pt/naps62/maestro), grouped
into milestones.
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from
"available now" (status backlog/pending, not blocked).
`.claude/linear.json` (legacy filename, `tracker: gitea`) wires `/yolo` and
`/work` to that tracker — they pick from open, unassigned issues whose
dependencies are all closed. `/blitz <milestone>` sweeps a whole milestone.
Labels carry one value per axis: `area/*`, `type/*`, `difficulty/*`.
`difficulty` drives model selection downstream, so it tracks the shape of the
work, not its size.
## Repo layout
@@ -72,6 +78,10 @@ before changing anything that creates or removes one.
sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering.
`docs/PREVIEW.md` is the contract for what the content pane shows while the
operator moves the sidebar highlight — what a preview may touch, what ends it,
what commits it. Read it before changing sidebar-driven navigation.
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
or is upgraded under them — the build stamp on `/api/info`, the status
indicator's states, the blocking overlay, grace and poll timings. Read it
+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,
/// 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,
+469 -22
View File
@@ -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,52 @@ 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>,
/// A sidebar highlight waiting out its debounce before it becomes a
/// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must
/// not open an attach per row it passes over.
pending_preview: Option<(Uuid, std::time::Instant)>,
/// Sessions attached only because they were previewed, oldest first. Kept
/// connected past the preview so stepping back onto a row is instant, and
/// bounded so a long walk doesn't leave a socket per session behind.
preview_lru: Vec<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);
/// How long a highlight has to stand still before the pane previews it
/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't
/// attach to every row it crosses, short enough to read as immediate.
const PREVIEW_DELAY: Duration = Duration::from_millis(120);
/// How many preview-only attaches to keep connected behind the current one.
const PREVIEW_BUDGET: usize = 3;
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
@@ -170,13 +212,18 @@ impl App {
last_sent_size: HashMap::new(),
clicks: ClickTracker::default(),
focused_session: None,
pending_preview: None,
preview_lru: Vec::new(),
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 +231,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 +240,24 @@ 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());
let now = std::time::Instant::now();
let mut effects = self.poll_due_probes_at(now);
effects.extend(self.poll_due_preview_at(now));
if !effects.is_empty() {
self.epoch = self.epoch.wrapping_add(1);
}
self.perform(effects).await;
}
}
@@ -217,6 +271,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.
@@ -294,6 +352,7 @@ impl App {
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
}
}
self.refresh_preview();
true
}
@@ -415,8 +474,15 @@ impl App {
if let Some(previous) = self.state.active_session() {
self.release_session(previous);
}
effects.push(Effect::Attach(sid));
// A previewed session is already attached: committing
// to it keeps that stream rather than reopening one.
if self.emulators.get(sid).is_none() {
effects.push(Effect::Attach(sid));
}
}
// It is the pane's own session now, not a loan from the
// preview budget.
self.preview_lru.retain(|s| *s != sid);
self.state.attach_to_focused(sid);
}
}
@@ -443,9 +509,89 @@ impl App {
}
}
}
// Every action can move the highlight or the focus, and the preview is
// a function of both (`docs/PREVIEW.md`).
self.refresh_preview();
effects
}
/// Recompute what the pane should be previewing. A session highlighted in
/// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a
/// group row, the pane holding the keyboard, or the highlight already
/// sitting on the attached session — clears the preview at once.
fn refresh_preview(&mut self) {
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
_ => None,
};
match wanted {
Some(sid) if self.state.active_session() != Some(sid) => {
if self.state.preview != Some(sid) {
self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY));
}
}
_ => {
self.pending_preview = None;
self.clear_preview();
}
}
}
/// The debounce elapsed: show the highlighted session, attaching it if
/// nothing already is.
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let Some((sid, due)) = self.pending_preview else {
return Vec::new();
};
if now < due {
return Vec::new();
}
self.pending_preview = None;
if self.state.preview == Some(sid) {
return Vec::new();
}
self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1);
let fresh = self.emulators.get(sid).is_none();
if fresh {
self.preview_lru.retain(|s| *s != sid);
self.preview_lru.push(sid);
}
self.trim_preview_budget();
if fresh {
vec![Effect::Attach(sid)]
} else {
Vec::new()
}
}
fn clear_preview(&mut self) {
if self.state.preview.take().is_some() {
self.epoch = self.epoch.wrapping_add(1);
}
self.trim_preview_budget();
}
/// Drop preview-only attaches past the budget. What a tab holds, and what
/// is on screen right now, are never candidates.
fn trim_preview_budget(&mut self) {
while self.preview_lru.len() > PREVIEW_BUDGET {
let held: HashSet<Uuid> = self
.state
.tabs
.iter()
.filter_map(crate::state::Tab::session)
.chain(self.state.preview)
.collect();
let Some(pos) = self.preview_lru.iter().position(|sid| !held.contains(sid)) else {
break;
};
let victim = self.preview_lru.remove(pos);
self.release_session(victim);
}
}
/// Open a stream for `sid` if one isn't already running.
fn attach(&mut self, sid: Uuid) {
if self.emulators.get(sid).is_some() {
@@ -959,9 +1105,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.displayed_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
@@ -975,21 +1158,31 @@ impl App {
highlight: self.state.sidebar_highlight.clone(),
attached,
};
let active_sid = self.state.active_session();
let pane = active_sid.and_then(|sid| {
let shown_sid = self.state.displayed_session();
let previewing = self.state.preview.is_some();
let pane = shown_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
screen: h.screen.clone(),
status: PaneStatus::from(h.state()),
focused: matches!(self.state.focus, Focus::Pane),
// A preview never owns the keyboard, so it never draws a
// cursor either (`docs/PREVIEW.md`).
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
})
});
let pane_title = match active_sid {
Some(sid) => snap
.sessions
.iter()
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into()),
let pane_title = match shown_sid {
Some(sid) => {
let name = snap
.sessions
.iter()
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into());
if previewing {
format!("{name} · preview")
} else {
name
}
}
None => "session".into(),
};
@@ -1017,7 +1210,7 @@ impl App {
// Resize propagation: record the active pane's rect, recompute the
// per-dimension min for every attached session, send Resize when
// changed.
if let (Some(sid), Some(rect)) = (active_sid, self.region.pane) {
if let (Some(sid), Some(rect)) = (shown_sid, self.region.pane) {
self.emulators.resize_tracker_mut().set_pane_size(
sid,
"active",
@@ -1257,6 +1450,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();
@@ -1332,6 +1619,165 @@ mod tests {
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
}
/// Seed the catalog with `ids` as sessions in one directory.
fn app_with_sessions(ids: &[Uuid]) -> App {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: ids
.iter()
.map(|id| Session {
id: *id,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata: Default::default(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
})
.collect(),
});
app
}
fn fake_handle() -> crate::session_task::SessionHandle {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
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: Arc::new(AtomicU64::new(0)),
}
}
fn after_debounce() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1)
}
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
/// tab taking it on.
#[test]
fn highlighting_a_session_previews_it_once_the_debounce_elapses() {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
// Nothing yet: the highlight has to stand still first.
assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]);
assert_eq!(app.state.preview, None);
assert_eq!(
app.poll_due_preview_at(after_debounce()),
vec![Effect::Attach(a)]
);
assert_eq!(app.state.preview, Some(a));
assert_eq!(app.state.displayed_session(), Some(a));
// The tab is untouched — the preview is a view, not a layout change.
assert_eq!(app.state.active_tab().session(), None);
}
/// A fast walk down the list attaches to where it stopped, not to every
/// row it crossed.
#[test]
fn walking_past_rows_only_previews_the_one_it_lands_on() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext);
let effects = app.poll_due_preview_at(after_debounce());
assert_eq!(effects, vec![Effect::Attach(b)]);
assert_eq!(app.state.preview, Some(b));
}
/// The pane's own session is not something to preview.
#[test]
fn highlighting_the_attached_session_clears_the_preview() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.state.attach_to_focused(a);
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
assert_eq!(app.state.preview, Some(b));
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
app.refresh_preview();
assert_eq!(app.state.preview, None);
assert_eq!(app.state.displayed_session(), Some(a));
}
/// Moving off every session — onto a group row, or into the pane — puts
/// back what the tab was showing.
#[test]
fn leaving_the_session_rows_ends_the_preview() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.state.attach_to_focused(a);
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
assert_eq!(app.state.preview, None);
assert_eq!(app.state.displayed_session(), Some(a));
}
/// Committing keeps the stream the preview already opened.
#[test]
fn attaching_to_a_previewed_session_reuses_its_stream() {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
app.poll_due_preview_at(after_debounce());
// Stand in for the attach the effect would have opened.
app.emulators.insert(a, fake_handle());
let effects = app.dispatch(Action::AttachHighlighted);
assert_eq!(effects, vec![]);
assert_eq!(app.state.active_tab().session(), Some(a));
assert_eq!(app.state.preview, None);
// No longer on loan from the preview budget.
assert!(!app.preview_lru.contains(&a));
}
/// Preview-only attaches are kept for a step back, but bounded.
#[test]
fn preview_attaches_are_dropped_past_the_budget() {
let ids: Vec<Uuid> = (1..=(PREVIEW_BUDGET as u128 + 2))
.map(Uuid::from_u128)
.collect();
let mut app = app_with_sessions(&ids);
for id in &ids {
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.emulators.insert(*id, fake_handle());
}
assert_eq!(app.preview_lru.len(), PREVIEW_BUDGET);
// The oldest rows previewed are gone; the newest are still warm.
assert!(app.emulators.get(ids[0]).is_none());
assert!(app.emulators.get(*ids.last().unwrap()).is_some());
}
/// Seed the catalog with one session and highlight it.
fn app_with_highlighted_session(sid: Uuid) -> App {
use chrono::Utc;
@@ -2091,6 +2537,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);
+10
View File
@@ -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();
+10 -9
View File
@@ -52,10 +52,11 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// because Normal mode only exists while the sidebar is focused, so
// nothing forwards it to a pty.
("tab", "AttachHighlighted"),
("s", "OpenSpawnModal"),
// Fast-forward spawn (#67): the spawn key's neighbour does the same
// `n`/`N`, the same pair the web client binds.
("n", "OpenSpawnModal"),
// Fast-forward spawn (#67): the spawn key's shifted twin does the same
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("N", "FastForwardSpawn"),
("x", "KillHighlighted"),
("r", "RenameHighlighted"),
// Focus
@@ -220,11 +221,11 @@ mod tests {
Some(Action::AttachHighlighted),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
cfg.keymap.resolve(Mode::Normal, "n"),
Some(Action::OpenSpawnModal),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "S"),
cfg.keymap.resolve(Mode::Normal, "N"),
Some(Action::FastForwardSpawn),
);
}
@@ -311,18 +312,18 @@ mod tests {
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
}
/// Regression for #70: the previous canonical form of a real Shift+S
/// keypress was `"shift-S"`, which the default `"S"` binding never
/// Regression for #70: the previous canonical form of a real Shift+N
/// keypress was `"shift-N"`, which the default `"N"` binding never
/// matched. Drive the actual crossterm event, not the binding string,
/// so the dead-key bug can't come back unnoticed.
#[test]
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
fn real_shift_n_keypress_resolves_to_fast_forward_spawn() {
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
use crate::input::{canonicalise, Region};
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
assert_eq!(
+1
View File
@@ -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());
+2 -1
View File
@@ -597,7 +597,8 @@ mod tests {
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
/// the uppercase letter already says "shifted"; a redundant prefix would
/// make the default `S` binding unreachable from the keyboard (#70).
/// make every uppercase binding (the default `N`) unreachable from the
/// keyboard (#70).
#[test]
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
let k = InputKey::Char {
+12 -1
View File
@@ -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);
+35 -1
View File
@@ -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);
}
}
+16
View File
@@ -63,6 +63,11 @@ pub struct AppState {
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<SidebarItem>,
/// The session the pane is *previewing* (`docs/PREVIEW.md`): shown because
/// the sidebar highlight is on it, not because a tab holds it. It never
/// enters `tabs`, is never reported to the daemon as focused, and is
/// dropped the moment the highlight moves on.
pub preview: Option<Uuid>,
/// Group paths whose cards are hidden. This is UI state rather than
/// catalog state, so it survives every catalog redraw.
pub collapsed_sidebar_groups: HashSet<PathBuf>,
@@ -91,6 +96,7 @@ impl AppState {
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
preview: None,
collapsed_sidebar_groups: HashSet::new(),
overlay: None,
}
@@ -126,6 +132,9 @@ impl AppState {
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
// Committing to what was previewed: the pane owns it now, so the
// preview has nothing left to say.
self.preview = None;
self.active_tab_mut().pane = Some(session_id);
self.focus = Focus::Pane;
self.mode = Mode::Insert;
@@ -150,6 +159,13 @@ impl AppState {
self.active_tab().session()
}
/// What the pane actually draws: the preview when one is up, otherwise the
/// active tab's session. A preview covers the pane without replacing it,
/// so ending one gives back exactly what was there (`docs/PREVIEW.md`).
pub fn displayed_session(&self) -> Option<Uuid> {
self.preview.or_else(|| self.active_session())
}
/// Move the sidebar highlight one step through the rendered item
/// sequence, in the direction of `delta` (positive = down), landing only
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
+2 -1
View File
@@ -141,7 +141,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
[tui.keymap.normal]
"i" = "EnterInsert"
"s" = "OpenSpawnModal"
"n" = "OpenSpawnModal"
"N" = "FastForwardSpawn"
"x" = "KillHighlighted"
"r" = "RenameHighlighted"
"?" = "ShowHelp"
+71
View File
@@ -0,0 +1,71 @@
# PREVIEW.md — the sidebar preview contract
One rule for what the content pane shows while the operator is only moving
around the sidebar, so the web client and the TUI behave the same. Read this
before changing either. If the behaviour needs to change, change it here first.
## The rule
Highlighting a session in the sidebar shows that session in the content pane,
as if it were focused, **without focusing it and without changing the layout**.
Moving the highlight elsewhere puts back exactly what was on screen before.
A preview is a view, not a state. Nothing about it is durable:
- The layout is byte-for-byte identical before the preview and after it ends.
- Nothing is persisted while a preview is up (the web client's layout is saved
to `localStorage`; a preview never reaches it).
- The daemon is never told the previewed pane is focused, so a preview never
clears a session's `AwaitingInput` glow (`docs/SIDEBAR.md`).
- The keyboard stays in the sidebar: the previewed terminal is on screen but
does not take focus, so the next `j`/`k` still walks the list.
## Where the preview is shown
| The previewed session is… | What happens |
|---|---|
| Already in a pane, in the active tab | That pane comes forward (its tab's focus moves to it). |
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
The throwaway tab never counts as an open session: the tab bar does not appear
because of it, the mobile shell does not switch away from the session list for
it, and the sidebar does not mark the session as open.
## What ends a preview
- The highlight moves to another session — that session is previewed instead.
- The highlight leaves every session (a folder row, or the list losing the
keyboard, or the pointer leaving the list): the previous view comes back.
- Any real layout change (split, close, new tab, tab switch, drag): the preview
is discarded first, so a throwaway tab is never baked into the arrangement.
## What commits a preview
Choosing the previewed session — clicking or pressing Enter on its row,
clicking into its pane, or handing the keyboard to the pane (web: `Tab` from
the list; TUI: the attach binding) — keeps exactly what is on screen. A
throwaway tab becomes a real one; a focus move inside an existing tab stands.
Nothing moves a second time on commit.
## Timing
A preview follows the highlight closely enough to be usable as a way to look
around, and lags a pointer enough not to flash rows crossed on the way
somewhere else.
| Trigger | Delay |
|---|---|
| Keyboard step (`j`/`k`, arrows) | 60 ms |
| Pointer hover (web, mouse only — never touch) | 150 ms |
| Clearing (highlight left every session) | same delay as the trigger that set it |
Touch has no hover, and a tap already opens a session, so touch never previews.
## Cost
A previewed session that no pane holds needs a live attach. Both clients keep
it connected after the preview ends — the web client parks it in the connection
pool's LRU, the TUI keeps its emulator until it falls out of the preview
budget — so stepping back onto a row is instant rather than re-fetching
scrollback.
+19 -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
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
@@ -274,3 +274,21 @@ than jumping to the end of the list.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
same chord back): an attached terminal owns every plain keystroke. The TUI uses
`ctrl-w h`.
Everything else is the same key on both clients:
| key | action |
|---------|---------------------------------------------|
| `j`/`k` | move the highlight (arrows too) |
| `Enter` | open the session, or expand the folder |
| `Tab` | hand the keyboard to the pane |
| `c` | collapse or expand the highlighted folder |
| `r` | rename the highlighted session |
| `x` | kill the highlighted session |
| `n` | open the spawn form |
| `N` | spawn with every default resolved |
| `?` | show the shortcut overlay |
The TUI binds these through its keymap, so a user can rebind any of them; the
web client's are fixed. A binding added to one client belongs in this table and
in both help overlays.
+13 -3
View File
@@ -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
+1645
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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": {
+8
View File
@@ -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: {}
+89 -5
View File
@@ -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.
@@ -26,8 +32,10 @@ export interface AttachConnection {
readonly sessionId: Uuid;
/** The `.attach` element (header + terminal body) to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit to the pane, focus, sync size. */
onMount(): void;
/** Becomes the visible view: fit to the pane, focus, sync size.
* `focus: false` mounts without taking the keyboard — what a sidebar
* preview needs, so j/k keeps working while the pane is on screen. */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
@@ -135,6 +143,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) => {
@@ -237,6 +311,10 @@ export function createAttachConnection(
}
let mounted = false;
// A previewed pane is visible but must not steal the keyboard from the
// sidebar, so mounting can opt out of the focus grab (and out of the one
// the socket does when it finishes connecting).
let focusOnMount = true;
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
@@ -315,7 +393,7 @@ export function createAttachConnection(
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted) term.focus();
if (mounted && focusOnMount) term.focus();
};
ws.onmessage = (ev) => {
@@ -400,17 +478,22 @@ export function createAttachConnection(
return {
sessionId,
el,
onMount() {
onMount(mountOpts?: { focus?: boolean }) {
mounted = true;
focusOnMount = mountOpts?.focus !== false;
acquireWebgl();
// Defer a tick so the element has its content-pane dimensions before fit.
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (ws.readyState === WebSocket.OPEN) term.focus();
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
});
},
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 +510,7 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
releaseWebgl();
try {
ws.close();
} catch {
+58 -1
View File
@@ -364,6 +364,11 @@ export interface SidebarOptions {
/** Daemon link behind the footer's status dot. Omitted in tests and in the
* design kit, which render the sidebar without a daemon. */
link?: DaemonLink;
/** The highlight moved onto a session, or off every session (`null`): the
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
* `source` is there so a mouse sweep can be debounced differently from a
* keyboard step. */
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -528,6 +533,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
};
const sessionOf = (key: string | null): string | null =>
key?.startsWith("session:") ? key.slice("session:".length) : null;
const moveNav = (delta: number) => {
const rows = navRows();
if (rows.length === 0) return;
@@ -539,6 +547,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
opts.onPreview?.(sessionOf(navKey), "key");
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
@@ -563,9 +572,50 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
hovered = id;
opts.onPreview?.(id, "hover");
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null);
});
treeHost.addEventListener("pointerleave", (e) => {
if (e.pointerType === "touch") return;
setHovered(null);
});
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
// keyboard stays here while j/k runs.
treeHost.tabIndex = -1;
// The keyboard walk's preview belongs to the list having the keyboard: once
// it doesn't, the content pane goes back to what the operator actually opened.
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
const row = rowFor(navKey);
const id = row?.dataset.sessionId;
if (!row || !id) return undefined;
const session = store.snapshot().sessions.get(id);
return session ? { row, session } : undefined;
};
/** `c`: collapse or expand the highlighted folder, the TUI's
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
* the key does nothing rather than toggling some other folder. */
const toggleHighlightedFolder = () => {
const row = rowFor(navKey);
if (!row || !row.classList.contains("tree-folder-header")) return;
row.click();
applyNav();
};
treeHost.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
// The inline rename field lives inside a row and owns its own keys.
@@ -574,7 +624,14 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
else if (e.key === "Enter") activateNav();
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
else return;
else if (e.key === "c") toggleHighlightedFolder();
else if (e.key === "x") {
const hit = highlightedSession();
if (hit) void requestKill(hit.session, sessionName(hit.session));
} else if (e.key === "r") {
const hit = highlightedSession();
if (hit) beginRename(hit.row, hit.session);
} else return;
e.preventDefault();
});
+9 -5
View File
@@ -74,7 +74,7 @@ export class ConnectionPool {
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
* exists (refreshing LRU recency). Does not touch other mounted panes.
*/
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
let conn = this.cache.get(id);
if (conn) {
this.cache.delete(id);
@@ -93,9 +93,9 @@ export class ConnectionPool {
}
if (conn.el.parentElement !== pane) {
pane.replaceChildren(conn.el);
conn.onMount();
conn.onMount(opts);
} else if (!this.mounted.has(id)) {
conn.onMount();
conn.onMount(opts);
}
this.mounted.add(id);
this.evict();
@@ -114,13 +114,17 @@ export class ConnectionPool {
/**
* Reconcile the visible set to exactly `entries` (in render order): park any
* mounted session not listed, mount/refresh each listed one in its pane.
* `focus: false` shows the panes without taking the keyboard (preview).
*/
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): void {
setVisible(
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
opts?: { focus?: boolean },
): void {
const next = new Set(entries.map((e) => e.id));
for (const id of [...this.mounted]) {
if (!next.has(id)) this.unmount(id);
}
for (const e of entries) this.mount(e.id, e.pane, e.info);
for (const e of entries) this.mount(e.id, e.pane, e.info, opts);
}
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
+3
View File
@@ -31,6 +31,9 @@ export const SHORTCUTS: ShortcutGroup[] = [
{ keys: ["j"], description: "Highlight the next session" },
{ keys: ["k"], description: "Highlight the previous session" },
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
{ keys: ["c"], description: "Collapse or expand the highlighted folder" },
{ keys: ["r"], description: "Rename the highlighted session" },
{ keys: ["x"], description: "Kill the highlighted session" },
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
],
+36 -3
View File
@@ -104,9 +104,26 @@ shellEl.dataset.route = "catalog";
// after the welcome renderer). The hook only fires on user interaction, by which
// point `mv` is assigned.
let mv: MultiView;
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
// mouse sweep waits long enough that rows crossed on the way somewhere else
// never flash up. The same delay applies to clearing, so the gap between two
// rows doesn't blink the previous view back.
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
let previewTimer: number | undefined;
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
clearTimeout(previewTimer);
previewTimer = window.setTimeout(() => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
}, PREVIEW_DELAY[source]);
}
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
link,
onPreview: schedulePreview,
});
const content = document.createElement("section");
content.className = "content";
@@ -184,7 +201,9 @@ function infoFor(sessionId: string) {
/** Drive the shell's responsive layout off whether any pane is open. */
function updateShellRoute(): void {
const hasPanes = openSessions(layout).size > 0;
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
// not swap the list out for a terminal on the way past a row.
const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0;
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
}
@@ -244,11 +263,18 @@ mv = createMultiView({
onLayoutChange: scheduleSave,
});
let contentShowsMv = false;
function showMultiView(): void {
/** Put the multi-view in the content pane, without rendering it. A preview
* renders once, on its own terms (`docs/PREVIEW.md`); a render here would
* first re-mount the pane it is about to replace, and that mount takes the
* keyboard away from the list being walked. */
function ensureMultiView(): void {
if (!contentShowsMv) {
content.replaceChildren(mv.el);
contentShowsMv = true;
}
}
function showMultiView(): void {
ensureMultiView();
mv.render();
updateShellRoute();
}
@@ -375,7 +401,13 @@ window.addEventListener("keydown", (e) => {
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs.
const previewed = mv.previewedSessionId();
if (previewed) focusSession(previewed);
pool.focusTerminal();
});
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
@@ -386,6 +418,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that session's row silently ignored. */
function focusSession(sessionId: string): void {
clearTimeout(previewTimer);
showMultiView();
mv.openSession(sessionId);
updateShellRoute();
+181 -13
View File
@@ -11,6 +11,7 @@ import {
addTab,
closeTab,
findLeaf,
freshId,
type Dir,
type Layout,
leaves,
@@ -19,6 +20,7 @@ import {
openSessions,
removeLeaf,
splitLeaf,
type Tab,
} from "./layout.js";
import type { AttachInfo } from "./attach.js";
@@ -57,11 +59,22 @@ export interface MultiView {
/** Rebuild the view from the current layout. */
render: () => void;
focusedSessionId: () => string | null;
/** Show `sessionId` in the content pane without committing to it
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
* tab carries it. `null` puts back whatever was on screen before. */
previewSession: (sessionId: string | null) => void;
/** The session being previewed, or null. */
previewedSessionId: () => string | null;
}
export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
const persist = () => opts.onLayoutChange?.();
// A preview must never reach localStorage: it is undone the moment the
// highlight moves on, and the layout it would save is not the operator's.
const persist = () => {
if (preview) return;
opts.onLayoutChange?.();
};
const el = document.createElement("div");
el.className = "mv";
@@ -83,8 +96,119 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
render();
});
// ---- preview (docs/PREVIEW.md) ----
// A preview is a temporary *view* change, never a layout change: it records
// exactly what it moved so ending it puts the layout back byte for byte, and
// it neither persists nor reports pane focus to the daemon while it is up.
interface Preview {
sessionId: string;
/** Tab that was active before the preview. */
prevActiveTabId: string;
/** Focused leaf to restore, when the preview stole focus in a real tab. */
prevFocus: { tabId: string; leafId: string | null } | null;
/** Throwaway tab built for a session that no pane was showing. */
tempTabId: string | null;
}
let preview: Preview | null = null;
/// Set while a preview-driven render runs: ending a preview re-mounts the
/// pane that was there, and that mount must not pull the keyboard out of the
/// sidebar the operator is still walking.
let quietRender = false;
/** Render without any mount taking the keyboard. */
function renderQuietly(): void {
quietRender = true;
try {
render();
} finally {
quietRender = false;
}
}
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
/** Undo the preview's moves. Mutates only — the caller renders. */
function restorePreview(): boolean {
const p = preview;
if (!p) return false;
preview = null;
if (p.tempTabId) {
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
if (i >= 0) layout.tabs.splice(i, 1);
} else if (p.prevFocus) {
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
if (t) t.focusedLeafId = p.prevFocus.leafId;
}
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
? p.prevActiveTabId
: layout.tabs[0].id;
return true;
}
/** Keep what the preview is showing: its throwaway tab becomes a real one,
* and the focus it moved stands. Returns true when `sessionId` was the
* session being previewed; any other preview is simply discarded. */
function settlePreview(sessionId: string): boolean {
const p = preview;
if (!p) return false;
if (p.sessionId !== sessionId) {
restorePreview();
return false;
}
preview = null;
if (p.tempTabId) {
const t = layout.tabs.find((x) => x.id === p.tempTabId);
if (t) t.title = `Tab ${layout.tabs.length}`;
}
return true;
}
function previewSession(sessionId: string | null): void {
if (preview?.sessionId === sessionId) return;
if (sessionId === null) {
if (restorePreview()) renderQuietly();
return;
}
restorePreview();
const prevActiveTabId = layout.activeTabId;
const loc = locateSession(layout, sessionId);
if (loc) {
preview = {
sessionId,
prevActiveTabId,
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
tempTabId: null,
};
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
} else {
// Not on screen anywhere: a throwaway tab carries it, so no existing
// pane is evicted and nothing the operator arranged moves.
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
layout.tabs.push(tab);
layout.activeTabId = tab.id;
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
}
renderQuietly();
}
/** Any user action that changes the layout for real ends the preview first,
* so it never bakes a throwaway tab into the arrangement. */
function dropPreview(): void {
restorePreview();
}
// ---- highlight helpers ----
/** Sessions the operator actually has open — the preview's throwaway tab
* does not count, or hovering the sidebar would flip the shell's route. */
function realOpenSessions(): Set<string> {
const out = openSessions(layout);
if (preview?.tempTabId) out.delete(preview.sessionId);
return out;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
@@ -98,9 +222,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
}
pool.setFocus(focusedLeafSession());
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: openSessions(layout),
open: realOpenSessions(),
focused: focusedLeafSession(),
});
}
@@ -168,6 +294,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pane.addEventListener(
"pointerdown",
(e) => {
// Clicking into a previewed pane is the operator choosing it.
if (preview && leaf.sessionId === preview.sessionId) {
settlePreview(leaf.sessionId);
persist();
}
focusLeaf(leaf.id);
// Drag a pane by its title bar (desktop only — no docking on mobile).
if (isMobile) return;
@@ -252,24 +383,31 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
for (const t of layout.tabs) {
const tab = document.createElement("button");
tab.type = "button";
tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : "");
tab.className =
"mv-tab" +
(t.id === layout.activeTabId ? " is-active" : "") +
(isTempTab(t.id) ? " is-preview" : "");
const n = leaves(t.root).filter((l) => l.sessionId).length;
const label = document.createElement("span");
label.textContent = t.title + (n ? ` · ${n}` : "");
tab.appendChild(label);
tab.addEventListener("click", () => {
if (isTempTab(t.id)) return; // a preview tab is not a place to go
dropPreview();
if (layout.activeTabId !== t.id) {
layout.activeTabId = t.id;
render();
}
render();
});
if (layout.tabs.length > 1) {
// A preview tab is not the operator's to close: it goes on its own.
if (layout.tabs.length > 1 && !isTempTab(t.id)) {
const close = document.createElement("span");
close.className = "mv-tab-close";
close.textContent = "✕";
close.title = "Close tab";
close.addEventListener("click", (e) => {
e.stopPropagation();
dropPreview();
closeTab(layout, t.id);
render();
});
@@ -283,6 +421,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
add.textContent = "+";
add.title = "New tab";
add.addEventListener("click", () => {
dropPreview();
addTab(layout);
render();
});
@@ -292,12 +431,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- mutations ----
function splitFocused(leafId: string, dir: Dir): void {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
render();
}
function closePane(leafId: string): void {
dropPreview();
const tab = activeTab(layout);
removeLeaf(tab, leafId);
if (tab.focusedLeafId === leafId) {
@@ -307,6 +448,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
}
function openSession(sessionId: string): void {
// Opening what is already previewed keeps exactly what is on screen: the
// throwaway tab becomes a real one instead of the session moving again.
if (settlePreview(sessionId)) {
render();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
@@ -337,6 +484,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
/// operator was reading (#100) — what the Spawn button asks for.
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
if (settlePreview(sessionId)) {
render();
opts.onOpenSessionsChange?.();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
@@ -384,13 +536,19 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
// The preview's throwaway tab must not make the bar appear and vanish as
// the highlight moves, so it does not count towards "one tab only".
el.classList.toggle(
"mv-solo-tab",
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
);
el.classList.toggle("mv-previewing", preview !== null);
if (!tab.root) {
pool.setVisible([]);
pool.setFocus(null);
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null });
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
persist();
return;
}
@@ -408,7 +566,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
// A preview keeps the keyboard in the sidebar: mounting must not focus the
// terminal, or j/k would stop walking the list after the first step.
pool.setVisible(
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
{ focus: !preview && !quietRender },
);
syncFocus();
persist();
}
@@ -431,12 +594,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// Only the focused leaf's session is live on mobile; the rest park.
pool.setVisible(
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
{ focus: !preview && !quietRender },
);
pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
}
function closeSession(sessionId: string): void {
dropPreview();
const loc = locateSession(layout, sessionId);
if (!loc) return;
removeLeaf(loc.tab, loc.leaf.id);
@@ -628,6 +793,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
}
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
dropPreview();
runDrag({ kind: "session", sessionId }, e, { deferred: true });
}
@@ -636,9 +802,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
openSession,
openSessionBeside,
closeSession,
openSessions: () => openSessions(layout),
openSessions: realOpenSessions,
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
previewSession,
previewedSessionId: () => preview?.sessionId ?? null,
};
}
+218
View File
@@ -0,0 +1,218 @@
// @vitest-environment happy-dom
// Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the
// content pane without changing the layout the operator built.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js";
import { serializeLayout } from "./persistence.js";
import { createMultiView, type MultiView } from "./multiview.js";
import type { ConnectionPool } from "./connections.js";
import type { SidebarHandle } from "./catalog.js";
function stubFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
interface Spy {
pool: ConnectionPool;
visible: () => string[];
focusCalls: (string | null)[];
mountedWithFocus: (boolean | undefined)[];
}
function fakePool(): Spy {
let visible: string[] = [];
const focusCalls: (string | null)[] = [];
const mountedWithFocus: (boolean | undefined)[] = [];
const pool = {
setVisible(entries: { id: string }[], opts?: { focus?: boolean }) {
visible = entries.map((e) => e.id);
mountedWithFocus.push(opts?.focus);
},
setFocus(id: string | null) {
focusCalls.push(id);
},
} as unknown as ConnectionPool;
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
}
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
focusList() {},
hasListFocus() {
return false;
},
dispose() {},
};
}
function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView {
const mv = createMultiView({
pool,
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
onLayoutChange,
});
document.body.appendChild(mv.el);
mv.render();
return mv;
}
/** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */
function twoTabs(): Layout {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
layout.activeTabId = second.id;
return layout;
}
describe("sidebar preview", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("brings a session's own tab forward, then puts the layout back", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(spy.visible()).toEqual(["s1"]);
expect(mv.previewedSessionId()).toBe("s1");
mv.previewSession(null);
expect(spy.visible()).toEqual(["s2"]);
expect(mv.previewedSessionId()).toBeNull();
expect(serializeLayout(layout)).toEqual(before);
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
expect(spy.visible()).toEqual(["s9"]);
expect(layout.tabs).toHaveLength(3);
// The throwaway tab is not something the operator has open.
expect(mv.openSessions().has("s9")).toBe(false);
mv.previewSession(null);
expect(layout.tabs).toHaveLength(2);
expect(serializeLayout(layout)).toEqual(before);
});
it("stepping between previews leaves only the newest one on screen", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
mv.previewSession("s1");
mv.previewSession("s8");
expect(spy.visible()).toEqual(["s8"]);
expect(layout.tabs).toHaveLength(3);
mv.previewSession(null);
expect(serializeLayout(layout)).toEqual(before);
});
it("opening what is previewed keeps it: the throwaway tab becomes real", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
mv.openSession("s9");
expect(mv.previewedSessionId()).toBeNull();
expect(layout.tabs).toHaveLength(3);
expect(mv.openSessions().has("s9")).toBe(true);
expect(spy.visible()).toEqual(["s9"]);
// Nothing the operator had open was evicted to make room.
expect(mv.openSessions().has("s2")).toBe(true);
});
it("never persists, and saves again once the preview is settled", () => {
const layout = twoTabs();
const spy = fakePool();
const saves = vi.fn();
const mv = makeMv(layout, spy.pool, saves);
saves.mockClear();
mv.previewSession("s9");
expect(saves).not.toHaveBeenCalled();
mv.previewSession(null);
expect(saves).toHaveBeenCalled();
});
it("never reports a previewed pane as focused to the daemon", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
spy.focusCalls.length = 0;
mv.previewSession("s1");
mv.previewSession("s9");
expect(spy.focusCalls).toEqual([]);
mv.previewSession(null);
expect(spy.focusCalls).toEqual(["s2"]);
});
it("mounts a preview without taking the keyboard", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
spy.mountedWithFocus.length = 0;
mv.previewSession("s1");
expect(spy.mountedWithFocus).toEqual([false]);
mv.openSession("s1");
expect(spy.mountedWithFocus.at(-1)).toBe(true);
});
it("keeps the tab bar hidden while a lone tab is previewing", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
mv.previewSession("s9");
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
expect(mv.el.classList.contains("mv-previewing")).toBe(true);
mv.previewSession(null);
expect(mv.el.classList.contains("mv-previewing")).toBe(false);
});
});
+82 -2
View File
@@ -39,8 +39,11 @@ const A = "aaaaaaaa-0000-0000-0000-00000000000a";
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
/** Two sessions in sibling directories: two folder headers, two cards. */
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
el: HTMLElement;
handle: ReturnType<typeof renderSidebar>;
} {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts);
// Mounted: `focusList` moves real DOM focus, which needs a live document.
document.body.replaceChildren(handle.el);
return { el: handle.el, handle };
@@ -117,6 +120,43 @@ describe("sidebar keyboard navigation", () => {
expect(asked).toBe(1);
});
it("c collapses and expands the highlighted folder", () => {
const { el } = twoFolders();
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
header.click(); // collapse /one, so the walk stops on its header
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "c");
// Expanded: the card it was hiding is back in the walk.
expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull();
});
it("r renames the highlighted session in place", () => {
const { el } = twoFolders();
press(tree(el), "j");
press(tree(el), "r");
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
expect(field).not.toBeNull();
// Escape settles the rename, so the module-level "a row is being renamed"
// flag doesn't leak into the next test.
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
it("x kills the highlighted session", async () => {
const { el } = twoFolders();
const killed: string[] = [];
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
killed.push(`${init?.method ?? "GET"} ${String(input)}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof window.fetch;
press(tree(el), "j");
press(tree(el), "x");
await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
});
it("leaves the keyboard alone while a row is being renamed", () => {
const { el } = twoFolders();
press(tree(el), "j");
@@ -126,3 +166,43 @@ describe("sidebar keyboard navigation", () => {
expect(highlighted(el)).toBe(`session:${A}`);
});
});
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
press(tree(el), "j");
press(tree(el), "j");
expect(seen).toEqual([
[A, "key"],
[B, "key"],
]);
tree(el).dispatchEvent(new FocusEvent("blur"));
expect(seen.at(-1)).toEqual([null, "key"]);
});
it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([[A, "hover"]]);
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toEqual([null, "hover"]);
});
it("ignores touch, where a tap already opens the session", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
expect(seen).toEqual([]);
});
});
+77
View File
@@ -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 {
@@ -2838,6 +2883,38 @@ input.leaf-rename:focus {
color: var(--ink);
}
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
operator only highlighted, so it reads as provisional — a dashed tab and a
dashed inset ring on the canvas — and reverts when the highlight moves. */
.mv-tab.is-preview {
border-style: dashed;
font-style: italic;
}
.mv.mv-previewing .mv-canvas::before {
content: "preview";
position: absolute;
top: var(--sp-2);
right: var(--sp-3);
z-index: var(--z-sticky);
padding: 1px var(--sp-2);
border-radius: var(--r-1);
font: 500 var(--fs-11) / 1.5 var(--font-mono);
letter-spacing: 0.04em;
color: var(--accent-ink);
background: var(--accent);
opacity: 0.85;
pointer-events: none;
}
.mv.mv-previewing .mv-canvas::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border: 1px dashed var(--accent-ring);
border-radius: var(--r-1);
z-index: var(--z-sticky);
}
/* canvas + welcome */
.mv-canvas {
position: relative;
+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;
}
}