Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
706942ddd7 | ||
|
|
52877abe29 | ||
|
|
43fc3a3735 | ||
|
|
ff1ffb377c | ||
|
|
ba74d3515b | ||
|
|
8325bc6d9a | ||
|
|
8f499dbee3 | ||
|
|
511fed408c | ||
|
|
ba02b11f13 | ||
|
|
59ede15d2b |
@@ -96,20 +96,32 @@ pub async fn dispatch(
|
||||
}
|
||||
|
||||
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||
/// user's data dir and register it in their Claude Code settings. Idempotent
|
||||
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
||||
/// user's data dir and register it in every agent's hook file. Idempotent and
|
||||
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
|
||||
/// agent failing is reported and does not stop the others.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn run_hooks_install() -> Result<(), CliError> {
|
||||
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||
let changed = maestro_hooks::install_for_home(&home)?;
|
||||
if changed {
|
||||
println!(
|
||||
"installed claude-code activity hook: {} → {}",
|
||||
maestro_hooks::script_path(&home).display(),
|
||||
maestro_hooks::settings_path(&home).display()
|
||||
);
|
||||
} else {
|
||||
println!("claude-code activity hook already up to date");
|
||||
let script = maestro_hooks::script_path(&home);
|
||||
for (target, result) in maestro_hooks::install_for_home(&home) {
|
||||
let agent = target.label();
|
||||
let settings = target.settings_path(&home);
|
||||
match result {
|
||||
Ok(true) => {
|
||||
println!(
|
||||
"installed {agent} activity hook: {} → {}",
|
||||
script.display(),
|
||||
settings.display()
|
||||
);
|
||||
if target == maestro_hooks::Target::Codex {
|
||||
// Codex will not run a hook it has not been told to
|
||||
// trust, and that approval is the operator's to give.
|
||||
println!(" codex will ask you to trust this hook the next time it starts");
|
||||
}
|
||||
}
|
||||
Ok(false) => println!("{agent} activity hook already up to date"),
|
||||
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
|
||||
/// 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";
|
||||
|
||||
/// 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)]
|
||||
pub enum ConfigError {
|
||||
#[error("io: {0}")]
|
||||
@@ -152,6 +157,15 @@ pub struct TuiSection {
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TuiTheme {
|
||||
|
||||
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
|
||||
) -> Result<(), axum::Error> {
|
||||
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
|
||||
// screenful immediately instead of a blank terminal. The replay lock the
|
||||
// bridge held during snapshot+subscribe guarantees no byte appears both
|
||||
// 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() {
|
||||
manager.release_viewport(id, viewport).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
|
||||
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
||||
match ctrl {
|
||||
ClientControl::Resize { cols, rows } => {
|
||||
let _ = manager.resize(id, cols, rows).await;
|
||||
let _ = manager.set_viewport(id, viewport, cols, rows).await;
|
||||
}
|
||||
ClientControl::Ping => {
|
||||
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 {
|
||||
let _ = sink.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
@@ -51,17 +51,23 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||
// session spawned right away should already carry the hook.
|
||||
if config.hooks_auto_install {
|
||||
match crate::hooks::auto_install(&config.home_dir) {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!("installed claude-code activity hook (#106)");
|
||||
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
|
||||
let agent = target.label();
|
||||
match outcome {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!(agent, "installed activity hook (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Upgraded => {
|
||||
tracing::info!(agent, "updated activity hook to this build (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Failed => {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
"hooks auto-install failed; run `maestro hooks install` manually"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Upgraded => {
|
||||
tracing::info!("updated claude-code activity hook to this build (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Failed => {
|
||||
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// No worktree dir to pre-create: without an override the worktrees live
|
||||
@@ -105,6 +111,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
projects,
|
||||
config.leader.clone(),
|
||||
));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
|
||||
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
|
||||
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||
/// `true`.
|
||||
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 {
|
||||
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
project_roots,
|
||||
project_scan_depth,
|
||||
hooks_auto_install: file.hooks.auto_install(),
|
||||
leader: file.tui.leader().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
|
||||
mod tests {
|
||||
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]
|
||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
//! First-start hook auto-install (#106).
|
||||
//!
|
||||
//! The daemon runs [`auto_install`] once at startup: unless the user's
|
||||
//! `~/.claude/settings.json` already carries exactly what this build ships
|
||||
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
|
||||
//! same installer `maestro hooks install` uses writes it. That covers
|
||||
//! upgrades as well as first starts: an install from a maestro that had fewer
|
||||
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
|
||||
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
|
||||
//! a failure is warned about and never blocks startup. The same global hook
|
||||
//! serves every Claude Code session on the machine, so nothing here targets
|
||||
//! repositories or worktrees.
|
||||
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
|
||||
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
|
||||
//! file already carries exactly what this build ships (and `[hooks]
|
||||
//! .auto_install` hasn't opted out — checked by the caller), the same
|
||||
//! installer `maestro hooks install` uses writes it. That covers upgrades as
|
||||
//! well as first starts: an install from a maestro that had fewer events —
|
||||
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
|
||||
//! settings — is rewritten rather than left half-wired. Best-effort: a
|
||||
//! failure is warned about and never blocks startup, and one target failing
|
||||
//! does not stop the others. The same global hook serves every session on the
|
||||
//! machine, so nothing here targets repositories or worktrees.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Install the Claude Code activity hook for `home_dir` unless the current
|
||||
/// one is already there or the home dir is unresolvable (`""`). Returns what
|
||||
/// happened; an older install being brought up to date is
|
||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
|
||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
||||
use maestro_hooks::Target;
|
||||
|
||||
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
|
||||
/// any whose install is already current. Returns what happened per target —
|
||||
/// an older install being brought up to date is
|
||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
|
||||
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
|
||||
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
|
||||
Target::ALL
|
||||
.iter()
|
||||
.map(|&target| (target, auto_install_target(target, home_dir)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
|
||||
if home_dir.is_empty() {
|
||||
return AutoInstallOutcome::Skipped;
|
||||
}
|
||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
||||
let script = maestro_hooks::script_path(Path::new(home_dir));
|
||||
if maestro_hooks::is_current(&settings, &script) {
|
||||
let home = Path::new(home_dir);
|
||||
let settings = target.settings_path(home);
|
||||
let script = maestro_hooks::script_path(home);
|
||||
if maestro_hooks::is_current(target, &settings, &script) {
|
||||
return AutoInstallOutcome::AlreadyPresent;
|
||||
}
|
||||
let stale = maestro_hooks::is_installed(&settings);
|
||||
match maestro_hooks::install_for_home(Path::new(home_dir)) {
|
||||
match maestro_hooks::install_target(target, home) {
|
||||
Ok(_) if stale => AutoInstallOutcome::Upgraded,
|
||||
Ok(_) => AutoInstallOutcome::Installed,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
|
||||
tracing::warn!(
|
||||
target = target.label(),
|
||||
error = %e,
|
||||
"hooks auto-install failed (non-fatal)"
|
||||
);
|
||||
AutoInstallOutcome::Failed
|
||||
}
|
||||
}
|
||||
@@ -52,15 +68,50 @@ pub enum AutoInstallOutcome {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The outcome recorded for one target.
|
||||
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
|
||||
outcomes
|
||||
.iter()
|
||||
.find(|(t, _)| *t == target)
|
||||
.map(|(_, o)| *o)
|
||||
.expect("every target is reported")
|
||||
}
|
||||
|
||||
/// Everything happens under a temp `$HOME`; the developer's own
|
||||
/// `~/.claude/settings.json` is never read or written.
|
||||
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
|
||||
/// written.
|
||||
#[test]
|
||||
fn installs_when_settings_carry_no_maestro_hook() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outcome = auto_install(dir.path().to_str().unwrap());
|
||||
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
assert!(maestro_hooks::is_installed(&settings));
|
||||
let outcomes = auto_install(dir.path().to_str().unwrap());
|
||||
for target in Target::ALL {
|
||||
assert_eq!(
|
||||
outcome(&outcomes, target),
|
||||
AutoInstallOutcome::Installed,
|
||||
"{}",
|
||||
target.label()
|
||||
);
|
||||
assert!(maestro_hooks::is_installed(
|
||||
&target.settings_path(dir.path())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Every agent maestro knows about is hooked, not just Claude Code: a
|
||||
/// codex session reports activity and its first prompt the same way.
|
||||
#[test]
|
||||
fn codex_gets_its_own_hook_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
auto_install(dir.path().to_str().unwrap());
|
||||
let hooks = dir.path().join(".codex/hooks.json");
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
|
||||
let events = root["hooks"].as_object().unwrap();
|
||||
assert!(events.contains_key("PermissionRequest"));
|
||||
assert!(
|
||||
!events.contains_key("Notification"),
|
||||
"codex has no Notification"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -69,14 +120,22 @@ mod tests {
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
// A current install is left exactly as it is.
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
let before = std::fs::read_to_string(&settings).unwrap();
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read_to_string(&settings).unwrap(),
|
||||
"already-installed settings must not be rewritten"
|
||||
);
|
||||
let before: Vec<String> = Target::ALL
|
||||
.iter()
|
||||
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
|
||||
.collect();
|
||||
let outcomes = auto_install(home);
|
||||
for (i, target) in Target::ALL.iter().enumerate() {
|
||||
assert_eq!(
|
||||
outcome(&outcomes, *target),
|
||||
AutoInstallOutcome::AlreadyPresent
|
||||
);
|
||||
assert_eq!(
|
||||
before[i],
|
||||
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
|
||||
"already-installed settings must not be rewritten"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An install from an older maestro — present, but missing an event this
|
||||
@@ -87,7 +146,7 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
let settings = Target::ClaudeCode.settings_path(dir.path());
|
||||
let script = maestro_hooks::script_path(dir.path());
|
||||
|
||||
let mut root: serde_json::Value =
|
||||
@@ -96,12 +155,23 @@ mod tests {
|
||||
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
|
||||
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
|
||||
assert!(maestro_hooks::is_current(&settings, &script));
|
||||
let outcomes = auto_install(home);
|
||||
assert_eq!(
|
||||
outcome(&outcomes, Target::ClaudeCode),
|
||||
AutoInstallOutcome::Upgraded
|
||||
);
|
||||
assert!(maestro_hooks::is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings,
|
||||
&script
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_home_is_skipped() {
|
||||
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
|
||||
let outcomes = auto_install("");
|
||||
for target in Target::ALL {
|
||||
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod spawn_history;
|
||||
#[cfg(feature = "web")]
|
||||
pub mod static_bundle;
|
||||
pub mod tmux;
|
||||
pub mod viewport;
|
||||
pub mod worktree;
|
||||
pub mod worktree_probe;
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ pub struct LocalApiState {
|
||||
pub presets: Arc<Vec<SpawnPreset>>,
|
||||
/// Project discovery (#54) backing `GET /api/projects`.
|
||||
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 {
|
||||
@@ -219,6 +223,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
||||
presets: (*s.presets).clone(),
|
||||
build: crate::release::build_info(),
|
||||
instance: crate::release::instance_id().to_string(),
|
||||
leader: (*s.leader).clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -374,6 +379,7 @@ pub fn state_for(
|
||||
home_dir: impl Into<String>,
|
||||
presets: Vec<SpawnPreset>,
|
||||
projects: Arc<crate::projects::ProjectScanner>,
|
||||
leader: impl Into<String>,
|
||||
) -> LocalApiState {
|
||||
LocalApiState {
|
||||
manager,
|
||||
@@ -381,6 +387,7 @@ pub fn state_for(
|
||||
home_dir: Arc::new(home_dir.into()),
|
||||
presets: Arc::new(presets),
|
||||
projects,
|
||||
leader: Arc::new(leader.into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
|
||||
use crate::sidecar::{SidecarError, SidecarStore};
|
||||
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
||||
use crate::tmux::TmuxError;
|
||||
use crate::viewport::{ViewportId, Viewports};
|
||||
use crate::worktree::{WorktreeError, WorktreeManager};
|
||||
|
||||
/// Capacity of the session-events broadcast channel. A subscriber that falls
|
||||
@@ -166,6 +168,14 @@ struct Inner {
|
||||
report_ttl: Duration,
|
||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||
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 {
|
||||
@@ -206,6 +216,8 @@ impl SessionManager {
|
||||
report_ttl: crate::activity::REPORT_TTL,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
viewports: Mutex::new(Viewports::new()),
|
||||
next_viewport: AtomicU64::new(0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -770,6 +782,52 @@ impl SessionManager {
|
||||
.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
|
||||
/// requested name is blank so the session falls back to its command.
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
|
||||
"/home/test",
|
||||
presets,
|
||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness {
|
||||
async fn spawn_with(
|
||||
hostname: &str,
|
||||
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 {
|
||||
let fx = DaemonFixture::new();
|
||||
let manager = fx.manager.clone();
|
||||
@@ -48,6 +56,7 @@ async fn spawn_with(
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
leader,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() {
|
||||
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]
|
||||
async fn info_reports_build_and_instance() {
|
||||
// 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())
|
||||
);
|
||||
}
|
||||
|
||||
/// 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)]);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
|
||||
"/home/test",
|
||||
maestro_config::SpawnSection::default().resolve().unwrap(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
+142
-55
@@ -1,4 +1,4 @@
|
||||
//! Claude Code activity hook (#106).
|
||||
//! Agent activity hook (#106).
|
||||
//!
|
||||
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
|
||||
//! the daemon's first-start auto-install:
|
||||
@@ -6,28 +6,40 @@
|
||||
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
||||
//! posts an [`ActivityState`](maestro-protocol) to
|
||||
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
|
||||
//! global hook runs in every Claude Code session on the machine, so it exits
|
||||
//! global hook runs in every agent session on the machine, so it exits
|
||||
//! 0 and silently when either #103 env var is unset, and it never lets a
|
||||
//! daemon failure escape (short curl timeout, status ignored).
|
||||
//! * [`install`] — idempotent registration of that script in
|
||||
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
|
||||
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
|
||||
//! in the file is preserved verbatim.
|
||||
//! * [`install`] — idempotent registration of that script in one [`Target`]'s
|
||||
//! hook file. Only entries maestro itself wrote (identified by
|
||||
//! [`HOOK_MARKER`]) are touched; every other hook and every other field in
|
||||
//! the file is preserved verbatim.
|
||||
//!
|
||||
//! One script serves every [`Target`]: Claude Code and Codex read the same
|
||||
//! `{"hooks": {Event: [{"hooks": [{"type": "command", "command": …}]}]}}`
|
||||
//! shape and hand the hook the same JSON on stdin. Only the file's location
|
||||
//! and the event set differ.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Marker substring every maestro-installed hook command carries. The
|
||||
/// installer rewrites or removes exactly these entries — nothing else in the
|
||||
/// user's settings file.
|
||||
///
|
||||
/// It still says `claude` although the script now serves every [`Target`]:
|
||||
/// the marker is how an install from an older maestro is recognised, and
|
||||
/// renaming it would orphan every entry already on disk.
|
||||
pub const HOOK_MARKER: &str = "maestro-claude-hook";
|
||||
|
||||
/// File name of the installed script (carries [`HOOK_MARKER`]).
|
||||
/// File name of the installed script (carries [`HOOK_MARKER`]). One script
|
||||
/// for every [`Target`] — see the note on [`HOOK_MARKER`] for the name.
|
||||
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||
|
||||
/// Hook events installed only with the `haiku-autoname` feature (#131).
|
||||
/// `UserPromptSubmit` carries the prompt the user just submitted; the script
|
||||
/// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
|
||||
/// still-provisionally-named session after its first one.
|
||||
/// Hook events installed only with the `haiku-autoname` feature (#131), for
|
||||
/// every [`Target`] — Claude Code and Codex both spell the event
|
||||
/// `UserPromptSubmit` and both put the submitted text in the payload's
|
||||
/// `prompt`. The script forwards the whole payload to
|
||||
/// `/api/sessions/:id/prompt`, which names a still-provisionally-named
|
||||
/// session after its first one.
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")];
|
||||
|
||||
@@ -43,7 +55,7 @@ pub const PROMPT_EVENTS: [(&str, &str); 0] = [];
|
||||
/// Claude Code's own name for "the turn ended on an API error"; there is no
|
||||
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||
/// `Error`.
|
||||
pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
pub const CLAUDE_EVENTS: [(&str, &str); 5] = [
|
||||
// Tool starting: the agent is demonstrably producing work, even if the
|
||||
// tool itself runs for minutes.
|
||||
("PreToolUse", "Active"),
|
||||
@@ -58,17 +70,86 @@ pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
("StopFailure", "Error"),
|
||||
];
|
||||
|
||||
/// Codex hook event → `ActivityState` spelling posted to the daemon.
|
||||
///
|
||||
/// Codex's event set is close to Claude Code's but not identical: a pending
|
||||
/// approval arrives as `PermissionRequest` rather than `Notification`, and
|
||||
/// there is no `StopFailure`, so a codex session never reports `Error`.
|
||||
pub const CODEX_EVENTS: [(&str, &str); 4] = [
|
||||
("PreToolUse", "Active"),
|
||||
("PostToolUse", "Active"),
|
||||
// Codex is blocked on the operator approving a tool call.
|
||||
("PermissionRequest", "AwaitingInput"),
|
||||
("Stop", "AwaitingInput"),
|
||||
];
|
||||
|
||||
/// An agent whose hook file maestro writes into.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// `~/.claude/settings.json`.
|
||||
ClaudeCode,
|
||||
/// `~/.codex/hooks.json`. Codex asks the operator to trust a hook it has
|
||||
/// not seen before, so an install here does nothing until they approve it
|
||||
/// once inside codex — maestro never writes that approval itself.
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// Every target an install covers. Both files are written whether or not
|
||||
/// the agent is on the machine: an unused hook file costs nothing, and
|
||||
/// the alternative is a session spawned after the daemon started missing
|
||||
/// the hook because its agent arrived later.
|
||||
pub const ALL: [Target; 2] = [Target::ClaudeCode, Target::Codex];
|
||||
|
||||
/// How the target is named in logs and CLI output.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Target::ClaudeCode => "claude-code",
|
||||
Target::Codex => "codex",
|
||||
}
|
||||
}
|
||||
|
||||
/// The file this target's hook entries are registered in, under `home`.
|
||||
pub fn settings_path(self, home: &Path) -> std::path::PathBuf {
|
||||
match self {
|
||||
Target::ClaudeCode => home.join(".claude/settings.json"),
|
||||
Target::Codex => home.join(".codex/hooks.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity events this target emits.
|
||||
pub fn activity_events(self) -> &'static [(&'static str, &'static str)] {
|
||||
match self {
|
||||
Target::ClaudeCode => &CLAUDE_EVENTS,
|
||||
Target::Codex => &CODEX_EVENTS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every event installed for this target: its activity events plus, with
|
||||
/// `haiku-autoname` on, the prompt event both agents spell the same way.
|
||||
pub fn events(self) -> impl Iterator<Item = (&'static str, &'static str)> {
|
||||
self.activity_events()
|
||||
.iter()
|
||||
.chain(PROMPT_EVENTS.iter())
|
||||
.copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
||||
# Exits silently unless running inside a session maestro spawned (#103 env).
|
||||
# Installed by maestro (#106): report agent activity to its daemon.
|
||||
# One script for every agent maestro installs hooks into; the state to post
|
||||
# is chosen by the caller, so the two event sets differ only in what is
|
||||
# registered where. Exits silently unless running inside a session maestro
|
||||
# spawned (#103 env).
|
||||
# Event -> ActivityState mapping:
|
||||
# PreToolUse -> Active (tool started; agent is working)
|
||||
# PostToolUse -> Active (tool finished)
|
||||
# Notification -> AwaitingInput (permission / idle notice)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (turn ended on an API error)
|
||||
# Prompt -> the payload is forwarded to the prompt route (#131)
|
||||
# PreToolUse -> Active (tool started; agent is working)
|
||||
# PostToolUse -> Active (tool finished)
|
||||
# Notification -> AwaitingInput (claude code: permission / idle)
|
||||
# PermissionRequest -> AwaitingInput (codex: waiting on an approval)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (claude code: turn ended on an error)
|
||||
# Prompt -> the payload is forwarded to the prompt route (#131)
|
||||
state="$1"
|
||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||
[ -n "$MAESTRO_URL" ] || exit 0
|
||||
@@ -115,34 +196,29 @@ pub fn script_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// The user-level Claude Code settings file for a given home directory.
|
||||
pub fn settings_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".claude/settings.json")
|
||||
}
|
||||
|
||||
/// Whether `settings_path` already carries a maestro hook (#106). Presence
|
||||
/// only: an install from an older release answers `true` even when it is
|
||||
/// missing events this build ships — see [`is_current`].
|
||||
/// Whether the file at `settings_path` already carries a maestro hook
|
||||
/// (#106). Presence only: an install from an older release answers `true`
|
||||
/// even when it is missing events this build ships — see [`is_current`].
|
||||
pub fn is_installed(settings_path: &Path) -> bool {
|
||||
settings_json(settings_path)
|
||||
.map(|root| find_maestro_commands(&root).next().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether what is on disk is what this build ships: the script at
|
||||
/// `script_path` byte-for-byte, and exactly one settings entry per event in
|
||||
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
|
||||
/// Whether what is on disk is what this build ships for `target`: the script
|
||||
/// at `script_path` byte-for-byte, and exactly one settings entry per one of
|
||||
/// the target's [`Target::events`], pointing at that script.
|
||||
///
|
||||
/// This is the upgrade question. A maestro that gains an event — #131 added
|
||||
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
|
||||
/// existing user with a present but incomplete install, which
|
||||
/// [`is_installed`] cannot tell apart from a current one.
|
||||
pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
|
||||
/// This is the upgrade question, asked per [`Target`]. A maestro that gains
|
||||
/// an event — #131 added `UserPromptSubmit` long after #106 had installed
|
||||
/// itself — leaves every existing user with a present but incomplete
|
||||
/// install, which [`is_installed`] cannot tell apart from a current one.
|
||||
pub fn is_current(target: Target, settings_path: &Path, script_path: &Path) -> bool {
|
||||
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
|
||||
return false;
|
||||
}
|
||||
settings_json(settings_path)
|
||||
.map(|root| maestro_entries(&root) == expected_entries(script_path))
|
||||
.map(|root| maestro_entries(&root) == expected_entries(target, script_path))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -173,14 +249,13 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
|
||||
found
|
||||
}
|
||||
|
||||
/// What [`install`] writes for `script_path`, in the same shape.
|
||||
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
|
||||
let mut want: Vec<(String, String)> = HOOK_EVENTS
|
||||
.iter()
|
||||
.chain(PROMPT_EVENTS.iter())
|
||||
/// What [`install`] writes for `target` and `script_path`, in the same shape.
|
||||
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> {
|
||||
let mut want: Vec<(String, String)> = target
|
||||
.events()
|
||||
.map(|(event, state)| {
|
||||
(
|
||||
(*event).to_string(),
|
||||
event.to_string(),
|
||||
format!("{} {state}", script_path.display()),
|
||||
)
|
||||
})
|
||||
@@ -207,28 +282,40 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the full hook setup for `home`: write the script under
|
||||
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
|
||||
/// Install the full hook setup for one target under `home`: write the script
|
||||
/// under `.local/share/maestro/` and register it in the target's hook file.
|
||||
/// Idempotent; returns whether anything changed on disk.
|
||||
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
|
||||
pub fn install_target(target: Target, home: &Path) -> Result<bool, HooksError> {
|
||||
let script = script_path(home);
|
||||
let settings = settings_path(home);
|
||||
let settings = target.settings_path(home);
|
||||
// A script left over from an older release counts as a change even when
|
||||
// the settings entries pointing at it are already right.
|
||||
let changed = !is_current(&settings, &script);
|
||||
let changed = !is_current(target, &settings, &script);
|
||||
write_script(&script)?;
|
||||
install(&settings, &script)?;
|
||||
install(target, &settings, &script)?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
|
||||
/// settings file at `settings_path`. Idempotent: existing maestro entries are
|
||||
/// updated in place wherever they sit, stale ones removed, duplicates folded;
|
||||
/// every non-maestro entry and every unrelated top-level field survives.
|
||||
/// Install for every [`Target::ALL`], reporting each one separately. One
|
||||
/// target failing — an unwritable file, unparseable JSON — never stops the
|
||||
/// others: an agent whose hook could not be written is one agent without
|
||||
/// activity reporting, not a failed install.
|
||||
pub fn install_for_home(home: &Path) -> Vec<(Target, Result<bool, HooksError>)> {
|
||||
Target::ALL
|
||||
.iter()
|
||||
.map(|&target| (target, install_target(target, home)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Register the hook command for every one of `target`'s [`Target::events`]
|
||||
/// in the settings file at `settings_path`. Idempotent: existing maestro
|
||||
/// entries are updated in place wherever they sit, stale ones removed,
|
||||
/// duplicates folded; every non-maestro entry and every unrelated top-level
|
||||
/// field survives.
|
||||
///
|
||||
/// Never inspects or writes anywhere but `settings_path` — callers pass the
|
||||
/// user's real home or a temp one in tests, never a repository.
|
||||
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
|
||||
pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
|
||||
let mut root = settings_json(settings_path)?;
|
||||
|
||||
// Strip every maestro entry wherever it currently sits, so an older
|
||||
@@ -236,7 +323,7 @@ pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksErro
|
||||
strip_maestro_entries(&mut root);
|
||||
|
||||
let hooks = ensure_object(&mut root, "hooks");
|
||||
for (event, state) in HOOK_EVENTS.iter().chain(PROMPT_EVENTS.iter()).copied() {
|
||||
for (event, state) in target.events() {
|
||||
let groups = ensure_array(hooks, event);
|
||||
let mut group = serde_json::Map::new();
|
||||
group.insert(
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
||||
//! and the settings-file installer. Everything happens under a temp `$HOME`;
|
||||
//! the developer's own `~/.claude/settings.json` is never touched.
|
||||
//! the developer's own `~/.claude/settings.json` and `~/.codex/hooks.json`
|
||||
//! are never touched.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// How many commands one install writes: every activity event, plus the
|
||||
/// prompt event when `haiku-autoname` is on (#131).
|
||||
const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
|
||||
/// How many commands one install writes for a target: its activity events,
|
||||
/// plus the prompt event when `haiku-autoname` is on (#131).
|
||||
fn installed_events(target: Target) -> usize {
|
||||
target.events().count()
|
||||
}
|
||||
|
||||
/// The Claude Code hook file under a temp home. Most installer tests only
|
||||
/// need one target, and this is the one they were written against.
|
||||
fn settings_path(home: &Path) -> std::path::PathBuf {
|
||||
Target::ClaudeCode.settings_path(home)
|
||||
}
|
||||
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
@@ -70,7 +79,7 @@ fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a
|
||||
/// exit 0, no output, stdin drained.
|
||||
#[test]
|
||||
fn script_exits_silently_without_maestro_env() {
|
||||
for (event, _) in HOOK_EVENTS {
|
||||
for (event, _) in CLAUDE_EVENTS {
|
||||
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
||||
@@ -103,7 +112,7 @@ fn script_posts_mapped_state_to_the_activity_endpoint() {
|
||||
/// Every event maps to the state the contract specifies.
|
||||
#[test]
|
||||
fn event_state_mapping_matches_the_contract() {
|
||||
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
|
||||
let map: std::collections::HashMap<&str, &str> = CLAUDE_EVENTS.into_iter().collect();
|
||||
assert_eq!(map["PreToolUse"], "Active");
|
||||
assert_eq!(map["PostToolUse"], "Active");
|
||||
assert_eq!(map["Notification"], "AwaitingInput");
|
||||
@@ -203,10 +212,10 @@ fn fresh_install_registers_every_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
write_script(&script_path(home)).unwrap();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings_path(home));
|
||||
assert_eq!(cmds.len(), INSTALLED_EVENTS);
|
||||
assert_eq!(cmds.len(), installed_events(Target::ClaudeCode));
|
||||
let joined = cmds.join("\n");
|
||||
assert_eq!(joined.matches("Active").count(), 2);
|
||||
assert!(joined.contains("Error"));
|
||||
@@ -221,15 +230,15 @@ fn fresh_install_registers_every_event() {
|
||||
fn install_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
let once = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
assert_eq!(once, twice);
|
||||
assert_eq!(
|
||||
installed_commands(&settings_path(home)).len(),
|
||||
INSTALLED_EVENTS
|
||||
installed_events(Target::ClaudeCode)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -257,12 +266,12 @@ fn install_updates_an_older_maestro_hook_in_place() {
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(&settings, &new_script).unwrap();
|
||||
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.len(),
|
||||
INSTALLED_EVENTS,
|
||||
installed_events(Target::ClaudeCode),
|
||||
"old entry replaced, not kept: {cmds:?}"
|
||||
);
|
||||
assert!(!cmds.iter().any(|c| c == old_script));
|
||||
@@ -298,7 +307,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(&settings, &new_script).unwrap();
|
||||
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
@@ -311,7 +320,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||
INSTALLED_EVENTS,
|
||||
installed_events(Target::ClaudeCode),
|
||||
"{cmds:?}"
|
||||
);
|
||||
}
|
||||
@@ -321,9 +330,15 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
fn is_installed_tracks_reality() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!is_installed(&settings_path(home)));
|
||||
install_for_home(home).unwrap();
|
||||
assert!(is_installed(&settings_path(home)));
|
||||
for target in Target::ALL {
|
||||
assert!(!is_installed(&target.settings_path(home)));
|
||||
}
|
||||
for (_, result) in install_for_home(home) {
|
||||
result.unwrap();
|
||||
}
|
||||
for target in Target::ALL {
|
||||
assert!(is_installed(&target.settings_path(home)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The full home-level install leaves the script executable.
|
||||
@@ -331,7 +346,9 @@ fn is_installed_tracks_reality() {
|
||||
fn install_for_home_writes_an_executable_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_for_home(home).unwrap();
|
||||
for (_, result) in install_for_home(home) {
|
||||
result.unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -350,7 +367,7 @@ fn install_for_home_writes_an_executable_script() {
|
||||
fn install_registers_the_prompt_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
||||
@@ -367,7 +384,7 @@ fn install_registers_the_prompt_event() {
|
||||
fn install_registers_no_prompt_event_without_the_feature() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
||||
@@ -436,8 +453,12 @@ fn the_script_posts_a_prompt_payload_unchanged() {
|
||||
fn is_current_rejects_an_install_missing_an_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_for_home(home).unwrap();
|
||||
assert!(is_current(&settings_path(home), &script_path(home)));
|
||||
install_target(Target::ClaudeCode, home).unwrap();
|
||||
assert!(is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
|
||||
// Drop one event, as an older install would have left the file.
|
||||
let mut root: serde_json::Value =
|
||||
@@ -450,7 +471,11 @@ fn is_current_rejects_an_install_missing_an_event() {
|
||||
.unwrap();
|
||||
|
||||
assert!(is_installed(&settings_path(home)));
|
||||
assert!(!is_current(&settings_path(home), &script_path(home)));
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
}
|
||||
|
||||
/// A settings file that is complete but points at a script from an older
|
||||
@@ -459,9 +484,13 @@ fn is_current_rejects_an_install_missing_an_event() {
|
||||
fn is_current_rejects_a_stale_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_for_home(home).unwrap();
|
||||
install_target(Target::ClaudeCode, home).unwrap();
|
||||
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
assert!(!is_current(&settings_path(home), &script_path(home)));
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
}
|
||||
|
||||
/// Nothing installed at all is not current.
|
||||
@@ -469,18 +498,93 @@ fn is_current_rejects_a_stale_script() {
|
||||
fn is_current_rejects_a_missing_install() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!is_current(&settings_path(home), &script_path(home)));
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
}
|
||||
|
||||
/// `install_for_home` reports a stale script as a change, even when the
|
||||
/// `install_target` reports a stale script as a change, even when the
|
||||
/// settings entries pointing at it already read correctly.
|
||||
#[test]
|
||||
fn install_for_home_reports_a_stale_script_as_a_change() {
|
||||
fn install_target_reports_a_stale_script_as_a_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(install_for_home(home).unwrap());
|
||||
assert!(!install_for_home(home).unwrap());
|
||||
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
||||
assert!(!install_target(Target::ClaudeCode, home).unwrap());
|
||||
|
||||
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
assert!(install_for_home(home).unwrap());
|
||||
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
||||
}
|
||||
|
||||
/// Codex is hooked in its own file, with its own event set: the two agents
|
||||
/// share the script but not the events, so neither file carries the other's
|
||||
/// spellings (#131 follow-up — a codex session was never named because
|
||||
/// nothing installed its `UserPromptSubmit` hook).
|
||||
#[test]
|
||||
fn each_target_gets_its_own_file_and_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
for (_, result) in install_for_home(home) {
|
||||
result.unwrap();
|
||||
}
|
||||
|
||||
let claude = events_in(&Target::ClaudeCode.settings_path(home));
|
||||
let codex = events_in(&Target::Codex.settings_path(home));
|
||||
assert_eq!(
|
||||
Target::Codex.settings_path(home),
|
||||
home.join(".codex/hooks.json")
|
||||
);
|
||||
|
||||
// Claude Code's failure signal has no codex equivalent, and codex's
|
||||
// approval notice is spelled differently.
|
||||
assert!(claude.contains(&"StopFailure".to_string()));
|
||||
assert!(claude.contains(&"Notification".to_string()));
|
||||
assert!(!codex.contains(&"StopFailure".to_string()));
|
||||
assert!(!codex.contains(&"Notification".to_string()));
|
||||
assert!(codex.contains(&"PermissionRequest".to_string()));
|
||||
|
||||
// Both report work and both report a finished turn.
|
||||
for event in ["PreToolUse", "PostToolUse", "Stop"] {
|
||||
assert!(claude.contains(&event.to_string()), "claude {event}");
|
||||
assert!(codex.contains(&event.to_string()), "codex {event}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
installed_commands(&Target::Codex.settings_path(home)).len(),
|
||||
installed_events(Target::Codex)
|
||||
);
|
||||
}
|
||||
|
||||
/// #131: a codex session names itself the same way a Claude Code one does —
|
||||
/// same event spelling, same script, same `Prompt` argument.
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
#[test]
|
||||
fn codex_reports_its_first_prompt_too() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_target(Target::Codex, home).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(Target::Codex.settings_path(home)).unwrap())
|
||||
.unwrap();
|
||||
let cmd = root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(cmd.ends_with(" Prompt"), "{cmd}");
|
||||
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
||||
}
|
||||
|
||||
/// Every event name a settings file registers a maestro hook under.
|
||||
fn events_in(settings: &Path) -> Vec<String> {
|
||||
serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(settings).unwrap())
|
||||
.unwrap()
|
||||
.get("hooks")
|
||||
.and_then(|h| h.as_object())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(event, _)| event.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -554,6 +554,12 @@ pub struct DaemonInfo {
|
||||
/// network blip, even when the build did not change.
|
||||
#[serde(default)]
|
||||
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
|
||||
|
||||
@@ -114,7 +114,7 @@ impl TuiConfig {
|
||||
TuiTheme::Nord => Palette::NORD,
|
||||
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)
|
||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
||||
|
||||
@@ -250,6 +250,7 @@ mod tests {
|
||||
dirty: false,
|
||||
},
|
||||
instance: instance.into(),
|
||||
leader: maestro_config::DEFAULT_LEADER.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-6
@@ -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
|
||||
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
|
||||
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
|
||||
@@ -152,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
|
||||
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.
|
||||
- Each action raises the flow it already has: kill through the confirmation
|
||||
both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
|
||||
(`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.
|
||||
|
||||
|
||||
+2
-1
@@ -136,7 +136,8 @@ cover every v1 action — only override what you want to change:
|
||||
```toml
|
||||
[tui]
|
||||
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`
|
||||
|
||||
[tui.keymap.normal]
|
||||
|
||||
@@ -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. |
|
||||
| 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 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. |
|
||||
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
|
||||
+20
-1
@@ -250,6 +250,10 @@ Two rulings specific to a reported `Error` (#114):
|
||||
process changes do not clear it — only the agent itself may withdraw what
|
||||
the agent itself reported. Re-reporting `Error` extends the TTL rather than
|
||||
stacking reports.
|
||||
- **Only Claude Code reports `Error`.** Codex has no counterpart to
|
||||
`StopFailure` in its hook event set, so a codex turn that ends on an API
|
||||
error reports `AwaitingInput` and its card carries no error mark. A card
|
||||
without the mark means "nothing reported a failure", not "nothing failed".
|
||||
|
||||
If a client receives an `ActivityState` variant it does not know, it must not
|
||||
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
|
||||
@@ -316,7 +320,7 @@ Everything else is the same key on both clients:
|
||||
| `Tab` | hand the keyboard to the pane |
|
||||
| `c` | collapse or expand the highlighted folder |
|
||||
| `r` | rename the highlighted session |
|
||||
| `x` | kill the highlighted session |
|
||||
| `x` | kill the highlighted session (confirms) |
|
||||
| `n` | open the spawn form |
|
||||
| `N` | spawn with every default resolved |
|
||||
| `?` | show the shortcut overlay |
|
||||
@@ -324,3 +328,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
|
||||
web client's are fixed. A binding added to one client belongs in this table and
|
||||
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.
|
||||
|
||||
@@ -65,7 +65,7 @@ async function flush(): Promise<void> {
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
@@ -75,6 +75,12 @@ describe("kill from the action page", () => {
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
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(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
@@ -101,6 +107,8 @@ describe("kill from the action page", () => {
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
|
||||
+40
-1
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
|
||||
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[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
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(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 () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||
|
||||
+67
-41
@@ -1,6 +1,6 @@
|
||||
import { escapeHtml } from "./html.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 { toggleHelp } from "./help.js";
|
||||
import { CommandSet, defineCommands } from "./commands.js";
|
||||
@@ -39,6 +39,15 @@ export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to open the command bar with an empty query. Dispatched by
|
||||
* the sidebar's search field, which is the touch entry point to the bar
|
||||
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
|
||||
* Handled in main.ts, which owns the session snapshot the bar needs. */
|
||||
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
|
||||
export function requestOpenCommandBar(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||
* connection pool. */
|
||||
@@ -221,7 +230,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void requestKill(cur, sessionName(cur));
|
||||
requestKill(cur, sessionName(cur));
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||
@@ -239,7 +248,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
||||
onSelect: () => requestKill(cur, sessionName(cur)),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -284,26 +293,33 @@ function treeSignature(node: TreeNode): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
||||
* a forced kill. Anything else surfaces as an alert.
|
||||
* Kill behind a confirmation (#160): a kill is destructive and unattended
|
||||
* agents live in these sessions, so the web asks first, the way the TUI always
|
||||
* has. The daemon decides the rest — it refuses a kill over a worktree holding
|
||||
* 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> {
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
openForceKillDialog({
|
||||
name,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
reason: e.detail,
|
||||
onForce: () => killSession(s.id, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
alert(`Failed to kill: ${(e as Error).message}`);
|
||||
}
|
||||
function requestKill(s: Session, name: string): void {
|
||||
openKillConfirmDialog({
|
||||
name,
|
||||
blockers: sessionBlockers(s),
|
||||
onKill: async () => {
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
openForceKillDialog({
|
||||
name,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
reason: e.detail,
|
||||
onForce: () => killSession(s.id, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,16 +377,12 @@ function beginRename(row: HTMLElement, s: Session): void {
|
||||
input.select();
|
||||
}
|
||||
|
||||
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||
// The tree has one empty state: no sessions at all. The old "No matches" one
|
||||
// belonged to the inline substring filter, which the command bar replaced
|
||||
// (#157) — the bar's own no-results state took over with it.
|
||||
function renderEmpty(onSpawn: () => void): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "empty";
|
||||
if (filtering) {
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">⌕</div>
|
||||
<div class="empty-title">No matches</div>
|
||||
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
||||
return el;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||
<div class="empty-title">No sessions yet</div>
|
||||
@@ -441,12 +453,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// renders with absolute paths until it lands.
|
||||
let homeDir: string | undefined;
|
||||
|
||||
// The field never holds a query: it is the touch entry point to the command
|
||||
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
|
||||
// on-screen keyboard over a field nothing is typed into.
|
||||
const searchHeader = document.createElement("header");
|
||||
searchHeader.className = "sidebar-search";
|
||||
searchHeader.innerHTML = `
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
readonly
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autocomplete="off"
|
||||
@@ -555,11 +571,23 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
handle.addEventListener("pointerup", endDrag);
|
||||
handle.addEventListener("pointercancel", endDrag);
|
||||
|
||||
// Touch entry (#157): the field is a button that looks like a field. It
|
||||
// never holds a query — focusing or tapping it hands the keyboard straight
|
||||
// to the bar's own input, which is also why it blurs first: leaving the
|
||||
// focus here would make the bar impossible to close without reopening it.
|
||||
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
||||
let filter = "";
|
||||
search.addEventListener("input", () => {
|
||||
filter = search.value;
|
||||
refresh();
|
||||
const openBar = () => {
|
||||
search.value = "";
|
||||
search.blur();
|
||||
requestOpenCommandBar();
|
||||
};
|
||||
search.addEventListener("focus", openBar);
|
||||
// A tap can land without a focus event (iOS Safari on a blurred field), so
|
||||
// the pointer path opens the bar too; the blur above keeps the two from
|
||||
// stacking two overlays.
|
||||
search.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
openBar();
|
||||
});
|
||||
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||
@@ -698,7 +726,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
},
|
||||
"list.kill": () => {
|
||||
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)),
|
||||
}),
|
||||
@@ -716,8 +744,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
if (renamingId !== null) return;
|
||||
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);
|
||||
const tree = buildTree(sessions, homeDir);
|
||||
const signature = sessions.length === 0 ? "EMPTY" : 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;
|
||||
@@ -728,9 +756,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||
);
|
||||
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
||||
return;
|
||||
}
|
||||
if (tree.kind === "folder") {
|
||||
@@ -763,7 +789,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// than by row.
|
||||
const killHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
void requestKill(s, sessionName(s));
|
||||
requestKill(s, sessionName(s));
|
||||
};
|
||||
const renameHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
COMMAND_SPECS,
|
||||
CommandSet,
|
||||
commandSpecs,
|
||||
DEFAULT_LEADER,
|
||||
canonicalizeKey,
|
||||
controlByte,
|
||||
@@ -165,6 +166,37 @@ describe("the leader", () => {
|
||||
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", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
|
||||
+31
-9
@@ -32,13 +32,26 @@ export interface Command extends CommandSpec {
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** Leader used when the daemon does not name one — an older daemon, or an
|
||||
* 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";
|
||||
|
||||
/** 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[] = [
|
||||
/** Every command the web client has, for one leader. The single list: labels,
|
||||
* chords and grouping live here and nowhere else. Chords behind the leader are
|
||||
* 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",
|
||||
label: "Open the spawn form",
|
||||
@@ -65,6 +78,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
label: "Send a literal Ctrl-K to the pane",
|
||||
group: "Pane",
|
||||
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
||||
leaderKeys: (leader) => [`${leader} ctrl-k`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
@@ -72,6 +86,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
label: "Send a literal leader key to the pane",
|
||||
group: "Pane",
|
||||
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
||||
leaderKeys: (leader) => [`${leader} ${leader}`],
|
||||
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
|
||||
* test failure rather than a key that silently does nothing. */
|
||||
export function defineCommands(handlers: Record<string, () => void>): Command[] {
|
||||
* test failure rather than a key that silently does nothing. `leader` picks
|
||||
* 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]) => {
|
||||
const spec = SPEC_BY_ID.get(id);
|
||||
const spec = byId.get(id);
|
||||
if (!spec) throw new Error(`unknown command id: ${id}`);
|
||||
return { ...spec, run };
|
||||
});
|
||||
|
||||
@@ -34,12 +34,14 @@ describe("resolveFastForward (#67)", () => {
|
||||
const res = resolveFastForward(projects, presets, "amber-falcon");
|
||||
expect(res.project?.path).toBe("/repos/fresh");
|
||||
expect(res.command).toBe("aider");
|
||||
expect(res.name).toBe("amber-falcon");
|
||||
expect(res.handle).toBe("amber-falcon");
|
||||
|
||||
const req = buildFastForwardRequest(res)!;
|
||||
expect(req.cwd).toBe("/repos/fresh");
|
||||
expect(req.command).toBe("aider");
|
||||
expect(req.metadata).toEqual({ name: "amber-falcon" });
|
||||
// No name (#131): the daemon names the session after its first prompt,
|
||||
// and a name here would collide with the branch handle.
|
||||
expect(req.metadata).toBeUndefined();
|
||||
expect(req.worktree).toEqual({
|
||||
base_repo: "/repos/fresh",
|
||||
branch: "amber-falcon",
|
||||
@@ -88,9 +90,9 @@ describe("resolveFastForward (#67)", () => {
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("downgrades to a draft when the generated name is missing", () => {
|
||||
it("downgrades to a draft when the generated handle is missing", () => {
|
||||
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
||||
expect(res.name).toBeUndefined();
|
||||
expect(res.handle).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -108,6 +110,17 @@ describe("resolveFastForward (#67)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds the handle as the branch and leaves the name field empty (#131)", () => {
|
||||
const res = resolveFastForward(
|
||||
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
|
||||
presets,
|
||||
"amber-falcon",
|
||||
);
|
||||
const seed = fastForwardSeed(res);
|
||||
expect(seed.name).toBe("");
|
||||
expect(seed.worktreeBranch).toBe("amber-falcon");
|
||||
});
|
||||
|
||||
it("leaves the worktree default alone when not even a project resolved", () => {
|
||||
// Not `false`: the form defaults the toggle on and a false would stick,
|
||||
// so the fallback draft would disagree with a normally-opened draft and
|
||||
|
||||
+19
-13
@@ -10,19 +10,21 @@ import { sortProjects } from "./projectList.js";
|
||||
export interface FastForwardResolution {
|
||||
project?: Project;
|
||||
command?: string;
|
||||
name?: string;
|
||||
/** A generated handle for the worktree branch (#131). Not a session name:
|
||||
* the fast path names no session. */
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
/** The defaults: the currently focused session's project when it's still a
|
||||
* known project, else the most recent one (fresh-first, same ordering the
|
||||
* picker shows) — so `N` lands where the operator currently is, matching
|
||||
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||
* falls back to the first non-custom preset, and the name is freshly
|
||||
* generated when the daemon supplied one. */
|
||||
* falls back to the first non-custom preset, and the branch handle is
|
||||
* freshly generated when the daemon supplied one. */
|
||||
export function resolveFastForward(
|
||||
projects: Project[],
|
||||
presets: SpawnPreset[],
|
||||
name?: string,
|
||||
handle?: string,
|
||||
focusedCwd?: string,
|
||||
): FastForwardResolution {
|
||||
const project =
|
||||
@@ -30,41 +32,45 @@ export function resolveFastForward(
|
||||
const lastCommand = project?.last_command?.trim();
|
||||
const command =
|
||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||
return { project, command, name: name || undefined };
|
||||
return { project, command, handle: handle || undefined };
|
||||
}
|
||||
|
||||
/** The immediate spawn request — worktree on, new branch named after the
|
||||
* generated session name — or `null` when any default is missing. */
|
||||
* generated handle — or `null` when any default is missing.
|
||||
*
|
||||
* The request carries no name (#131): the daemon assigns a provisional one
|
||||
* and replaces it once the session sees its first prompt. A name here would
|
||||
* also collide with the branch handle's own session. */
|
||||
export function buildFastForwardRequest(
|
||||
res: FastForwardResolution,
|
||||
): CreateSessionRequest | null {
|
||||
const { project, command, name } = res;
|
||||
if (!project || !command?.trim() || !name) return null;
|
||||
const { project, command, handle } = res;
|
||||
if (!project || !command?.trim() || !handle) return null;
|
||||
return {
|
||||
cwd: project.path,
|
||||
command,
|
||||
metadata: { name },
|
||||
worktree: {
|
||||
base_repo: project.base_repo ?? project.path,
|
||||
branch: name,
|
||||
branch: handle,
|
||||
create_branch: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed for the fallback spawn form: whatever did resolve goes into the
|
||||
* form; the rest stays with its own defaults. */
|
||||
* form; the rest stays with its own defaults. The name field stays empty
|
||||
* (#131) — the handle seeds the branch, not the session name. */
|
||||
export function fastForwardSeed(res: FastForwardResolution) {
|
||||
return {
|
||||
cwd: res.project?.path ?? "",
|
||||
command: res.command ?? "",
|
||||
name: res.name ?? "",
|
||||
name: "",
|
||||
// Left undefined rather than false when nothing resolved: the form
|
||||
// defaults the toggle on, and `false` would stick, so the fallback form
|
||||
// would disagree with both a normally-opened form and the TUI's fast path
|
||||
// (#67 review).
|
||||
worktreeEnabled: res.project ? true : undefined,
|
||||
worktreeBranch:
|
||||
res.project && res.name ? res.name : undefined,
|
||||
res.project && res.handle ? res.handle : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
+28
-1
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
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";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -44,6 +44,33 @@ describe("help overlay", () => {
|
||||
.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 ?", () => {
|
||||
press("?", { shiftKey: true });
|
||||
expect(isHelpOpen()).toBe(true);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeForceKillDialog,
|
||||
openForceKillDialog,
|
||||
openKillConfirmDialog,
|
||||
} from "./killDialog.js";
|
||||
|
||||
describe("kill dialogs", () => {
|
||||
beforeEach(() => {
|
||||
closeForceKillDialog();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("focuses the kill button, so Enter confirms", () => {
|
||||
const el = openKillConfirmDialog({
|
||||
name: "alpha",
|
||||
blockers: null,
|
||||
onKill: () => Promise.resolve(),
|
||||
});
|
||||
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
|
||||
});
|
||||
|
||||
it("focuses the force button too", () => {
|
||||
const el = openForceKillDialog({
|
||||
name: "alpha",
|
||||
blockers: null,
|
||||
reason: "worktree is dirty.",
|
||||
onForce: () => Promise.resolve(),
|
||||
});
|
||||
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
|
||||
});
|
||||
});
|
||||
+84
-23
@@ -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
|
||||
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
||||
* refusal gets its own dialog: it names what is in the way and offers the
|
||||
* override the daemon's `force=true` already implements. A kill the daemon
|
||||
* accepts never reaches here — there is nothing to warn about.
|
||||
* A kill is destructive and unattended agents live in these sessions, so both
|
||||
* clients ask before one goes through — the TUI's `KillConfirm`, and
|
||||
* `openKillConfirmDialog` here (#160). Where the session's worktree still
|
||||
* holds uncommitted or unpushed work, the confirmation says so up front, the
|
||||
* 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";
|
||||
|
||||
/** 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 {
|
||||
/** Session name, as the sidebar shows it. */
|
||||
name: string;
|
||||
@@ -30,9 +60,9 @@ export function closeForceKillDialog(): void {
|
||||
open = null;
|
||||
}
|
||||
|
||||
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
||||
* can never stack two dialogs. */
|
||||
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
/** Mount one of the prompts. Opening while one is up replaces it, so the
|
||||
* confirmation and a refusal that follows it can never stack. */
|
||||
function mount(p: KillDialogSpec): HTMLElement {
|
||||
closeForceKillDialog();
|
||||
|
||||
const el = document.createElement("div");
|
||||
@@ -50,9 +80,7 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
|
||||
const body = document.createElement("p");
|
||||
body.className = "kill-dialog-body";
|
||||
body.textContent = 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.`;
|
||||
body.textContent = p.body;
|
||||
|
||||
const error = document.createElement("p");
|
||||
error.className = "kill-dialog-error";
|
||||
@@ -65,22 +93,27 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", closeForceKillDialog);
|
||||
const force = document.createElement("button");
|
||||
force.type = "button";
|
||||
force.className = "btn btn-danger kill-dialog-force";
|
||||
force.textContent = "Force kill";
|
||||
force.addEventListener("click", () => {
|
||||
force.disabled = true;
|
||||
const confirm = document.createElement("button");
|
||||
confirm.type = "button";
|
||||
confirm.className = `btn btn-danger ${p.confirmClass}`;
|
||||
confirm.textContent = p.confirmLabel;
|
||||
confirm.addEventListener("click", () => {
|
||||
confirm.disabled = true;
|
||||
void p
|
||||
.onForce()
|
||||
.then(closeForceKillDialog)
|
||||
.onConfirm()
|
||||
.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) => {
|
||||
force.disabled = false;
|
||||
if (open?.el !== el) return;
|
||||
confirm.disabled = false;
|
||||
error.hidden = false;
|
||||
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
||||
});
|
||||
});
|
||||
actions.append(cancel, force);
|
||||
actions.append(cancel, confirm);
|
||||
|
||||
panel.append(title, body, error, actions);
|
||||
|
||||
@@ -99,6 +132,34 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
};
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
open = { el, onKeydown };
|
||||
cancel.focus();
|
||||
// The acting button takes the focus in both prompts: the operator already
|
||||
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
|
||||
confirm.focus();
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
+72
-43
@@ -36,6 +36,7 @@ import { createPreviewSchedule } from "./previewSchedule.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
FOCUS_SIDEBAR_EVENT,
|
||||
OPEN_COMMAND_BAR_EVENT,
|
||||
OPEN_SPAWN_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
@@ -86,9 +87,15 @@ const store = new CatalogStore();
|
||||
// Absolute paths are a fine fallback until it lands.
|
||||
let homeDir: string | undefined;
|
||||
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(() => {
|
||||
/* 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
|
||||
@@ -349,14 +356,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||
}
|
||||
|
||||
async function requestFastForward(): Promise<void> {
|
||||
const [discovered, name] = await Promise.all([
|
||||
const [discovered, handle] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const presets = await spawnPresets();
|
||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
@@ -383,7 +390,7 @@ async function requestFastForward(): Promise<void> {
|
||||
// one outside any repo spawns without a worktree, since there is no repo to cut
|
||||
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const [discovered, name] = await Promise.all([
|
||||
const [discovered, handle] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
@@ -392,7 +399,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const presets = await spawnPresets();
|
||||
const known = discovered.find((p) => p.path === path);
|
||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||
const resolved = resolveFastForward([project], presets, name, path);
|
||||
const resolved = resolveFastForward([project], presets, handle, path);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
const inRepo =
|
||||
@@ -438,46 +445,68 @@ const spawnKeys = new CommandSet(
|
||||
// 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();
|
||||
}
|
||||
},
|
||||
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line
|
||||
// away from every pane, and leader ctrl-k gives it back as the literal
|
||||
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the
|
||||
// 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
|
||||
// byte and clears the pending chord instead of resolving as a dead chord
|
||||
// (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a
|
||||
// hardcoded byte, so a different leader would send its own.
|
||||
"pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)),
|
||||
}),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
||||
// on `/api/info`): 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).
|
||||
let currentLeader = DEFAULT_LEADER;
|
||||
|
||||
function buildFocusKeys(leader: string): CommandSet {
|
||||
return 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();
|
||||
}
|
||||
},
|
||||
// The escape hatch: binding ctrl-k takes readline's
|
||||
// kill-to-end-of-line away from every pane, and leader ctrl-k gives it
|
||||
// back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
|
||||
// this set, ahead of the 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 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
|
||||
// like any other, so the `?` overlay lists it without a second list.
|
||||
const barKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"bar.open": () => {
|
||||
// Opening while a session is previewed discards the preview first, like
|
||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||
schedulePreview.cancel();
|
||||
mv?.previewSession(null);
|
||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||
},
|
||||
}),
|
||||
);
|
||||
const openBar = () => {
|
||||
// Opening while a session is previewed discards the preview first, like
|
||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||
schedulePreview.cancel();
|
||||
mv?.previewSession(null);
|
||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||
};
|
||||
|
||||
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
|
||||
|
||||
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
|
||||
// phone, so the field the operator already reaches for opens the same bar.
|
||||
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
|
||||
|
||||
// Capture phase: an attached xterm swallows every keystroke it is given, so the
|
||||
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
|
||||
|
||||
+17
-3
@@ -215,6 +215,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
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
|
||||
* the DOM (cheap path for click-to-focus). */
|
||||
function syncFocus(): void {
|
||||
@@ -233,7 +247,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (!preview) pool.setFocus(focusedLeafSession());
|
||||
sidebar.setHighlights({
|
||||
open: realOpenSessions(),
|
||||
focused: focusedLeafSession(),
|
||||
focused: realFocusedSession(),
|
||||
});
|
||||
// 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.
|
||||
@@ -608,7 +622,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
{ focus: !preview && !quietRender },
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -817,7 +831,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
openSessions: realOpenSessions,
|
||||
beginSessionDrag,
|
||||
render,
|
||||
focusedSessionId: focusedLeafSession,
|
||||
focusedSessionId: realFocusedSession,
|
||||
previewSession,
|
||||
previewedSessionId: () => preview?.sessionId ?? null,
|
||||
};
|
||||
|
||||
+33
-1
@@ -47,12 +47,20 @@ function fakePool(): Spy {
|
||||
|
||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||
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 {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
setHighlights(h) {
|
||||
rowHighlights.open = h.open;
|
||||
rowHighlights.focused = h.focused;
|
||||
},
|
||||
setPreview(id: string | null) {
|
||||
previewRow.id = id;
|
||||
},
|
||||
@@ -95,6 +103,8 @@ describe("sidebar preview", () => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
previewRow.id = null;
|
||||
rowHighlights.open = new Set();
|
||||
rowHighlights.focused = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -134,6 +144,28 @@ describe("sidebar preview", () => {
|
||||
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", () => {
|
||||
const layout = twoTabs();
|
||||
const before = serializeLayout(layout);
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("sidebar keyboard navigation", () => {
|
||||
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 killed: string[] = [];
|
||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
@@ -153,6 +153,8 @@ describe("sidebar keyboard navigation", () => {
|
||||
|
||||
press(tree(el), "j");
|
||||
press(tree(el), "x");
|
||||
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await Promise.resolve();
|
||||
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The sidebar's search field is the touch entry point to the command bar
|
||||
// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the
|
||||
// shell to open the bar with an empty query, instead of filtering the tree.
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
|
||||
import { sessionBarCandidates } from "./commandBar.js";
|
||||
import { matchAndRank } from "./matcher.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"));
|
||||
});
|
||||
|
||||
function session(id: string, cwd: string): Session {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
cwd,
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
} as Session;
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
|
||||
const handle = renderSidebar(storeWith(sessions));
|
||||
document.body.appendChild(handle.el);
|
||||
const search = handle.el.querySelector<HTMLInputElement>(".search")!;
|
||||
const opens: number[] = [];
|
||||
const listener = () => opens.push(1);
|
||||
document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener);
|
||||
return {
|
||||
handle,
|
||||
search,
|
||||
opens,
|
||||
dispose: () => {
|
||||
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, listener);
|
||||
handle.dispose();
|
||||
handle.el.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar search field opens the command bar (#157)", () => {
|
||||
it("asks the shell to open the bar when the field is focused", () => {
|
||||
const t = mount();
|
||||
t.search.dispatchEvent(new FocusEvent("focus"));
|
||||
expect(t.opens).toHaveLength(1);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("opens on a tap, where a focus event may never arrive", () => {
|
||||
const t = mount();
|
||||
t.search.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||
expect(t.opens).toHaveLength(1);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("does not trap the keyboard in the field: it gives focus up on open", () => {
|
||||
const t = mount();
|
||||
t.search.focus();
|
||||
expect(document.activeElement).not.toBe(t.search);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("opens the bar with an empty query — the field never holds one", () => {
|
||||
const t = mount();
|
||||
t.search.focus();
|
||||
expect(t.search.value).toBe("");
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("is readonly: no on-screen keyboard over a field nothing is typed into", () => {
|
||||
const t = mount();
|
||||
expect(t.search.readOnly).toBe(true);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("no longer filters the tree in place", () => {
|
||||
const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]);
|
||||
t.search.value = "alpha";
|
||||
t.search.dispatchEvent(new Event("input"));
|
||||
expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
|
||||
const t = mount([]);
|
||||
expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet");
|
||||
t.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// The inline filter matched a session on its name and its directory, case- and
|
||||
// whitespace-insensitively. That behaviour did not go away with the field — it
|
||||
// moved into the bar, which ranks the same two fields (docs/COMMAND-BAR.md).
|
||||
describe("what the retired inline filter did, the bar now does", () => {
|
||||
it("matches a session on both its name and its directory", () => {
|
||||
const sessions = [session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")];
|
||||
const candidates = sessionBarCandidates(sessions, "/home/n");
|
||||
|
||||
const byName = matchAndRank("alpha", candidates).map((r) => r.candidate.session.id);
|
||||
expect(byName).toEqual(["alpha"]);
|
||||
|
||||
const byDir = matchAndRank("beta", candidates).map((r) => r.candidate.session.id);
|
||||
expect(byDir).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
it("ignores case and surrounding whitespace, and shows nothing on no match", () => {
|
||||
const candidates = sessionBarCandidates([session("alpha", "/home/n/Alpha")], "/home/n");
|
||||
|
||||
expect(matchAndRank(" ALPHA ", candidates)).toHaveLength(1);
|
||||
expect(matchAndRank("zzz", candidates)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+17
-2
@@ -1560,7 +1560,10 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
/* Phone: the bottom-sheet treatment the spawn modal already uses. */
|
||||
@media (max-width: 600px) {
|
||||
/* Bottom sheet at phone width, the same treatment (and the same 599px break)
|
||||
the spawn modal uses: the bar is reached by thumb here, through the sidebar's
|
||||
search field (#157), so it rides the bottom of the screen. */
|
||||
@media (max-width: 599px) {
|
||||
.cmdbar-overlay {
|
||||
grid-template-rows: 1fr auto;
|
||||
padding: 0;
|
||||
@@ -1569,9 +1572,21 @@ input.leaf-rename:focus {
|
||||
.cmdbar-panel {
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
max-height: 70dvh;
|
||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||
border-bottom: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Thumb-sized rows and a field that clears the on-screen keyboard's
|
||||
zoom-on-focus threshold (16px). */
|
||||
.cmdbar-input {
|
||||
font-size: var(--fs-17);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cmdbar-row {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-26
@@ -26,7 +26,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/work"), session("b", "/home/n/work")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root)).toEqual(["~/work"]);
|
||||
@@ -35,7 +34,7 @@ describe("buildTree", () => {
|
||||
});
|
||||
|
||||
it("collapses a linear chain into one folder", () => {
|
||||
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n", "") as TreeFolder;
|
||||
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n") as TreeFolder;
|
||||
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
||||
});
|
||||
|
||||
@@ -43,7 +42,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
||||
@@ -53,7 +51,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root)).toEqual(["~/git"]);
|
||||
@@ -65,30 +62,11 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("loose", ""), session("nested", "/home/n/work")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(root.children[0].kind).toBe("folder");
|
||||
expect(root.children[1].kind).toBe("leaf");
|
||||
});
|
||||
|
||||
it("filters on both session name and directory", () => {
|
||||
const sessions = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
|
||||
|
||||
const byDir = buildTree(sessions, "/home/n", "alpha") as TreeFolder;
|
||||
expect(names(byDir)).toEqual(["~/alpha"]);
|
||||
|
||||
const byCommand = buildTree(sessions, "/home/n", "claude") as TreeFolder;
|
||||
expect(names(byCommand)).toEqual(["~/alpha"]);
|
||||
|
||||
const noMatch = buildTree(sessions, "/home/n", "zzz") as TreeFolder;
|
||||
expect(noMatch.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores case and surrounding whitespace in the filter", () => {
|
||||
const root = buildTree([session("a", "/home/n/Alpha")], "/home/n", " ALPHA ") as TreeFolder;
|
||||
expect(root.children).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeLinear", () => {
|
||||
@@ -127,7 +105,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
||||
),
|
||||
],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||
@@ -147,7 +124,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
||||
),
|
||||
],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||
@@ -157,7 +133,7 @@ describe("worktree sessions group under their repository (#99)", () => {
|
||||
});
|
||||
|
||||
it("leaves a session without a worktree where it is", () => {
|
||||
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
|
||||
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n") as TreeFolder;
|
||||
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-6
@@ -25,19 +25,14 @@ export function compareNodes(a: TreeNode, b: TreeNode): number {
|
||||
return an.localeCompare(bn);
|
||||
}
|
||||
|
||||
export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
|
||||
export function buildTree(sessions: Session[], home: string | undefined): TreeNode {
|
||||
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
|
||||
const lc = filter.trim().toLowerCase();
|
||||
|
||||
for (const s of sessions) {
|
||||
// Grouped by repository, so a worktree session lands under its repo
|
||||
// rather than in a folder of its own (#99).
|
||||
const cwd = sessionTreePath(s);
|
||||
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
||||
if (lc) {
|
||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
||||
if (!haystack.includes(lc)) continue;
|
||||
}
|
||||
const parts = dir ? dir.split("/").filter(Boolean) : [];
|
||||
let cursor = root;
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -98,6 +98,10 @@ export interface DaemonInfo {
|
||||
* value across a reconnect means the daemon restarted. Absent on older
|
||||
* daemons, which the restart flow treats as "cannot tell". */
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user