Compare commits

...
Author SHA1 Message Date
Miguel Palhas ba74d3515b fix(maestro): keep sidebar accent on the picked session while previewing
ci / rust (push) Successful in 10m9s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 2m56s
A preview moved the active tab's focus, and the sidebar took that as the
focused session, so hovering another row dropped the accent from the row the
operator had actually opened. Report the pre-preview focus instead.
2026-09-04 08:15:35 +01:00
Miguel Palhas 8325bc6d9a Merge remote-tracking branch 'origin/main' into blitz/command-bar
ci / web (push) Successful in 37s
ci / flake (push) Successful in 2m52s
ci / rust (push) Successful in 5m44s
2026-09-03 22:58:41 +01:00
Miguel PalhasandClaude Opus 5 8f499dbee3 fix(maestro): arbitrate pane size across clients
ci / web (push) Successful in 52s
ci / flake (push) Successful in 2m35s
ci / rust (push) Successful in 5m57s
Every attached client pushed its own dimensions straight to
`tmux resize-window`, so two clients of different sizes resized the
shared window past each other on every report — 198 resizes in 90
seconds on a four-session daemon. Each flip repaints a full-screen agent
at a new height and orphans the rows it drew at the old one, leaving a
stale input box the operator types into and reads as a stuck pane.

The window now goes to the smallest attached client per axis, tmux's own
rule for multiple clients, and only when that size actually changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:28:40 +01:00
Miguel Palhas 511fed408c feat(maestro): confirm every kill in the web client
The TUI has always raised `KillConfirm` before `x` kills anything; the web
sent `DELETE /api/sessions/:id` on the first press and only prompted when the
daemon refused. The command bar puts both behind the same three keystrokes,
so the difference now reads as a bug (#160).

The web follows the TUI: a kill is destructive and unattended agents live in
these sessions. `killDialog.ts` grows a confirmation that shares the force
prompt's shell, names the blockers the card already knows about, and keeps the
focus on Cancel. The guarded refusal still opens its own dialog behind it.
2026-09-03 22:08:06 +01:00
Miguel Palhas ba02b11f13 feat(maestro): serve the configured leader to the web client
The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`,
a module constant. Rebinding one left the other on `ctrl-a`, so the two
escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`)
moved on one client and not the other.

`GET /api/info` now carries `leader`. The web binds that when it arrives and
rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command
bar render the configured chord; an absent or empty field — an older daemon,
or an unreachable `/api/info` — leaves it on `ctrl-a`.

Chose the existing `[tui].leader` served as-is over a new shared key: one
knob, no precedence rules between two spellings, and no migration for
operators who already set it. The name is a wart — it is the leader for every
client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md`
say so.
2026-09-03 18:33:01 +01:00
32 changed files with 813 additions and 101 deletions
+14
View File
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`. /// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070"; pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
/// Leader key both clients use when `[tui].leader` is unset (#159). Canonical
/// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey`
/// both produce.
pub const DEFAULT_LEADER: &str = "ctrl-a";
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ConfigError { pub enum ConfigError {
#[error("io: {0}")] #[error("io: {0}")]
@@ -152,6 +157,15 @@ pub struct TuiSection {
pub keymap_insert: BTreeMap<String, String>, pub keymap_insert: BTreeMap<String, String>,
} }
impl TuiSection {
/// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for
/// history, but read by every client: the daemon serves it on
/// `GET /api/info` so the web follows the same knob (#159).
pub fn leader(&self) -> &str {
self.leader.as_deref().unwrap_or(DEFAULT_LEADER)
}
}
/// Palette presets supported by the TUI's `[tui].theme` setting. /// Palette presets supported by the TUI's `[tui].theme` setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiTheme { pub enum TuiTheme {
+9 -1
View File
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
) -> Result<(), axum::Error> { ) -> Result<(), axum::Error> {
let (mut sink, mut stream) = socket.split(); let (mut sink, mut stream) = socket.split();
// This client's claim on the shared window size. Registered up front and
// released on every exit below, so a socket that dies mid-session stops
// constraining the size for the clients still attached.
let viewport = manager.attach_viewport();
// NAP-229: send buffered scrollback up-front so the client sees the last // NAP-229: send buffered scrollback up-front so the client sees the last
// screenful immediately instead of a blank terminal. The replay lock the // screenful immediately instead of a blank terminal. The replay lock the
// bridge held during snapshot+subscribe guarantees no byte appears both // bridge held during snapshot+subscribe guarantees no byte appears both
// here and on the live receiver, and none is dropped at the seam. // here and on the live receiver, and none is dropped at the seam.
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() { if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
manager.release_viewport(id, viewport).await;
return Ok(()); return Ok(());
} }
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) { if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl { match ctrl {
ClientControl::Resize { cols, rows } => { ClientControl::Resize { cols, rows } => {
let _ = manager.resize(id, cols, rows).await; let _ = manager.set_viewport(id, viewport, cols, rows).await;
} }
ClientControl::Ping => { ClientControl::Ping => {
let pong = serde_json::to_string(&ClientControl::Pong) let pong = serde_json::to_string(&ClientControl::Pong)
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
} }
} }
manager.release_viewport(id, viewport).await;
if session_ended { if session_ended {
let _ = sink.send(Message::Close(None)).await; let _ = sink.send(Message::Close(None)).await;
} }
+1
View File
@@ -105,6 +105,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.home_dir.clone(), config.home_dir.clone(),
config.spawn_presets.clone(), config.spawn_presets.clone(),
projects, projects,
config.leader.clone(),
)); ));
let listener = TcpListener::bind(config.bind_addr).await?; let listener = TcpListener::bind(config.bind_addr).await?;
+29
View File
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
/// start when none is present (#106): `[hooks].auto_install`, default /// start when none is present (#106): `[hooks].auto_install`, default
/// `true`. /// `true`.
pub hooks_auto_install: bool, pub hooks_auto_install: bool,
/// Canonical leader key advertised to clients in `GET /api/info` (#159):
/// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon
/// never binds it itself; it serves it so the web client and the TUI read
/// one knob.
pub leader: String,
} }
impl DaemonConfig { impl DaemonConfig {
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
project_roots, project_roots,
project_scan_depth, project_scan_depth,
hooks_auto_install: file.hooks.auto_install(), hooks_auto_install: file.hooks.auto_install(),
leader: file.tui.leader().to_string(),
}) })
} }
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
mod tests { mod tests {
use super::*; use super::*;
/// #159: the leader the operator set for the TUI is what the daemon
/// advertises, so rebinding it once moves both clients' chord.
#[test]
fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.leader, "ctrl-a");
std::fs::write(
&path,
r#"
[tui]
leader = "ctrl-b"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.leader, "ctrl-b");
}
#[test] #[test]
fn hooks_auto_install_defaults_true_and_is_opt_out() { fn hooks_auto_install_defaults_true_and_is_opt_out() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+1
View File
@@ -30,6 +30,7 @@ pub mod spawn_history;
#[cfg(feature = "web")] #[cfg(feature = "web")]
pub mod static_bundle; pub mod static_bundle;
pub mod tmux; pub mod tmux;
pub mod viewport;
pub mod worktree; pub mod worktree;
pub mod worktree_probe; pub mod worktree_probe;
+7
View File
@@ -39,6 +39,10 @@ pub struct LocalApiState {
pub presets: Arc<Vec<SpawnPreset>>, pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`. /// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>, pub projects: Arc<crate::projects::ProjectScanner>,
/// Configured leader key (`[tui].leader`) advertised in `GET /api/info`
/// (#159), so the web client binds the chord the operator configured
/// rather than its own default.
pub leader: Arc<String>,
} }
pub fn router(state: LocalApiState) -> Router { pub fn router(state: LocalApiState) -> Router {
@@ -219,6 +223,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
presets: (*s.presets).clone(), presets: (*s.presets).clone(),
build: crate::release::build_info(), build: crate::release::build_info(),
instance: crate::release::instance_id().to_string(), instance: crate::release::instance_id().to_string(),
leader: (*s.leader).clone(),
}) })
} }
@@ -374,6 +379,7 @@ pub fn state_for(
home_dir: impl Into<String>, home_dir: impl Into<String>,
presets: Vec<SpawnPreset>, presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>, projects: Arc<crate::projects::ProjectScanner>,
leader: impl Into<String>,
) -> LocalApiState { ) -> LocalApiState {
LocalApiState { LocalApiState {
manager, manager,
@@ -381,6 +387,7 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()), home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets), presets: Arc::new(presets),
projects, projects,
leader: Arc::new(leader.into()),
} }
} }
+58
View File
@@ -7,6 +7,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
use crate::sidecar::{SidecarError, SidecarStore}; use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord}; use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::TmuxError; use crate::tmux::TmuxError;
use crate::viewport::{ViewportId, Viewports};
use crate::worktree::{WorktreeError, WorktreeManager}; use crate::worktree::{WorktreeError, WorktreeManager};
/// Capacity of the session-events broadcast channel. A subscriber that falls /// Capacity of the session-events broadcast channel. A subscriber that falls
@@ -166,6 +168,14 @@ struct Inner {
report_ttl: Duration, report_ttl: Duration,
sessions: Mutex<HashMap<Uuid, SessionRecord>>, sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>, events: broadcast::Sender<CatalogEvent>,
/// Sizes reported by the clients attached to each session, and what tmux
/// was last told. See [`crate::viewport`] for why the window is not simply
/// set to whatever arrived most recently.
viewports: Mutex<Viewports>,
/// Hands out [`ViewportId`]s. Monotonic for the daemon's lifetime, so a
/// client that reconnects never collides with the registration its
/// previous socket has not finished releasing.
next_viewport: AtomicU64,
} }
impl SessionManager { impl SessionManager {
@@ -206,6 +216,8 @@ impl SessionManager {
report_ttl: crate::activity::REPORT_TTL, report_ttl: crate::activity::REPORT_TTL,
sessions: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()),
events, events,
viewports: Mutex::new(Viewports::new()),
next_viewport: AtomicU64::new(0),
}), }),
} }
} }
@@ -770,6 +782,52 @@ impl SessionManager {
.map_err(SessionError::Tmux) .map_err(SessionError::Tmux)
} }
/// Register a client that is about to start reporting its display size,
/// and hand back the handle it reports under.
///
/// Cheap and infallible: the session it will report against is not
/// consulted until the first [`Self::set_viewport`].
pub fn attach_viewport(&self) -> ViewportId {
self.inner.next_viewport.fetch_add(1, Ordering::Relaxed)
}
/// A client reported the size it can display.
///
/// The shared tmux window is resized only when the size arbitrated across
/// every attached client actually changes — see [`crate::viewport`]. Two
/// clients of different sizes used to resize the window past each other on
/// every report, which leaves a full-screen agent repainting into rows it
/// never clears and looking, to the operator, like a pane that has stopped
/// accepting input.
pub async fn set_viewport(
&self,
id: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Result<(), SessionError> {
let target = self
.inner
.viewports
.lock()
.await
.set(id, viewport, cols, rows);
match target {
Some((cols, rows)) => self.resize(id, cols, rows).await,
None => Ok(()),
}
}
/// Drop a detached client's claim on the window size, resizing to what the
/// clients still attached can display. Errors are the caller's to ignore:
/// a socket closing because its session died has nothing left to resize.
pub async fn release_viewport(&self, id: Uuid, viewport: ViewportId) {
let target = self.inner.viewports.lock().await.release(id, viewport);
if let Some((cols, rows)) = target {
let _ = self.resize(id, cols, rows).await;
}
}
/// Rename a session (#125): set `metadata.name`, or clear it when the /// Rename a session (#125): set `metadata.name`, or clear it when the
/// requested name is blank so the session falls back to its command. /// requested name is blank so the session falls back to its command.
/// ///
+186
View File
@@ -0,0 +1,186 @@
//! What size the shared tmux window should be, given every client watching it.
//!
//! A tmux window has one size, but a session can have several clients attached
//! at once — two browser panes, a browser and the TUI. Each reports the size
//! *it* can display, and applying whichever arrived last makes the window
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
//! repaint at a new height, and the rows it drew at the old height are never
//! cleared: the operator ends up looking at a stale input box, types into a
//! box that is not where the screen says it is, and concludes the pane is
//! stuck.
//!
//! So the window is sized to the smallest attached client on each axis — the
//! same rule tmux uses for its own multiple clients, and the only size every
//! client can render without truncation. The size last handed to tmux is
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
use std::collections::HashMap;
use uuid::Uuid;
/// One attached client within a session. Allocated by
/// [`crate::session::SessionManager`] and opaque to everything else.
pub type ViewportId = u64;
/// Per-session client sizes, and what tmux was last told.
#[derive(Debug, Default)]
pub struct Viewports {
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
applied: HashMap<Uuid, (u16, u16)>,
}
impl Viewports {
pub fn new() -> Self {
Self::default()
}
/// Record what `viewport` can display. Returns the size tmux must be set
/// to, or `None` when the window already has it.
pub fn set(
&mut self,
session: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Option<(u16, u16)> {
self.clients
.entry(session)
.or_default()
.insert(viewport, (cols, rows));
self.settle(session)
}
/// Drop a client that has detached. Returns the size tmux must be set to
/// now that it no longer constrains the window.
///
/// The last client leaving settles nothing: the window keeps the size it
/// has rather than snapping to some remembered default no one is watching.
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
let clients = self.clients.get_mut(&session)?;
clients.remove(&viewport);
if clients.is_empty() {
self.clients.remove(&session);
self.applied.remove(&session);
return None;
}
self.settle(session)
}
/// The smallest size every attached client can render, but only when it
/// differs from what tmux was last told.
///
/// A zero on either axis is a client that cannot display anything yet — a
/// pane still laying out, a hidden tab — and must not drag the shared
/// window down with it, so it is held but left out of the arbitration.
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
let clients = self.clients.get(&session)?;
let mut cols = u16::MAX;
let mut rows = u16::MAX;
for &(c, r) in clients.values() {
if c == 0 || r == 0 {
continue;
}
cols = cols.min(c);
rows = rows.min(r);
}
if cols == u16::MAX || rows == u16::MAX {
return None;
}
let target = (cols, rows);
if self.applied.get(&session) == Some(&target) {
return None;
}
self.applied.insert(session, target);
Some(target)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_report_settles_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
}
#[test]
fn a_report_that_changes_nothing_costs_no_resize() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.set(s, 1, 80, 24), None);
assert_eq!(v.set(s, 1, 80, 24), None);
}
/// The bug this module exists for: two clients of different sizes used to
/// resize the window past each other on every report.
#[test]
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
// Whatever order they re-report in, the answer stays the smaller one.
assert_eq!(v.set(s, 1, 53, 46), None);
assert_eq!(v.set(s, 2, 53, 20), None);
assert_eq!(v.set(s, 1, 53, 46), None);
}
#[test]
fn each_axis_is_arbitrated_on_its_own() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 200, 20);
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
}
#[test]
fn a_zero_sized_client_does_not_shrink_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(s, 2, 0, 0), None);
assert_eq!(v.set(s, 2, 0, 30), None);
}
#[test]
fn releasing_the_small_client_gives_the_window_back() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 46);
v.set(s, 2, 80, 20);
assert_eq!(v.release(s, 2), Some((80, 46)));
}
#[test]
fn releasing_the_last_client_settles_nothing() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 1), None);
// ...and the session is forgotten, so a fresh attach settles again.
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
}
#[test]
fn releasing_an_unknown_viewport_is_harmless() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.release(s, 7), None);
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 7), None);
}
#[test]
fn sessions_do_not_arbitrate_against_each_other() {
let mut v = Viewports::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
assert_eq!(v.set(a, 1, 80, 24), None);
}
}
+1
View File
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
"/home/test", "/home/test",
Vec::new(), Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+1
View File
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
"/home/test", "/home/test",
presets, presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+41
View File
@@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness {
async fn spawn_with( async fn spawn_with(
hostname: &str, hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>, projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
) -> Harness {
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
}
async fn spawn_with_leader(
hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
leader: &str,
) -> Harness { ) -> Harness {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
let manager = fx.manager.clone(); let manager = fx.manager.clone();
@@ -48,6 +56,7 @@ async fn spawn_with(
"/home/test", "/home/test",
presets, presets,
projects, projects,
leader,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
@@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() {
assert!(resp["version"].is_string()); assert!(resp["version"].is_string());
} }
/// #159: the web client reads its leader off `/api/info` instead of a
/// hardcoded constant, so the payload has to carry the configured one — not
/// the default dressed up as one.
#[tokio::test]
async fn info_advertises_the_configured_leader() {
let h = spawn_with_leader(
"naps-laptop",
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
"ctrl-b",
)
.await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["leader"], "ctrl-b");
}
#[tokio::test]
async fn info_reports_the_default_leader_when_unconfigured() {
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["leader"], "ctrl-a");
}
#[tokio::test] #[tokio::test]
async fn info_reports_build_and_instance() { async fn info_reports_build_and_instance() {
// The status indicator needs a release to name and a process to compare // The status indicator needs a release to name and a process to compare
@@ -555,3 +555,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
Some(&"http://127.0.0.1:7070".to_string()) Some(&"http://127.0.0.1:7070".to_string())
); );
} }
/// Two clients attached to one session used to resize the shared window past
/// each other on every report they made. Every flip repaints a full-screen
/// agent at a new height and orphans the rows it drew at the old one, which
/// reads to the operator as a pane that has stopped taking input.
#[tokio::test]
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
// redraw loop do. Neither report changes the arbitrated size.
for _ in 0..5 {
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
}
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(
resizes,
vec![(53, 46), (53, 20)],
"the window was resized more than the arbitrated size actually changed"
);
}
/// The smaller client leaving hands the window back to the one still attached.
#[tokio::test]
async fn releasing_a_client_resizes_to_what_is_left() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
fx.manager.release_viewport(s.id, tui).await;
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
}
+1
View File
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
"/home/test", "/home/test",
maestro_config::SpawnSection::default().resolve().unwrap(), maestro_config::SpawnSection::default().resolve().unwrap(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+6
View File
@@ -554,6 +554,12 @@ pub struct DaemonInfo {
/// network blip, even when the build did not change. /// network blip, even when the build did not change.
#[serde(default)] #[serde(default)]
pub instance: String, pub instance: String,
/// Canonical leader key the operator configured (`[tui].leader`, default
/// `ctrl-a`), served so the web client binds the same chord the TUI does
/// instead of its own hardcoded constant (#159). Empty from a daemon too
/// old to serve it, which clients read as "use your default".
#[serde(default)]
pub leader: String,
} }
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
+1 -1
View File
@@ -114,7 +114,7 @@ impl TuiConfig {
TuiTheme::Nord => Palette::NORD, TuiTheme::Nord => Palette::NORD,
TuiTheme::Dracula => Palette::DRACULA, TuiTheme::Dracula => Palette::DRACULA,
}; };
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a"); let leader_str = section.leader();
let leader = InputKey::parse(leader_str) let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?; .map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000)); let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
+1
View File
@@ -250,6 +250,7 @@ mod tests {
dirty: false, dirty: false,
}, },
instance: instance.into(), instance: instance.into(),
leader: maestro_config::DEFAULT_LEADER.into(),
} }
} }
+12 -6
View File
@@ -45,6 +45,12 @@ 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 `preventDefault`s it, since selecting all of a terminal pane does nothing
useful. useful.
One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
`GET /api/info`, and the web binds that instead of its own constant (#159). A
daemon too old to send the field, or an unreachable `/api/info`, leaves the web
on `ctrl-a`. The section is named `[tui]` for history — it is the leader for
every client, not the TUI's alone.
The leader itself needs the same kind of way back: holding it consumes every The leader itself needs the same kind of way back: holding it consumes every
press, on both clients, even before anything is bound behind it. Pressing it press, on both clients, even before anything is bound behind it. Pressing it
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
@@ -152,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
ambiguous. ambiguous.
- Escape, or backspace at an empty query, returns to the result list **with the - Escape, or backspace at an empty query, returns to the result list **with the
previous query intact**. Drilling in and backing out is free. previous query intact**. Drilling in and backing out is free.
- Each action raises the flow it already has: kill goes through the existing - Each action raises the flow it already has: kill through the confirmation
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
existing prompt pre-filled with the session's own name (`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
session's directory and command. The bar picks the action; it never seeded from the session's directory and command. The bar picks the action; it
reimplements one. never reimplements one.
No actions beyond those three, and no action across more than one session. No actions beyond those three, and no action across more than one session.
+2 -1
View File
@@ -136,7 +136,8 @@ cover every v1 action — only override what you want to change:
```toml ```toml
[tui] [tui]
theme = "dark" # see the theme list below theme = "dark" # see the theme list below
leader = "ctrl-a" # one byte that escapes Insert mode leader = "ctrl-a" # one byte that escapes Insert mode; the web reads
# the same setting off /api/info
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
[tui.keymap.normal] [tui.keymap.normal]
+1
View File
@@ -47,6 +47,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
| The canvas | A `preview` chip, top right, and a dashed inset ring. | | The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. | | A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. | | The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. | | A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge — see the tier ladder in Rows say it with fill, not with an edge — see the tier ladder in
+16 -1
View File
@@ -316,7 +316,7 @@ Everything else is the same key on both clients:
| `Tab` | hand the keyboard to the pane | | `Tab` | hand the keyboard to the pane |
| `c` | collapse or expand the highlighted folder | | `c` | collapse or expand the highlighted folder |
| `r` | rename the highlighted session | | `r` | rename the highlighted session |
| `x` | kill the highlighted session | | `x` | kill the highlighted session (confirms) |
| `n` | open the spawn form | | `n` | open the spawn form |
| `N` | spawn with every default resolved | | `N` | spawn with every default resolved |
| `?` | show the shortcut overlay | | `?` | show the shortcut overlay |
@@ -324,3 +324,18 @@ Everything else is the same key on both clients:
The TUI binds these through its keymap, so a user can rebind any of them; the 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 web client's are fixed. A binding added to one client belongs in this table and
in both help overlays. in both help overlays.
## Killing a session
**Every kill confirms first, in both clients** (#160). A kill is destructive
and unattended agents live in these sessions, so neither client sends
`DELETE /api/sessions/:id` off a single press. The TUI raises
`OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its
`✕` button and the command bar's action page both use. Cancel holds the focus
in the web's dialog, so nothing kills on a stray Enter.
The confirmation names what the card last saw in the way, when anything was —
the daemon will refuse an unforced kill over such a worktree, and saying so
before the attempt is cheaper than a refusal. What comes back from that refusal
is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns
what a forced kill leaves behind.
+9 -1
View File
@@ -65,7 +65,7 @@ async function flush(): Promise<void> {
} }
describe("kill from the action page", () => { describe("kill from the action page", () => {
it("goes through the daemon, without a first confirmation", async () => { it("goes through the same confirmation the sidebar raises (#160)", async () => {
const calls: string[] = []; const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => { window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`); calls.push(`${init?.method ?? "GET"} ${url}`);
@@ -75,6 +75,12 @@ describe("kill from the action page", () => {
mount(s); mount(s);
raise(KILL_SESSION_EVENT, s); raise(KILL_SESSION_EVENT, s);
await flush(); await flush();
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
"Kill amber-falcon",
);
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`); expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull(); expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
@@ -101,6 +107,8 @@ describe("kill from the action page", () => {
mount(s); mount(s);
raise(KILL_SESSION_EVENT, s); raise(KILL_SESSION_EVENT, s);
await flush(); await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog"); const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon"); expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
expect(dialog?.textContent).toContain("1 untracked file"); expect(dialog?.textContent).toContain("1 untracked file");
+40 -1
View File
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
}); });
it("kills straight away, without a first confirmation", async () => { it("confirms before an unguarded kill (#160)", async () => {
const calls: string[] = []; const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => { window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`); calls.push(`${init?.method ?? "GET"} ${url}`);
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([plain])).el; const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush(); await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`); expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull(); expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
it("kills nothing when the confirmation is cancelled (#160)", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
await flush();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
});
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000008",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
expect(dialog?.textContent).toContain("refused unless it is forced");
});
it("offers the override when the daemon refuses a guarded kill", async () => { it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = []; const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => { window.fetch = ((url: string, init?: RequestInit) => {
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([blocked])).el; const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush(); await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog"); const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file"); expect(dialog?.textContent).toContain("1 untracked file");
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click(); dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([blocked])).el; const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush(); await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click(); document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull(); expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0); expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
+31 -24
View File
@@ -1,6 +1,6 @@
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js"; import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js"; import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js"; import { toggleHelp } from "./help.js";
import { CommandSet, defineCommands } from "./commands.js"; import { CommandSet, defineCommands } from "./commands.js";
@@ -230,7 +230,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
void requestKill(cur, sessionName(cur)); requestKill(cur, sessionName(cur));
}); });
row.addEventListener("click", () => { row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id })); document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
@@ -248,7 +248,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
{ {
label: "Kill session", label: "Kill session",
danger: true, danger: true,
onSelect: () => void requestKill(cur, sessionName(cur)), onSelect: () => requestKill(cur, sessionName(cur)),
}, },
]); ]);
}); });
@@ -293,26 +293,33 @@ function treeSignature(node: TreeNode): string {
} }
/** /**
* Kill on the first click (#81): the daemon is the one that decides whether a * Kill behind a confirmation (#160): a kill is destructive and unattended
* session may go, and it refuses over a worktree holding unsaved work. Only * agents live in these sessions, so the web asks first, the way the TUI always
* that refusal is worth a prompt, and it needs one `confirm()` cannot give — * has. The daemon decides the rest — it refuses a kill over a worktree holding
* a forced kill. Anything else surfaces as an alert. * unsaved work, and that refusal needs a prompt the confirmation cannot give,
* a forced kill. Anything else surfaces in the dialog that asked.
*/ */
async function requestKill(s: Session, name: string): Promise<void> { function requestKill(s: Session, name: string): void {
try { openKillConfirmDialog({
await killSession(s.id); name,
} catch (e) { blockers: sessionBlockers(s),
if (e instanceof ApiRequestError && e.code === "kill_guarded") { onKill: async () => {
openForceKillDialog({ try {
name, await killSession(s.id);
blockers: e.unsavedWork ?? sessionBlockers(s), } catch (e) {
reason: e.detail, if (e instanceof ApiRequestError && e.code === "kill_guarded") {
onForce: () => killSession(s.id, true), openForceKillDialog({
}); name,
return; blockers: e.unsavedWork ?? sessionBlockers(s),
} reason: e.detail,
alert(`Failed to kill: ${(e as Error).message}`); onForce: () => killSession(s.id, true),
} });
return;
}
throw e;
}
},
});
} }
/** /**
@@ -719,7 +726,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
}, },
"list.kill": () => { "list.kill": () => {
const hit = highlightedSession(); const hit = highlightedSession();
if (hit) void requestKill(hit.session, sessionName(hit.session)); if (hit) requestKill(hit.session, sessionName(hit.session));
}, },
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)), "list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
}), }),
@@ -782,7 +789,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
// than by row. // than by row.
const killHandler = (ev: Event) => { const killHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
void requestKill(s, sessionName(s)); requestKill(s, sessionName(s));
}; };
const renameHandler = (ev: Event) => { const renameHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
+32
View File
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
COMMAND_SPECS, COMMAND_SPECS,
CommandSet, CommandSet,
commandSpecs,
DEFAULT_LEADER, DEFAULT_LEADER,
canonicalizeKey, canonicalizeKey,
controlByte, controlByte,
@@ -165,6 +166,37 @@ describe("the leader", () => {
expect(DEFAULT_LEADER).toBe("ctrl-a"); expect(DEFAULT_LEADER).toBe("ctrl-a");
}); });
// #159: the leader is the operator's, served on `/api/info`. Every chord
// behind it is built from it, so a rebound leader carries its escape hatches
// with it instead of leaving them on a stale ctrl-a.
it("builds the chords behind it from the configured leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
// Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
});
it("dispatches a configured leader's chord and not the default's", () => {
const literalCtrlK = vi.fn();
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
leader: "ctrl-b",
});
set.handle(key("b", { ctrlKey: true }));
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
expect(literalCtrlK).toHaveBeenCalledTimes(1);
// The old leader is an ordinary key again: it holds nothing, and the chord
// that used to follow it runs nothing.
set.handle(key("a", { ctrlKey: true }));
set.handle(key("k", { ctrlKey: true }));
expect(literalCtrlK).toHaveBeenCalledTimes(1);
});
it("sends the configured leader's own byte for the doubled chord", () => {
expect(controlByte("ctrl-b")).toBe("\x02");
});
it("holds the leader as a partial chord instead of selecting the page", () => { it("holds the leader as a partial chord instead of selecting the page", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
leader: DEFAULT_LEADER, leader: DEFAULT_LEADER,
+31 -9
View File
@@ -32,13 +32,26 @@ export interface Command extends CommandSpec {
run: () => void; run: () => void;
} }
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all /** Leader used when the daemon does not name one — an older daemon, or an
* in a browser, so a set that owns the leader `preventDefault`s it. */ * unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a`
* is select-all in a browser, so a set that owns the leader
* `preventDefault`s it. */
export const DEFAULT_LEADER = "ctrl-a"; export const DEFAULT_LEADER = "ctrl-a";
/** Every command the web client has. The single list this issue exists to /** Every command the web client has, for one leader. The single list: labels,
* create: labels, chords and grouping live here and nowhere else. */ * chords and grouping live here and nowhere else. Chords behind the leader are
export const COMMAND_SPECS: readonly CommandSpec[] = [ * built from it (#159), so the configured leader — served on `/api/info` as
* `leader` — is what the overlay renders and the sets dispatch. */
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
return SPECS.map(({ leaderKeys, ...spec }) =>
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
);
}
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
const SPECS: readonly SpecSource[] = [
{ {
id: "spawn.open", id: "spawn.open",
label: "Open the spawn form", label: "Open the spawn form",
@@ -65,6 +78,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
label: "Send a literal Ctrl-K to the pane", label: "Send a literal Ctrl-K to the pane",
group: "Pane", group: "Pane",
keys: [`${DEFAULT_LEADER} ctrl-k`], keys: [`${DEFAULT_LEADER} ctrl-k`],
leaderKeys: (leader) => [`${leader} ctrl-k`],
scope: "global", scope: "global",
}, },
{ {
@@ -72,6 +86,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
label: "Send a literal leader key to the pane", label: "Send a literal leader key to the pane",
group: "Pane", group: "Pane",
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`], keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
leaderKeys: (leader) => [`${leader} ${leader}`],
scope: "global", scope: "global",
}, },
{ {
@@ -160,13 +175,20 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
}, },
]; ];
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s])); /** The command list under the default leader. Iteration order everywhere else
* (the help overlay, the bar) comes from here. */
export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs();
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a /** 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. */ * test failure rather than a key that silently does nothing. `leader` picks
export function defineCommands(handlers: Record<string, () => void>): Command[] { * the chords behind the leader; omit it for a set that has none. */
export function defineCommands(
handlers: Record<string, () => void>,
leader: string = DEFAULT_LEADER,
): Command[] {
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
return Object.entries(handlers).map(([id, run]) => { return Object.entries(handlers).map(([id, run]) => {
const spec = SPEC_BY_ID.get(id); const spec = byId.get(id);
if (!spec) throw new Error(`unknown command id: ${id}`); if (!spec) throw new Error(`unknown command id: ${id}`);
return { ...spec, run }; return { ...spec, run };
}); });
+28 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { keyCaps, publishedCommands } from "./commands.js"; import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js"; import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
beforeEach(() => { beforeEach(() => {
@@ -44,6 +44,33 @@ describe("help overlay", () => {
.toEqual(publishedCommands().map((c) => c.id)); .toEqual(publishedCommands().map((c) => c.id));
}); });
// #159: the leader comes from the daemon, so the overlay must render the
// configured chord — a hardcoded `Ctrl A` is the visible half of the bug.
it("renders the configured leader's chord, not a hardcoded Ctrl A", () => {
const bound = defineCommands(
{ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} },
"ctrl-b",
);
publishCommands(bound);
try {
openHelp();
const keysOf = (id: string) =>
[
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
.querySelectorAll("kbd"),
]
.map((k) => k.textContent)
.join(" ");
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B");
} finally {
// Leave the registry as the rest of the suite expects it.
publishCommands(
defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }),
);
}
});
it("opens on ? and closes on a second ?", () => { it("opens on ? and closes on a second ?", () => {
press("?", { shiftKey: true }); press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true); expect(isHelpOpen()).toBe(true);
+83 -22
View File
@@ -1,14 +1,44 @@
/** /**
* Forced-kill prompt (#81). * The two kill prompts (#81, #160).
* *
* The daemon refuses to kill a session whose worktree still holds uncommitted * A kill is destructive and unattended agents live in these sessions, so both
* or unpushed work. `confirm()` can only ask the same question twice, so the * clients ask before one goes through — the TUI's `KillConfirm`, and
* refusal gets its own dialog: it names what is in the way and offers the * `openKillConfirmDialog` here (#160). Where the session's worktree still
* override the daemon's `force=true` already implements. A kill the daemon * holds uncommitted or unpushed work, the confirmation says so up front, the
* accepts never reaches here — there is nothing to warn about. * same way the TUI's does.
*
* The daemon decides the rest: it refuses a kill over such a worktree, and
* that refusal needs an answer the confirmation cannot give — a forced kill.
* `openForceKillDialog` is that second prompt. A kill the daemon accepts never
* reaches it.
*/ */
import { formatInTheWay, type InTheWay } from "./types.js"; import { formatInTheWay, type InTheWay } from "./types.js";
/** What both prompts need: a target to name and a button that acts on it. */
interface KillDialogSpec {
/** Session name, as the sidebar shows it. */
name: string;
/** Sentence under the title. */
body: string;
/** Label on the acting button. */
confirmLabel: string;
/** Extra class on the acting button, so tests and styles can find it. */
confirmClass: string;
/** Run it. A rejection is rendered in the dialog, which stays open so the
* press can be retried. */
onConfirm: () => Promise<void>;
}
export interface KillConfirmPrompt {
name: string;
/** What the card last saw in the way, when anything was. Shown before the
* attempt, so "this will be refused" is not a surprise. */
blockers: InTheWay | null;
/** Run the plain kill. Opening a further dialog from here is allowed: the
* confirmation only closes itself if it is still the one on screen. */
onKill: () => Promise<void>;
}
export interface ForceKillPrompt { export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */ /** Session name, as the sidebar shows it. */
name: string; name: string;
@@ -30,9 +60,9 @@ export function closeForceKillDialog(): void {
open = null; open = null;
} }
/** Mount the prompt. Opening while one is up replaces it, so a second refusal /** Mount one of the prompts. Opening while one is up replaces it, so the
* can never stack two dialogs. */ * confirmation and a refusal that follows it can never stack. */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement { function mount(p: KillDialogSpec): HTMLElement {
closeForceKillDialog(); closeForceKillDialog();
const el = document.createElement("div"); const el = document.createElement("div");
@@ -50,9 +80,7 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
const body = document.createElement("p"); const body = document.createElement("p");
body.className = "kill-dialog-body"; body.className = "kill-dialog-body";
body.textContent = p.blockers body.textContent = p.body;
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
const error = document.createElement("p"); const error = document.createElement("p");
error.className = "kill-dialog-error"; error.className = "kill-dialog-error";
@@ -65,22 +93,27 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
cancel.className = "btn btn-ghost kill-dialog-cancel"; cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel"; cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog); cancel.addEventListener("click", closeForceKillDialog);
const force = document.createElement("button"); const confirm = document.createElement("button");
force.type = "button"; confirm.type = "button";
force.className = "btn btn-danger kill-dialog-force"; confirm.className = `btn btn-danger ${p.confirmClass}`;
force.textContent = "Force kill"; confirm.textContent = p.confirmLabel;
force.addEventListener("click", () => { confirm.addEventListener("click", () => {
force.disabled = true; confirm.disabled = true;
void p void p
.onForce() .onConfirm()
.then(closeForceKillDialog) .then(() => {
// A handler that opened the refusal prompt has already replaced this
// dialog; closing here would take that one down with it.
if (open?.el === el) closeForceKillDialog();
})
.catch((e: unknown) => { .catch((e: unknown) => {
force.disabled = false; if (open?.el !== el) return;
confirm.disabled = false;
error.hidden = false; error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`; error.textContent = `Failed to kill: ${(e as Error).message}`;
}); });
}); });
actions.append(cancel, force); actions.append(cancel, confirm);
panel.append(title, body, error, actions); panel.append(title, body, error, actions);
@@ -99,6 +132,34 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
}; };
window.addEventListener("keydown", onKeydown, true); window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown }; open = { el, onKeydown };
// Cancel takes the focus in both prompts: a kill is never one keystroke
// away from whatever asked for the dialog.
cancel.focus(); cancel.focus();
return el; return el;
} }
/** Ask before a kill the daemon has not seen yet (#160). */
export function openKillConfirmDialog(p: KillConfirmPrompt): HTMLElement {
return mount({
name: p.name,
body: p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. The kill will be refused unless it is forced.`
: "The session and everything running in it go away. Its worktree stays on disk.",
confirmLabel: "Kill",
confirmClass: "kill-dialog-kill",
onConfirm: p.onKill,
});
}
/** Offer the override after the daemon refuses a guarded kill (#81). */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
return mount({
name: p.name,
body: p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`,
confirmLabel: "Force kill",
confirmClass: "kill-dialog-force",
onConfirm: p.onForce,
});
}
+54 -28
View File
@@ -87,9 +87,15 @@ const store = new CatalogStore();
// Absolute paths are a fine fallback until it lands. // Absolute paths are a fine fallback until it lands.
let homeDir: string | undefined; let homeDir: string | undefined;
void getDaemonInfo() void getDaemonInfo()
.then((info) => (homeDir = info.home_dir)) .then((info) => {
homeDir = info.home_dir;
// The leader is the operator's, not this module's (#159): a daemon that
// names one rebinds the chords hanging off it, and the `?` overlay and the
// command bar re-render from the rebuilt set.
if (info.leader) useLeader(info.leader);
})
.catch(() => { .catch(() => {
/* absolute paths are a fine fallback */ /* absolute paths and the default leader are fine fallbacks */
}); });
// The daemon's identity and liveness: names the release in the sidebar's status // The daemon's identity and liveness: names the release in the sidebar's status
@@ -439,32 +445,52 @@ const spawnKeys = new CommandSet(
// terminal owns every plain keystroke, so nothing unmodified could ever get // terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it. // the focus back out of it.
// //
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// key as a partial chord and takes select-all off it, so the escape hatch // on `/api/info`): it holds the key as a partial chord and takes select-all off
// docs/COMMAND-BAR.md describes has somewhere to land (#149). // it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
const focusKeys = new CommandSet( // (#149).
defineCommands({ let currentLeader = DEFAULT_LEADER;
"focus.toggle": () => {
if (sidebar.hasListFocus()) { function buildFocusKeys(leader: string): CommandSet {
pool.focusTerminal(); return new CommandSet(
} else { defineCommands(
drawer.open(); // no-op on desktop; on mobile the list is off-canvas {
sidebar.focusList(); "focus.toggle": () => {
} if (sidebar.hasListFocus()) {
}, pool.focusTerminal();
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line } else {
// away from every pane, and leader ctrl-k gives it back as the literal drawer.open(); // no-op on desktop; on mobile the list is off-canvas
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the sidebar.focusList();
// bar's, so the chord never opens the bar. }
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"), },
// The leader itself, pressed twice, tmux-style: sends one literal leader // The escape hatch: binding ctrl-k takes readline's
// byte and clears the pending chord instead of resolving as a dead chord // kill-to-end-of-line away from every pane, and leader ctrl-k gives it
// (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a // back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
// hardcoded byte, so a different leader would send its own. // this set, ahead of the bar's, so the chord never opens the bar.
"pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)), "pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
}), // The leader itself, pressed twice, tmux-style: sends one literal
{ leader: DEFAULT_LEADER }, // leader byte and clears the pending chord instead of resolving as a
); // dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
// hardcoded byte, so a rebound leader sends its own.
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
},
leader,
),
{ leader },
);
}
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
* same command ids replaces them, so the help overlay and the command bar —
* both of which render the published registry when they open — show the
* configured chord rather than the default. */
function useLeader(leader: string): void {
if (leader === currentLeader) return;
currentLeader = leader;
focusKeys = buildFocusKeys(leader);
}
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command // Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
// like any other, so the `?` overlay lists it without a second list. // like any other, so the `?` overlay lists it without a second list.
+17 -3
View File
@@ -215,6 +215,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return fl?.sessionId ?? null; return fl?.sessionId ?? null;
} }
/** The session the operator actually picked — what `focusedLeafSession()`
* would return with the preview undone. A preview moves the active tab and
* its focus, but it is a view: the accent stays on the row the operator
* chose until they choose another (docs/PREVIEW.md). */
function realFocusedSession(): string | null {
const p = preview;
if (!p) return focusedLeafSession();
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
const leafId =
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
const fl = leafId ? findLeaf(tab.root, leafId) : null;
return fl?.sessionId ?? null;
}
/** Update focus styling + pool focus + sidebar highlights without rebuilding /** Update focus styling + pool focus + sidebar highlights without rebuilding
* the DOM (cheap path for click-to-focus). */ * the DOM (cheap path for click-to-focus). */
function syncFocus(): void { function syncFocus(): void {
@@ -233,7 +247,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (!preview) pool.setFocus(focusedLeafSession()); if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({ sidebar.setHighlights({
open: realOpenSessions(), open: realOpenSessions(),
focused: focusedLeafSession(), focused: realFocusedSession(),
}); });
// The row the preview came from wears the same colour, so the sidebar and // The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights. // the pane read as one thing rather than two unrelated highlights.
@@ -608,7 +622,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
{ focus: !preview && !quietRender }, { focus: !preview && !quietRender },
); );
if (!preview) pool.setFocus(focused.sessionId ?? null); if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null }); sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
sidebar.setPreview(preview?.sessionId ?? null); sidebar.setPreview(preview?.sessionId ?? null);
} }
@@ -817,7 +831,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
openSessions: realOpenSessions, openSessions: realOpenSessions,
beginSessionDrag, beginSessionDrag,
render, render,
focusedSessionId: focusedLeafSession, focusedSessionId: realFocusedSession,
previewSession, previewSession,
previewedSessionId: () => preview?.sessionId ?? null, previewedSessionId: () => preview?.sessionId ?? null,
}; };
+33 -1
View File
@@ -47,12 +47,20 @@ function fakePool(): Spy {
/** Last value the controller pushed to `SidebarHandle.setPreview`. */ /** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null }; const previewRow: { id: string | null } = { id: null };
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
const rowHighlights: { open: Set<string>; focused: string | null } = {
open: new Set(),
focused: null,
};
function fakeSidebar(): SidebarHandle { function fakeSidebar(): SidebarHandle {
return { return {
el: document.createElement("aside"), el: document.createElement("aside"),
setActive() {}, setActive() {},
setHighlights() {}, setHighlights(h) {
rowHighlights.open = h.open;
rowHighlights.focused = h.focused;
},
setPreview(id: string | null) { setPreview(id: string | null) {
previewRow.id = id; previewRow.id = id;
}, },
@@ -95,6 +103,8 @@ describe("sidebar preview", () => {
stubFetch(); stubFetch();
document.body.replaceChildren(); document.body.replaceChildren();
previewRow.id = null; previewRow.id = null;
rowHighlights.open = new Set();
rowHighlights.focused = null;
}); });
afterEach(() => { afterEach(() => {
@@ -134,6 +144,28 @@ describe("sidebar preview", () => {
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0); expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
}); });
it("leaves the accent on the session the pane actually holds", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(rowHighlights.focused).toBe("s2");
// Walking past other rows never moves the accent off what the operator
// picked: only a real open does (docs/PREVIEW.md).
mv.previewSession("s1");
expect(rowHighlights.focused).toBe("s2");
expect(mv.focusedSessionId()).toBe("s2");
mv.previewSession("s9");
expect(rowHighlights.focused).toBe("s2");
mv.previewSession(null);
expect(rowHighlights.focused).toBe("s2");
mv.openSession("s1");
expect(rowHighlights.focused).toBe("s1");
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => { it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs(); const layout = twoTabs();
const before = serializeLayout(layout); const before = serializeLayout(layout);
+3 -1
View File
@@ -143,7 +143,7 @@ describe("sidebar keyboard navigation", () => {
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
}); });
it("x kills the highlighted session", async () => { it("x kills the highlighted session, once confirmed (#160)", async () => {
const { el } = twoFolders(); const { el } = twoFolders();
const killed: string[] = []; const killed: string[] = [];
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
@@ -153,6 +153,8 @@ describe("sidebar keyboard navigation", () => {
press(tree(el), "j"); press(tree(el), "j");
press(tree(el), "x"); press(tree(el), "x");
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await Promise.resolve(); await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true); expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
}); });
+4
View File
@@ -98,6 +98,10 @@ export interface DaemonInfo {
* value across a reconnect means the daemon restarted. Absent on older * value across a reconnect means the daemon restarted. Absent on older
* daemons, which the restart flow treats as "cannot tell". */ * daemons, which the restart flow treats as "cannot tell". */
instance?: string; instance?: string;
/** Canonical leader key the operator configured (`[tui].leader`, default
* `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or
* empty on an older daemon; the client then keeps its own default. */
leader?: string;
} }
/** Build provenance of the running daemon: what the status indicator names in /** Build provenance of the running daemon: what the status indicator names in