Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
328711cade | ||
|
|
b0504eb736 | ||
|
|
6075a177f5 | ||
|
|
6f0ac2bf4a | ||
|
|
f443f625a8 | ||
|
|
655b7b790e | ||
|
|
71e0e8d0ce | ||
|
|
582a7ca888 | ||
|
|
d8a5d95db4 | ||
|
|
51002fece8 | ||
|
|
2319b4e47b | ||
|
|
6635cff46e | ||
|
|
f6b2cf3ab4 | ||
|
|
05e7293af2 | ||
|
|
b1527f5fc1 | ||
|
|
7eeb43774a | ||
|
|
b36d571b21 | ||
|
|
e4601e3554 | ||
|
|
86eeae8296 | ||
|
|
4bb732d9b9 | ||
|
|
f669ebbddd |
@@ -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,11 +78,20 @@ 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
|
||||
before changing reconnect behaviour on either client.
|
||||
|
||||
`docs/COMMAND-BAR.md` is the contract for the `ctrl-k` command bar in both
|
||||
clients — how it opens, the sigils, the key model, the match-and-rank algorithm
|
||||
the two matchers must agree on, and the fixture table they are tested against.
|
||||
Read it before changing either bar or either matcher.
|
||||
|
||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||
|
||||
@@ -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,
|
||||
|
||||
+595
-31
@@ -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,56 @@ 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 for the pane's minimum gap 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)>,
|
||||
/// When the pane last swapped what it previews. `None` until the first
|
||||
/// one, which is why a highlight landing on a still sidebar shows at once.
|
||||
last_preview_at: Option<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);
|
||||
|
||||
/// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first
|
||||
/// highlight to land shows at once; this only bounds how often the pane may
|
||||
/// follow after that, so a fast walk down the list attaches to the row it
|
||||
/// started on and the row it stopped on rather than every row between.
|
||||
const PREVIEW_GAP: 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 +216,19 @@ impl App {
|
||||
last_sent_size: HashMap::new(),
|
||||
clicks: ClickTracker::default(),
|
||||
focused_session: None,
|
||||
pending_preview: None,
|
||||
last_preview_at: 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 +236,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 +245,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 +276,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.
|
||||
@@ -253,16 +316,26 @@ impl App {
|
||||
match self.chord.feed(&key, &known) {
|
||||
ChordResult::Pending => Ok(false),
|
||||
ChordResult::Resolved(canon) => {
|
||||
if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) {
|
||||
if matches!(act, Action::Quit) {
|
||||
return Ok(true);
|
||||
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
|
||||
if matches!(act, Some(Action::Quit)) {
|
||||
return Ok(true);
|
||||
}
|
||||
let pty_owns_keys =
|
||||
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane);
|
||||
match act {
|
||||
// `Noop` suppresses a binding, and a key with no binding in
|
||||
// Insert mode is the session's — so a key bound to `Noop`
|
||||
// reaches the pty too. That is the escape hatch for taking
|
||||
// a default binding back.
|
||||
Some(Action::Noop) | None if pty_owns_keys => {
|
||||
let effects = self.forward_to_session(&key).into_iter().collect();
|
||||
self.perform(effects).await;
|
||||
}
|
||||
let effects = self.dispatch(act);
|
||||
self.perform(effects).await;
|
||||
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
|
||||
{
|
||||
let effects = self.forward_to_session(&key).into_iter().collect();
|
||||
self.perform(effects).await;
|
||||
Some(act) => {
|
||||
let effects = self.dispatch(act);
|
||||
self.perform(effects).await;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -294,6 +367,7 @@ impl App {
|
||||
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
|
||||
}
|
||||
}
|
||||
self.refresh_preview();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -415,8 +489,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 +524,96 @@ 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 as soon as [`PREVIEW_GAP`] has passed since the
|
||||
/// last swap — immediately, for a highlight landing on a still list.
|
||||
/// Anything else — a group row, the pane holding the keyboard, or the
|
||||
/// highlight already sitting on the attached session — clears 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) {
|
||||
let now = std::time::Instant::now();
|
||||
let due = match self.last_preview_at {
|
||||
Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP,
|
||||
_ => now,
|
||||
};
|
||||
self.pending_preview = Some((sid, due));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.pending_preview = None;
|
||||
self.clear_preview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pane is free to follow: 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.last_preview_at = Some(now);
|
||||
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 +1127,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 +1180,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 +1232,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 +1472,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 +1641,188 @@ 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_gap() -> std::time::Instant {
|
||||
std::time::Instant::now() + PREVIEW_GAP + 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_without_waiting() {
|
||||
let a = Uuid::from_u128(1);
|
||||
let mut app = app_with_sessions(&[a]);
|
||||
app.dispatch(Action::SidebarNext);
|
||||
|
||||
// The list was still, so the pane follows on the next poll — no gap to
|
||||
// wait out first (docs/PREVIEW.md).
|
||||
assert_eq!(
|
||||
app.poll_due_preview_at(std::time::Instant::now()),
|
||||
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);
|
||||
}
|
||||
|
||||
/// The gap only bounds the *second* swap, so a burst costs two mounts.
|
||||
#[test]
|
||||
fn a_second_move_inside_the_gap_waits_for_it() {
|
||||
let a = Uuid::from_u128(1);
|
||||
let b = Uuid::from_u128(2);
|
||||
let mut app = app_with_sessions(&[a, b]);
|
||||
|
||||
app.dispatch(Action::SidebarNext);
|
||||
let now = std::time::Instant::now();
|
||||
assert_eq!(app.poll_due_preview_at(now), vec![Effect::Attach(a)]);
|
||||
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(app.poll_due_preview_at(now), vec![]);
|
||||
assert_eq!(app.state.preview, Some(a));
|
||||
|
||||
assert_eq!(
|
||||
app.poll_due_preview_at(after_gap()),
|
||||
vec![Effect::Attach(b)]
|
||||
);
|
||||
assert_eq!(app.state.preview, Some(b));
|
||||
}
|
||||
|
||||
/// 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_gap());
|
||||
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_gap());
|
||||
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_gap());
|
||||
|
||||
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_gap());
|
||||
// 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);
|
||||
// Each row is previewed in its own gap window, so all of them land.
|
||||
let mut clock = std::time::Instant::now();
|
||||
for id in &ids {
|
||||
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
|
||||
app.refresh_preview();
|
||||
clock += PREVIEW_GAP + Duration::from_millis(1);
|
||||
app.poll_due_preview_at(clock);
|
||||
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;
|
||||
@@ -2076,6 +2567,78 @@ mod tests {
|
||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||
}
|
||||
|
||||
/// Drive one key event through an app in Insert mode with a session
|
||||
/// attached, and report the bytes that reached that session.
|
||||
async fn bytes_sent_for(section: &TuiSection, ev: CtEvent) -> Option<Vec<u8>> {
|
||||
use crate::session_task::{SessionHandle, SessionState};
|
||||
use std::sync::atomic::{AtomicU64, AtomicU8};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
let sid = Uuid::from_u128(41);
|
||||
let mut app = App::new(
|
||||
TuiConfig::load(section).unwrap(),
|
||||
CatalogStore::default(),
|
||||
DaemonClient::new("http://127.0.0.1:7070"),
|
||||
DaemonLink::default(),
|
||||
);
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
app.emulators.insert(
|
||||
sid,
|
||||
SessionHandle {
|
||||
tx,
|
||||
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)),
|
||||
},
|
||||
);
|
||||
app.state.attach_to_focused(sid);
|
||||
app.state.mode = Mode::Insert;
|
||||
app.state.focus = Focus::Pane;
|
||||
app.handle_event(ev).await.unwrap();
|
||||
match rx.try_recv() {
|
||||
Ok(SessionInput::Bytes(bytes)) => Some(bytes),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn key_event(code: crossterm::event::KeyCode, mods: crossterm::event::KeyModifiers) -> CtEvent {
|
||||
CtEvent::Key(crossterm::event::KeyEvent::new(code, mods))
|
||||
}
|
||||
|
||||
/// Escape belongs to the session: full-screen apps (vim, Claude Code) read
|
||||
/// it constantly, so no Insert-mode default may swallow it. The leader is
|
||||
/// the way back to the sidebar.
|
||||
#[tokio::test]
|
||||
async fn escape_reaches_the_attached_session() {
|
||||
let sent = bytes_sent_for(
|
||||
&TuiSection::default(),
|
||||
key_event(
|
||||
crossterm::event::KeyCode::Esc,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sent, Some(vec![0x1b]));
|
||||
}
|
||||
|
||||
/// `Noop` suppresses a binding, and in Insert mode a key with no binding is
|
||||
/// the session's — so binding a key to `Noop` hands it to the pty.
|
||||
#[tokio::test]
|
||||
async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
|
||||
let mut section = TuiSection::default();
|
||||
section.keymap_insert.insert("ctrl-a".into(), "Noop".into());
|
||||
let sent = bytes_sent_for(
|
||||
§ion,
|
||||
key_event(
|
||||
crossterm::event::KeyCode::Char('a'),
|
||||
crossterm::event::KeyModifiers::CONTROL,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sent, Some(vec![0x01]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
|
||||
use crate::session_task::{SessionHandle, SessionState};
|
||||
@@ -2091,6 +2654,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();
|
||||
|
||||
@@ -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
|
||||
@@ -74,10 +75,13 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||
];
|
||||
|
||||
/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
|
||||
/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
|
||||
/// escape automatically; `esc` is a second, fixed way back to the sidebar.
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
|
||||
/// Default Insert-mode bindings — deliberately empty. Every key typed in
|
||||
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
||||
/// it constantly, so binding it here would make it unreachable. The only way
|
||||
/// back to the sidebar is the leader (`LeaveInsert`), which
|
||||
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
||||
/// the Insert escape with it.
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
||||
|
||||
impl TuiConfig {
|
||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||
@@ -220,18 +224,18 @@ 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),
|
||||
);
|
||||
}
|
||||
|
||||
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
|
||||
/// mode only exists while the sidebar is focused, so neither can leak
|
||||
/// into a pty; `esc` is a second way out of Insert alongside the leader.
|
||||
/// into a pty; `esc` stays unbound in Insert so it reaches the session.
|
||||
#[test]
|
||||
fn aoe_style_defaults_are_bound() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
@@ -240,10 +244,7 @@ mod tests {
|
||||
cfg.keymap.resolve(Mode::Normal, "tab"),
|
||||
Some(Action::AttachHighlighted),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "esc"),
|
||||
Some(Action::LeaveInsert),
|
||||
);
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "esc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -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!(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
# COMMAND-BAR.md — the command bar contract
|
||||
|
||||
One rule for the `ctrl-k` command bar in both clients, so the web bar and the
|
||||
TUI bar open the same way, rank the same results in the same order, and act on
|
||||
the same keys. Read this before changing either. Both clients implement exactly
|
||||
what is written here; if the behaviour needs to change, change it here first.
|
||||
|
||||
The bar is one surface with three jobs: **go to a session**, **run a command**,
|
||||
**spawn into a directory**. It is not a new place to configure anything and it
|
||||
does not own any action — it chooses one and hands off to the flow that already
|
||||
implements it.
|
||||
|
||||
## Opening
|
||||
|
||||
`ctrl-k` opens the bar from anywhere:
|
||||
|
||||
| Client | From | Notes |
|
||||
| --- | --- | --- |
|
||||
| TUI | Normal mode | Sidebar has the keyboard. |
|
||||
| TUI | Insert mode | The pane has the keyboard; the bar still opens. |
|
||||
| Web | Sidebar focused | Ordinary global binding. |
|
||||
| Web | Terminal focused | Intercepted before xterm sees it. |
|
||||
|
||||
It is a **keymap entry like any other**, not a hard-wired key. In the TUI it is
|
||||
bound in both `DEFAULT_NORMAL` and `DEFAULT_INSERT` and is rebindable through
|
||||
`[tui.keymap.*]`; because the help overlay builds its lines from the live
|
||||
keymap, it appears there without a second list to maintain. In the web it is an
|
||||
entry in the command registry, so the `?` overlay lists it for the same reason.
|
||||
|
||||
An attached xterm swallows every keystroke it is given, so the web binding is
|
||||
handled in the **capture phase**, ahead of the terminal — the same way the
|
||||
restart overlay swallows the app's shortcuts (`docs/RESTART.md`).
|
||||
|
||||
### Escape hatch — sending a literal Ctrl-K
|
||||
|
||||
Binding `ctrl-k` takes readline's kill-to-end-of-line away from every pane. The
|
||||
leader gives it back: **leader then `ctrl-k`** — `ctrl-a ctrl-k` by default —
|
||||
sends the literal `0x0B` byte to the focused pane and does not open the bar.
|
||||
|
||||
The TUI already has a leader (`ctrl-a`, `[tui].leader`), and this chord goes
|
||||
through the existing chord resolution, so rebinding the leader rebinds the
|
||||
escape hatch with it — exactly as the leader's Insert-mode escape already
|
||||
works. **The web gains a leader for this**, defaulting to `ctrl-a` and
|
||||
mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
|
||||
`preventDefault`s it, since selecting all of a terminal pane does nothing
|
||||
useful.
|
||||
|
||||
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
|
||||
none of them needs one.
|
||||
|
||||
### Focus round-trip
|
||||
|
||||
Closing the bar returns the keyboard to whatever held it when the bar opened,
|
||||
the terminal included. A round trip through the bar never silently moves focus,
|
||||
and never counts as a focus change for the daemon: opening the bar does not
|
||||
report the pane unfocused, and closing it does not report a new focus. The bar
|
||||
is a view over the app, not a place in it.
|
||||
|
||||
Opening the bar while a session is previewed (`docs/PREVIEW.md`) discards the
|
||||
preview first, like any other real change; the bar is not previewed into.
|
||||
|
||||
## Structure
|
||||
|
||||
The bar is a query line over a result list. By default the list is **flat and
|
||||
mixed** — sessions and commands ranked together — and rendered under **kind
|
||||
headers**: a header row naming the kind, then that kind's results.
|
||||
|
||||
Grouping is a rendering of the ranking, never a reordering of it:
|
||||
|
||||
- Results are ranked as one list by the algorithm below.
|
||||
- A kind's group holds its matches in that rank order.
|
||||
- **Groups are ordered by their best-scoring member.** The first row under the
|
||||
first header is therefore the globally best result, and the highlight starts
|
||||
on it.
|
||||
|
||||
Two sigils, typed as the **first character** of the query, narrow the list to
|
||||
one kind:
|
||||
|
||||
| Sigil | Kind | Candidates | Enter |
|
||||
| --- | --- | --- | --- |
|
||||
| `>` | commands | Every entry in the client's command registry — the web registry from the `?` overlay, the TUI's live `(chord, Action)` keymap for the current mode. | Runs the command. |
|
||||
| `~` | directories | Recent dirs (`GET /api/recent-dirs`) and the project list the spawn form already uses, with `GET /api/dirs` filling in as a path is typed. | Spawns a session there with every default resolved. |
|
||||
|
||||
While a sigil is in force there is one kind, so the kind header is dropped. A
|
||||
command row shows its **key chord** beside its label, so the bar teaches the
|
||||
keymap instead of replacing it. A `~` spawn takes the same fast-forward path as
|
||||
`N` / `FastForwardSpawn`: an unresolvable default opens the pre-filled spawn
|
||||
form rather than failing silently.
|
||||
|
||||
**Backspacing past a sigil returns to the mixed list.** The sigil is one
|
||||
character of the query like any other — deleting it is not a special key, it is
|
||||
the query becoming empty again.
|
||||
|
||||
The sigil is only a sigil in first position. `>` or `~` typed anywhere else is
|
||||
a literal query character (a directory query is where a `~` is most likely to
|
||||
be typed twice, and the second one must reach the matcher).
|
||||
|
||||
## Empty query
|
||||
|
||||
With no sigil and an empty query the bar lists **sessions only, most recently
|
||||
focused first**, so `ctrl-k enter` is "back to where I just was". Commands and
|
||||
directories need a sigil or a query to appear; an empty bar is a session
|
||||
switcher.
|
||||
|
||||
**Recency comes from the daemon**, not from client state — the same order in
|
||||
every client, on every device, including one that just loaded the page. The
|
||||
daemon stamps a last-focused time per session and ships it on the session like
|
||||
any other field (#145). Both of the ways focus moves stamp the same field: a
|
||||
client opening `/api/attach/:id`, and a client reporting focus explicitly at
|
||||
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
|
||||
|
||||
**A session that has never been focused sorts after every session that has**,
|
||||
and among those, most recently created first. Dead sessions sort after live
|
||||
ones regardless of recency: a session you cannot attach to is not where you
|
||||
just were.
|
||||
|
||||
Under `>` an empty query lists every command in registry order. Under `~` it
|
||||
lists recent dirs, most recent first, then the project list.
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `enter` | Open the highlighted result — attach the session, run the command, spawn into the directory. |
|
||||
| `tab` | Drill into the highlighted **session**'s action page. No effect on any other kind. |
|
||||
| `esc` | At the top level, close the bar. On an action page, return to the result list. |
|
||||
| `backspace` | Ordinary editing; at an empty query on an action page, return to the result list. |
|
||||
| `up` / `down` | Move the highlight. |
|
||||
| `ctrl-p` / `ctrl-n` | Move the highlight. |
|
||||
|
||||
The highlight does not wrap: it stops at the first and last row. Kind headers
|
||||
are never landed on — they are labels, not rows.
|
||||
|
||||
Enter on a session goes through the **same open path** the sidebar's click and
|
||||
the hash router already share, so a bar attach can never leave the layout in a
|
||||
state a click could not produce.
|
||||
|
||||
### Drill-in — the session action page
|
||||
|
||||
`tab` on a highlighted session opens that session's action page inside the same
|
||||
overlay: **kill**, **rename**, **spawn similar**. The page is still fuzzy
|
||||
searchable — the query line keeps working, now over those actions.
|
||||
|
||||
- The overlay names the session being acted on, so the target is never
|
||||
ambiguous.
|
||||
- Escape, or backspace at an empty query, returns to the result list **with the
|
||||
previous query intact**. Drilling in and backing out is free.
|
||||
- Each action raises the flow it already has: kill goes through the existing
|
||||
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the
|
||||
existing prompt pre-filled with the session's own name
|
||||
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the
|
||||
session's directory and command. The bar picks the action; it never
|
||||
reimplements one.
|
||||
|
||||
No actions beyond those three, and no action across more than one session.
|
||||
|
||||
## Touch entry
|
||||
|
||||
On the web, **tapping or focusing the sidebar's search field opens the bar**
|
||||
with an empty query. There is no `ctrl-k` on a phone and the product is
|
||||
mobile-first, so the field the operator already reaches for is the entry point.
|
||||
|
||||
This **replaces** the sidebar's inline substring filter — one search affordance
|
||||
in the header, not two beside each other. The tree's "No matches" empty state
|
||||
belonged to that filter and goes with it; the bar's own no-results state takes
|
||||
over (see the fixture `F4`).
|
||||
|
||||
At phone width the bar follows the bottom-sheet treatment the spawn modal
|
||||
already uses below 600px. Visuals otherwise come from `DESIGN.md`'s existing
|
||||
overlay tokens — the bar is the same family of surface as the spawn modal and
|
||||
the help overlay, not a new one.
|
||||
|
||||
## What a candidate is
|
||||
|
||||
The matcher is a pure function: `(query, candidates) → ranked subset with
|
||||
matched ranges`. It knows nothing about sessions, panes or the network. A
|
||||
candidate is a **kind** plus a set of **named text fields**, each with a fixed
|
||||
weight.
|
||||
|
||||
| Kind | Field | Weight | Text |
|
||||
| --- | --- | --- | --- |
|
||||
| session | `name` | `0` | The name as the sidebar shows it: `metadata.name` if set, else the command line (`docs/SIDEBAR.md`'s fallback). |
|
||||
| session | `directory` | `-24` | The directory the sidebar groups it under, abbreviated: `~/dev/api`, not `/home/me/dev/api`. |
|
||||
| session | `command` | `-32` | The full command line, arguments included. |
|
||||
| session | `tool` | `-40` | The tool id the card's icon stands for (`claude`, `codex`, `opencode`, …), absent when the program is not one we have a mark for. |
|
||||
| command | `label` | `0` | The plain-language label — the web registry's label, the TUI's `Action::help_label`. |
|
||||
| directory | `path` | `0` | The path as displayed, abbreviated the same way. |
|
||||
|
||||
A command's **key chord is displayed, not matched**. It is there to teach the
|
||||
keymap; matching it would make `>` results depend on a user's rebinds, and the
|
||||
two clients do not share a keymap.
|
||||
|
||||
A candidate **matches** if at least one of its fields matches. Its score is the
|
||||
**best** `field score + field weight` across its fields, and the matched ranges
|
||||
it returns are those of that winning field — so a session found through its
|
||||
directory highlights inside the directory, not inside its name.
|
||||
|
||||
## Match and rank
|
||||
|
||||
Two independent implementations (#146 in TypeScript, #147 in Rust) must produce
|
||||
the same order for the same input. Everything below is exact.
|
||||
|
||||
### Normalization
|
||||
|
||||
- **Query**: lowercase, then remove every whitespace character. The result is
|
||||
the *normalized query*. If it is empty, see "Empty query" below.
|
||||
- **Field text**: matched against its lowercased form. Bonuses are computed
|
||||
against the **original** text, because case is a signal (`camelCase`) that
|
||||
lowercasing destroys.
|
||||
- **Compact form** of a string: lowercase with whitespace removed. Used only
|
||||
for the exact and prefix tests.
|
||||
|
||||
Lowercasing is per-character simple lowercase (`char::to_lowercase` /
|
||||
`String.prototype.toLowerCase`). Positions are **character** positions —
|
||||
Unicode scalar values in Rust, UTF-16 code units in TypeScript, which agree for
|
||||
every character these fields realistically hold; ranges are returned in the
|
||||
host language's own unit, since they exist to slice that language's string.
|
||||
|
||||
### What a match is
|
||||
|
||||
Case-insensitive **subsequence**: every character of the normalized query
|
||||
appears in the lowercased field text, in order, not necessarily adjacent. A
|
||||
field with no such embedding does not match.
|
||||
|
||||
### Word boundaries
|
||||
|
||||
A matched character at position `p` earns exactly one character bonus, taking
|
||||
the first rule that applies:
|
||||
|
||||
| Rule | Bonus |
|
||||
| --- | --- |
|
||||
| `p == 0` — first character of the field | `+18` |
|
||||
| The character before it is a **separator**: `/` `\` `-` `_` `.` `:` or a space | `+16` |
|
||||
| It is uppercase and the character before it is lowercase (a camelCase hump) | `+12` |
|
||||
| It is a digit and the character before it is not | `+12` |
|
||||
| Otherwise | `0` |
|
||||
|
||||
That is one rule for both shapes the doc has to cover. In a **path**, `/` is a
|
||||
separator, so the first character of every segment is a word start: `foo` in
|
||||
`~/dev/foobar` starts a segment and outscores `foo` in `~/src/myfoo`, which
|
||||
starts nothing. In a **session name**, `-`, `_`, `.` and spaces do the same
|
||||
work, so `ws` reaches `web-search` more cheaply than `websearch`.
|
||||
|
||||
### Score
|
||||
|
||||
Let the query characters be matched at positions `p₀ < p₁ < … < pₘ₋₁`, and let
|
||||
`p₋₁ = -1`. For each matched character `i`, with `gap = pᵢ - pᵢ₋₁ - 1`:
|
||||
|
||||
```
|
||||
contribution(i) = 16 // every matched character
|
||||
+ bonus(pᵢ) // the table above
|
||||
+ (gap == 0 ? +8 // adjacent to the previous match
|
||||
: -min(gap, 12))
|
||||
```
|
||||
|
||||
`gap` for `i = 0` is the number of characters skipped before the first match, so
|
||||
a match that starts late is penalised exactly like an internal gap, and the cap
|
||||
is the same one.
|
||||
|
||||
The **field score** is the sum of the contributions, plus at most one whole-field
|
||||
adjustment:
|
||||
|
||||
- compact field text **equals** the normalized query → `+64`
|
||||
- else compact field text **starts with** the normalized query → `+32`
|
||||
|
||||
Length is deliberately not in the score — it is a tie-break, so a long field
|
||||
never loses to a short one that matched worse.
|
||||
|
||||
### Which embedding
|
||||
|
||||
A query can sit in a field many ways. The one that counts is the
|
||||
**highest-scoring** embedding; among embeddings that score the same, the
|
||||
**leftmost** — compare position vectors element by element, smaller wins.
|
||||
|
||||
Greedy leftmost-first matching is not sufficient and must not be used: in
|
||||
`maestro-tui`, `mtu` greedily takes `m₀ a₁ …` and never reaches `tui`. Both
|
||||
implementations use the obvious dynamic program over
|
||||
`(query index, text position)`, carrying the best score and its position
|
||||
vector, since the gap term depends only on the previous matched position.
|
||||
|
||||
The whole-field adjustment is constant for a `(query, field)` pair, so adding it
|
||||
after the dynamic program cannot change which embedding wins.
|
||||
|
||||
### Ordering
|
||||
|
||||
Sort by score, highest first. Equal scores break in this order:
|
||||
|
||||
1. **Kind** — `session`, then `command`, then `directory`.
|
||||
2. **Length of the matched field text**, shorter first.
|
||||
3. **The matched field text**, ascending, compared by code point, case
|
||||
sensitive.
|
||||
4. **Input order.** The sort is stable, so candidates the caller ordered
|
||||
deliberately — sessions most recently focused first, commands in registry
|
||||
order — keep that order when nothing else separates them.
|
||||
|
||||
Kind leads because that is the ruling the ambiguous cases need: a session named
|
||||
`kill` and the `kill` command that score alike put the session first, because
|
||||
the operator who typed a session's name meant the session.
|
||||
|
||||
### Empty query
|
||||
|
||||
A normalized query that is empty **matches every candidate**, each with score
|
||||
`0` and no ranges, in input order. The bar's own rules decide what is in the
|
||||
candidate list at that point (sessions only at the top level); the matcher does
|
||||
not filter by kind.
|
||||
|
||||
## Fixtures
|
||||
|
||||
These are the contract. Both matchers are tested against exactly these cases;
|
||||
the prose above is the explanation of why they come out this way.
|
||||
|
||||
Candidates are written as `kind(fields)`; unlisted fields are absent.
|
||||
`session(name, dir, command, tool)`. Scores are shown for diagnosis — **the
|
||||
order is what is being asserted**, and an implementation that ranks correctly
|
||||
with different absolute numbers has still failed, because the numbers are how
|
||||
the next fixture stays predictable.
|
||||
|
||||
### F1 — empty query keeps input order
|
||||
|
||||
```
|
||||
query: ""
|
||||
candidates: session("kill", "~/dev/api", "bash")
|
||||
session("killer-app", "~/dev/killer-app", "claude --resume", "claude")
|
||||
command("Kill selected session")
|
||||
expected: session("kill") score 0
|
||||
session("killer-app") score 0
|
||||
command("Kill selected session") score 0
|
||||
```
|
||||
|
||||
Every candidate matches, in the order given. No ranges.
|
||||
|
||||
### F2 — a session named `kill` against the `kill` command
|
||||
|
||||
```
|
||||
query: "kill"
|
||||
candidates: (as F1)
|
||||
expected: session("kill") 178 name
|
||||
session("killer-app") 146 name
|
||||
command("Kill selected session") 146 label
|
||||
```
|
||||
|
||||
`kill` wins on the exact bonus. The other two **tie at 146** — both are a
|
||||
contiguous prefix match — and the kind tie-break puts the session first.
|
||||
|
||||
### F3 — a fragment matching mid-segment inside a path
|
||||
|
||||
```
|
||||
query: "foo"
|
||||
candidates: directory("~/dev/foobar")
|
||||
directory("~/src/myfoo")
|
||||
directory("~/foo")
|
||||
expected: directory("~/foo") 78 ranges [2,3,4]
|
||||
directory("~/dev/foobar") 74 ranges [6,7,8]
|
||||
directory("~/src/myfoo") 56 ranges [8,9,10]
|
||||
```
|
||||
|
||||
The first two start a path segment; the third is mid-segment and loses by the
|
||||
whole boundary bonus. `~/foo` beats `~/dev/foobar` only because less was
|
||||
skipped before the match.
|
||||
|
||||
### F4 — a query matching nothing
|
||||
|
||||
```
|
||||
query: "zzz"
|
||||
candidates: session("kill", "~/dev/api", "bash")
|
||||
command("Kill selected session")
|
||||
expected: (empty)
|
||||
```
|
||||
|
||||
The matcher returns an empty list, never a list of zero-scored candidates. The
|
||||
bar renders its no-results state.
|
||||
|
||||
### F5 — a non-contiguous subsequence
|
||||
|
||||
```
|
||||
query: "mtu"
|
||||
candidates: session("maestro-tui")
|
||||
session("maestro-daemon")
|
||||
session("mtui-scratch")
|
||||
expected: session("mtui-scratch") 122 ranges [0,1,2]
|
||||
session("maestro-tui") 91 ranges [0,8,9]
|
||||
```
|
||||
|
||||
`maestro-daemon` has no `u` after its `t` and does not match. `maestro-tui`
|
||||
does, across a gap — which greedy matching would miss entirely.
|
||||
|
||||
### F6 — a name beats a directory
|
||||
|
||||
```
|
||||
query: "api"
|
||||
candidates: session("api", "~/dev/web", "bash")
|
||||
session("worker", "~/dev/api", "bash")
|
||||
expected: session("api") 154 name
|
||||
session("worker") 50 directory
|
||||
```
|
||||
|
||||
The second session is found through its directory, and highlights there.
|
||||
|
||||
### F7 — case-insensitive, tie broken by length
|
||||
|
||||
```
|
||||
query: "api"
|
||||
candidates: session("API-Gateway")
|
||||
session("apiary")
|
||||
expected: session("apiary") 122 name
|
||||
session("API-Gateway") 122 name
|
||||
```
|
||||
|
||||
Both are prefix matches and score identically. Same kind, so length decides:
|
||||
6 characters before 11. Input order does not save `API-Gateway`.
|
||||
|
||||
### F8 — the tool field
|
||||
|
||||
```
|
||||
query: "claude"
|
||||
candidates: session("nameless", "~/dev/x", "claude --resume", "claude")
|
||||
session("clay", "~/dev/y", "bash")
|
||||
expected: session("nameless") 186 tool
|
||||
```
|
||||
|
||||
`clay` has no `claude` subsequence anywhere and drops out. `nameless` matches
|
||||
its `tool` exactly (`+64`, weight `-40`, giving 186) which beats the prefix
|
||||
match on its `command` field (`+32`, weight `-32`, giving 162).
|
||||
|
||||
### F9 — a word boundary inside a session name
|
||||
|
||||
```
|
||||
query: "ws"
|
||||
candidates: session("websearch")
|
||||
session("web-search")
|
||||
expected: session("web-search") 71 ranges [0,4]
|
||||
session("websearch") 56 ranges [0,3]
|
||||
```
|
||||
|
||||
The `s` after `-` earns the boundary bonus; the `s` inside `websearch` earns
|
||||
nothing.
|
||||
|
||||
### F10 — command labels
|
||||
|
||||
```
|
||||
query: "se"
|
||||
candidates: command("Kill selected session")
|
||||
command("Create a session")
|
||||
command("Show help")
|
||||
expected: command("Show help") 53 ranges [0,6]
|
||||
command("Kill selected session") 51 ranges [5,6]
|
||||
command("Create a session") 47 ranges [9,10]
|
||||
```
|
||||
|
||||
`Show help` starts at position 0; the other two start at a word boundary but
|
||||
later, and pay for the skipped characters.
|
||||
|
||||
### F11 — a query against session directories
|
||||
|
||||
```
|
||||
query: "dev"
|
||||
candidates: session("api", "~/dev/api", "bash")
|
||||
session("notes", "~/documents/dev-notes", "nvim")
|
||||
session("scratch", "~/srv/devops", "bash")
|
||||
expected: session("api") 54 directory, ranges [2,3,4]
|
||||
session("scratch") 50 directory, ranges [6,7,8]
|
||||
session("notes") 44 directory, ranges [12,13,14]
|
||||
```
|
||||
|
||||
All three start a path segment. They separate purely on how much was skipped
|
||||
first, and `notes` hits the `-12` gap cap.
|
||||
|
||||
### F12 — exact beats prefix
|
||||
|
||||
```
|
||||
query: "quit"
|
||||
candidates: command("Quit Maestro")
|
||||
command("Quit")
|
||||
expected: command("Quit") 178 label
|
||||
command("Quit Maestro") 146 label
|
||||
```
|
||||
|
||||
The 32-point difference between the exact and prefix adjustments is the whole
|
||||
gap; nothing else about the two matches differs.
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# 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 is never delayed.** Landing on a row — by pointer or by `j`/`k` —
|
||||
shows it on the next frame, and so does leaving every row. There is no
|
||||
settling period in front of the first preview, because that period would sit
|
||||
in front of the one preview the operator actually meant.
|
||||
|
||||
What is bounded is how *often* the pane may swap: at most once per 120 ms.
|
||||
A burst inside one window — `j` held down, or the pointer sweeping the length
|
||||
of the sidebar — shows the row it started on, then the row it ended on, and
|
||||
none of the rows in between. So a walk costs a couple of pane mounts rather
|
||||
than one per row, without the operator ever waiting on one.
|
||||
|
||||
The highlight itself is not bounded by any of this: the row lights up on the
|
||||
same frame as the input, whatever the pane behind it is doing.
|
||||
|
||||
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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1645
File diff suppressed because it is too large
Load Diff
@@ -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: {}
|
||||
|
||||
+104
-12
@@ -1,15 +1,31 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { attachWsUrl } from "./routes.js";
|
||||
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
||||
import { CommandSet, defineCommands } from "./commands.js";
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
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";
|
||||
|
||||
// The pane's half of the command registry (#148): plain Escape hands the
|
||||
// keyboard back to the session list, the pair to the list's Tab.
|
||||
const paneKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
|
||||
}),
|
||||
{ preventDefault: false },
|
||||
);
|
||||
|
||||
/** 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 +42,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. */
|
||||
@@ -48,8 +66,6 @@ export interface AttachOptions {
|
||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** What the header shows about the session (name / command). Optional — falls
|
||||
* back to a short session id when the session isn't known yet. */
|
||||
export interface AttachInfo {
|
||||
@@ -135,13 +151,79 @@ 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.
|
||||
// shell/app. `false` tells xterm to skip its own handling of this event —
|
||||
// which is also why the set doesn't `preventDefault`: swallowing here is
|
||||
// xterm's return value, not the browser's.
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== "keydown" || e.key !== "Escape") return true;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
|
||||
document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT));
|
||||
return false;
|
||||
if (e.type !== "keydown") return true;
|
||||
return !paneKeys.handle(e);
|
||||
});
|
||||
|
||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||||
@@ -237,6 +319,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 +401,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 +486,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 +518,7 @@ export function createAttachConnection(
|
||||
visual?.removeEventListener("resize", onResize);
|
||||
dataDisp.dispose();
|
||||
resizeDisp.dispose();
|
||||
releaseWebgl();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
|
||||
+146
-19
@@ -2,7 +2,8 @@ import { escapeHtml } from "./html.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||
import { openForceKillDialog } from "./killDialog.js";
|
||||
import { openContextMenu } from "./contextMenu.js";
|
||||
import { isTypingTarget, toggleHelp } from "./help.js";
|
||||
import { toggleHelp } from "./help.js";
|
||||
import { CommandSet, defineCommands } from "./commands.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
import {
|
||||
@@ -164,6 +165,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// The row outlives the snapshot it was built from: a refresh that changes
|
||||
// nothing structural patches it in place rather than replacing it, so the
|
||||
// handlers below have to read the current session, not the one captured here.
|
||||
let cur = node.session;
|
||||
const s = node.session;
|
||||
const now = Date.now();
|
||||
const row = document.createElement("a");
|
||||
@@ -197,33 +202,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void requestKill(s, name);
|
||||
void requestKill(cur, sessionName(cur));
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
openContextMenu(ev.clientX, ev.clientY, [
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
||||
},
|
||||
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
||||
]);
|
||||
});
|
||||
leafPatches.set(row, (next) => {
|
||||
cur = next;
|
||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||
);
|
||||
const rail = row.querySelector(".activity-rail");
|
||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||
const meta = row.querySelector(".card-line2");
|
||||
if (meta) meta.outerHTML = metaLine(next, Date.now());
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Refresh a standing card from a newer snapshot of its session, keeping the
|
||||
* element (and so the pointer's `:hover`) exactly where it is. Registered by
|
||||
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
|
||||
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
|
||||
|
||||
/**
|
||||
* Everything about the tree that a patch cannot fix up.
|
||||
*
|
||||
* The daemon re-sends a session on every activity, foreground or cwd shift —
|
||||
* a couple of times a minute per live session — and rebuilding the sidebar for
|
||||
* each one drops `:hover` from the row under the pointer. Refreshes whose
|
||||
* signature is unchanged are patched into the standing rows instead; anything
|
||||
* that moves, renames, appears or dies changes the signature and rebuilds.
|
||||
*/
|
||||
function treeSignature(node: TreeNode): string {
|
||||
if (node.kind === "folder") {
|
||||
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
|
||||
if (node.name && collapsed.has(node.path)) return head;
|
||||
return `${head}(${node.children.map(treeSignature).join(",")})`;
|
||||
}
|
||||
const s = node.session;
|
||||
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||
@@ -364,6 +410,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) => void;
|
||||
}
|
||||
|
||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||
@@ -528,6 +579,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 +593,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
navKey = next.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
next.scrollIntoView({ block: "nearest" });
|
||||
opts.onPreview?.(sessionOf(navKey));
|
||||
};
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||
@@ -563,20 +618,77 @@ 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);
|
||||
};
|
||||
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;
|
||||
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.
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
||||
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;
|
||||
e.preventDefault();
|
||||
});
|
||||
// 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));
|
||||
|
||||
/** 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();
|
||||
};
|
||||
|
||||
// The session list's half of the command registry (#148). One set per
|
||||
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
|
||||
// labels and chords still come from the one registry.
|
||||
const listKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"list.next": () => moveNav(1),
|
||||
"list.prev": () => moveNav(-1),
|
||||
"list.activate": () => activateNav(),
|
||||
"list.toggleFolder": () => toggleHighlightedFolder(),
|
||||
"list.rename": () => {
|
||||
const hit = highlightedSession();
|
||||
if (hit) beginRename(hit.row, hit.session);
|
||||
},
|
||||
"list.kill": () => {
|
||||
const hit = highlightedSession();
|
||||
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
||||
},
|
||||
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
||||
}),
|
||||
);
|
||||
|
||||
// The inline rename field lives inside a row and owns its own keys; the set's
|
||||
// plain-key rule keeps this walk out of it.
|
||||
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
|
||||
|
||||
/** Signature of what is currently on screen — see `treeSignature`. */
|
||||
let renderedSignature: string | null = null;
|
||||
|
||||
const refresh = () => {
|
||||
// Rebuilding the tree now would blow away the field being typed into.
|
||||
@@ -584,6 +696,15 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir, filter);
|
||||
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
||||
if (signature === renderedSignature) {
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||
if (s) leafPatches.get(row)?.(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
@@ -607,7 +728,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
/* absolute paths are a fine fallback */
|
||||
});
|
||||
|
||||
const rerenderHandler = () => refresh();
|
||||
// Dispatched when something outside the store changed what the tree should
|
||||
// look like — a folder collapsed, a rename ended — none of which the store
|
||||
// snapshot can show, so the signature check has to be bypassed.
|
||||
const rerenderHandler = () => {
|
||||
renderedSignature = null;
|
||||
refresh();
|
||||
};
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
const ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: ID,
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A store whose updates actually reach subscribers, unlike the one-shot
|
||||
* harness in catalog.test.ts. */
|
||||
function liveStore(initial: Session[]) {
|
||||
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
|
||||
const listeners = new Set<CatalogListener>();
|
||||
const store = {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
listeners.add(fn);
|
||||
fn(state);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
const push = (sessions: Session[]) => {
|
||||
state.sessions = new Map(sessions.map((s) => [s.id, s]));
|
||||
for (const fn of listeners) fn(state);
|
||||
};
|
||||
return { store, push };
|
||||
}
|
||||
|
||||
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
|
||||
|
||||
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
|
||||
it("patches an activity change into the standing row instead of rebuilding it", () => {
|
||||
const { store, push } = liveStore([session({ activity: "Idle" })]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ activity: "AwaitingInput" })]);
|
||||
|
||||
// Same element: a rebuild under the pointer would drop `:hover` and restart
|
||||
// the row's transitions, which is the flicker this avoids.
|
||||
expect(rowOf(el)).toBe(before);
|
||||
// …and the card still tells the truth about the session.
|
||||
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
});
|
||||
|
||||
it("rebuilds when the row set changes", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
|
||||
|
||||
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
});
|
||||
|
||||
it("rebuilds when a session dies", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ status: "Dead" })]);
|
||||
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
expect(rowOf(el).dataset.status).toBe("dead");
|
||||
});
|
||||
|
||||
it("rebuilds when a session is renamed", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ metadata: { name: "harmony" } })]);
|
||||
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
COMMAND_SPECS,
|
||||
CommandSet,
|
||||
DEFAULT_LEADER,
|
||||
canonicalizeKey,
|
||||
defineCommands,
|
||||
isTypingTarget,
|
||||
keyCaps,
|
||||
publishedCommands,
|
||||
} from "./commands.js";
|
||||
|
||||
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init });
|
||||
}
|
||||
|
||||
function el(html: string): HTMLElement {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = html;
|
||||
return d.firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
/** Dispatch on a target, so `isTypingTarget` sees something other than null. */
|
||||
function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean {
|
||||
const e = key(k, init);
|
||||
Object.defineProperty(e, "target", { value: target });
|
||||
return set.handle(e);
|
||||
}
|
||||
|
||||
describe("command specs", () => {
|
||||
it("has a unique id and at least one chord per command", () => {
|
||||
const ids = COMMAND_SPECS.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const spec of COMMAND_SPECS) {
|
||||
expect(spec.keys.length).toBeGreaterThan(0);
|
||||
expect(spec.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("binds each chord once within a scope", () => {
|
||||
const seen = new Set<string>();
|
||||
for (const spec of COMMAND_SPECS) {
|
||||
for (const k of spec.keys) {
|
||||
const slot = `${spec.scope}:${k}`;
|
||||
expect(seen.has(slot)).toBe(false);
|
||||
seen.add(slot);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a handler for an id that isn't in the registry", () => {
|
||||
expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/);
|
||||
});
|
||||
|
||||
it("carries every spec field onto the defined command", () => {
|
||||
const [cmd] = defineCommands({ "spawn.open": () => {} });
|
||||
expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalizeKey", () => {
|
||||
it("spells keys the way the TUI's InputKey::canonical does", () => {
|
||||
expect(canonicalizeKey(key("n"))).toBe("n");
|
||||
expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N");
|
||||
expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?");
|
||||
expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a");
|
||||
expect(canonicalizeKey(key("Escape"))).toBe("esc");
|
||||
expect(canonicalizeKey(key("Enter"))).toBe("enter");
|
||||
expect(canonicalizeKey(key("Tab"))).toBe("tab");
|
||||
expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab");
|
||||
expect(canonicalizeKey(key("ArrowDown"))).toBe("down");
|
||||
});
|
||||
|
||||
it("folds a modified letter to lower case with an explicit shift-", () => {
|
||||
expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
||||
expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
||||
});
|
||||
|
||||
it("has no canonical form for a modifier pressed alone", () => {
|
||||
expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull();
|
||||
expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyCaps", () => {
|
||||
it("renders one cap per modifier and key", () => {
|
||||
expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]);
|
||||
expect(keyCaps("enter")).toEqual(["Enter"]);
|
||||
expect(keyCaps("j")).toEqual(["j"]);
|
||||
expect(keyCaps("N")).toEqual(["N"]);
|
||||
expect(keyCaps("?")).toEqual(["?"]);
|
||||
expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandSet", () => {
|
||||
it("runs the command bound to a chord, and swallows the browser default", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
||||
const e = key("n");
|
||||
expect(set.handle(e)).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves an unbound key alone", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
||||
const e = key("q");
|
||||
expect(set.handle(e)).toBe(false);
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a modified key as a different binding", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
||||
expect(set.handle(key("n", { ctrlKey: true }))).toBe(false);
|
||||
expect(set.handle(key("n", { metaKey: true }))).toBe(false);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs every alias of a command", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "list.next": run }));
|
||||
set.handle(key("j"));
|
||||
set.handle(key("ArrowDown"));
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
|
||||
const plain = vi.fn();
|
||||
const chorded = vi.fn();
|
||||
const set = new CommandSet(
|
||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
||||
);
|
||||
const input = el("<input>");
|
||||
expect(pressOn(set, input, "n")).toBe(false);
|
||||
expect(plain).not.toHaveBeenCalled();
|
||||
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
|
||||
expect(chorded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps pane commands live inside the terminal's textarea", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
||||
preventDefault: false,
|
||||
});
|
||||
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||
const e = key("Escape");
|
||||
Object.defineProperty(e, "target", { value: textarea });
|
||||
expect(set.handle(e)).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
// xterm is told to swallow by the return value, not by the browser default.
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the leader", () => {
|
||||
it("defaults to the TUI's ctrl-a", () => {
|
||||
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
||||
});
|
||||
|
||||
it("holds the leader as a partial chord instead of selecting the page", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
});
|
||||
const e = key("a", { ctrlKey: true });
|
||||
expect(set.handle(e)).toBe(true);
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows the key after the leader rather than running its plain binding", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
});
|
||||
set.handle(key("a", { ctrlKey: true }));
|
||||
expect(set.handle(key("n"))).toBe(true);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
// The next key is an ordinary one again.
|
||||
set.handle(key("n"));
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets a held leader once the chord timeout passes", () => {
|
||||
const run = vi.fn();
|
||||
let now = 0;
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
chordTimeoutMs: 1000,
|
||||
now: () => now,
|
||||
});
|
||||
set.handle(key("a", { ctrlKey: true }));
|
||||
now = 1500;
|
||||
set.handle(key("n"));
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves select-all alone in a real form field", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
});
|
||||
const input = el("<input>");
|
||||
const e = key("a", { ctrlKey: true });
|
||||
Object.defineProperty(e, "target", { value: input });
|
||||
expect(set.handle(e)).toBe(false);
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishing", () => {
|
||||
it("lists a constructed set's commands, replacing an id rather than doubling it", () => {
|
||||
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
||||
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
||||
const ids = publishedCommands().map((c) => c.id);
|
||||
expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]);
|
||||
});
|
||||
|
||||
it("keeps published commands in registry order", () => {
|
||||
const order = COMMAND_SPECS.map((s) => s.id);
|
||||
const ids = publishedCommands().map((c) => c.id);
|
||||
expect(ids).toEqual(order.filter((id) => ids.includes(id)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTypingTarget", () => {
|
||||
it("is true for form fields and editable nodes, false otherwise", () => {
|
||||
expect(isTypingTarget(el("<input>"))).toBe(true);
|
||||
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
||||
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
||||
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
||||
expect(isTypingTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
// The web client's command registry (#148). One entry per command — a stable
|
||||
// id, a plain-language label, the key chord it answers to, and a `run()` — so
|
||||
// there is exactly one list of what the client can do. The `?` overlay renders
|
||||
// from it (`help.ts`), and every keybinding is dispatched through it; a command
|
||||
// added here is bound and documented in the same edit.
|
||||
//
|
||||
// Keys are canonical strings, the same spelling the TUI's `InputKey::canonical`
|
||||
// produces (`ctrl-shift-h`, `enter`, `esc`, `n`, `N`) and chords are those
|
||||
// joined by a space (`ctrl-a ctrl-k`). Matching the TUI's spelling is what lets
|
||||
// docs/COMMAND-BAR.md describe one key model for both clients.
|
||||
|
||||
/** Where a command's keys are listened for. `global` is the window, `list` the
|
||||
* sidebar's session tree, `pane` the attached terminal, `overlay` the help
|
||||
* dialog while it is up. */
|
||||
export type CommandScope = "global" | "list" | "pane" | "overlay";
|
||||
|
||||
export interface CommandSpec {
|
||||
/** Stable identifier. Never rendered; safe to bind config and tests to. */
|
||||
id: string;
|
||||
/** Plain-language label — what the `?` overlay and (later) the command bar
|
||||
* show. */
|
||||
label: string;
|
||||
/** Help-overlay section. Groups render in the order they first appear here. */
|
||||
group: string;
|
||||
/** Canonical chords that run it. The first is the one shown to the operator;
|
||||
* the rest are aliases (`j` and `down`). */
|
||||
keys: string[];
|
||||
scope: CommandScope;
|
||||
}
|
||||
|
||||
export interface Command extends CommandSpec {
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/** Every command the web client has. The single list this issue exists to
|
||||
* create: labels, chords and grouping live here and nowhere else. */
|
||||
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
{
|
||||
id: "spawn.open",
|
||||
label: "Open the spawn form",
|
||||
group: "Sessions",
|
||||
keys: ["n"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "spawn.fastForward",
|
||||
label: "Spawn a session with every default resolved",
|
||||
group: "Sessions",
|
||||
keys: ["N"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "focus.toggle",
|
||||
label: "Move the keyboard between the session list and the pane",
|
||||
group: "Session list",
|
||||
keys: ["ctrl-shift-h"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "list.next",
|
||||
label: "Highlight the next session",
|
||||
group: "Session list",
|
||||
keys: ["j", "down"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.prev",
|
||||
label: "Highlight the previous session",
|
||||
group: "Session list",
|
||||
keys: ["k", "up"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.activate",
|
||||
label: "Open the highlighted session, or expand a folder",
|
||||
group: "Session list",
|
||||
keys: ["enter"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.toggleFolder",
|
||||
label: "Collapse or expand the highlighted folder",
|
||||
group: "Session list",
|
||||
keys: ["c"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.rename",
|
||||
label: "Rename the highlighted session",
|
||||
group: "Session list",
|
||||
keys: ["r"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.kill",
|
||||
label: "Kill the highlighted session",
|
||||
group: "Session list",
|
||||
keys: ["x"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.focusPane",
|
||||
label: "Send the keyboard to the pane",
|
||||
group: "Session list",
|
||||
keys: ["tab"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "pane.focusList",
|
||||
label: "Hand the keyboard back to the session list, from the pane",
|
||||
group: "Session list",
|
||||
keys: ["esc"],
|
||||
scope: "pane",
|
||||
},
|
||||
{
|
||||
id: "help.toggle",
|
||||
label: "Show or hide this help",
|
||||
group: "Help",
|
||||
keys: ["?"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "help.close",
|
||||
label: "Close the help overlay or an open menu",
|
||||
group: "Help",
|
||||
keys: ["esc"],
|
||||
scope: "overlay",
|
||||
},
|
||||
{
|
||||
id: "debug.toggleHud",
|
||||
label: "Toggle the debug HUD",
|
||||
group: "Debug",
|
||||
keys: ["ctrl-shift-d"],
|
||||
scope: "global",
|
||||
},
|
||||
];
|
||||
|
||||
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s]));
|
||||
|
||||
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
||||
* test failure rather than a key that silently does nothing. */
|
||||
export function defineCommands(handlers: Record<string, () => void>): Command[] {
|
||||
return Object.entries(handlers).map(([id, run]) => {
|
||||
const spec = SPEC_BY_ID.get(id);
|
||||
if (!spec) throw new Error(`unknown command id: ${id}`);
|
||||
return { ...spec, run };
|
||||
});
|
||||
}
|
||||
|
||||
// ---- what the help overlay renders ----
|
||||
|
||||
const published = new Map<string, Command>();
|
||||
|
||||
/** Record commands as bound. Re-registering an id replaces it, so a second
|
||||
* sidebar (tests, hot reload) doesn't duplicate rows in the overlay. */
|
||||
export function publishCommands(cmds: Command[]): void {
|
||||
for (const c of cmds) published.set(c.id, c);
|
||||
}
|
||||
|
||||
/** Every bound command, in `COMMAND_SPECS` order. */
|
||||
export function publishedCommands(): Command[] {
|
||||
return COMMAND_SPECS.map((s) => published.get(s.id)).filter((c): c is Command => c !== undefined);
|
||||
}
|
||||
|
||||
// ---- key canonicalisation ----
|
||||
|
||||
const NAMED: Record<string, string> = {
|
||||
Escape: "esc",
|
||||
Enter: "enter",
|
||||
Tab: "tab",
|
||||
Backspace: "backspace",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
Home: "home",
|
||||
End: "end",
|
||||
PageUp: "pageup",
|
||||
PageDown: "pagedown",
|
||||
Delete: "delete",
|
||||
" ": "space",
|
||||
};
|
||||
|
||||
/** The canonical string for one keydown. Mirrors the TUI's
|
||||
* `InputKey::canonical`: modifier prefixes in ctrl/alt/shift order, then the
|
||||
* key. A shifted character already spells itself (`N`, `?`), so `shift-` is
|
||||
* only written when another modifier is present and the char was folded to
|
||||
* lower case. */
|
||||
export function canonicalizeKey(e: KeyboardEvent): string | null {
|
||||
if (!e.key) return null;
|
||||
// Modifier keys pressed alone are never a binding.
|
||||
if (e.key === "Control" || e.key === "Alt" || e.key === "Shift" || e.key === "Meta") return null;
|
||||
const named = NAMED[e.key];
|
||||
if (named === "tab" && e.shiftKey) return "backtab";
|
||||
let out = "";
|
||||
if (e.ctrlKey) out += "ctrl-";
|
||||
if (e.altKey) out += "alt-";
|
||||
if (e.metaKey) out += "meta-";
|
||||
if (named) {
|
||||
if (e.shiftKey) out += "shift-";
|
||||
return out + named;
|
||||
}
|
||||
if (e.key.length === 1) {
|
||||
if (out === "") return e.key;
|
||||
if (e.shiftKey) out += "shift-";
|
||||
return out + e.key.toLowerCase();
|
||||
}
|
||||
if (e.shiftKey) out += "shift-";
|
||||
return out + e.key.toLowerCase();
|
||||
}
|
||||
|
||||
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
|
||||
|
||||
/** True when a chord carries a modifier or more than one key. Plain single-key
|
||||
* chords are the ones that stay deaf inside form fields. */
|
||||
function isChorded(chord: string): boolean {
|
||||
return chord.includes(" ") || MOD_PREFIX.test(chord);
|
||||
}
|
||||
|
||||
/** True when the event target swallows plain keystrokes — a form field, or the
|
||||
* hidden textarea xterm types into. Every plain single-key binding is deaf in
|
||||
* the same places. */
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el || typeof el.tagName !== "string") return false;
|
||||
return (
|
||||
el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT" ||
|
||||
el.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
||||
* also where the leader has to work — a pane is exactly where the operator
|
||||
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
||||
function isTerminalTextarea(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
||||
}
|
||||
|
||||
// ---- display ----
|
||||
|
||||
const CAPS: Record<string, string> = {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
meta: "Meta",
|
||||
shift: "Shift",
|
||||
esc: "Esc",
|
||||
enter: "Enter",
|
||||
tab: "Tab",
|
||||
backtab: "Shift Tab",
|
||||
backspace: "Backspace",
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
left: "Left",
|
||||
right: "Right",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
pageup: "PgUp",
|
||||
pagedown: "PgDn",
|
||||
delete: "Del",
|
||||
space: "Space",
|
||||
};
|
||||
|
||||
/** Keycaps for a chord, left to right: `ctrl-shift-h` → Ctrl, Shift, H. A
|
||||
* multi-key chord separates its keys with a `then` cap. */
|
||||
export function keyCaps(chord: string): string[] {
|
||||
const caps: string[] = [];
|
||||
for (const [i, key] of chord.split(" ").entries()) {
|
||||
if (i > 0) caps.push("then");
|
||||
const parts = key.split("-");
|
||||
// A bare `-` key splits into two empties; treat it as the key itself.
|
||||
if (parts.every((p) => p === "")) {
|
||||
caps.push("-");
|
||||
continue;
|
||||
}
|
||||
const last = parts.pop() as string;
|
||||
const mods = parts.filter((p) => p !== "");
|
||||
for (const m of mods) caps.push(CAPS[m] ?? m);
|
||||
caps.push(CAPS[last] ?? (mods.length > 0 && last.length === 1 ? last.toUpperCase() : last));
|
||||
}
|
||||
return caps;
|
||||
}
|
||||
|
||||
// ---- dispatch ----
|
||||
|
||||
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
||||
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
||||
export const DEFAULT_LEADER = "ctrl-a";
|
||||
|
||||
/** How long a partial chord waits for its next key — the TUI's
|
||||
* `chord_timeout_ms` default. */
|
||||
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
||||
|
||||
export interface CommandSetOptions {
|
||||
/** Canonical leader key. A set given one holds every leader press as a
|
||||
* pending chord, whether or not anything is bound behind it yet. */
|
||||
leader?: string | null;
|
||||
chordTimeoutMs?: number;
|
||||
/** Call `preventDefault()` on a handled key. Off for the terminal's own key
|
||||
* handler, which swallows by returning `false` to xterm instead. */
|
||||
preventDefault?: boolean;
|
||||
/** Clock, for tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** A group of commands listening on one surface. Resolves a keydown to a
|
||||
* command the way the TUI's `ChordState` does: hold while the sequence is a
|
||||
* strict prefix of some binding, otherwise resolve and dispatch. */
|
||||
export class CommandSet {
|
||||
private readonly byChord = new Map<string, Command>();
|
||||
private readonly leader: string | null;
|
||||
private readonly timeout: number;
|
||||
private readonly preventDefault: boolean;
|
||||
private readonly now: () => number;
|
||||
private held: string[] = [];
|
||||
private lastPress = 0;
|
||||
|
||||
constructor(
|
||||
readonly commands: Command[],
|
||||
opts: CommandSetOptions = {},
|
||||
) {
|
||||
for (const c of commands) for (const k of c.keys) this.byChord.set(k, c);
|
||||
this.leader = opts.leader ?? null;
|
||||
this.timeout = opts.chordTimeoutMs ?? DEFAULT_CHORD_TIMEOUT_MS;
|
||||
this.preventDefault = opts.preventDefault ?? true;
|
||||
this.now = opts.now ?? (() => Date.now());
|
||||
publishCommands(commands);
|
||||
}
|
||||
|
||||
/** Whether any binding starts with `candidate` and continues. */
|
||||
private isPrefix(candidate: string): boolean {
|
||||
const marker = `${candidate} `;
|
||||
for (const chord of this.byChord.keys()) if (chord.startsWith(marker)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Feed one keydown. Returns true when the key was consumed — dispatched, or
|
||||
* held as a partial chord. */
|
||||
handle(e: KeyboardEvent): boolean {
|
||||
const key = canonicalizeKey(e);
|
||||
if (key === null) return false;
|
||||
if (this.held.length > 0 && this.now() - this.lastPress > this.timeout) this.held = [];
|
||||
const candidate = this.held.length > 0 ? `${this.held.join(" ")} ${key}` : key;
|
||||
this.lastPress = this.now();
|
||||
|
||||
// The leader is a prefix by construction, so it stops being select-all
|
||||
// before anything is bound behind it (#149 binds the first chord).
|
||||
if (this.leader !== null && this.held.length === 0 && key === this.leader) {
|
||||
if (isTypingTarget(e.target) && !isTerminalTextarea(e.target)) return false;
|
||||
this.held = [key];
|
||||
if (this.preventDefault) e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isPrefix(candidate)) {
|
||||
this.held.push(key);
|
||||
if (this.preventDefault) e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
const wasChord = this.held.length > 0;
|
||||
this.held = [];
|
||||
const cmd = this.byChord.get(candidate);
|
||||
if (!cmd) {
|
||||
// A chord that resolved to nothing is still consumed: the leader took
|
||||
// the key, so it must not also fall through to a plain binding.
|
||||
return wasChord;
|
||||
}
|
||||
// Plain single-key bindings are deaf where typing happens; chorded ones are
|
||||
// not, and neither are the pane's and the overlay's, which are only fed
|
||||
// from the surface that owns them.
|
||||
if (
|
||||
!isChorded(candidate) &&
|
||||
cmd.scope !== "pane" &&
|
||||
cmd.scope !== "overlay" &&
|
||||
isTypingTarget(e.target)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.preventDefault) e.preventDefault();
|
||||
cmd.run();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+5
-6
@@ -2,6 +2,8 @@
|
||||
// survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by
|
||||
// `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change.
|
||||
|
||||
import { CommandSet, defineCommands } from "./commands.js";
|
||||
|
||||
const STORAGE_KEY = "maestro:hud";
|
||||
export const HUD_TOGGLE_EVENT = "maestro-hud-toggle";
|
||||
|
||||
@@ -31,12 +33,9 @@ export function toggleHud(): void {
|
||||
setHudEnabled(!isHudEnabled());
|
||||
}
|
||||
|
||||
const hudKeys = new CommandSet(defineCommands({ "debug.toggleHud": () => toggleHud() }));
|
||||
|
||||
/** Global keybind: Ctrl+Shift+D toggles the debug HUD. */
|
||||
export function installHudKeybind(): void {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
|
||||
e.preventDefault();
|
||||
toggleHud();
|
||||
}
|
||||
});
|
||||
window.addEventListener("keydown", (e) => hudKeys.handle(e));
|
||||
}
|
||||
|
||||
+18
-30
@@ -1,14 +1,8 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SHORTCUTS,
|
||||
closeHelp,
|
||||
installHelpKeybind,
|
||||
isHelpOpen,
|
||||
isTypingTarget,
|
||||
openHelp,
|
||||
} from "./help.js";
|
||||
import { keyCaps, publishedCommands } from "./commands.js";
|
||||
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
||||
|
||||
beforeEach(() => {
|
||||
installHelpKeybind();
|
||||
@@ -30,14 +24,24 @@ function rows(): string[] {
|
||||
}
|
||||
|
||||
describe("help overlay", () => {
|
||||
it("renders every registered shortcut, grouped", () => {
|
||||
it("renders exactly the commands the registry has bound, grouped", () => {
|
||||
openHelp();
|
||||
const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
|
||||
expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
|
||||
expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
|
||||
expect(document.querySelectorAll(".help-desc")).toHaveLength(
|
||||
SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
|
||||
const groups = groupedCommands();
|
||||
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
|
||||
groups.map((g) => g.title),
|
||||
);
|
||||
expect(rows()).toEqual(
|
||||
groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))),
|
||||
);
|
||||
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
|
||||
groups.flatMap((g) => g.items.map((c) => c.label)),
|
||||
);
|
||||
});
|
||||
|
||||
it("names each row by its command id, so a row can't drift from its command", () => {
|
||||
openHelp();
|
||||
expect([...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId))
|
||||
.toEqual(publishedCommands().map((c) => c.id));
|
||||
});
|
||||
|
||||
it("opens on ? and closes on a second ?", () => {
|
||||
@@ -94,19 +98,3 @@ describe("help overlay", () => {
|
||||
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTypingTarget", () => {
|
||||
it("is true for form fields and editable nodes, false otherwise", () => {
|
||||
const el = (html: string) => {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = html;
|
||||
return d.firstElementChild as HTMLElement;
|
||||
};
|
||||
expect(isTypingTarget(el("<input>"))).toBe(true);
|
||||
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
||||
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
||||
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
||||
expect(isTypingTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+37
-76
@@ -1,66 +1,15 @@
|
||||
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
||||
* overlay. One registry drives both the rendering and what we claim is bound,
|
||||
* so a binding added to `main.ts` without an entry here reads as missing. */
|
||||
* overlay. It renders the command registry (`commands.ts`) — the same list
|
||||
* that dispatches the keys — so a binding can never be documented as one
|
||||
* thing and do another. */
|
||||
|
||||
export interface Shortcut {
|
||||
/** Keycaps, rendered left to right as separate <kbd>. */
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ShortcutGroup {
|
||||
title: string;
|
||||
items: Shortcut[];
|
||||
}
|
||||
|
||||
export const SHORTCUTS: ShortcutGroup[] = [
|
||||
{
|
||||
title: "Sessions",
|
||||
items: [
|
||||
{ keys: ["n"], description: "Open the spawn form" },
|
||||
{ keys: ["N"], description: "Spawn a session with every default resolved" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Session list",
|
||||
items: [
|
||||
{
|
||||
keys: ["Ctrl", "Shift", "H"],
|
||||
description: "Move the keyboard between the session list and the pane",
|
||||
},
|
||||
{ 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: ["Tab"], description: "Send the keyboard to the pane" },
|
||||
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
items: [
|
||||
{ keys: ["?"], description: "Show or hide this help" },
|
||||
{ keys: ["Esc"], description: "Close the help overlay or an open menu" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Debug",
|
||||
items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
|
||||
},
|
||||
];
|
||||
|
||||
/** True when the event target swallows plain keystrokes — a form field, or the
|
||||
* hidden textarea xterm types into. Shared with `main.ts` so every global
|
||||
* single-key binding is deaf in the same places. */
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el || typeof el.tagName !== "string") return false;
|
||||
return (
|
||||
el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT" ||
|
||||
el.isContentEditable
|
||||
);
|
||||
}
|
||||
import {
|
||||
CommandSet,
|
||||
defineCommands,
|
||||
keyCaps,
|
||||
publishedCommands,
|
||||
type Command,
|
||||
} from "./commands.js";
|
||||
|
||||
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
||||
|
||||
@@ -105,7 +54,7 @@ export function openHelp(): HTMLElement {
|
||||
head.append(title, close);
|
||||
panel.appendChild(head);
|
||||
|
||||
for (const group of SHORTCUTS) {
|
||||
for (const group of groupedCommands()) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "help-group";
|
||||
const heading = document.createElement("h3");
|
||||
@@ -113,19 +62,20 @@ export function openHelp(): HTMLElement {
|
||||
heading.textContent = group.title;
|
||||
const list = document.createElement("dl");
|
||||
list.className = "help-list";
|
||||
for (const item of group.items) {
|
||||
for (const cmd of group.items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "help-row";
|
||||
row.dataset.commandId = cmd.id;
|
||||
const keys = document.createElement("dt");
|
||||
keys.className = "help-keys";
|
||||
for (const key of item.keys) {
|
||||
for (const key of keyCaps(cmd.keys[0])) {
|
||||
const cap = document.createElement("kbd");
|
||||
cap.textContent = key;
|
||||
keys.appendChild(cap);
|
||||
}
|
||||
const desc = document.createElement("dd");
|
||||
desc.className = "help-desc";
|
||||
desc.textContent = item.description;
|
||||
desc.textContent = cmd.label;
|
||||
row.append(keys, desc);
|
||||
list.appendChild(row);
|
||||
}
|
||||
@@ -150,6 +100,26 @@ export function openHelp(): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
/** The registry's commands, in registry order, bucketed into the help
|
||||
* overlay's sections. Groups appear in the order their first command does. */
|
||||
export function groupedCommands(): { title: string; items: Command[] }[] {
|
||||
const groups: { title: string; items: Command[] }[] = [];
|
||||
for (const cmd of publishedCommands()) {
|
||||
let group = groups.find((g) => g.title === cmd.group);
|
||||
if (!group) {
|
||||
group = { title: cmd.group, items: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(cmd);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// The overlay's own two commands. `help.close` is `overlay`-scoped: it is only
|
||||
// fed keys while the dialog is up.
|
||||
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() }));
|
||||
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() }));
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
|
||||
@@ -158,16 +128,7 @@ export function installHelpKeybind(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
e.preventDefault();
|
||||
closeHelp();
|
||||
return;
|
||||
}
|
||||
// Shift is how `?` is typed on most layouts; the others would be a
|
||||
// different binding entirely.
|
||||
if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
e.preventDefault();
|
||||
toggleHelp();
|
||||
if (open && overlayKeys.handle(e)) return;
|
||||
helpKeys.handle(e);
|
||||
});
|
||||
}
|
||||
|
||||
+69
-29
@@ -6,7 +6,8 @@ import { renderSidebar } from "./catalog.js";
|
||||
import { ConnectionPool } from "./connections.js";
|
||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands } from "./commands.js";
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { installRestartOverlay } from "./restartOverlay.js";
|
||||
@@ -29,6 +30,7 @@ import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { createPreviewSchedule } from "./previewSchedule.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
FOCUS_SIDEBAR_EVENT,
|
||||
@@ -104,9 +106,20 @@ 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): the first move shows at once, and a burst
|
||||
// — j held down, or the pointer sweeping the whole list — is coalesced so it
|
||||
// costs a couple of pane mounts instead of one per row walked.
|
||||
const schedulePreview = createPreviewSchedule((sessionId) => {
|
||||
ensureMultiView();
|
||||
mv?.previewSession(sessionId);
|
||||
updateShellRoute();
|
||||
});
|
||||
|
||||
const sidebar = renderSidebar(store, {
|
||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||
link,
|
||||
onPreview: schedulePreview,
|
||||
});
|
||||
const content = document.createElement("section");
|
||||
content.className = "content";
|
||||
@@ -184,7 +197,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 +259,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();
|
||||
}
|
||||
@@ -340,42 +362,59 @@ async function requestFastForward(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn.
|
||||
// Ignored while typing anywhere (form fields, the terminal's hidden
|
||||
// textarea, search) so an attached session's keys are never hijacked.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
// The help overlay owns the keyboard while it is up.
|
||||
if (isHelpOpen()) return;
|
||||
if (e.key === "n") {
|
||||
e.preventDefault();
|
||||
requestOpenSpawn();
|
||||
} else if (e.key === "N") {
|
||||
e.preventDefault();
|
||||
void requestFastForward();
|
||||
}
|
||||
});
|
||||
// The global half of the command registry (#148). Two sets, because they
|
||||
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
||||
// the plain keys go deaf behind it while the focus chord does not — an
|
||||
// attached terminal must never be able to trap the focus keys.
|
||||
//
|
||||
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
||||
// keys, so `isTypingTarget` keeps them out of form fields and the terminal's
|
||||
// hidden textarea.
|
||||
const spawnKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"spawn.open": () => requestOpenSpawn(),
|
||||
"spawn.fastForward": () => void requestFastForward(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
||||
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
||||
// the focus back out of it.
|
||||
//
|
||||
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the
|
||||
// key as a partial chord and takes select-all off it, so the escape hatch
|
||||
// docs/COMMAND-BAR.md describes has somewhere to land (#149).
|
||||
const focusKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"focus.toggle": () => {
|
||||
if (sidebar.hasListFocus()) {
|
||||
pool.focusTerminal();
|
||||
} else {
|
||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||
sidebar.focusList();
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
||||
if (e.key !== "H" && e.key !== "h") return;
|
||||
e.preventDefault();
|
||||
if (sidebar.hasListFocus()) {
|
||||
pool.focusTerminal();
|
||||
} else {
|
||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||
sidebar.focusList();
|
||||
}
|
||||
if (focusKeys.handle(e)) return;
|
||||
// The help overlay owns the keyboard while it is up.
|
||||
if (isHelpOpen()) return;
|
||||
spawnKeys.handle(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 +425,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 {
|
||||
schedulePreview.cancel();
|
||||
showMultiView();
|
||||
mv.openSession(sessionId);
|
||||
updateShellRoute();
|
||||
|
||||
+181
-13
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js";
|
||||
|
||||
/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */
|
||||
function harness() {
|
||||
let now = 1_000;
|
||||
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||
let nextHandle = 1;
|
||||
const emitted: (string | null)[] = [];
|
||||
|
||||
const schedule = createPreviewSchedule((id) => emitted.push(id), {
|
||||
now: () => now,
|
||||
setTimer: (fn, ms) => {
|
||||
const h = nextHandle++;
|
||||
timers.set(h, { at: now + ms, fn });
|
||||
return h;
|
||||
},
|
||||
clearTimer: (h) => {
|
||||
timers.delete(h);
|
||||
},
|
||||
});
|
||||
|
||||
/** Advance the clock, firing any timer that comes due. */
|
||||
const advance = (ms: number) => {
|
||||
now += ms;
|
||||
for (const [h, t] of [...timers]) {
|
||||
if (t.at <= now) {
|
||||
timers.delete(h);
|
||||
t.fn();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { schedule, emitted, advance };
|
||||
}
|
||||
|
||||
describe("preview schedule", () => {
|
||||
it("shows the first preview immediately", () => {
|
||||
const { schedule, emitted } = harness();
|
||||
schedule("s1");
|
||||
expect(emitted).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("coalesces a burst into one trailing preview", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
// A pointer sweep across the list, or j held down: every row in between
|
||||
// is walked well inside one gap.
|
||||
schedule("s1");
|
||||
schedule("s2");
|
||||
schedule("s3");
|
||||
expect(emitted).toEqual(["s1"]);
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
expect(emitted).toEqual(["s1", "s3"]);
|
||||
});
|
||||
|
||||
it("shows the next preview immediately once the gap has passed", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
schedule("s1");
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
schedule("s2");
|
||||
expect(emitted).toEqual(["s1", "s2"]);
|
||||
});
|
||||
|
||||
it("drops a superseded trailing preview rather than replaying it", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
schedule("s1");
|
||||
schedule("s2");
|
||||
schedule("s1"); // back where we started inside the same gap
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
// s1 is already on screen, so the trailing edge has nothing to do.
|
||||
expect(emitted).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("clears through the same gap, so leaving and re-entering does not blink", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
schedule("s1");
|
||||
schedule(null); // pointer crosses the gap between two rows
|
||||
schedule("s2");
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
expect(emitted).toEqual(["s1", "s2"]);
|
||||
});
|
||||
|
||||
it("cancel drops a pending trailing preview", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
schedule("s1");
|
||||
schedule("s2");
|
||||
schedule.cancel();
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
expect(emitted).toEqual(["s1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* When the content pane follows the sidebar highlight (docs/PREVIEW.md).
|
||||
*
|
||||
* The highlight itself is never scheduled — that is a class on a row and lands
|
||||
* on the same frame as the input. This governs only the pane swap behind it,
|
||||
* which mounts a terminal and, for a session the connection pool isn't already
|
||||
* holding, opens an attach socket the daemon answers with a `tmux capture-pane`.
|
||||
*
|
||||
* A plain debounce would put that cost in front of every preview, including the
|
||||
* one preview the operator actually meant. Instead the first move shows at once
|
||||
* and the rest of a burst is coalesced: walking a list with `j` held down, or
|
||||
* sweeping the pointer from the top of the sidebar to the bottom, costs one
|
||||
* preview at the start and one at the row you stopped on, not one per row.
|
||||
*/
|
||||
|
||||
/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a
|
||||
* couple of terminals rather than one per row; short enough that a deliberate
|
||||
* second move never waits on it. */
|
||||
export const PREVIEW_MIN_GAP_MS = 120;
|
||||
|
||||
export interface PreviewScheduleDeps {
|
||||
now?: () => number;
|
||||
setTimer?: (fn: () => void, ms: number) => number;
|
||||
clearTimer?: (handle: number) => void;
|
||||
/** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */
|
||||
minGapMs?: number;
|
||||
}
|
||||
|
||||
export interface PreviewSchedule {
|
||||
(sessionId: string | null): void;
|
||||
/** Drop a pending trailing preview without showing it. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `show` so it fires on the leading edge and at most once per gap.
|
||||
*
|
||||
* `show` is called with the session to preview, or `null` to put back whatever
|
||||
* the operator actually opened.
|
||||
*/
|
||||
export function createPreviewSchedule(
|
||||
show: (sessionId: string | null) => void,
|
||||
deps: PreviewScheduleDeps = {},
|
||||
): PreviewSchedule {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms));
|
||||
const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h));
|
||||
const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS;
|
||||
|
||||
let lastShownAt = Number.NEGATIVE_INFINITY;
|
||||
let shown: string | null | undefined;
|
||||
let pendingTimer: number | undefined;
|
||||
let pendingId: string | null = null;
|
||||
|
||||
const emit = (id: string | null) => {
|
||||
// Nothing to do when the burst ended on the view already up: re-showing it
|
||||
// would tear down and re-mount the pane for no visible change.
|
||||
if (shown === id) return;
|
||||
shown = id;
|
||||
lastShownAt = now();
|
||||
show(id);
|
||||
};
|
||||
|
||||
const cancelPending = () => {
|
||||
if (pendingTimer === undefined) return;
|
||||
clearTimer(pendingTimer);
|
||||
pendingTimer = undefined;
|
||||
};
|
||||
|
||||
const schedule = ((sessionId: string | null) => {
|
||||
pendingId = sessionId;
|
||||
const waited = now() - lastShownAt;
|
||||
if (waited >= minGap) {
|
||||
cancelPending();
|
||||
emit(sessionId);
|
||||
return;
|
||||
}
|
||||
if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins
|
||||
pendingTimer = setTimer(() => {
|
||||
pendingTimer = undefined;
|
||||
emit(pendingId);
|
||||
}, minGap - waited);
|
||||
}) as PreviewSchedule;
|
||||
|
||||
schedule.cancel = cancelPending;
|
||||
return schedule;
|
||||
}
|
||||
@@ -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,40 @@ 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)[] = [];
|
||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||
|
||||
press(tree(el), "j");
|
||||
press(tree(el), "j");
|
||||
expect(seen).toEqual([A, B]);
|
||||
|
||||
tree(el).dispatchEvent(new FocusEvent("blur"));
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
||||
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: "mouse" }));
|
||||
expect(seen).toEqual([A]);
|
||||
// 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)).toBe(null);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
+85
-7
@@ -1064,7 +1064,6 @@ textarea {
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
transition: color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.tree-folder-header:hover {
|
||||
@@ -1076,9 +1075,9 @@ textarea {
|
||||
text-align: center;
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-11);
|
||||
transition:
|
||||
transform var(--dur) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
/* The rotation is the collapse animation and stays; the hover colour is
|
||||
immediate, like the row's own hover. */
|
||||
transition: transform var(--dur) var(--ease-out);
|
||||
}
|
||||
|
||||
.tree-folder-header[aria-expanded="false"] .chevron {
|
||||
@@ -1115,9 +1114,11 @@ textarea {
|
||||
border-radius: var(--r-1);
|
||||
color: var(--ink);
|
||||
position: relative;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
/* No transition on `background`: it is what hover paints, and easing it puts
|
||||
a visible delay between the pointer landing on a row and the row admitting
|
||||
it. The ring (`.is-highlighted`, `.is-active`) still eases — that one
|
||||
follows a keyboard walk, not the pointer. */
|
||||
transition: box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
|
||||
@@ -2023,6 +2024,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 +2884,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;
|
||||
|
||||
@@ -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