Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4851ad9101 | ||
|
|
f0c272e0b1 | ||
|
|
624f6f19c8 | ||
|
|
08161ae042 | ||
|
|
1e3c7b294d | ||
|
|
49ab7e9c81 | ||
|
|
0cc25d7e1c | ||
|
|
706942ddd7 | ||
|
|
52877abe29 | ||
|
|
43fc3a3735 | ||
|
|
ff1ffb377c | ||
|
|
ba74d3515b |
@@ -96,20 +96,32 @@ pub async fn dispatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `maestro hooks install` (#106): write the shipped hook script under the
|
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||||
/// user's data dir and register it in their Claude Code settings. Idempotent
|
/// user's data dir and register it in every agent's hook file. Idempotent and
|
||||||
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
/// 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)]
|
#[allow(clippy::result_large_err)]
|
||||||
fn run_hooks_install() -> Result<(), CliError> {
|
fn run_hooks_install() -> Result<(), CliError> {
|
||||||
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||||
let changed = maestro_hooks::install_for_home(&home)?;
|
let script = maestro_hooks::script_path(&home);
|
||||||
if changed {
|
for (target, result) in maestro_hooks::install_for_home(&home) {
|
||||||
println!(
|
let agent = target.label();
|
||||||
"installed claude-code activity hook: {} → {}",
|
let settings = target.settings_path(&home);
|
||||||
maestro_hooks::script_path(&home).display(),
|
match result {
|
||||||
maestro_hooks::settings_path(&home).display()
|
Ok(true) => {
|
||||||
);
|
println!(
|
||||||
} else {
|
"installed {agent} activity hook: {} → {}",
|
||||||
println!("claude-code activity hook already up to date");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||||
// session spawned right away should already carry the hook.
|
// session spawned right away should already carry the hook.
|
||||||
if config.hooks_auto_install {
|
if config.hooks_auto_install {
|
||||||
match crate::hooks::auto_install(&config.home_dir) {
|
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
|
||||||
crate::hooks::AutoInstallOutcome::Installed => {
|
let agent = target.label();
|
||||||
tracing::info!("installed claude-code activity hook (#106)");
|
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
|
// No worktree dir to pre-create: without an override the worktrees live
|
||||||
|
|||||||
@@ -1,37 +1,53 @@
|
|||||||
//! First-start hook auto-install (#106).
|
//! First-start hook auto-install (#106).
|
||||||
//!
|
//!
|
||||||
//! The daemon runs [`auto_install`] once at startup: unless the user's
|
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
|
||||||
//! `~/.claude/settings.json` already carries exactly what this build ships
|
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
|
||||||
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
|
//! file already carries exactly what this build ships (and `[hooks]
|
||||||
//! same installer `maestro hooks install` uses writes it. That covers
|
//! .auto_install` hasn't opted out — checked by the caller), the same
|
||||||
//! upgrades as well as first starts: an install from a maestro that had fewer
|
//! installer `maestro hooks install` uses writes it. That covers upgrades as
|
||||||
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
|
//! well as first starts: an install from a maestro that had fewer events —
|
||||||
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
|
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
|
||||||
//! a failure is warned about and never blocks startup. The same global hook
|
//! settings — is rewritten rather than left half-wired. Best-effort: a
|
||||||
//! serves every Claude Code session on the machine, so nothing here targets
|
//! failure is warned about and never blocks startup, and one target failing
|
||||||
//! repositories or worktrees.
|
//! 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;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Install the Claude Code activity hook for `home_dir` unless the current
|
use maestro_hooks::Target;
|
||||||
/// one is already there or the home dir is unresolvable (`""`). Returns what
|
|
||||||
/// happened; an older install being brought up to date is
|
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
|
||||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
|
/// any whose install is already current. Returns what happened per target —
|
||||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
/// 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() {
|
if home_dir.is_empty() {
|
||||||
return AutoInstallOutcome::Skipped;
|
return AutoInstallOutcome::Skipped;
|
||||||
}
|
}
|
||||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
let home = Path::new(home_dir);
|
||||||
let script = maestro_hooks::script_path(Path::new(home_dir));
|
let settings = target.settings_path(home);
|
||||||
if maestro_hooks::is_current(&settings, &script) {
|
let script = maestro_hooks::script_path(home);
|
||||||
|
if maestro_hooks::is_current(target, &settings, &script) {
|
||||||
return AutoInstallOutcome::AlreadyPresent;
|
return AutoInstallOutcome::AlreadyPresent;
|
||||||
}
|
}
|
||||||
let stale = maestro_hooks::is_installed(&settings);
|
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(_) if stale => AutoInstallOutcome::Upgraded,
|
||||||
Ok(_) => AutoInstallOutcome::Installed,
|
Ok(_) => AutoInstallOutcome::Installed,
|
||||||
Err(e) => {
|
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
|
AutoInstallOutcome::Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,15 +68,50 @@ pub enum AutoInstallOutcome {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// 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]
|
#[test]
|
||||||
fn installs_when_settings_carry_no_maestro_hook() {
|
fn installs_when_settings_carry_no_maestro_hook() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let outcome = auto_install(dir.path().to_str().unwrap());
|
let outcomes = auto_install(dir.path().to_str().unwrap());
|
||||||
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
for target in Target::ALL {
|
||||||
let settings = dir.path().join(".claude/settings.json");
|
assert_eq!(
|
||||||
assert!(maestro_hooks::is_installed(&settings));
|
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]
|
#[test]
|
||||||
@@ -69,14 +120,22 @@ mod tests {
|
|||||||
let home = dir.path().to_str().unwrap();
|
let home = dir.path().to_str().unwrap();
|
||||||
auto_install(home);
|
auto_install(home);
|
||||||
// A current install is left exactly as it is.
|
// A current install is left exactly as it is.
|
||||||
let settings = dir.path().join(".claude/settings.json");
|
let before: Vec<String> = Target::ALL
|
||||||
let before = std::fs::read_to_string(&settings).unwrap();
|
.iter()
|
||||||
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
|
||||||
assert_eq!(
|
.collect();
|
||||||
before,
|
let outcomes = auto_install(home);
|
||||||
std::fs::read_to_string(&settings).unwrap(),
|
for (i, target) in Target::ALL.iter().enumerate() {
|
||||||
"already-installed settings must not be rewritten"
|
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
|
/// An install from an older maestro — present, but missing an event this
|
||||||
@@ -87,7 +146,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path().to_str().unwrap();
|
let home = dir.path().to_str().unwrap();
|
||||||
auto_install(home);
|
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 script = maestro_hooks::script_path(dir.path());
|
||||||
|
|
||||||
let mut root: serde_json::Value =
|
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(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
|
||||||
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
||||||
|
|
||||||
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
|
let outcomes = auto_install(home);
|
||||||
assert!(maestro_hooks::is_current(&settings, &script));
|
assert_eq!(
|
||||||
|
outcome(&outcomes, Target::ClaudeCode),
|
||||||
|
AutoInstallOutcome::Upgraded
|
||||||
|
);
|
||||||
|
assert!(maestro_hooks::is_current(
|
||||||
|
Target::ClaudeCode,
|
||||||
|
&settings,
|
||||||
|
&script
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unresolvable_home_is_skipped() {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,16 +111,33 @@ impl WorktreeManager {
|
|||||||
// Clear registrations whose directory is gone first, so a
|
// Clear registrations whose directory is gone first, so a
|
||||||
// previously-leaked entry doesn't push this branch to a suffix.
|
// previously-leaked entry doesn't push this branch to a suffix.
|
||||||
prune(base_repo)?;
|
prune(base_repo)?;
|
||||||
// Anything occupying the target — a tree we left behind, one the
|
let (branch, path) = if create_branch {
|
||||||
// operator made, an unrelated directory — is sidestepped with a
|
// Worktrees are commonly abandoned without deleting their branch,
|
||||||
// numeric suffix on the directory; the branch stays whatever was
|
// so a random handle can collide with one of its own past runs.
|
||||||
// requested (#77). The daemon never adopts what it finds.
|
// Unlike the `add_existing` arm below, the suffix goes on the
|
||||||
let mut path = self.target_path(base_repo, branch);
|
// branch name itself — the path is derived from it anyway.
|
||||||
let mut n = 1;
|
let mut candidate = branch.to_string();
|
||||||
while path.symlink_metadata().is_ok() {
|
let mut path = self.target_path(base_repo, &candidate);
|
||||||
n += 1;
|
let mut n = 1;
|
||||||
path = self.target_path_suffixed(base_repo, branch, n);
|
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
|
||||||
}
|
n += 1;
|
||||||
|
candidate = format!("{branch}-{n}");
|
||||||
|
path = self.target_path(base_repo, &candidate);
|
||||||
|
}
|
||||||
|
(candidate, path)
|
||||||
|
} else {
|
||||||
|
// Existing branch: the name must stay exact, so only the
|
||||||
|
// directory — anything occupying it, ours or the operator's — is
|
||||||
|
// sidestepped with a numeric suffix (#77). The daemon never
|
||||||
|
// adopts what it finds.
|
||||||
|
let mut path = self.target_path(base_repo, branch);
|
||||||
|
let mut n = 1;
|
||||||
|
while path.symlink_metadata().is_ok() {
|
||||||
|
n += 1;
|
||||||
|
path = self.target_path_suffixed(base_repo, branch, n);
|
||||||
|
}
|
||||||
|
(branch.to_string(), path)
|
||||||
|
};
|
||||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||||
// yet; the target itself must not.
|
// yet; the target itself must not.
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -129,10 +146,10 @@ impl WorktreeManager {
|
|||||||
let mut cmd = Command::new("git");
|
let mut cmd = Command::new("git");
|
||||||
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
||||||
if create_branch {
|
if create_branch {
|
||||||
cmd.arg("-b").arg(branch).arg(&path);
|
cmd.arg("-b").arg(&branch).arg(&path);
|
||||||
} else {
|
} else {
|
||||||
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
||||||
cmd.arg(&path).arg(branch);
|
cmd.arg(&path).arg(&branch);
|
||||||
}
|
}
|
||||||
let out = cmd.output()?;
|
let out = cmd.output()?;
|
||||||
check(out)?;
|
check(out)?;
|
||||||
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
|||||||
Some(candidate.to_path_buf())
|
Some(candidate.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
|
||||||
|
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(base_repo)
|
||||||
|
.args(["show-ref", "--verify", "--quiet"])
|
||||||
|
.arg(format!("refs/heads/{branch}"))
|
||||||
|
.status()
|
||||||
|
.is_ok_and(|s| s.success())
|
||||||
|
}
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||||
/// no longer exists.
|
/// no longer exists.
|
||||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||||
|
|||||||
@@ -497,6 +497,57 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A random handle can reuse the name of a branch left behind by an earlier,
|
||||||
|
/// already-removed worktree — `git worktree add -b` refuses outright in that
|
||||||
|
/// case, even though the directory is free. The daemon suffixes the branch
|
||||||
|
/// itself (not just the directory) and both land on `-2`.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn colliding_branch_name_gets_suffixed_too() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
|
||||||
|
// No worktree directory in the way, but the branch already exists.
|
||||||
|
run_git(&["branch", "handle"], base.path());
|
||||||
|
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "handle", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn onto a taken branch name");
|
||||||
|
|
||||||
|
let suffixed = base.path().join("worktrees").join("handle-2");
|
||||||
|
assert_eq!(
|
||||||
|
s.cwd, suffixed,
|
||||||
|
"the directory must follow the suffixed branch name"
|
||||||
|
);
|
||||||
|
assert!(suffixed.is_dir());
|
||||||
|
|
||||||
|
let registrations = git_worktree_list(base.path());
|
||||||
|
assert!(
|
||||||
|
registrations.iter().any(|l| l.contains("handle-2")),
|
||||||
|
"the -2 branch's worktree must be registered: {registrations:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let branches = String::from_utf8(
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(base.path())
|
||||||
|
.args(["branch", "--list"])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
branches.contains("handle-2"),
|
||||||
|
"the branch itself must be named handle-2: {branches}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
/// #77/#88: the target path holds a plain directory the daemon never made and
|
/// #77/#88: the target path holds a plain directory the daemon never made and
|
||||||
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
||||||
/// beside it and leaves every byte of it alone.
|
/// beside it and leaves every byte of it alone.
|
||||||
|
|||||||
+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
|
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
|
||||||
//! the daemon's first-start auto-install:
|
//! the daemon's first-start auto-install:
|
||||||
@@ -6,28 +6,40 @@
|
|||||||
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
||||||
//! posts an [`ActivityState`](maestro-protocol) to
|
//! posts an [`ActivityState`](maestro-protocol) to
|
||||||
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
|
//! `$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
|
//! 0 and silently when either #103 env var is unset, and it never lets a
|
||||||
//! daemon failure escape (short curl timeout, status ignored).
|
//! daemon failure escape (short curl timeout, status ignored).
|
||||||
//! * [`install`] — idempotent registration of that script in
|
//! * [`install`] — idempotent registration of that script in one [`Target`]'s
|
||||||
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
|
//! hook file. Only entries maestro itself wrote (identified by
|
||||||
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
|
//! [`HOOK_MARKER`]) are touched; every other hook and every other field in
|
||||||
//! in the file is preserved verbatim.
|
//! 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;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Marker substring every maestro-installed hook command carries. The
|
/// Marker substring every maestro-installed hook command carries. The
|
||||||
/// installer rewrites or removes exactly these entries — nothing else in the
|
/// installer rewrites or removes exactly these entries — nothing else in the
|
||||||
/// user's settings file.
|
/// 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";
|
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";
|
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||||
|
|
||||||
/// Hook events installed only with the `haiku-autoname` feature (#131).
|
/// Hook events installed only with the `haiku-autoname` feature (#131), for
|
||||||
/// `UserPromptSubmit` carries the prompt the user just submitted; the script
|
/// every [`Target`] — Claude Code and Codex both spell the event
|
||||||
/// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
|
/// `UserPromptSubmit` and both put the submitted text in the payload's
|
||||||
/// still-provisionally-named session after its first one.
|
/// `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")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")];
|
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
|
/// 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
|
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||||
/// `Error`.
|
/// `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 starting: the agent is demonstrably producing work, even if the
|
||||||
// tool itself runs for minutes.
|
// tool itself runs for minutes.
|
||||||
("PreToolUse", "Active"),
|
("PreToolUse", "Active"),
|
||||||
@@ -58,17 +70,86 @@ pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
|||||||
("StopFailure", "Error"),
|
("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.
|
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||||
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||||
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
# Installed by maestro (#106): report agent activity to its daemon.
|
||||||
# Exits silently unless running inside a session maestro spawned (#103 env).
|
# 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:
|
# Event -> ActivityState mapping:
|
||||||
# PreToolUse -> Active (tool started; agent is working)
|
# PreToolUse -> Active (tool started; agent is working)
|
||||||
# PostToolUse -> Active (tool finished)
|
# PostToolUse -> Active (tool finished)
|
||||||
# Notification -> AwaitingInput (permission / idle notice)
|
# Notification -> AwaitingInput (claude code: permission / idle)
|
||||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
# PermissionRequest -> AwaitingInput (codex: waiting on an approval)
|
||||||
# StopFailure -> Error (turn ended on an API error)
|
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||||
# Prompt -> the payload is forwarded to the prompt route (#131)
|
# StopFailure -> Error (claude code: turn ended on an error)
|
||||||
|
# Prompt -> the payload is forwarded to the prompt route (#131)
|
||||||
state="$1"
|
state="$1"
|
||||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||||
[ -n "$MAESTRO_URL" ] || 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)
|
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user-level Claude Code settings file for a given home directory.
|
/// Whether the file at `settings_path` already carries a maestro hook
|
||||||
pub fn settings_path(home: &Path) -> std::path::PathBuf {
|
/// (#106). Presence only: an install from an older release answers `true`
|
||||||
home.join(".claude/settings.json")
|
/// even when it is missing events this build ships — see [`is_current`].
|
||||||
}
|
|
||||||
|
|
||||||
/// 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`].
|
|
||||||
pub fn is_installed(settings_path: &Path) -> bool {
|
pub fn is_installed(settings_path: &Path) -> bool {
|
||||||
settings_json(settings_path)
|
settings_json(settings_path)
|
||||||
.map(|root| find_maestro_commands(&root).next().is_some())
|
.map(|root| find_maestro_commands(&root).next().is_some())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether what is on disk is what this build ships: the script at
|
/// Whether what is on disk is what this build ships for `target`: the script
|
||||||
/// `script_path` byte-for-byte, and exactly one settings entry per event in
|
/// at `script_path` byte-for-byte, and exactly one settings entry per one of
|
||||||
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
|
/// the target's [`Target::events`], pointing at that script.
|
||||||
///
|
///
|
||||||
/// This is the upgrade question. A maestro that gains an event — #131 added
|
/// This is the upgrade question, asked per [`Target`]. A maestro that gains
|
||||||
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
|
/// an event — #131 added `UserPromptSubmit` long after #106 had installed
|
||||||
/// existing user with a present but incomplete install, which
|
/// itself — leaves every existing user with a present but incomplete
|
||||||
/// [`is_installed`] cannot tell apart from a current one.
|
/// install, which [`is_installed`] cannot tell apart from a current one.
|
||||||
pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
|
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) {
|
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
settings_json(settings_path)
|
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)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +249,13 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`install`] writes for `script_path`, in the same shape.
|
/// What [`install`] writes for `target` and `script_path`, in the same shape.
|
||||||
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
|
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> {
|
||||||
let mut want: Vec<(String, String)> = HOOK_EVENTS
|
let mut want: Vec<(String, String)> = target
|
||||||
.iter()
|
.events()
|
||||||
.chain(PROMPT_EVENTS.iter())
|
|
||||||
.map(|(event, state)| {
|
.map(|(event, state)| {
|
||||||
(
|
(
|
||||||
(*event).to_string(),
|
event.to_string(),
|
||||||
format!("{} {state}", script_path.display()),
|
format!("{} {state}", script_path.display()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -207,28 +282,40 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the full hook setup for `home`: write the script under
|
/// Install the full hook setup for one target under `home`: write the script
|
||||||
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
|
/// under `.local/share/maestro/` and register it in the target's hook file.
|
||||||
/// Idempotent; returns whether anything changed on disk.
|
/// 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 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
|
// A script left over from an older release counts as a change even when
|
||||||
// the settings entries pointing at it are already right.
|
// 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)?;
|
write_script(&script)?;
|
||||||
install(&settings, &script)?;
|
install(target, &settings, &script)?;
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
|
/// Install for every [`Target::ALL`], reporting each one separately. One
|
||||||
/// settings file at `settings_path`. Idempotent: existing maestro entries are
|
/// target failing — an unwritable file, unparseable JSON — never stops the
|
||||||
/// updated in place wherever they sit, stale ones removed, duplicates folded;
|
/// others: an agent whose hook could not be written is one agent without
|
||||||
/// every non-maestro entry and every unrelated top-level field survives.
|
/// 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
|
/// Never inspects or writes anywhere but `settings_path` — callers pass the
|
||||||
/// user's real home or a temp one in tests, never a repository.
|
/// 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)?;
|
let mut root = settings_json(settings_path)?;
|
||||||
|
|
||||||
// Strip every maestro entry wherever it currently sits, so an older
|
// 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);
|
strip_maestro_entries(&mut root);
|
||||||
|
|
||||||
let hooks = ensure_object(&mut root, "hooks");
|
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 groups = ensure_array(hooks, event);
|
||||||
let mut group = serde_json::Map::new();
|
let mut group = serde_json::Map::new();
|
||||||
group.insert(
|
group.insert(
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
||||||
//! and the settings-file installer. Everything happens under a temp `$HOME`;
|
//! 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::io::{Read, Write};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// How many commands one install writes: every activity event, plus the
|
/// How many commands one install writes for a target: its activity events,
|
||||||
/// prompt event when `haiku-autoname` is on (#131).
|
/// plus the prompt event when `haiku-autoname` is on (#131).
|
||||||
const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
|
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 {
|
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||||
let mut cmd = Command::new("/bin/sh");
|
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.
|
/// exit 0, no output, stdin drained.
|
||||||
#[test]
|
#[test]
|
||||||
fn script_exits_silently_without_maestro_env() {
|
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"}"#);
|
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||||
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
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.
|
/// Every event maps to the state the contract specifies.
|
||||||
#[test]
|
#[test]
|
||||||
fn event_state_mapping_matches_the_contract() {
|
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["PreToolUse"], "Active");
|
||||||
assert_eq!(map["PostToolUse"], "Active");
|
assert_eq!(map["PostToolUse"], "Active");
|
||||||
assert_eq!(map["Notification"], "AwaitingInput");
|
assert_eq!(map["Notification"], "AwaitingInput");
|
||||||
@@ -203,10 +212,10 @@ fn fresh_install_registers_every_event() {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
write_script(&script_path(home)).unwrap();
|
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));
|
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");
|
let joined = cmds.join("\n");
|
||||||
assert_eq!(joined.matches("Active").count(), 2);
|
assert_eq!(joined.matches("Active").count(), 2);
|
||||||
assert!(joined.contains("Error"));
|
assert!(joined.contains("Error"));
|
||||||
@@ -221,15 +230,15 @@ fn fresh_install_registers_every_event() {
|
|||||||
fn install_is_idempotent() {
|
fn install_is_idempotent() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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();
|
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();
|
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||||
assert_eq!(once, twice);
|
assert_eq!(once, twice);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
installed_commands(&settings_path(home)).len(),
|
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()
|
.path()
|
||||||
.join(".local/share/maestro")
|
.join(".local/share/maestro")
|
||||||
.join(SCRIPT_FILE_NAME);
|
.join(SCRIPT_FILE_NAME);
|
||||||
install(&settings, &new_script).unwrap();
|
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||||
|
|
||||||
let cmds = installed_commands(&settings);
|
let cmds = installed_commands(&settings);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cmds.len(),
|
cmds.len(),
|
||||||
INSTALLED_EVENTS,
|
installed_events(Target::ClaudeCode),
|
||||||
"old entry replaced, not kept: {cmds:?}"
|
"old entry replaced, not kept: {cmds:?}"
|
||||||
);
|
);
|
||||||
assert!(!cmds.iter().any(|c| c == old_script));
|
assert!(!cmds.iter().any(|c| c == old_script));
|
||||||
@@ -298,7 +307,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
|||||||
.path()
|
.path()
|
||||||
.join(".local/share/maestro")
|
.join(".local/share/maestro")
|
||||||
.join(SCRIPT_FILE_NAME);
|
.join(SCRIPT_FILE_NAME);
|
||||||
install(&settings, &new_script).unwrap();
|
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||||
|
|
||||||
let root: serde_json::Value =
|
let root: serde_json::Value =
|
||||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
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);
|
let cmds = installed_commands(&settings);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||||
INSTALLED_EVENTS,
|
installed_events(Target::ClaudeCode),
|
||||||
"{cmds:?}"
|
"{cmds:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -321,9 +330,15 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
|||||||
fn is_installed_tracks_reality() {
|
fn is_installed_tracks_reality() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
assert!(!is_installed(&settings_path(home)));
|
for target in Target::ALL {
|
||||||
install_for_home(home).unwrap();
|
assert!(!is_installed(&target.settings_path(home)));
|
||||||
assert!(is_installed(&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.
|
/// 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() {
|
fn install_for_home_writes_an_executable_script() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
install_for_home(home).unwrap();
|
for (_, result) in install_for_home(home) {
|
||||||
|
result.unwrap();
|
||||||
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -350,7 +367,7 @@ fn install_for_home_writes_an_executable_script() {
|
|||||||
fn install_registers_the_prompt_event() {
|
fn install_registers_the_prompt_event() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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 =
|
let root: serde_json::Value =
|
||||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
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() {
|
fn install_registers_no_prompt_event_without_the_feature() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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 =
|
let root: serde_json::Value =
|
||||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
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() {
|
fn is_current_rejects_an_install_missing_an_event() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
install_for_home(home).unwrap();
|
install_target(Target::ClaudeCode, home).unwrap();
|
||||||
assert!(is_current(&settings_path(home), &script_path(home)));
|
assert!(is_current(
|
||||||
|
Target::ClaudeCode,
|
||||||
|
&settings_path(home),
|
||||||
|
&script_path(home)
|
||||||
|
));
|
||||||
|
|
||||||
// Drop one event, as an older install would have left the file.
|
// Drop one event, as an older install would have left the file.
|
||||||
let mut root: serde_json::Value =
|
let mut root: serde_json::Value =
|
||||||
@@ -450,7 +471,11 @@ fn is_current_rejects_an_install_missing_an_event() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(is_installed(&settings_path(home)));
|
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
|
/// 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() {
|
fn is_current_rejects_a_stale_script() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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();
|
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.
|
/// 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() {
|
fn is_current_rejects_a_missing_install() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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.
|
/// settings entries pointing at it already read correctly.
|
||||||
#[test]
|
#[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 dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
assert!(install_for_home(home).unwrap());
|
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
||||||
assert!(!install_for_home(home).unwrap());
|
assert!(!install_target(Target::ClaudeCode, home).unwrap());
|
||||||
|
|
||||||
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
|||||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||||
| A throwaway tab | Dashed border, italic label. |
|
| A throwaway tab | Dashed border, italic label. |
|
||||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||||
|
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
|
||||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||||
|
|
||||||
Rows say it with fill, not with an edge — see the tier ladder in
|
Rows say it with fill, not with an edge — see the tier ladder in
|
||||||
|
|||||||
+8
-3
@@ -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
|
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
|
the agent itself reported. Re-reporting `Error` extends the TTL rather than
|
||||||
stacking reports.
|
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
|
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)
|
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
|
||||||
@@ -303,9 +307,10 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
|
|||||||
reachable by clicking a folder header — still steps to its neighbour rather
|
reachable by clicking a folder header — still steps to its neighbour rather
|
||||||
than jumping to the end of the list.
|
than jumping to the end of the list.
|
||||||
|
|
||||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
|
||||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
by default — and the same chord back): an attached terminal owns every plain
|
||||||
`ctrl-w h`.
|
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
|
||||||
|
uses `ctrl-w h`.
|
||||||
|
|
||||||
Everything else is the same key on both clients:
|
Everything else is the same key on both clients:
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -35,10 +35,18 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
|
|||||||
back to a short hash of the branch.
|
back to a short hash of the branch.
|
||||||
|
|
||||||
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
||||||
a free one. The suffix names the **directory only**. The branch is whatever was
|
a free one.
|
||||||
requested; a second worktree of the same branch is not what happens here, because
|
|
||||||
git refuses to check the same branch out twice and the branch picker already
|
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
|
||||||
marks branches that are checked out elsewhere.
|
itself, and the directory follows since it is derived from the branch — a
|
||||||
|
random handle can otherwise reuse the name of a branch some earlier, already
|
||||||
|
removed worktree left behind, and `git worktree add -b` refuses outright even
|
||||||
|
though the directory is free.
|
||||||
|
|
||||||
|
For an *existing* branch (`create_branch: false`) the name must stay exact, so
|
||||||
|
only the directory is suffixed — a second worktree of the same branch is not
|
||||||
|
what happens here, because git refuses to check the same branch out twice and
|
||||||
|
the branch picker already marks branches that are checked out elsewhere.
|
||||||
|
|
||||||
### The `worktree_root` override
|
### The `worktree_root` override
|
||||||
|
|
||||||
|
|||||||
+23
-22
@@ -4,8 +4,6 @@ import { Terminal } from "@xterm/xterm";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { attachWsUrl } from "./routes.js";
|
import { attachWsUrl } from "./routes.js";
|
||||||
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
|
||||||
import { CommandSet, defineCommands } from "./commands.js";
|
|
||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||||
@@ -14,15 +12,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
|||||||
import type { ClientControl, Uuid } from "./types.js";
|
import type { ClientControl, Uuid } from "./types.js";
|
||||||
import { WebglPolicy } from "./webglRenderer.js";
|
import { WebglPolicy } from "./webglRenderer.js";
|
||||||
|
|
||||||
// The pane's half of the command registry (#148): plain Escape hands the
|
|
||||||
// keyboard back to the session list, the pair to the list's Tab.
|
|
||||||
const paneKeys = new CommandSet(
|
|
||||||
defineCommands({
|
|
||||||
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
|
|
||||||
}),
|
|
||||||
{ preventDefault: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||||
* rationed rather than handed to every connection. */
|
* rationed rather than handed to every connection. */
|
||||||
const webglPolicy = new WebglPolicy();
|
const webglPolicy = new WebglPolicy();
|
||||||
@@ -221,15 +210,6 @@ export function createAttachConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain Escape hands the keyboard back to the session list instead of the
|
|
||||||
// shell/app. `false` tells xterm to skip its own handling of this event —
|
|
||||||
// which is also why the set doesn't `preventDefault`: swallowing here is
|
|
||||||
// xterm's return value, not the browser's.
|
|
||||||
term.attachCustomKeyEventHandler((e) => {
|
|
||||||
if (e.type !== "keydown") return true;
|
|
||||||
return !paneKeys.handle(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||||||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||||
// • normal buffer → term.scrollLines walks the scrollback.
|
// • normal buffer → term.scrollLines walks the scrollback.
|
||||||
@@ -379,6 +359,9 @@ export function createAttachConnection(
|
|||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
let reconnectTimer: number | null = null;
|
let reconnectTimer: number | null = null;
|
||||||
|
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
|
||||||
|
// Follow it once per socket; later scrolling remains under the operator's control.
|
||||||
|
let awaitingReplay = true;
|
||||||
|
|
||||||
const sendControl = (msg: ClientControl) => {
|
const sendControl = (msg: ClientControl) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
@@ -398,6 +381,7 @@ export function createAttachConnection(
|
|||||||
};
|
};
|
||||||
|
|
||||||
function connect(): void {
|
function connect(): void {
|
||||||
|
awaitingReplay = true;
|
||||||
ws = new WebSocket(attachWsUrl(sessionId));
|
ws = new WebSocket(attachWsUrl(sessionId));
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
@@ -418,10 +402,20 @@ export function createAttachConnection(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
term.write(new Uint8Array(ev.data));
|
const replay = awaitingReplay;
|
||||||
|
awaitingReplay = false;
|
||||||
|
term.write(new Uint8Array(ev.data), () => {
|
||||||
|
if (replay) term.scrollToBottom();
|
||||||
|
});
|
||||||
} else if (ev.data instanceof Blob) {
|
} else if (ev.data instanceof Blob) {
|
||||||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
||||||
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
|
void ev.data.arrayBuffer().then((b) => {
|
||||||
|
const replay = awaitingReplay;
|
||||||
|
awaitingReplay = false;
|
||||||
|
term.write(new Uint8Array(b), () => {
|
||||||
|
if (replay) term.scrollToBottom();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -470,6 +464,12 @@ export function createAttachConnection(
|
|||||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||||
sendControl({ type: "Resize", cols, rows });
|
sendControl({ type: "Resize", cols, rows });
|
||||||
});
|
});
|
||||||
|
// xterm has no built-in copy-on-select; mirror the selection to the
|
||||||
|
// clipboard ourselves (best-effort — insecure contexts lack the API).
|
||||||
|
const selectionDisp = term.onSelectionChange(() => {
|
||||||
|
const text = term.getSelection();
|
||||||
|
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", onResize);
|
||||||
const visual = window.visualViewport ?? null;
|
const visual = window.visualViewport ?? null;
|
||||||
@@ -525,6 +525,7 @@ export function createAttachConnection(
|
|||||||
visual?.removeEventListener("resize", onResize);
|
visual?.removeEventListener("resize", onResize);
|
||||||
dataDisp.dispose();
|
dataDisp.dispose();
|
||||||
resizeDisp.dispose();
|
resizeDisp.dispose();
|
||||||
|
selectionDisp.dispose();
|
||||||
releaseWebgl();
|
releaseWebgl();
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ export function requestOpenCommandBar(): void {
|
|||||||
* connection pool. */
|
* connection pool. */
|
||||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||||
|
|
||||||
/** Ask the shell to hand the keyboard back to the session list — what Escape
|
|
||||||
* does when the pane's terminal is focused. Handled in main.ts. */
|
|
||||||
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
|
||||||
|
|
||||||
/** Ask the shell to open the spawn modal seeded from an existing session
|
/** Ask the shell to open the spawn modal seeded from an existing session
|
||||||
* ("Spawn similar"). Detail is the session being cloned. */
|
* ("Spawn similar"). Detail is the session being cloned. */
|
||||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||||
|
|||||||
+12
-14
@@ -133,31 +133,28 @@ describe("CommandSet", () => {
|
|||||||
expect(run).toHaveBeenCalledTimes(2);
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
|
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
|
||||||
const plain = vi.fn();
|
const plain = vi.fn();
|
||||||
const chorded = vi.fn();
|
const chorded = vi.fn();
|
||||||
const set = new CommandSet(
|
const set = new CommandSet(
|
||||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
|
||||||
|
{ leader: DEFAULT_LEADER },
|
||||||
);
|
);
|
||||||
const input = el("<input>");
|
const input = el("<input>");
|
||||||
expect(pressOn(set, input, "n")).toBe(false);
|
expect(pressOn(set, input, "n")).toBe(false);
|
||||||
expect(plain).not.toHaveBeenCalled();
|
expect(plain).not.toHaveBeenCalled();
|
||||||
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
|
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
|
||||||
|
// left for the pane's shell/app instead.
|
||||||
|
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
pressOn(set, terminal, "a", { ctrlKey: true });
|
||||||
|
expect(pressOn(set, terminal, "h")).toBe(true);
|
||||||
expect(chorded).toHaveBeenCalledTimes(1);
|
expect(chorded).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps pane commands live inside the terminal's textarea", () => {
|
it("leaves a plain Escape for the pane instead of binding it", () => {
|
||||||
const run = vi.fn();
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
||||||
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
|
||||||
preventDefault: false,
|
|
||||||
});
|
|
||||||
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
const e = key("Escape");
|
expect(pressOn(set, textarea, "Escape")).toBe(false);
|
||||||
Object.defineProperty(e, "target", { value: textarea });
|
|
||||||
expect(set.handle(e)).toBe(true);
|
|
||||||
expect(run).toHaveBeenCalledTimes(1);
|
|
||||||
// xterm is told to swallow by the return value, not by the browser default.
|
|
||||||
expect(e.defaultPrevented).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +170,7 @@ describe("the leader", () => {
|
|||||||
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
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.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
||||||
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
||||||
|
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
|
||||||
// Commands that do not hang off the leader are untouched.
|
// Commands that do not hang off the leader are untouched.
|
||||||
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-8
@@ -93,7 +93,8 @@ const SPECS: readonly SpecSource[] = [
|
|||||||
id: "focus.toggle",
|
id: "focus.toggle",
|
||||||
label: "Move the keyboard between the session list and the pane",
|
label: "Move the keyboard between the session list and the pane",
|
||||||
group: "Session list",
|
group: "Session list",
|
||||||
keys: ["ctrl-shift-h"],
|
keys: [`${DEFAULT_LEADER} h`],
|
||||||
|
leaderKeys: (leader) => [`${leader} h`],
|
||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -145,13 +146,6 @@ const SPECS: readonly SpecSource[] = [
|
|||||||
keys: ["tab"],
|
keys: ["tab"],
|
||||||
scope: "list",
|
scope: "list",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "pane.focusList",
|
|
||||||
label: "Hand the keyboard back to the session list, from the pane",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["esc"],
|
|
||||||
scope: "pane",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "help.toggle",
|
id: "help.toggle",
|
||||||
label: "Show or hide this help",
|
label: "Show or hide this help",
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ describe("resolveFastForward (#67)", () => {
|
|||||||
const res = resolveFastForward(projects, presets, "amber-falcon");
|
const res = resolveFastForward(projects, presets, "amber-falcon");
|
||||||
expect(res.project?.path).toBe("/repos/fresh");
|
expect(res.project?.path).toBe("/repos/fresh");
|
||||||
expect(res.command).toBe("aider");
|
expect(res.command).toBe("aider");
|
||||||
expect(res.name).toBe("amber-falcon");
|
expect(res.handle).toBe("amber-falcon");
|
||||||
|
|
||||||
const req = buildFastForwardRequest(res)!;
|
const req = buildFastForwardRequest(res)!;
|
||||||
expect(req.cwd).toBe("/repos/fresh");
|
expect(req.cwd).toBe("/repos/fresh");
|
||||||
expect(req.command).toBe("aider");
|
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({
|
expect(req.worktree).toEqual({
|
||||||
base_repo: "/repos/fresh",
|
base_repo: "/repos/fresh",
|
||||||
branch: "amber-falcon",
|
branch: "amber-falcon",
|
||||||
@@ -88,9 +90,9 @@ describe("resolveFastForward (#67)", () => {
|
|||||||
expect(buildFastForwardRequest(res)).toBeNull();
|
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);
|
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
||||||
expect(res.name).toBeUndefined();
|
expect(res.handle).toBeUndefined();
|
||||||
expect(buildFastForwardRequest(res)).toBeNull();
|
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", () => {
|
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,
|
// 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
|
// 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 {
|
export interface FastForwardResolution {
|
||||||
project?: Project;
|
project?: Project;
|
||||||
command?: string;
|
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
|
/** 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
|
* known project, else the most recent one (fresh-first, same ordering the
|
||||||
* picker shows) — so `N` lands where the operator currently is, matching
|
* picker shows) — so `N` lands where the operator currently is, matching
|
||||||
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||||
* falls back to the first non-custom preset, and the name is freshly
|
* falls back to the first non-custom preset, and the branch handle is
|
||||||
* generated when the daemon supplied one. */
|
* freshly generated when the daemon supplied one. */
|
||||||
export function resolveFastForward(
|
export function resolveFastForward(
|
||||||
projects: Project[],
|
projects: Project[],
|
||||||
presets: SpawnPreset[],
|
presets: SpawnPreset[],
|
||||||
name?: string,
|
handle?: string,
|
||||||
focusedCwd?: string,
|
focusedCwd?: string,
|
||||||
): FastForwardResolution {
|
): FastForwardResolution {
|
||||||
const project =
|
const project =
|
||||||
@@ -30,41 +32,45 @@ export function resolveFastForward(
|
|||||||
const lastCommand = project?.last_command?.trim();
|
const lastCommand = project?.last_command?.trim();
|
||||||
const command =
|
const command =
|
||||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
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
|
/** 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(
|
export function buildFastForwardRequest(
|
||||||
res: FastForwardResolution,
|
res: FastForwardResolution,
|
||||||
): CreateSessionRequest | null {
|
): CreateSessionRequest | null {
|
||||||
const { project, command, name } = res;
|
const { project, command, handle } = res;
|
||||||
if (!project || !command?.trim() || !name) return null;
|
if (!project || !command?.trim() || !handle) return null;
|
||||||
return {
|
return {
|
||||||
cwd: project.path,
|
cwd: project.path,
|
||||||
command,
|
command,
|
||||||
metadata: { name },
|
|
||||||
worktree: {
|
worktree: {
|
||||||
base_repo: project.base_repo ?? project.path,
|
base_repo: project.base_repo ?? project.path,
|
||||||
branch: name,
|
branch: handle,
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed for the fallback spawn form: whatever did resolve goes into the
|
/** 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) {
|
export function fastForwardSeed(res: FastForwardResolution) {
|
||||||
return {
|
return {
|
||||||
cwd: res.project?.path ?? "",
|
cwd: res.project?.path ?? "",
|
||||||
command: res.command ?? "",
|
command: res.command ?? "",
|
||||||
name: res.name ?? "",
|
name: "",
|
||||||
// Left undefined rather than false when nothing resolved: the form
|
// Left undefined rather than false when nothing resolved: the form
|
||||||
// defaults the toggle on, and `false` would stick, so the fallback 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
|
// would disagree with both a normally-opened form and the TUI's fast path
|
||||||
// (#67 review).
|
// (#67 review).
|
||||||
worktreeEnabled: res.project ? true : undefined,
|
worktreeEnabled: res.project ? true : undefined,
|
||||||
worktreeBranch:
|
worktreeBranch:
|
||||||
res.project && res.name ? res.name : undefined,
|
res.project && res.handle ? res.handle : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,9 +132,9 @@ function mount(p: KillDialogSpec): HTMLElement {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeydown, true);
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
open = { el, onKeydown };
|
open = { el, onKeydown };
|
||||||
// Cancel takes the focus in both prompts: a kill is never one keystroke
|
// The acting button takes the focus in both prompts: the operator already
|
||||||
// away from whatever asked for the dialog.
|
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
|
||||||
cancel.focus();
|
confirm.focus();
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
emptyLayout,
|
emptyLayout,
|
||||||
|
equalizeRatios,
|
||||||
leaves,
|
leaves,
|
||||||
locateSession,
|
locateSession,
|
||||||
openSessions,
|
openSessions,
|
||||||
@@ -44,6 +45,23 @@ describe("splitLeaf", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("equalizeRatios", () => {
|
||||||
|
it("resets nested splits to equal width, not just the newest one", () => {
|
||||||
|
const tab = tabWithOneLeaf("s1");
|
||||||
|
const first = leaves(tab.root)[0].id;
|
||||||
|
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
|
||||||
|
const second = leaves(tab.root)[1].id;
|
||||||
|
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
|
||||||
|
|
||||||
|
equalizeRatios(tab.root);
|
||||||
|
|
||||||
|
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
|
||||||
|
expect(root.ratio).toBeCloseTo(1 / 3);
|
||||||
|
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
|
||||||
|
expect(inner.ratio).toBeCloseTo(1 / 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("removeLeaf", () => {
|
describe("removeLeaf", () => {
|
||||||
it("collapses the parent split into the surviving sibling", () => {
|
it("collapses the parent split into the surviving sibling", () => {
|
||||||
const tab = tabWithOneLeaf("s1");
|
const tab = tabWithOneLeaf("s1");
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ export function removeLeaf(tab: Tab, leafId: string): void {
|
|||||||
tab.root = tab.root ? rec(tab.root) : null;
|
tab.root = tab.root ? rec(tab.root) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset every split ratio under `node` so all leaves get equal width,
|
||||||
|
* regardless of split direction or nesting order. Returns the leaf count. */
|
||||||
|
export function equalizeRatios(node: Node | null): number {
|
||||||
|
if (!node) return 0;
|
||||||
|
if (node.kind === "leaf") return 1;
|
||||||
|
const na = equalizeRatios(node.a);
|
||||||
|
const nb = equalizeRatios(node.b);
|
||||||
|
node.ratio = na / (na + nb);
|
||||||
|
return na + nb;
|
||||||
|
}
|
||||||
|
|
||||||
export function addTab(layout: Layout): Tab {
|
export function addTab(layout: Layout): Tab {
|
||||||
const tab: Tab = {
|
const tab: Tab = {
|
||||||
id: freshId("t"),
|
id: freshId("t"),
|
||||||
|
|||||||
+11
-15
@@ -35,7 +35,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
|
|||||||
import { createPreviewSchedule } from "./previewSchedule.js";
|
import { createPreviewSchedule } from "./previewSchedule.js";
|
||||||
import {
|
import {
|
||||||
FOCUS_PANE_EVENT,
|
FOCUS_PANE_EVENT,
|
||||||
FOCUS_SIDEBAR_EVENT,
|
|
||||||
OPEN_COMMAND_BAR_EVENT,
|
OPEN_COMMAND_BAR_EVENT,
|
||||||
OPEN_SPAWN_EVENT,
|
OPEN_SPAWN_EVENT,
|
||||||
SESSION_SELECT_EVENT,
|
SESSION_SELECT_EVENT,
|
||||||
@@ -356,14 +355,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestFastForward(): Promise<void> {
|
async function requestFastForward(): Promise<void> {
|
||||||
const [discovered, name] = await Promise.all([
|
const [discovered, handle] = await Promise.all([
|
||||||
fetchProjects().catch(() => [] as Project[]),
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
fetchNames(1)
|
fetchNames(1)
|
||||||
.then((batch) => batch.names[0])
|
.then((batch) => batch.names[0])
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
const presets = await spawnPresets();
|
const presets = await spawnPresets();
|
||||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
|
||||||
const req = buildFastForwardRequest(resolved);
|
const req = buildFastForwardRequest(resolved);
|
||||||
if (req) {
|
if (req) {
|
||||||
try {
|
try {
|
||||||
@@ -390,7 +389,7 @@ async function requestFastForward(): Promise<void> {
|
|||||||
// one outside any repo spawns without a worktree, since there is no repo to cut
|
// 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.
|
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||||
async function requestSpawnInDirectory(path: string): Promise<void> {
|
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||||
const [discovered, name] = await Promise.all([
|
const [discovered, handle] = await Promise.all([
|
||||||
fetchProjects().catch(() => [] as Project[]),
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
fetchNames(1)
|
fetchNames(1)
|
||||||
.then((batch) => batch.names[0])
|
.then((batch) => batch.names[0])
|
||||||
@@ -399,7 +398,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
|
|||||||
const presets = await spawnPresets();
|
const presets = await spawnPresets();
|
||||||
const known = discovered.find((p) => p.path === path);
|
const known = discovered.find((p) => p.path === path);
|
||||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? 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);
|
const req = buildFastForwardRequest(resolved);
|
||||||
if (req) {
|
if (req) {
|
||||||
const inRepo =
|
const inRepo =
|
||||||
@@ -440,10 +439,11 @@ const spawnKeys = new CommandSet(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
// Leader-h moves the keyboard between the session list and the pane, the web
|
||||||
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
|
||||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
|
||||||
// the focus back out of it.
|
// is already claimed by the browser (history), so nothing unmodified — or
|
||||||
|
// modified with a common browser chord — could get the focus back out of it.
|
||||||
//
|
//
|
||||||
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
// 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
|
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
||||||
@@ -528,8 +528,8 @@ window.addEventListener(
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
|
||||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
// above) hands it back the other way.
|
||||||
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
||||||
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
||||||
// being provisional rather than snapping back the moment the list blurs.
|
// being provisional rather than snapping back the moment the list blurs.
|
||||||
@@ -537,10 +537,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
|
|||||||
if (previewed) focusSession(previewed);
|
if (previewed) focusSession(previewed);
|
||||||
pool.focusTerminal();
|
pool.focusTerminal();
|
||||||
});
|
});
|
||||||
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
|
||||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
|
||||||
sidebar.focusList();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Open/focus a session and reflect it in the shell chrome. Shared by the
|
/** Open/focus a session and reflect it in the shell chrome. Shared by the
|
||||||
* hash router and the sidebar's click-driven path (#133), which can't rely
|
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||||
|
|||||||
+21
-3
@@ -10,6 +10,7 @@ import {
|
|||||||
activeTab,
|
activeTab,
|
||||||
addTab,
|
addTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
equalizeRatios,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
freshId,
|
freshId,
|
||||||
type Dir,
|
type Dir,
|
||||||
@@ -215,6 +216,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return fl?.sessionId ?? null;
|
return fl?.sessionId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The session the operator actually picked — what `focusedLeafSession()`
|
||||||
|
* would return with the preview undone. A preview moves the active tab and
|
||||||
|
* its focus, but it is a view: the accent stays on the row the operator
|
||||||
|
* chose until they choose another (docs/PREVIEW.md). */
|
||||||
|
function realFocusedSession(): string | null {
|
||||||
|
const p = preview;
|
||||||
|
if (!p) return focusedLeafSession();
|
||||||
|
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
|
||||||
|
const leafId =
|
||||||
|
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
|
||||||
|
const fl = leafId ? findLeaf(tab.root, leafId) : null;
|
||||||
|
return fl?.sessionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
||||||
* the DOM (cheap path for click-to-focus). */
|
* the DOM (cheap path for click-to-focus). */
|
||||||
function syncFocus(): void {
|
function syncFocus(): void {
|
||||||
@@ -233,7 +248,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (!preview) pool.setFocus(focusedLeafSession());
|
if (!preview) pool.setFocus(focusedLeafSession());
|
||||||
sidebar.setHighlights({
|
sidebar.setHighlights({
|
||||||
open: realOpenSessions(),
|
open: realOpenSessions(),
|
||||||
focused: focusedLeafSession(),
|
focused: realFocusedSession(),
|
||||||
});
|
});
|
||||||
// The row the preview came from wears the same colour, so the sidebar and
|
// The row the preview came from wears the same colour, so the sidebar and
|
||||||
// the pane read as one thing rather than two unrelated highlights.
|
// the pane read as one thing rather than two unrelated highlights.
|
||||||
@@ -443,6 +458,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
dropPreview();
|
dropPreview();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +544,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
target.sessionId = sessionId;
|
target.sessionId = sessionId;
|
||||||
} else {
|
} else {
|
||||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
opts.onOpenSessionsChange?.();
|
opts.onOpenSessionsChange?.();
|
||||||
@@ -608,7 +625,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
{ focus: !preview && !quietRender },
|
{ focus: !preview && !quietRender },
|
||||||
);
|
);
|
||||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||||
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
|
||||||
sidebar.setPreview(preview?.sessionId ?? null);
|
sidebar.setPreview(preview?.sessionId ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +818,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (source.kind === "pane" && self) removeLeaf(tab, self);
|
if (source.kind === "pane" && self) removeLeaf(tab, self);
|
||||||
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
||||||
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
|
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,7 +835,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
openSessions: realOpenSessions,
|
openSessions: realOpenSessions,
|
||||||
beginSessionDrag,
|
beginSessionDrag,
|
||||||
render,
|
render,
|
||||||
focusedSessionId: focusedLeafSession,
|
focusedSessionId: realFocusedSession,
|
||||||
previewSession,
|
previewSession,
|
||||||
previewedSessionId: () => preview?.sessionId ?? null,
|
previewedSessionId: () => preview?.sessionId ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-1
@@ -47,12 +47,20 @@ function fakePool(): Spy {
|
|||||||
|
|
||||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||||
const previewRow: { id: string | null } = { id: null };
|
const previewRow: { id: string | null } = { id: null };
|
||||||
|
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
|
||||||
|
const rowHighlights: { open: Set<string>; focused: string | null } = {
|
||||||
|
open: new Set(),
|
||||||
|
focused: null,
|
||||||
|
};
|
||||||
|
|
||||||
function fakeSidebar(): SidebarHandle {
|
function fakeSidebar(): SidebarHandle {
|
||||||
return {
|
return {
|
||||||
el: document.createElement("aside"),
|
el: document.createElement("aside"),
|
||||||
setActive() {},
|
setActive() {},
|
||||||
setHighlights() {},
|
setHighlights(h) {
|
||||||
|
rowHighlights.open = h.open;
|
||||||
|
rowHighlights.focused = h.focused;
|
||||||
|
},
|
||||||
setPreview(id: string | null) {
|
setPreview(id: string | null) {
|
||||||
previewRow.id = id;
|
previewRow.id = id;
|
||||||
},
|
},
|
||||||
@@ -95,6 +103,8 @@ describe("sidebar preview", () => {
|
|||||||
stubFetch();
|
stubFetch();
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
previewRow.id = null;
|
previewRow.id = null;
|
||||||
|
rowHighlights.open = new Set();
|
||||||
|
rowHighlights.focused = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -134,6 +144,28 @@ describe("sidebar preview", () => {
|
|||||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves the accent on the session the pane actually holds", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
expect(rowHighlights.focused).toBe("s2");
|
||||||
|
|
||||||
|
// Walking past other rows never moves the accent off what the operator
|
||||||
|
// picked: only a real open does (docs/PREVIEW.md).
|
||||||
|
mv.previewSession("s1");
|
||||||
|
expect(rowHighlights.focused).toBe("s2");
|
||||||
|
expect(mv.focusedSessionId()).toBe("s2");
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(rowHighlights.focused).toBe("s2");
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(rowHighlights.focused).toBe("s2");
|
||||||
|
|
||||||
|
mv.openSession("s1");
|
||||||
|
expect(rowHighlights.focused).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||||
const layout = twoTabs();
|
const layout = twoTabs();
|
||||||
const before = serializeLayout(layout);
|
const before = serializeLayout(layout);
|
||||||
|
|||||||
Reference in New Issue
Block a user