Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f801b12e87 | ||
|
|
706942ddd7 | ||
|
|
52877abe29 | ||
|
|
43fc3a3735 | ||
|
|
ff1ffb377c | ||
|
|
ba74d3515b | ||
|
|
8325bc6d9a | ||
|
|
8f499dbee3 | ||
|
|
511fed408c | ||
|
|
ba02b11f13 | ||
|
|
59ede15d2b | ||
|
|
331039df11 | ||
|
|
151438094e | ||
|
|
c1c283c0f8 | ||
|
|
1b260b5c20 | ||
|
|
3eb1d7ddbe | ||
|
|
9d6b69cc49 | ||
|
|
b2cbb23fe2 | ||
|
|
9fe9fe4628 | ||
|
|
e6a254b734 | ||
|
|
dc783778c8 | ||
|
|
f8b434dd1d |
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
|
|||||||
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
|
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
|
||||||
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
||||||
|
|
||||||
|
/// Leader key both clients use when `[tui].leader` is unset (#159). Canonical
|
||||||
|
/// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey`
|
||||||
|
/// both produce.
|
||||||
|
pub const DEFAULT_LEADER: &str = "ctrl-a";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
@@ -152,6 +157,15 @@ pub struct TuiSection {
|
|||||||
pub keymap_insert: BTreeMap<String, String>,
|
pub keymap_insert: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TuiSection {
|
||||||
|
/// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for
|
||||||
|
/// history, but read by every client: the daemon serves it on
|
||||||
|
/// `GET /api/info` so the web follows the same knob (#159).
|
||||||
|
pub fn leader(&self) -> &str {
|
||||||
|
self.leader.as_deref().unwrap_or(DEFAULT_LEADER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TuiTheme {
|
pub enum TuiTheme {
|
||||||
|
|||||||
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
|
|||||||
) -> Result<(), axum::Error> {
|
) -> Result<(), axum::Error> {
|
||||||
let (mut sink, mut stream) = socket.split();
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
|
||||||
|
// This client's claim on the shared window size. Registered up front and
|
||||||
|
// released on every exit below, so a socket that dies mid-session stops
|
||||||
|
// constraining the size for the clients still attached.
|
||||||
|
let viewport = manager.attach_viewport();
|
||||||
|
|
||||||
// NAP-229: send buffered scrollback up-front so the client sees the last
|
// NAP-229: send buffered scrollback up-front so the client sees the last
|
||||||
// screenful immediately instead of a blank terminal. The replay lock the
|
// screenful immediately instead of a blank terminal. The replay lock the
|
||||||
// bridge held during snapshot+subscribe guarantees no byte appears both
|
// bridge held during snapshot+subscribe guarantees no byte appears both
|
||||||
// here and on the live receiver, and none is dropped at the seam.
|
// here and on the live receiver, and none is dropped at the seam.
|
||||||
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
|
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
|
||||||
|
manager.release_viewport(id, viewport).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
|
|||||||
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
||||||
match ctrl {
|
match ctrl {
|
||||||
ClientControl::Resize { cols, rows } => {
|
ClientControl::Resize { cols, rows } => {
|
||||||
let _ = manager.resize(id, cols, rows).await;
|
let _ = manager.set_viewport(id, viewport, cols, rows).await;
|
||||||
}
|
}
|
||||||
ClientControl::Ping => {
|
ClientControl::Ping => {
|
||||||
let pong = serde_json::to_string(&ClientControl::Pong)
|
let pong = serde_json::to_string(&ClientControl::Pong)
|
||||||
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
manager.release_viewport(id, viewport).await;
|
||||||
|
|
||||||
if session_ended {
|
if session_ended {
|
||||||
let _ = sink.send(Message::Close(None)).await;
|
let _ = sink.send(Message::Close(None)).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -105,6 +111,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
projects,
|
projects,
|
||||||
|
config.leader.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
|
|||||||
/// start when none is present (#106): `[hooks].auto_install`, default
|
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||||
/// `true`.
|
/// `true`.
|
||||||
pub hooks_auto_install: bool,
|
pub hooks_auto_install: bool,
|
||||||
|
/// Canonical leader key advertised to clients in `GET /api/info` (#159):
|
||||||
|
/// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon
|
||||||
|
/// never binds it itself; it serves it so the web client and the TUI read
|
||||||
|
/// one knob.
|
||||||
|
pub leader: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonConfig {
|
impl DaemonConfig {
|
||||||
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
project_roots,
|
project_roots,
|
||||||
project_scan_depth,
|
project_scan_depth,
|
||||||
hooks_auto_install: file.hooks.auto_install(),
|
hooks_auto_install: file.hooks.auto_install(),
|
||||||
|
leader: file.tui.leader().to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// #159: the leader the operator set for the TUI is what the daemon
|
||||||
|
/// advertises, so rebinding it once moves both clients' chord.
|
||||||
|
#[test]
|
||||||
|
fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
std::fs::write(&path, "").unwrap();
|
||||||
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
|
assert_eq!(cfg.leader, "ctrl-a");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[tui]
|
||||||
|
leader = "ctrl-b"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
|
assert_eq!(cfg.leader, "ctrl-b");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub mod spawn_history;
|
|||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
pub mod static_bundle;
|
pub mod static_bundle;
|
||||||
pub mod tmux;
|
pub mod tmux;
|
||||||
|
pub mod viewport;
|
||||||
pub mod worktree;
|
pub mod worktree;
|
||||||
pub mod worktree_probe;
|
pub mod worktree_probe;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ pub struct LocalApiState {
|
|||||||
pub presets: Arc<Vec<SpawnPreset>>,
|
pub presets: Arc<Vec<SpawnPreset>>,
|
||||||
/// Project discovery (#54) backing `GET /api/projects`.
|
/// Project discovery (#54) backing `GET /api/projects`.
|
||||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||||
|
/// Configured leader key (`[tui].leader`) advertised in `GET /api/info`
|
||||||
|
/// (#159), so the web client binds the chord the operator configured
|
||||||
|
/// rather than its own default.
|
||||||
|
pub leader: Arc<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: LocalApiState) -> Router {
|
pub fn router(state: LocalApiState) -> Router {
|
||||||
@@ -219,6 +223,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
|||||||
presets: (*s.presets).clone(),
|
presets: (*s.presets).clone(),
|
||||||
build: crate::release::build_info(),
|
build: crate::release::build_info(),
|
||||||
instance: crate::release::instance_id().to_string(),
|
instance: crate::release::instance_id().to_string(),
|
||||||
|
leader: (*s.leader).clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +379,7 @@ pub fn state_for(
|
|||||||
home_dir: impl Into<String>,
|
home_dir: impl Into<String>,
|
||||||
presets: Vec<SpawnPreset>,
|
presets: Vec<SpawnPreset>,
|
||||||
projects: Arc<crate::projects::ProjectScanner>,
|
projects: Arc<crate::projects::ProjectScanner>,
|
||||||
|
leader: impl Into<String>,
|
||||||
) -> LocalApiState {
|
) -> LocalApiState {
|
||||||
LocalApiState {
|
LocalApiState {
|
||||||
manager,
|
manager,
|
||||||
@@ -381,6 +387,7 @@ pub fn state_for(
|
|||||||
home_dir: Arc::new(home_dir.into()),
|
home_dir: Arc::new(home_dir.into()),
|
||||||
presets: Arc::new(presets),
|
presets: Arc::new(presets),
|
||||||
projects,
|
projects,
|
||||||
|
leader: Arc::new(leader.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
|
|||||||
use crate::sidecar::{SidecarError, SidecarStore};
|
use crate::sidecar::{SidecarError, SidecarStore};
|
||||||
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
||||||
use crate::tmux::TmuxError;
|
use crate::tmux::TmuxError;
|
||||||
|
use crate::viewport::{ViewportId, Viewports};
|
||||||
use crate::worktree::{WorktreeError, WorktreeManager};
|
use crate::worktree::{WorktreeError, WorktreeManager};
|
||||||
|
|
||||||
/// Capacity of the session-events broadcast channel. A subscriber that falls
|
/// Capacity of the session-events broadcast channel. A subscriber that falls
|
||||||
@@ -166,6 +168,14 @@ struct Inner {
|
|||||||
report_ttl: Duration,
|
report_ttl: Duration,
|
||||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||||
events: broadcast::Sender<CatalogEvent>,
|
events: broadcast::Sender<CatalogEvent>,
|
||||||
|
/// Sizes reported by the clients attached to each session, and what tmux
|
||||||
|
/// was last told. See [`crate::viewport`] for why the window is not simply
|
||||||
|
/// set to whatever arrived most recently.
|
||||||
|
viewports: Mutex<Viewports>,
|
||||||
|
/// Hands out [`ViewportId`]s. Monotonic for the daemon's lifetime, so a
|
||||||
|
/// client that reconnects never collides with the registration its
|
||||||
|
/// previous socket has not finished releasing.
|
||||||
|
next_viewport: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
@@ -206,6 +216,8 @@ impl SessionManager {
|
|||||||
report_ttl: crate::activity::REPORT_TTL,
|
report_ttl: crate::activity::REPORT_TTL,
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
events,
|
events,
|
||||||
|
viewports: Mutex::new(Viewports::new()),
|
||||||
|
next_viewport: AtomicU64::new(0),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -770,6 +782,52 @@ impl SessionManager {
|
|||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a client that is about to start reporting its display size,
|
||||||
|
/// and hand back the handle it reports under.
|
||||||
|
///
|
||||||
|
/// Cheap and infallible: the session it will report against is not
|
||||||
|
/// consulted until the first [`Self::set_viewport`].
|
||||||
|
pub fn attach_viewport(&self) -> ViewportId {
|
||||||
|
self.inner.next_viewport.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client reported the size it can display.
|
||||||
|
///
|
||||||
|
/// The shared tmux window is resized only when the size arbitrated across
|
||||||
|
/// every attached client actually changes — see [`crate::viewport`]. Two
|
||||||
|
/// clients of different sizes used to resize the window past each other on
|
||||||
|
/// every report, which leaves a full-screen agent repainting into rows it
|
||||||
|
/// never clears and looking, to the operator, like a pane that has stopped
|
||||||
|
/// accepting input.
|
||||||
|
pub async fn set_viewport(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
viewport: ViewportId,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<(), SessionError> {
|
||||||
|
let target = self
|
||||||
|
.inner
|
||||||
|
.viewports
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.set(id, viewport, cols, rows);
|
||||||
|
match target {
|
||||||
|
Some((cols, rows)) => self.resize(id, cols, rows).await,
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a detached client's claim on the window size, resizing to what the
|
||||||
|
/// clients still attached can display. Errors are the caller's to ignore:
|
||||||
|
/// a socket closing because its session died has nothing left to resize.
|
||||||
|
pub async fn release_viewport(&self, id: Uuid, viewport: ViewportId) {
|
||||||
|
let target = self.inner.viewports.lock().await.release(id, viewport);
|
||||||
|
if let Some((cols, rows)) = target {
|
||||||
|
let _ = self.resize(id, cols, rows).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rename a session (#125): set `metadata.name`, or clear it when the
|
/// Rename a session (#125): set `metadata.name`, or clear it when the
|
||||||
/// requested name is blank so the session falls back to its command.
|
/// requested name is blank so the session falls back to its command.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! What size the shared tmux window should be, given every client watching it.
|
||||||
|
//!
|
||||||
|
//! A tmux window has one size, but a session can have several clients attached
|
||||||
|
//! at once — two browser panes, a browser and the TUI. Each reports the size
|
||||||
|
//! *it* can display, and applying whichever arrived last makes the window
|
||||||
|
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
|
||||||
|
//! repaint at a new height, and the rows it drew at the old height are never
|
||||||
|
//! cleared: the operator ends up looking at a stale input box, types into a
|
||||||
|
//! box that is not where the screen says it is, and concludes the pane is
|
||||||
|
//! stuck.
|
||||||
|
//!
|
||||||
|
//! So the window is sized to the smallest attached client on each axis — the
|
||||||
|
//! same rule tmux uses for its own multiple clients, and the only size every
|
||||||
|
//! client can render without truncation. The size last handed to tmux is
|
||||||
|
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// One attached client within a session. Allocated by
|
||||||
|
/// [`crate::session::SessionManager`] and opaque to everything else.
|
||||||
|
pub type ViewportId = u64;
|
||||||
|
|
||||||
|
/// Per-session client sizes, and what tmux was last told.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Viewports {
|
||||||
|
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
|
||||||
|
applied: HashMap<Uuid, (u16, u16)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewports {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record what `viewport` can display. Returns the size tmux must be set
|
||||||
|
/// to, or `None` when the window already has it.
|
||||||
|
pub fn set(
|
||||||
|
&mut self,
|
||||||
|
session: Uuid,
|
||||||
|
viewport: ViewportId,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Option<(u16, u16)> {
|
||||||
|
self.clients
|
||||||
|
.entry(session)
|
||||||
|
.or_default()
|
||||||
|
.insert(viewport, (cols, rows));
|
||||||
|
self.settle(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a client that has detached. Returns the size tmux must be set to
|
||||||
|
/// now that it no longer constrains the window.
|
||||||
|
///
|
||||||
|
/// The last client leaving settles nothing: the window keeps the size it
|
||||||
|
/// has rather than snapping to some remembered default no one is watching.
|
||||||
|
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
|
||||||
|
let clients = self.clients.get_mut(&session)?;
|
||||||
|
clients.remove(&viewport);
|
||||||
|
if clients.is_empty() {
|
||||||
|
self.clients.remove(&session);
|
||||||
|
self.applied.remove(&session);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.settle(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smallest size every attached client can render, but only when it
|
||||||
|
/// differs from what tmux was last told.
|
||||||
|
///
|
||||||
|
/// A zero on either axis is a client that cannot display anything yet — a
|
||||||
|
/// pane still laying out, a hidden tab — and must not drag the shared
|
||||||
|
/// window down with it, so it is held but left out of the arbitration.
|
||||||
|
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
|
||||||
|
let clients = self.clients.get(&session)?;
|
||||||
|
let mut cols = u16::MAX;
|
||||||
|
let mut rows = u16::MAX;
|
||||||
|
for &(c, r) in clients.values() {
|
||||||
|
if c == 0 || r == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cols = cols.min(c);
|
||||||
|
rows = rows.min(r);
|
||||||
|
}
|
||||||
|
if cols == u16::MAX || rows == u16::MAX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = (cols, rows);
|
||||||
|
if self.applied.get(&session) == Some(&target) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.applied.insert(session, target);
|
||||||
|
Some(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_first_report_settles_the_window() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_report_that_changes_nothing_costs_no_resize() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
v.set(s, 1, 80, 24);
|
||||||
|
assert_eq!(v.set(s, 1, 80, 24), None);
|
||||||
|
assert_eq!(v.set(s, 1, 80, 24), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug this module exists for: two clients of different sizes used to
|
||||||
|
/// resize the window past each other on every report.
|
||||||
|
#[test]
|
||||||
|
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
|
||||||
|
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
|
||||||
|
// Whatever order they re-report in, the answer stays the smaller one.
|
||||||
|
assert_eq!(v.set(s, 1, 53, 46), None);
|
||||||
|
assert_eq!(v.set(s, 2, 53, 20), None);
|
||||||
|
assert_eq!(v.set(s, 1, 53, 46), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_axis_is_arbitrated_on_its_own() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
v.set(s, 1, 200, 20);
|
||||||
|
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_sized_client_does_not_shrink_the_window() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
|
||||||
|
assert_eq!(v.set(s, 2, 0, 0), None);
|
||||||
|
assert_eq!(v.set(s, 2, 0, 30), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn releasing_the_small_client_gives_the_window_back() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
v.set(s, 1, 80, 46);
|
||||||
|
v.set(s, 2, 80, 20);
|
||||||
|
assert_eq!(v.release(s, 2), Some((80, 46)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn releasing_the_last_client_settles_nothing() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
v.set(s, 1, 80, 24);
|
||||||
|
assert_eq!(v.release(s, 1), None);
|
||||||
|
// ...and the session is forgotten, so a fresh attach settles again.
|
||||||
|
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn releasing_an_unknown_viewport_is_harmless() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
assert_eq!(v.release(s, 7), None);
|
||||||
|
v.set(s, 1, 80, 24);
|
||||||
|
assert_eq!(v.release(s, 7), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sessions_do_not_arbitrate_against_each_other() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let a = Uuid::new_v4();
|
||||||
|
let b = Uuid::new_v4();
|
||||||
|
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
|
||||||
|
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
|
||||||
|
assert_eq!(v.set(a, 1, 80, 24), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness {
|
|||||||
async fn spawn_with(
|
async fn spawn_with(
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||||
|
) -> Harness {
|
||||||
|
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_with_leader(
|
||||||
|
hostname: &str,
|
||||||
|
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||||
|
leader: &str,
|
||||||
) -> Harness {
|
) -> Harness {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
@@ -48,6 +56,7 @@ async fn spawn_with(
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
projects,
|
projects,
|
||||||
|
leader,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
@@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() {
|
|||||||
assert!(resp["version"].is_string());
|
assert!(resp["version"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #159: the web client reads its leader off `/api/info` instead of a
|
||||||
|
/// hardcoded constant, so the payload has to carry the configured one — not
|
||||||
|
/// the default dressed up as one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn info_advertises_the_configured_leader() {
|
||||||
|
let h = spawn_with_leader(
|
||||||
|
"naps-laptop",
|
||||||
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
"ctrl-b",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["leader"], "ctrl-b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn info_reports_the_default_leader_when_unconfigured() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["leader"], "ctrl-a");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn info_reports_build_and_instance() {
|
async fn info_reports_build_and_instance() {
|
||||||
// The status indicator needs a release to name and a process to compare
|
// The status indicator needs a release to name and a process to compare
|
||||||
|
|||||||
@@ -555,3 +555,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
|
|||||||
Some(&"http://127.0.0.1:7070".to_string())
|
Some(&"http://127.0.0.1:7070".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two clients attached to one session used to resize the shared window past
|
||||||
|
/// each other on every report they made. Every flip repaints a full-screen
|
||||||
|
/// agent at a new height and orphans the rows it drew at the old one, which
|
||||||
|
/// reads to the operator as a pane that has stopped taking input.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
let web = fx.manager.attach_viewport();
|
||||||
|
let tui = fx.manager.attach_viewport();
|
||||||
|
|
||||||
|
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
|
||||||
|
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
|
||||||
|
// redraw loop do. Neither report changes the arbitrated size.
|
||||||
|
for _ in 0..5 {
|
||||||
|
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizes: Vec<_> = fx
|
||||||
|
.fake
|
||||||
|
.calls()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
resizes,
|
||||||
|
vec![(53, 46), (53, 20)],
|
||||||
|
"the window was resized more than the arbitrated size actually changed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smaller client leaving hands the window back to the one still attached.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn releasing_a_client_resizes_to_what_is_left() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
let web = fx.manager.attach_viewport();
|
||||||
|
let tui = fx.manager.attach_viewport();
|
||||||
|
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
|
||||||
|
|
||||||
|
fx.manager.release_viewport(s.id, tui).await;
|
||||||
|
|
||||||
|
let resizes: Vec<_> = fx
|
||||||
|
.fake
|
||||||
|
.calls()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
maestro_config::SpawnSection::default().resolve().unwrap(),
|
maestro_config::SpawnSection::default().resolve().unwrap(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,41 @@ pub fn command_program(command: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
||||||
|
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
||||||
|
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
||||||
|
const BY_PROGRAM: [(&str, &str); 17] = [
|
||||||
|
("claude", "claude"),
|
||||||
|
("claude-code", "claude"),
|
||||||
|
("codex", "codex"),
|
||||||
|
("opencode", "opencode"),
|
||||||
|
("gemini", "gemini"),
|
||||||
|
("aider", "aider"),
|
||||||
|
("cursor", "cursor"),
|
||||||
|
("cursor-agent", "cursor"),
|
||||||
|
("pi", "pi"),
|
||||||
|
("bash", "shell"),
|
||||||
|
("zsh", "shell"),
|
||||||
|
("sh", "shell"),
|
||||||
|
("fish", "shell"),
|
||||||
|
("dash", "shell"),
|
||||||
|
("ksh", "shell"),
|
||||||
|
("nu", "shell"),
|
||||||
|
("xonsh", "shell"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
||||||
|
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
|
||||||
|
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
|
||||||
|
/// the same tools the web's `splitCommand().tool` does.
|
||||||
|
pub fn command_tool(command: &str) -> Option<&'static str> {
|
||||||
|
let program = command_program(command).to_lowercase();
|
||||||
|
BY_PROGRAM
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == program)
|
||||||
|
.map(|(_, tool)| *tool)
|
||||||
|
}
|
||||||
|
|
||||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||||
fn is_env_assignment(word: &str) -> bool {
|
fn is_env_assignment(word: &str) -> bool {
|
||||||
let mut chars = word.chars();
|
let mut chars = word.chars();
|
||||||
@@ -476,4 +511,22 @@ mod tests {
|
|||||||
assert_eq!(command_program("sudo"), "sudo");
|
assert_eq!(command_program("sudo"), "sudo");
|
||||||
assert_eq!(command_program(""), "");
|
assert_eq!(command_program(""), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
|
||||||
|
/// tools the web's `splitCommand().tool` does.
|
||||||
|
#[test]
|
||||||
|
fn command_tool_names_the_mark_the_card_shows() {
|
||||||
|
assert_eq!(command_tool("claude --resume"), Some("claude"));
|
||||||
|
assert_eq!(command_tool("npx opencode"), Some("opencode"));
|
||||||
|
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
|
||||||
|
assert_eq!(command_tool("CLAUDE"), Some("claude"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Narrow on purpose: an unrecognised program has no mark, so the
|
||||||
|
/// candidate has no `tool` field to match rather than a wrong one.
|
||||||
|
#[test]
|
||||||
|
fn command_tool_is_absent_for_an_unrecognised_program() {
|
||||||
|
assert_eq!(command_tool("hx src/main.rs"), None);
|
||||||
|
assert_eq!(command_tool(""), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -554,6 +554,12 @@ pub struct DaemonInfo {
|
|||||||
/// network blip, even when the build did not change.
|
/// network blip, even when the build did not change.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub instance: String,
|
pub instance: String,
|
||||||
|
/// Canonical leader key the operator configured (`[tui].leader`, default
|
||||||
|
/// `ctrl-a`), served so the web client binds the same chord the TUI does
|
||||||
|
/// instead of its own hardcoded constant (#159). Empty from a daemon too
|
||||||
|
/// old to serve it, which clients read as "use your default".
|
||||||
|
#[serde(default)]
|
||||||
|
pub leader: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ pub enum Action {
|
|||||||
RenameHighlighted,
|
RenameHighlighted,
|
||||||
|
|
||||||
// Chrome
|
// Chrome
|
||||||
|
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
|
||||||
|
/// over the sessions, ranked by the shared matcher.
|
||||||
|
OpenCommandBar,
|
||||||
|
/// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B`
|
||||||
|
/// byte to the focused pane instead of opening the command bar, giving
|
||||||
|
/// readline's kill-to-end-of-line back.
|
||||||
|
SendLiteralCtrlK,
|
||||||
|
/// Escape hatch (#158): the leader pressed twice sends one literal
|
||||||
|
/// leader byte to the focused pane and drops the pending chord, the way
|
||||||
|
/// tmux resolves a doubled prefix key.
|
||||||
|
SendLiteralLeader,
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
ShowHelp,
|
ShowHelp,
|
||||||
Quit,
|
Quit,
|
||||||
@@ -94,6 +105,9 @@ impl Action {
|
|||||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||||
Action::KillHighlighted => "Kill selected session".into(),
|
Action::KillHighlighted => "Kill selected session".into(),
|
||||||
Action::RenameHighlighted => "Rename selected session".into(),
|
Action::RenameHighlighted => "Rename selected session".into(),
|
||||||
|
Action::OpenCommandBar => "Go to a session".into(),
|
||||||
|
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
|
||||||
|
Action::SendLiteralLeader => "Send literal leader key to session".into(),
|
||||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
Action::ShowHelp => "Show help".into(),
|
Action::ShowHelp => "Show help".into(),
|
||||||
Action::Quit => "Quit Maestro".into(),
|
Action::Quit => "Quit Maestro".into(),
|
||||||
@@ -120,6 +134,9 @@ impl Action {
|
|||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||||
|
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
||||||
|
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
|
||||||
|
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
|
||||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||||
"Quit" => return Ok(Action::Quit),
|
"Quit" => return Ok(Action::Quit),
|
||||||
@@ -239,10 +256,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command bar is an ordinary keymap entry, so it must round-trip
|
||||||
|
/// like every other action (#153).
|
||||||
|
#[test]
|
||||||
|
fn open_command_bar_round_trips() {
|
||||||
|
assert_eq!(
|
||||||
|
Action::parse("OpenCommandBar").unwrap(),
|
||||||
|
Action::OpenCommandBar
|
||||||
|
);
|
||||||
|
assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar");
|
||||||
|
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The escape hatch (#156) is an ordinary action like any other.
|
||||||
|
#[test]
|
||||||
|
fn send_literal_ctrl_k_round_trips() {
|
||||||
|
assert_eq!(
|
||||||
|
Action::parse("SendLiteralCtrlK").unwrap(),
|
||||||
|
Action::SendLiteralCtrlK
|
||||||
|
);
|
||||||
|
assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK");
|
||||||
|
assert_eq!(
|
||||||
|
Action::SendLiteralCtrlK.help_label(),
|
||||||
|
"Send literal Ctrl-K to session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_round_trips() {
|
fn display_round_trips() {
|
||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
|
Action::OpenCommandBar,
|
||||||
|
Action::SendLiteralCtrlK,
|
||||||
Action::RenameHighlighted,
|
Action::RenameHighlighted,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
Action::OpenSpawnModal,
|
Action::OpenSpawnModal,
|
||||||
|
|||||||
+880
-54
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("pageup", "ScrollPanePageUp"),
|
("pageup", "ScrollPanePageUp"),
|
||||||
("pagedown", "ScrollPanePageDown"),
|
("pagedown", "ScrollPanePageDown"),
|
||||||
// Chrome
|
// Chrome
|
||||||
|
// The command bar (#153). An ordinary entry, so it is rebindable and the
|
||||||
|
// help overlay picks it up from the live keymap.
|
||||||
|
("ctrl-k", "OpenCommandBar"),
|
||||||
("ctrl-w b", "ToggleSidebar"),
|
("ctrl-w b", "ToggleSidebar"),
|
||||||
("?", "ShowHelp"),
|
("?", "ShowHelp"),
|
||||||
("ctrl-w q", "Quit"),
|
("ctrl-w q", "Quit"),
|
||||||
@@ -75,13 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Default Insert-mode bindings — deliberately empty. Every key typed in
|
/// Default Insert-mode bindings — almost empty on purpose. Every key typed in
|
||||||
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
||||||
/// it constantly, so binding it here would make it unreachable. The only way
|
/// it constantly, so binding it here would make it unreachable. The only way
|
||||||
/// back to the sidebar is the leader (`LeaveInsert`), which
|
/// back to the sidebar is the leader (`LeaveInsert`), which
|
||||||
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
||||||
/// the Insert escape with it.
|
/// the Insert escape with it.
|
||||||
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
///
|
||||||
|
/// The one exception is the command bar (#153): `ctrl-k` opens it from inside
|
||||||
|
/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's
|
||||||
|
/// kill-to-end-of-line is given back through the leader (#156).
|
||||||
|
const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")];
|
||||||
|
|
||||||
impl TuiConfig {
|
impl TuiConfig {
|
||||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||||
@@ -107,7 +114,7 @@ impl TuiConfig {
|
|||||||
TuiTheme::Nord => Palette::NORD,
|
TuiTheme::Nord => Palette::NORD,
|
||||||
TuiTheme::Dracula => Palette::DRACULA,
|
TuiTheme::Dracula => Palette::DRACULA,
|
||||||
};
|
};
|
||||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
let leader_str = section.leader();
|
||||||
let leader = InputKey::parse(leader_str)
|
let leader = InputKey::parse(leader_str)
|
||||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||||
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
||||||
@@ -139,6 +146,25 @@ impl TuiConfig {
|
|||||||
keymap
|
keymap
|
||||||
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
|
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
|
||||||
.expect("leader canonical form is valid by construction");
|
.expect("leader canonical form is valid by construction");
|
||||||
|
// Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from
|
||||||
|
// the same leader spec so rebinding leader rebinds this chord too.
|
||||||
|
keymap
|
||||||
|
.set(
|
||||||
|
Mode::Insert,
|
||||||
|
&format!("{} ctrl-k", leader.canonical()),
|
||||||
|
"SendLiteralCtrlK",
|
||||||
|
)
|
||||||
|
.expect("leader canonical form is valid by construction");
|
||||||
|
// Leader, leader → the literal-leader escape hatch (#158), tmux-style:
|
||||||
|
// pressing it twice sends the leader byte itself. Built from the same
|
||||||
|
// leader spec, so rebinding leader rebinds this chord too.
|
||||||
|
keymap
|
||||||
|
.set(
|
||||||
|
Mode::Insert,
|
||||||
|
&format!("{} {}", leader.canonical(), leader.canonical()),
|
||||||
|
"SendLiteralLeader",
|
||||||
|
)
|
||||||
|
.expect("leader canonical form is valid by construction");
|
||||||
|
|
||||||
for (input, action) in §ion.keymap_normal {
|
for (input, action) in §ion.keymap_normal {
|
||||||
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
||||||
@@ -271,6 +297,60 @@ mod tests {
|
|||||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The literal-Ctrl-K escape hatch (#156) is chorded off the default
|
||||||
|
/// leader, `ctrl-a ctrl-k` — not the command bar.
|
||||||
|
#[test]
|
||||||
|
fn default_leader_escapes_a_literal_ctrl_k() {
|
||||||
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"),
|
||||||
|
Some(Action::SendLiteralCtrlK),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not
|
||||||
|
/// just the Insert escape — proving the chord is built from the
|
||||||
|
/// configured leader rather than a hardcoded `ctrl-a`.
|
||||||
|
#[test]
|
||||||
|
fn rebinding_leader_moves_the_literal_ctrl_k_chord() {
|
||||||
|
let mut section = TuiSection::default();
|
||||||
|
section.leader = Some("ctrl-b".into());
|
||||||
|
let cfg = TuiConfig::load(§ion).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"),
|
||||||
|
Some(Action::SendLiteralCtrlK),
|
||||||
|
);
|
||||||
|
// The old leader's chord is gone along with its Insert escape.
|
||||||
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The literal-leader escape hatch (#158) is chorded off the leader
|
||||||
|
/// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution.
|
||||||
|
#[test]
|
||||||
|
fn default_leader_escapes_a_literal_leader() {
|
||||||
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"),
|
||||||
|
Some(Action::SendLiteralLeader),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebinding the leader must rebind the literal-leader chord with it,
|
||||||
|
/// not just the Insert escape and the Ctrl-K chord — proving it too is
|
||||||
|
/// built from the configured leader rather than a hardcoded `ctrl-a`.
|
||||||
|
#[test]
|
||||||
|
fn rebinding_leader_moves_the_literal_leader_chord() {
|
||||||
|
let mut section = TuiSection::default();
|
||||||
|
section.leader = Some("ctrl-b".into());
|
||||||
|
let cfg = TuiConfig::load(§ion).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-b"),
|
||||||
|
Some(Action::SendLiteralLeader),
|
||||||
|
);
|
||||||
|
// The old leader's chord is gone along with the rest of its bindings.
|
||||||
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn user_normal_override_replaces_default() {
|
fn user_normal_override_replaces_default() {
|
||||||
let mut section = TuiSection::default();
|
let mut section = TuiSection::default();
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ mod tests {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
},
|
},
|
||||||
instance: instance.into(),
|
instance: instance.into(),
|
||||||
|
leader: maestro_config::DEFAULT_LEADER.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ pub struct DraftPane {
|
|||||||
/// respawns there without touching the picker. Consumed by the first
|
/// respawns there without touching the picker. Consumed by the first
|
||||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||||
pub seed_cwd: Option<PathBuf>,
|
pub seed_cwd: Option<PathBuf>,
|
||||||
|
/// Command to preselect once the preset list lands — "spawn similar"'s
|
||||||
|
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
|
||||||
|
/// `None` once applied or when nothing seeded it.
|
||||||
|
pub seed_command: Option<String>,
|
||||||
/// The repository picked in step one ([`group_key`] of its main box),
|
/// The repository picked in step one ([`group_key`] of its main box),
|
||||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||||
/// cached, so a project-list refresh can't leave it stale.
|
/// cached, so a project-list refresh can't leave it stale.
|
||||||
@@ -291,6 +295,7 @@ impl Default for DraftPane {
|
|||||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
|
seed_command: None,
|
||||||
custom_command: crate::text_input::TextInput::default(),
|
custom_command: crate::text_input::TextInput::default(),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
filter: crate::text_input::TextInput::default(),
|
filter: crate::text_input::TextInput::default(),
|
||||||
@@ -342,6 +347,27 @@ impl DraftPane {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
self.apply_seed_command();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||||
|
/// daemon offers one, otherwise the custom escape hatch with the command
|
||||||
|
/// typed in.
|
||||||
|
fn apply_seed_command(&mut self) {
|
||||||
|
let Some(command) = self.seed_command.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match self.presets.iter().position(|p| p.command == command) {
|
||||||
|
Some(index) => self.selected_preset = index,
|
||||||
|
None => {
|
||||||
|
self.selected_preset = self
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.command.is_empty())
|
||||||
|
.unwrap_or(self.selected_preset);
|
||||||
|
self.custom_command = crate::text_input::TextInput::with_value(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the project snapshot: sort fresh-first (#54), group by
|
/// Replace the project snapshot: sort fresh-first (#54), group by
|
||||||
@@ -1688,6 +1714,48 @@ mod tests {
|
|||||||
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
|
||||||
|
/// the tile that runs it when the daemon offers one.
|
||||||
|
#[test]
|
||||||
|
fn a_seeded_command_selects_its_own_preset() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
seed_command: Some("aider".into()),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![
|
||||||
|
SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "aider".into(),
|
||||||
|
label: "Aider".into(),
|
||||||
|
command: "aider".into(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert_eq!(draft.command(), "aider");
|
||||||
|
assert!(!draft.is_custom());
|
||||||
|
assert_eq!(draft.seed_command, None, "consumed by the first load");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command no preset runs falls back to the custom tile with the command
|
||||||
|
/// typed in, so the clone still spawns what it cloned.
|
||||||
|
#[test]
|
||||||
|
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
seed_command: Some("bash -l".into()),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
}]);
|
||||||
|
assert!(draft.is_custom());
|
||||||
|
assert_eq!(draft.command(), "bash -l");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
|
|||||||
@@ -49,6 +49,33 @@ pub fn resolve(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
});
|
});
|
||||||
|
defaults(project, presets, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the defaults for a spawn pinned to one directory — the command
|
||||||
|
/// bar's `~` (#154), where the operator named the directory and nothing may
|
||||||
|
/// override it. Unlike [`resolve`] there is no recency fallback: a directory
|
||||||
|
/// the project scan does not know leaves `project` `None`, which is an
|
||||||
|
/// unresolvable default, so the keystroke opens the pre-filled form instead of
|
||||||
|
/// spawning somewhere else.
|
||||||
|
pub fn resolve_at(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
projects: &[Project],
|
||||||
|
presets: &[SpawnPreset],
|
||||||
|
handle: Option<String>,
|
||||||
|
) -> FastForwardDefaults {
|
||||||
|
let project = projects.iter().find(|p| p.path == dir).cloned();
|
||||||
|
defaults(project, presets, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The project's last command, or the first non-custom preset, wrapped up with
|
||||||
|
/// the rest of the defaults. Shared by both resolvers so the two entry points
|
||||||
|
/// can never settle on different commands.
|
||||||
|
fn defaults(
|
||||||
|
project: Option<Project>,
|
||||||
|
presets: &[SpawnPreset],
|
||||||
|
handle: Option<String>,
|
||||||
|
) -> FastForwardDefaults {
|
||||||
let command = project
|
let command = project
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.last_command.clone())
|
.and_then(|p| p.last_command.clone())
|
||||||
@@ -159,6 +186,46 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command bar's `~` names the directory; nothing may override it.
|
||||||
|
#[test]
|
||||||
|
fn resolve_at_pins_the_directory_over_the_recency_ranking() {
|
||||||
|
let projects = vec![
|
||||||
|
project("/repos/fresh", 0, Some("aider")),
|
||||||
|
project("/repos/pinned", 30, Some("zsh")),
|
||||||
|
];
|
||||||
|
let resolved = resolve_at(
|
||||||
|
std::path::Path::new("/repos/pinned"),
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
Some("otter".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.project.as_ref().map(|p| p.path.clone()),
|
||||||
|
Some(PathBuf::from("/repos/pinned"))
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.command.as_deref(), Some("zsh"));
|
||||||
|
let req = resolved.build_request().expect("every default resolved");
|
||||||
|
assert_eq!(req.cwd, PathBuf::from("/repos/pinned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory the scan does not know resolves no project, so there is no
|
||||||
|
/// request to build — the caller opens the pre-filled form instead of
|
||||||
|
/// spawning somewhere else.
|
||||||
|
#[test]
|
||||||
|
fn resolve_at_takes_no_fallback_for_an_unknown_directory() {
|
||||||
|
let projects = vec![project("/repos/fresh", 0, Some("aider"))];
|
||||||
|
let resolved = resolve_at(
|
||||||
|
std::path::Path::new("/elsewhere"),
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
Some("otter".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.project, None);
|
||||||
|
// The preset still resolves; the missing project is what blocks it.
|
||||||
|
assert_eq!(resolved.command.as_deref(), Some("claude"));
|
||||||
|
assert_eq!(resolved.build_request(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_every_default_from_the_most_recent_project() {
|
fn resolves_every_default_from_the_most_recent_project() {
|
||||||
let projects = vec![
|
let projects = vec![
|
||||||
|
|||||||
@@ -387,6 +387,9 @@ pub struct ChordState {
|
|||||||
held: Vec<String>,
|
held: Vec<String>,
|
||||||
last_press: Option<Instant>,
|
last_press: Option<Instant>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
/// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting
|
||||||
|
/// to be replayed as a fresh keystroke by the caller.
|
||||||
|
replay: Option<InputKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChordState {
|
impl ChordState {
|
||||||
@@ -395,6 +398,7 @@ impl ChordState {
|
|||||||
held: Vec::new(),
|
held: Vec::new(),
|
||||||
last_press: None,
|
last_press: None,
|
||||||
timeout,
|
timeout,
|
||||||
|
replay: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +408,13 @@ impl ChordState {
|
|||||||
/// binding; wait for the next key.
|
/// binding; wait for the next key.
|
||||||
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
|
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
|
||||||
/// up in the keymap. The held buffer is cleared.
|
/// up in the keymap. The held buffer is cleared.
|
||||||
|
///
|
||||||
|
/// A held prefix that is itself directly bound (e.g. a leader key bound
|
||||||
|
/// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves
|
||||||
|
/// to that binding the moment the next key fails to extend it, rather
|
||||||
|
/// than swallowing the held key and losing its meaning — the new key
|
||||||
|
/// never belonged to the chord and is handed back via [`Self::take_replay`]
|
||||||
|
/// so the caller can process it as its own keystroke.
|
||||||
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
|
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
|
||||||
if let Some(t) = self.last_press {
|
if let Some(t) = self.last_press {
|
||||||
if t.elapsed() > self.timeout {
|
if t.elapsed() > self.timeout {
|
||||||
@@ -428,11 +439,31 @@ impl ChordState {
|
|||||||
|
|
||||||
if is_prefix {
|
if is_prefix {
|
||||||
self.held.push(key_s);
|
self.held.push(key_s);
|
||||||
ChordResult::Pending
|
return ChordResult::Pending;
|
||||||
} else {
|
|
||||||
self.held.clear();
|
|
||||||
ChordResult::Resolved(candidate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if known_bindings.contains(&candidate) {
|
||||||
|
self.held.clear();
|
||||||
|
return ChordResult::Resolved(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.held.is_empty() {
|
||||||
|
let held_str = self.held.join(" ");
|
||||||
|
self.held.clear();
|
||||||
|
if known_bindings.contains(&held_str) {
|
||||||
|
self.replay = Some(key.clone());
|
||||||
|
return ChordResult::Resolved(held_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChordResult::Resolved(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the key bumped out of an abandoned chord by the last [`Self::feed`]
|
||||||
|
/// call, if any. The caller feeds it through again as a brand new
|
||||||
|
/// keystroke.
|
||||||
|
pub fn take_replay(&mut self) -> Option<InputKey> {
|
||||||
|
self.replay.take()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,6 +764,41 @@ mod tests {
|
|||||||
assert_eq!(r, ChordResult::Resolved("g x".into()));
|
assert_eq!(r, ChordResult::Resolved("g x".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and
|
||||||
|
/// `ctrl-a ctrl-k`) must still fire its own binding when the next key
|
||||||
|
/// doesn't complete the chord — the held key's meaning isn't lost, and
|
||||||
|
/// the new key comes back via `take_replay` for the caller to process on
|
||||||
|
/// its own (#156).
|
||||||
|
#[test]
|
||||||
|
fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() {
|
||||||
|
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||||
|
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let r = chord.feed(&InputKey::ctrl_char('a'), &known);
|
||||||
|
assert_eq!(r, ChordResult::Pending);
|
||||||
|
|
||||||
|
let r = chord.feed(&InputKey::char('j'), &known);
|
||||||
|
assert_eq!(r, ChordResult::Resolved("ctrl-a".into()));
|
||||||
|
assert_eq!(chord.take_replay(), Some(InputKey::char('j')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full chord still resolves normally when the next key completes
|
||||||
|
/// it — no replay, no early fire of the shorter binding.
|
||||||
|
#[test]
|
||||||
|
fn a_completed_chord_off_a_bound_prefix_has_no_replay() {
|
||||||
|
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||||
|
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
chord.feed(&InputKey::ctrl_char('a'), &known);
|
||||||
|
let r = chord.feed(&InputKey::ctrl_char('k'), &known);
|
||||||
|
assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into()));
|
||||||
|
assert_eq!(chord.take_replay(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chord_state_lone_key_resolves_immediately() {
|
fn chord_state_lone_key_resolves_immediately() {
|
||||||
let mut chord = ChordState::new(Duration::from_secs(10));
|
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod action;
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod command_bar;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod daemon_link;
|
pub mod daemon_link;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ pub struct RenderInputs<'a> {
|
|||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
/// Edit buffer behind the rename prompt (#125).
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
pub rename_input: &'a crate::text_input::TextInput,
|
pub rename_input: &'a crate::text_input::TextInput,
|
||||||
|
/// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is
|
||||||
|
/// up.
|
||||||
|
pub command_bar: Option<&'a crate::command_bar::CommandBar>,
|
||||||
/// Liveness + release of the daemon, for the status bar's indicator and the
|
/// Liveness + release of the daemon, for the status bar's indicator and the
|
||||||
/// restart overlay (`docs/RESTART.md`).
|
/// restart overlay (`docs/RESTART.md`).
|
||||||
pub link: &'a crate::daemon_link::LinkStatus,
|
pub link: &'a crate::daemon_link::LinkStatus,
|
||||||
@@ -198,6 +201,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
}
|
}
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
|
Some(OverlayKind::CommandBar) => {
|
||||||
|
if let Some(bar) = inputs.command_bar {
|
||||||
|
if let Some((x, y)) = bar.render(f, area) {
|
||||||
|
f.set_cursor_position((x, y));
|
||||||
|
}
|
||||||
|
map.modal = Some(area);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
inputs.help.render(f, area);
|
inputs.help.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
@@ -428,6 +439,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link,
|
link,
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(120, 24);
|
let backend = ratatui::backend::TestBackend::new(120, 24);
|
||||||
@@ -524,6 +536,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -578,6 +591,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -619,6 +633,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ pub enum OverlayKind {
|
|||||||
/// so this stays comparable.
|
/// so this stays comparable.
|
||||||
Rename(Uuid),
|
Rename(Uuid),
|
||||||
Help,
|
Help,
|
||||||
|
/// The `ctrl-k` command bar (#153). Its query buffer and result list live
|
||||||
|
/// on `App` as [`crate::command_bar::CommandBar`], for the same reason the
|
||||||
|
/// rename buffer does — so this stays comparable.
|
||||||
|
CommandBar,
|
||||||
Toast(String),
|
Toast(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-8
@@ -45,8 +45,21 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
|
|||||||
`preventDefault`s it, since selecting all of a terminal pane does nothing
|
`preventDefault`s it, since selecting all of a terminal pane does nothing
|
||||||
useful.
|
useful.
|
||||||
|
|
||||||
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
|
One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
|
||||||
none of them needs one.
|
`GET /api/info`, and the web binds that instead of its own constant (#159). A
|
||||||
|
daemon too old to send the field, or an unreachable `/api/info`, leaves the web
|
||||||
|
on `ctrl-a`. The section is named `[tui]` for history — it is the leader for
|
||||||
|
every client, not the TUI's alone.
|
||||||
|
|
||||||
|
The leader itself needs the same kind of way back: holding it consumes every
|
||||||
|
press, on both clients, even before anything is bound behind it. Pressing it
|
||||||
|
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
|
||||||
|
focused pane and clears the pending chord, the way tmux resolves a doubled
|
||||||
|
prefix key. This chord is also built from the configured leader, so a
|
||||||
|
rebound leader carries its own escape hatch with it.
|
||||||
|
|
||||||
|
Only Ctrl-K and the leader itself get an escape hatch. No other control
|
||||||
|
character is intercepted, so none of them needs one.
|
||||||
|
|
||||||
### Focus round-trip
|
### Focus round-trip
|
||||||
|
|
||||||
@@ -145,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
|
|||||||
ambiguous.
|
ambiguous.
|
||||||
- Escape, or backspace at an empty query, returns to the result list **with the
|
- Escape, or backspace at an empty query, returns to the result list **with the
|
||||||
previous query intact**. Drilling in and backing out is free.
|
previous query intact**. Drilling in and backing out is free.
|
||||||
- Each action raises the flow it already has: kill goes through the existing
|
- Each action raises the flow it already has: kill through the confirmation
|
||||||
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the
|
both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
|
||||||
existing prompt pre-filled with the session's own name
|
(`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
|
||||||
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the
|
session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
|
||||||
session's directory and command. The bar picks the action; it never
|
seeded from the session's directory and command. The bar picks the action; it
|
||||||
reimplements one.
|
never reimplements one.
|
||||||
|
|
||||||
No actions beyond those three, and no action across more than one session.
|
No actions beyond those three, and no action across more than one session.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -136,7 +136,8 @@ cover every v1 action — only override what you want to change:
|
|||||||
```toml
|
```toml
|
||||||
[tui]
|
[tui]
|
||||||
theme = "dark" # see the theme list below
|
theme = "dark" # see the theme list below
|
||||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
leader = "ctrl-a" # one byte that escapes Insert mode; the web reads
|
||||||
|
# the same setting off /api/info
|
||||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||||
|
|
||||||
[tui.keymap.normal]
|
[tui.keymap.normal]
|
||||||
|
|||||||
+2
-1
@@ -46,7 +46,8 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
|||||||
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
||||||
| 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/focused state the row already had. |
|
| 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
|
||||||
|
|||||||
+21
-2
@@ -189,7 +189,7 @@ furthest down wins the paint.
|
|||||||
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
|
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
|
||||||
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
|
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
|
||||||
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
|
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
|
||||||
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
|
| Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) |
|
||||||
|
|
||||||
The web ladder is written as one ordered run of equal-specificity rules in
|
The web ladder is written as one ordered run of equal-specificity rules in
|
||||||
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
||||||
@@ -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)
|
||||||
@@ -316,7 +320,7 @@ Everything else is the same key on both clients:
|
|||||||
| `Tab` | hand the keyboard to the pane |
|
| `Tab` | hand the keyboard to the pane |
|
||||||
| `c` | collapse or expand the highlighted folder |
|
| `c` | collapse or expand the highlighted folder |
|
||||||
| `r` | rename the highlighted session |
|
| `r` | rename the highlighted session |
|
||||||
| `x` | kill the highlighted session |
|
| `x` | kill the highlighted session (confirms) |
|
||||||
| `n` | open the spawn form |
|
| `n` | open the spawn form |
|
||||||
| `N` | spawn with every default resolved |
|
| `N` | spawn with every default resolved |
|
||||||
| `?` | show the shortcut overlay |
|
| `?` | show the shortcut overlay |
|
||||||
@@ -324,3 +328,18 @@ Everything else is the same key on both clients:
|
|||||||
The TUI binds these through its keymap, so a user can rebind any of them; the
|
The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||||
web client's are fixed. A binding added to one client belongs in this table and
|
web client's are fixed. A binding added to one client belongs in this table and
|
||||||
in both help overlays.
|
in both help overlays.
|
||||||
|
|
||||||
|
## Killing a session
|
||||||
|
|
||||||
|
**Every kill confirms first, in both clients** (#160). A kill is destructive
|
||||||
|
and unattended agents live in these sessions, so neither client sends
|
||||||
|
`DELETE /api/sessions/:id` off a single press. The TUI raises
|
||||||
|
`OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its
|
||||||
|
`✕` button and the command bar's action page both use. Cancel holds the focus
|
||||||
|
in the web's dialog, so nothing kills on a stray Enter.
|
||||||
|
|
||||||
|
The confirmation names what the card last saw in the way, when anything was —
|
||||||
|
the daemon will refuse an unforced kill over such a worktree, and saying so
|
||||||
|
before the attempt is cheaper than a refusal. What comes back from that refusal
|
||||||
|
is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns
|
||||||
|
what a forced kill leaves behind.
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export interface AttachConnection {
|
|||||||
onUnmount(): void;
|
onUnmount(): void;
|
||||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||||
focusTerminal(): void;
|
focusTerminal(): void;
|
||||||
|
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
|
||||||
|
* handling — the leader's literal Ctrl-K escape hatch
|
||||||
|
* (docs/COMMAND-BAR.md). */
|
||||||
|
sendInput(data: string): void;
|
||||||
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -506,6 +510,9 @@ export function createAttachConnection(
|
|||||||
focusTerminal() {
|
focusTerminal() {
|
||||||
term.focus();
|
term.focus();
|
||||||
},
|
},
|
||||||
|
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||||
|
// bar already take.
|
||||||
|
sendInput,
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
//
|
||||||
|
// The sidebar's half of the command bar's action page (#151): the bar picks
|
||||||
|
// kill or rename and raises the flow the card already has, so what the page
|
||||||
|
// runs is what the ✕ button and the `r` key run.
|
||||||
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelector(".kill-dialog")?.remove();
|
||||||
|
mounted?.dispose();
|
||||||
|
mounted = null;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||||
|
cwd: "/home/naps/tea/maestro",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: { name: "amber-falcon" },
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWith(sessions: Session[]): CatalogStore {
|
||||||
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||||
|
return {
|
||||||
|
snapshot: () => state,
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
fn(state);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(s: Session): HTMLElement {
|
||||||
|
const handle = renderSidebar(storeWith([s]));
|
||||||
|
mounted = handle;
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
return handle.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function raise(name: string, s: Session): void {
|
||||||
|
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("kill from the action page", () => {
|
||||||
|
it("goes through the same confirmation the sidebar raises (#160)", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
mount(s);
|
||||||
|
raise(KILL_SESSION_EVENT, s);
|
||||||
|
await flush();
|
||||||
|
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
|
||||||
|
"Kill amber-falcon",
|
||||||
|
);
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
||||||
|
window.fetch = ((url: string) => {
|
||||||
|
if (String(url).includes("force=true")) {
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
code: "kill_guarded",
|
||||||
|
message: "worktree has unsaved work",
|
||||||
|
unsaved_work: { untracked_files: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 409, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const s = session({ blocked_work: { untracked_files: 1 } });
|
||||||
|
mount(s);
|
||||||
|
raise(KILL_SESSION_EVENT, s);
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||||
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rename from the action page", () => {
|
||||||
|
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
const el = mount(s);
|
||||||
|
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
||||||
|
folder?.click();
|
||||||
|
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
||||||
|
raise(RENAME_SESSION_EVENT, s);
|
||||||
|
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
const el = mount(s);
|
||||||
|
raise(RENAME_SESSION_EVENT, s);
|
||||||
|
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||||
|
expect(input?.value).toBe("amber-falcon");
|
||||||
|
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
||||||
|
});
|
||||||
|
});
|
||||||
+40
-1
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kills straight away, without a first confirmation", async () => {
|
it("confirms before an unguarded kill (#160)", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([plain])).el;
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("kills nothing when the confirmation is cancelled (#160)", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof fetch;
|
||||||
|
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
|
||||||
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||||
|
await flush();
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
|
||||||
|
const blocked = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000008",
|
||||||
|
blocked_work: { untracked_files: 1 },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
|
expect(dialog?.textContent).toContain("refused unless it is forced");
|
||||||
|
});
|
||||||
|
|
||||||
it("offers the override when the daemon refuses a guarded kill", async () => {
|
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([blocked])).el;
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
const dialog = document.querySelector(".kill-dialog");
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
expect(dialog?.textContent).toContain("1 untracked file");
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||||
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([blocked])).el;
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||||
|
|||||||
+115
-40
@@ -1,12 +1,13 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
import { openForceKillDialog } from "./killDialog.js";
|
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { toggleHelp } from "./help.js";
|
import { toggleHelp } from "./help.js";
|
||||||
import { CommandSet, defineCommands } from "./commands.js";
|
import { CommandSet, defineCommands } from "./commands.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { InTheWay, Session } from "./types.js";
|
import type { InTheWay, Session } from "./types.js";
|
||||||
import {
|
import {
|
||||||
|
abbreviateCwd,
|
||||||
abbreviateFolderName,
|
abbreviateFolderName,
|
||||||
blockerStats,
|
blockerStats,
|
||||||
customSessionName,
|
customSessionName,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
sessionBlockers,
|
sessionBlockers,
|
||||||
isProvisionalName,
|
isProvisionalName,
|
||||||
sessionName,
|
sessionName,
|
||||||
|
sessionTreePath,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +39,15 @@ export function requestOpenSpawn(): void {
|
|||||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ask the shell to open the command bar with an empty query. Dispatched by
|
||||||
|
* the sidebar's search field, which is the touch entry point to the bar
|
||||||
|
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
|
||||||
|
* Handled in main.ts, which owns the session snapshot the bar needs. */
|
||||||
|
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
|
||||||
|
export function requestOpenCommandBar(): void {
|
||||||
|
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||||
* connection pool. */
|
* connection pool. */
|
||||||
@@ -50,6 +61,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
|||||||
* ("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";
|
||||||
|
|
||||||
|
/** Ask the sidebar to kill a session — the command bar's action page picking
|
||||||
|
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
||||||
|
* through the same confirm-and-blockers flow the card's ✕ does
|
||||||
|
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
||||||
|
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
||||||
|
|
||||||
|
/** Ask the sidebar to rename a session — the command bar's action page picking
|
||||||
|
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
||||||
|
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
||||||
|
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
||||||
|
|
||||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||||
* hash navigation, but a split/drag can move focus without ever touching
|
* hash navigation, but a split/drag can move focus without ever touching
|
||||||
* `location.hash` — so a click back onto a session whose hash still
|
* `location.hash` — so a click back onto a session whose hash still
|
||||||
@@ -208,7 +230,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
killBtn.addEventListener("click", (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
void requestKill(cur, sessionName(cur));
|
requestKill(cur, sessionName(cur));
|
||||||
});
|
});
|
||||||
row.addEventListener("click", () => {
|
row.addEventListener("click", () => {
|
||||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||||
@@ -226,7 +248,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
{
|
{
|
||||||
label: "Kill session",
|
label: "Kill session",
|
||||||
danger: true,
|
danger: true,
|
||||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
onSelect: () => requestKill(cur, sessionName(cur)),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -271,26 +293,33 @@ function treeSignature(node: TreeNode): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
* Kill behind a confirmation (#160): a kill is destructive and unattended
|
||||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
* agents live in these sessions, so the web asks first, the way the TUI always
|
||||||
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
* has. The daemon decides the rest — it refuses a kill over a worktree holding
|
||||||
* a forced kill. Anything else surfaces as an alert.
|
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
|
||||||
|
* a forced kill. Anything else surfaces in the dialog that asked.
|
||||||
*/
|
*/
|
||||||
async function requestKill(s: Session, name: string): Promise<void> {
|
function requestKill(s: Session, name: string): void {
|
||||||
try {
|
openKillConfirmDialog({
|
||||||
await killSession(s.id);
|
name,
|
||||||
} catch (e) {
|
blockers: sessionBlockers(s),
|
||||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
onKill: async () => {
|
||||||
openForceKillDialog({
|
try {
|
||||||
name,
|
await killSession(s.id);
|
||||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
} catch (e) {
|
||||||
reason: e.detail,
|
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||||
onForce: () => killSession(s.id, true),
|
openForceKillDialog({
|
||||||
});
|
name,
|
||||||
return;
|
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||||
}
|
reason: e.detail,
|
||||||
alert(`Failed to kill: ${(e as Error).message}`);
|
onForce: () => killSession(s.id, true),
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,16 +377,12 @@ function beginRename(row: HTMLElement, s: Session): void {
|
|||||||
input.select();
|
input.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
// The tree has one empty state: no sessions at all. The old "No matches" one
|
||||||
|
// belonged to the inline substring filter, which the command bar replaced
|
||||||
|
// (#157) — the bar's own no-results state took over with it.
|
||||||
|
function renderEmpty(onSpawn: () => void): HTMLElement {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "empty";
|
el.className = "empty";
|
||||||
if (filtering) {
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="empty-glyph" aria-hidden="true">⌕</div>
|
|
||||||
<div class="empty-title">No matches</div>
|
|
||||||
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||||
<div class="empty-title">No sessions yet</div>
|
<div class="empty-title">No sessions yet</div>
|
||||||
@@ -428,12 +453,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
// renders with absolute paths until it lands.
|
// renders with absolute paths until it lands.
|
||||||
let homeDir: string | undefined;
|
let homeDir: string | undefined;
|
||||||
|
|
||||||
|
// The field never holds a query: it is the touch entry point to the command
|
||||||
|
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
|
||||||
|
// on-screen keyboard over a field nothing is typed into.
|
||||||
const searchHeader = document.createElement("header");
|
const searchHeader = document.createElement("header");
|
||||||
searchHeader.className = "sidebar-search";
|
searchHeader.className = "sidebar-search";
|
||||||
searchHeader.innerHTML = `
|
searchHeader.innerHTML = `
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
class="search"
|
class="search"
|
||||||
|
readonly
|
||||||
placeholder="Search sessions…"
|
placeholder="Search sessions…"
|
||||||
aria-label="Search sessions"
|
aria-label="Search sessions"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -542,11 +571,23 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
handle.addEventListener("pointerup", endDrag);
|
handle.addEventListener("pointerup", endDrag);
|
||||||
handle.addEventListener("pointercancel", endDrag);
|
handle.addEventListener("pointercancel", endDrag);
|
||||||
|
|
||||||
|
// Touch entry (#157): the field is a button that looks like a field. It
|
||||||
|
// never holds a query — focusing or tapping it hands the keyboard straight
|
||||||
|
// to the bar's own input, which is also why it blurs first: leaving the
|
||||||
|
// focus here would make the bar impossible to close without reopening it.
|
||||||
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
||||||
let filter = "";
|
const openBar = () => {
|
||||||
search.addEventListener("input", () => {
|
search.value = "";
|
||||||
filter = search.value;
|
search.blur();
|
||||||
refresh();
|
requestOpenCommandBar();
|
||||||
|
};
|
||||||
|
search.addEventListener("focus", openBar);
|
||||||
|
// A tap can land without a focus event (iOS Safari on a blurred field), so
|
||||||
|
// the pointer path opens the bar too; the blur above keeps the two from
|
||||||
|
// stacking two overlays.
|
||||||
|
search.addEventListener("pointerdown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openBar();
|
||||||
});
|
});
|
||||||
|
|
||||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||||
@@ -685,7 +726,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
},
|
},
|
||||||
"list.kill": () => {
|
"list.kill": () => {
|
||||||
const hit = highlightedSession();
|
const hit = highlightedSession();
|
||||||
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
if (hit) requestKill(hit.session, sessionName(hit.session));
|
||||||
},
|
},
|
||||||
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
||||||
}),
|
}),
|
||||||
@@ -703,8 +744,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
if (renamingId !== null) return;
|
if (renamingId !== null) return;
|
||||||
const state = store.snapshot();
|
const state = store.snapshot();
|
||||||
const sessions = [...state.sessions.values()];
|
const sessions = [...state.sessions.values()];
|
||||||
const tree = buildTree(sessions, homeDir, filter);
|
const tree = buildTree(sessions, homeDir);
|
||||||
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
|
||||||
if (signature === renderedSignature) {
|
if (signature === renderedSignature) {
|
||||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||||
@@ -715,9 +756,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
renderedSignature = signature;
|
renderedSignature = signature;
|
||||||
treeHost.replaceChildren();
|
treeHost.replaceChildren();
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
treeHost.appendChild(
|
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
||||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (tree.kind === "folder") {
|
if (tree.kind === "folder") {
|
||||||
@@ -745,6 +784,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
};
|
};
|
||||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
|
||||||
|
// The command bar's action page (#151) picks an action and hands it here:
|
||||||
|
// both flows are the ones the card already has, reached by session rather
|
||||||
|
// than by row.
|
||||||
|
const killHandler = (ev: Event) => {
|
||||||
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
|
requestKill(s, sessionName(s));
|
||||||
|
};
|
||||||
|
const renameHandler = (ev: Event) => {
|
||||||
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
|
const row = rowForSession(s);
|
||||||
|
if (row) beginRename(row, s);
|
||||||
|
};
|
||||||
|
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
||||||
|
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||||
|
|
||||||
|
/** The card a session is rendered as, expanding whatever folder hides it
|
||||||
|
* first: the rename prompt lives inside the card, so a collapsed folder
|
||||||
|
* would otherwise swallow the request. */
|
||||||
|
function rowForSession(s: Session): HTMLElement | null {
|
||||||
|
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
||||||
|
const found = find();
|
||||||
|
if (found) return found;
|
||||||
|
// Folder paths are the abbreviated directory's segments joined back up
|
||||||
|
// (see buildTree), so the session's own path is normalised the same way
|
||||||
|
// before it is compared against them.
|
||||||
|
const cwd = sessionTreePath(s);
|
||||||
|
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
||||||
|
for (const folder of [...collapsed]) {
|
||||||
|
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
||||||
|
}
|
||||||
|
rerenderHandler();
|
||||||
|
return find();
|
||||||
|
}
|
||||||
|
|
||||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||||
|
|
||||||
@@ -794,6 +867,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
// refusing to refresh.
|
// refusing to refresh.
|
||||||
renamingId = null;
|
renamingId = null;
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
||||||
|
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+420
-6
@@ -1,18 +1,30 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
import {
|
||||||
|
KILL_SESSION_EVENT,
|
||||||
|
RENAME_SESSION_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
} from "./catalog.js";
|
||||||
import {
|
import {
|
||||||
barGroups,
|
barGroups,
|
||||||
barRows,
|
barRows,
|
||||||
closeCommandBar,
|
closeCommandBar,
|
||||||
|
commandBarCandidates,
|
||||||
|
dedupeCommands,
|
||||||
|
directoryBarCandidates,
|
||||||
|
directoryCandidate,
|
||||||
isCommandBarOpen,
|
isCommandBarOpen,
|
||||||
openCommandBar,
|
openCommandBar,
|
||||||
orderSessionsForBar,
|
orderSessionsForBar,
|
||||||
|
parseBarQuery,
|
||||||
sessionBarCandidates,
|
sessionBarCandidates,
|
||||||
|
sessionActionCandidates,
|
||||||
sessionCandidate,
|
sessionCandidate,
|
||||||
|
SPAWN_DIRECTORY_EVENT,
|
||||||
} from "./commandBar.js";
|
} from "./commandBar.js";
|
||||||
import { COMMAND_SPECS } from "./commands.js";
|
import { COMMAND_SPECS, type Command } from "./commands.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
function session(id: string, over: Partial<Session> = {}): Session {
|
function session(id: string, over: Partial<Session> = {}): Session {
|
||||||
@@ -173,8 +185,12 @@ describe("the overlay", () => {
|
|||||||
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Sessions only: the mixed list's command half is the sigil describe's
|
||||||
|
// business, and the registry is whatever another test file happened to bind.
|
||||||
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
||||||
return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} });
|
return openCommandBar(
|
||||||
|
over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
it("is a registry command, so the help overlay lists it", () => {
|
it("is a registry command, so the help overlay lists it", () => {
|
||||||
@@ -252,7 +268,7 @@ describe("the overlay", () => {
|
|||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||||
openCommandBar({ sessions, homeDir: "/home/me" });
|
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
|
||||||
press("ArrowDown");
|
press("ArrowDown");
|
||||||
press("Enter");
|
press("Enter");
|
||||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||||
@@ -262,7 +278,7 @@ describe("the overlay", () => {
|
|||||||
|
|
||||||
it("Enter on an empty result list does nothing", () => {
|
it("Enter on an empty result list does nothing", () => {
|
||||||
const onOpenSession = vi.fn();
|
const onOpenSession = vi.fn();
|
||||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||||
type("zzz");
|
type("zzz");
|
||||||
press("Enter");
|
press("Enter");
|
||||||
expect(onOpenSession).not.toHaveBeenCalled();
|
expect(onOpenSession).not.toHaveBeenCalled();
|
||||||
@@ -301,9 +317,407 @@ describe("the overlay", () => {
|
|||||||
|
|
||||||
it("clicking a row opens that session", () => {
|
it("clicking a row opens that session", () => {
|
||||||
const onOpenSession = vi.fn();
|
const onOpenSession = vi.fn();
|
||||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||||
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
||||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||||
expect(isCommandBarOpen()).toBe(false);
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- the sigils (#150) ----
|
||||||
|
|
||||||
|
function command(id: string, label: string, keys: string[], run = () => {}): Command {
|
||||||
|
return { id, label, group: "Test", keys, scope: "global", run };
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
function rowKeys(): string[][] {
|
||||||
|
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
|
||||||
|
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parsing the query line", () => {
|
||||||
|
it("reads a sigil only in first position", () => {
|
||||||
|
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
|
||||||
|
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
|
||||||
|
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
||||||
|
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a sigil character elsewhere in the query literal", () => {
|
||||||
|
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
|
||||||
|
// The case that matters: the second `~` of a directory query reaches the
|
||||||
|
// matcher.
|
||||||
|
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the mixed list when the sigil is backspaced away", () => {
|
||||||
|
expect(parseBarQuery(">").sigil).toBe(">");
|
||||||
|
expect(parseBarQuery("").sigil).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("command candidates", () => {
|
||||||
|
it("dedupes by action, not by chord", () => {
|
||||||
|
const run = () => {};
|
||||||
|
const kill = command("a.kill", "Kill the session", ["x"], run);
|
||||||
|
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
|
||||||
|
const other = command("c.rename", "Rename the session", ["r"]);
|
||||||
|
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches on the label only, and keeps the chord for display", () => {
|
||||||
|
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
|
||||||
|
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
|
||||||
|
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("directory candidates", () => {
|
||||||
|
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
||||||
|
const c = directoryCandidate("/home/me/dev/api", "/home/me");
|
||||||
|
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
||||||
|
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
|
||||||
|
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
|
||||||
|
"/home/me/dev/api",
|
||||||
|
"/tmp/scratch",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the sigils in the overlay", () => {
|
||||||
|
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
|
||||||
|
const ran: string[] = [];
|
||||||
|
const commands = [
|
||||||
|
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
|
||||||
|
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ran.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||||
|
return openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands,
|
||||||
|
onOpenSession: () => {},
|
||||||
|
loadDirectories: async () => [],
|
||||||
|
listDirEntries: async () => [],
|
||||||
|
onSpawnDirectory: () => {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||||
|
open();
|
||||||
|
type(">");
|
||||||
|
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
|
||||||
|
expect(rowKeys()).toEqual([["n"], ["x"]]);
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a command on its label and never on its chord", () => {
|
||||||
|
open();
|
||||||
|
type(">kill");
|
||||||
|
expect(rowNames()).toEqual(["Kill the highlighted session"]);
|
||||||
|
// `n` and `x` are chords, not label text: querying one finds nothing that
|
||||||
|
// the label does not also justify.
|
||||||
|
type(">ctrlx");
|
||||||
|
expect(rowNames()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the highlighted command on Enter", () => {
|
||||||
|
open();
|
||||||
|
type(">kill");
|
||||||
|
press("Enter");
|
||||||
|
expect(ran).toEqual(["list.kill"]);
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||||
|
open();
|
||||||
|
type("s");
|
||||||
|
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||||
|
"Sessions",
|
||||||
|
"Commands",
|
||||||
|
]);
|
||||||
|
type(">spawn");
|
||||||
|
expect(rowNames()).toEqual(["Open the spawn form"]);
|
||||||
|
// Backspacing the sigil away is ordinary editing.
|
||||||
|
type("spawn");
|
||||||
|
expect(rowNames()).toContain("Open the spawn form");
|
||||||
|
type("");
|
||||||
|
expect(rowNames()).toEqual(["api"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists recent dirs then projects under `~`", async () => {
|
||||||
|
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
|
||||||
|
type("~");
|
||||||
|
await flush();
|
||||||
|
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||||
|
const asked: string[] = [];
|
||||||
|
open({
|
||||||
|
loadDirectories: async () => ["/home/me/notes"],
|
||||||
|
listDirEntries: async (dir) => {
|
||||||
|
asked.push(dir);
|
||||||
|
return ["maestro", "api"];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The second `~` is a literal query character and reaches the matcher.
|
||||||
|
type("~~/dev/mae");
|
||||||
|
await flush();
|
||||||
|
expect(asked).toEqual(["~/dev/"]);
|
||||||
|
expect(rowNames()).toEqual(["~/dev/maestro"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns in the highlighted directory on Enter", async () => {
|
||||||
|
const spawned: string[] = [];
|
||||||
|
open({
|
||||||
|
loadDirectories: async () => ["~/dev/api"],
|
||||||
|
onSpawnDirectory: (path) => spawned.push(path),
|
||||||
|
});
|
||||||
|
type("~api");
|
||||||
|
await flush();
|
||||||
|
press("Enter");
|
||||||
|
expect(spawned).toEqual(["/home/me/dev/api"]);
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||||
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||||
|
openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands,
|
||||||
|
onOpenSession: () => {},
|
||||||
|
loadDirectories: async () => ["/home/me/dev/api"],
|
||||||
|
listDirEntries: async () => [],
|
||||||
|
});
|
||||||
|
type("~");
|
||||||
|
await flush();
|
||||||
|
press("Enter");
|
||||||
|
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||||
|
expect(seen).toEqual(["/home/me/dev/api"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the focus round-trip over a pane (#152)", () => {
|
||||||
|
function terminalTextarea(): HTMLTextAreaElement {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.className = "xterm-helper-textarea";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
return ta;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("hands the keyboard back to the terminal that had it", () => {
|
||||||
|
const ta = terminalTextarea();
|
||||||
|
ta.focus();
|
||||||
|
openCommandBar({ sessions: [], commands: [] });
|
||||||
|
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
|
||||||
|
press("Escape");
|
||||||
|
expect(document.activeElement).toBe(ta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no focus change to the daemon, opening or closing", () => {
|
||||||
|
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
try {
|
||||||
|
const ta = terminalTextarea();
|
||||||
|
ta.focus();
|
||||||
|
openCommandBar({ sessions: [], commands: [] });
|
||||||
|
press("Escape");
|
||||||
|
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
|
||||||
|
String(url).includes("/focus"),
|
||||||
|
);
|
||||||
|
expect(focusCalls).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the session action page (#151)", () => {
|
||||||
|
const sessions = [
|
||||||
|
session("api", {
|
||||||
|
metadata: { name: "api" },
|
||||||
|
current_cwd: "/home/me/dev/api",
|
||||||
|
last_focused_at: "2026-01-01T11:00:00Z",
|
||||||
|
}),
|
||||||
|
session("worker", {
|
||||||
|
metadata: { name: "worker" },
|
||||||
|
current_cwd: "/home/me/dev/worker",
|
||||||
|
last_focused_at: "2026-01-01T10:00:00Z",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||||
|
return openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands: [],
|
||||||
|
onOpenSession: () => {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerText(): string | undefined {
|
||||||
|
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||||
|
expect(activeName()).toBe("Kill session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the session being acted on", () => {
|
||||||
|
open();
|
||||||
|
press("ArrowDown");
|
||||||
|
press("Tab");
|
||||||
|
expect(headerText()).toContain("worker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the kind headers, since the page is one kind", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays fuzzy-searchable over the three actions", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type("ren");
|
||||||
|
expect(rowNames()).toEqual(["Rename session"]);
|
||||||
|
type("spwn");
|
||||||
|
expect(rowNames()).toEqual(["Spawn similar"]);
|
||||||
|
type("zzz");
|
||||||
|
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a command row", () => {
|
||||||
|
openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
|
||||||
|
onOpenSession: () => {},
|
||||||
|
});
|
||||||
|
type(">");
|
||||||
|
const before = rowNames();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(before);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a directory row", async () => {
|
||||||
|
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
|
||||||
|
type("~");
|
||||||
|
await Promise.resolve();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["~/dev/api"]);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||||
|
open();
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
expect(headerText()).toContain("worker");
|
||||||
|
press("Escape");
|
||||||
|
expect(isCommandBarOpen()).toBe(true);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the result list on backspace at an empty query", () => {
|
||||||
|
open();
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
type("");
|
||||||
|
press("Backspace");
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps backspace ordinary while the page's query has text", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type("ren");
|
||||||
|
press("Backspace");
|
||||||
|
expect(headerText()).toContain("api");
|
||||||
|
expect(rowNames()).toEqual(["Rename session"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the bar when the page's Escape has already been used", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
press("Escape");
|
||||||
|
press("Escape");
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (const [hook, key] of [
|
||||||
|
["onKillSession", "Kill session"],
|
||||||
|
["onRenameSession", "Rename session"],
|
||||||
|
["onSpawnSimilar", "Spawn similar"],
|
||||||
|
] as const) {
|
||||||
|
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
type(key);
|
||||||
|
press("Enter");
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
}
|
||||||
|
expect(seen).toEqual([
|
||||||
|
"onKillSession:worker",
|
||||||
|
"onRenameSession:worker",
|
||||||
|
"onSpawnSimilar:worker",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands each action to the flow it already has, by default", () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const listeners: Array<[string, EventListener]> = [
|
||||||
|
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
];
|
||||||
|
for (const [name, fn] of listeners) document.addEventListener(name, fn);
|
||||||
|
try {
|
||||||
|
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type(action);
|
||||||
|
press("Enter");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
|
||||||
|
}
|
||||||
|
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no action beyond the three", () => {
|
||||||
|
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||||
|
"kill",
|
||||||
|
"rename",
|
||||||
|
"spawn-similar",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+460
-32
@@ -1,33 +1,58 @@
|
|||||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||||
// list, opened from the sidebar, closed with Escape.
|
// list, opened from the sidebar, closed with Escape.
|
||||||
//
|
//
|
||||||
// Sessions are the only kind here (#149); the sigils, the action page and the
|
// Three kinds live here: sessions and commands, ranked together by default,
|
||||||
// terminal-focus binding land in #150-#152.
|
// and directories, which need the `~` sigil. `tab` on a session drills into
|
||||||
|
// its action page (#151), which is the same overlay over three actions.
|
||||||
|
|
||||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||||
|
import {
|
||||||
|
KILL_SESSION_EVENT,
|
||||||
|
RENAME_SESSION_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
} from "./catalog.js";
|
||||||
|
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
||||||
import {
|
import {
|
||||||
matchAndRank,
|
matchAndRank,
|
||||||
type CandidateKind,
|
type CandidateKind,
|
||||||
|
type CommandCandidate,
|
||||||
|
type DirectoryCandidate,
|
||||||
type MatchRange,
|
type MatchRange,
|
||||||
type MatchResult,
|
type MatchResult,
|
||||||
type SessionCandidate,
|
type SessionCandidate,
|
||||||
} from "./matcher.js";
|
} from "./matcher.js";
|
||||||
|
import { splitPath } from "./paths.js";
|
||||||
import { splitCommand } from "./toolIcon.js";
|
import { splitCommand } from "./toolIcon.js";
|
||||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||||
|
|
||||||
/** A matcher candidate that remembers which session it came from, so a result
|
/** A matcher candidate that remembers what it came from, so a result can be
|
||||||
* can be acted on without a second lookup. The extra property is invisible to
|
* acted on without a second lookup. The extra property is invisible to the
|
||||||
* the matcher, which only ever reads `kind` and `fields`. */
|
* matcher, which only ever reads `kind` and `fields`. */
|
||||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||||
|
export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||||
|
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
||||||
|
* ranks; `path` is the one a spawn is made in. */
|
||||||
|
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||||
|
|
||||||
export type BarCandidate = SessionBarCandidate;
|
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||||
|
* single `label` field, ranked exactly like a registry command — and carries
|
||||||
|
* the action it stands for instead of a registry entry. */
|
||||||
|
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||||
|
|
||||||
|
export type BarCandidate =
|
||||||
|
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
|
||||||
export type BarResult = MatchResult<BarCandidate>;
|
export type BarResult = MatchResult<BarCandidate>;
|
||||||
|
|
||||||
|
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
||||||
|
* shell owns the spawn, which is the same fast-forward path `N` takes. */
|
||||||
|
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||||
|
|
||||||
/** One kind's slice of the ranked list, in rank order. */
|
/** One kind's slice of the ranked list, in rank order. */
|
||||||
export interface BarGroup {
|
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||||
kind: CandidateKind;
|
kind: CandidateKind;
|
||||||
title: string;
|
title: string;
|
||||||
results: BarResult[];
|
results: MatchResult<C>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||||
@@ -36,6 +61,63 @@ const KIND_TITLES: Record<CandidateKind, string> = {
|
|||||||
directory: "Directories",
|
directory: "Directories",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- the sigils ----
|
||||||
|
|
||||||
|
export type BarSigil = ">" | "~";
|
||||||
|
|
||||||
|
export interface BarQuery {
|
||||||
|
/** The sigil in force, or null for the mixed list. */
|
||||||
|
sigil: BarSigil | null;
|
||||||
|
/** What the matcher sees: everything after a sigil, or the whole query. */
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a raw query line into its sigil and the query proper.
|
||||||
|
*
|
||||||
|
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
||||||
|
* literal query character, which is what a directory query with a second `~`
|
||||||
|
* in it depends on. Backspacing past a sigil needs no special case either —
|
||||||
|
* the first character goes and the query is a mixed one again. */
|
||||||
|
export function parseBarQuery(raw: string): BarQuery {
|
||||||
|
const first = raw[0];
|
||||||
|
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
||||||
|
return { sigil: null, query: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the session action page ----
|
||||||
|
|
||||||
|
/** The actions a session's action page offers, and no others: the bar picks
|
||||||
|
* one and hands off to the flow that already implements it
|
||||||
|
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||||
|
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||||
|
|
||||||
|
export interface SessionAction {
|
||||||
|
id: SessionActionId;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||||
|
{ id: "kill", label: "Kill session" },
|
||||||
|
{ id: "rename", label: "Rename session" },
|
||||||
|
{ id: "spawn-similar", label: "Spawn similar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The action page's candidates, in the order above — which is the order an
|
||||||
|
* empty query lists them in, since the matcher keeps input order. */
|
||||||
|
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||||
|
return SESSION_ACTIONS.map((action) => ({
|
||||||
|
kind: "command",
|
||||||
|
action,
|
||||||
|
fields: { label: action.label },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||||
|
* `command` kind, so what separates them is what they carry. */
|
||||||
|
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||||
|
return candidate.kind === "command" && "action" in candidate;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- candidates ----
|
// ---- candidates ----
|
||||||
|
|
||||||
function focusedAt(s: Session): number | null {
|
function focusedAt(s: Session): number | null {
|
||||||
@@ -99,14 +181,92 @@ export function sessionBarCandidates(
|
|||||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop the commands that would list twice. Several chords can be bound to one
|
||||||
|
* action — the registry already carries its aliases on one entry, but nothing
|
||||||
|
* stops two entries running the same handler, and listed naively that is two
|
||||||
|
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
||||||
|
* displayed, so a command with two of them is still one row. */
|
||||||
|
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
||||||
|
const seenRun = new Set<Command["run"]>();
|
||||||
|
const seenLabel = new Set<string>();
|
||||||
|
const kept: Command[] = [];
|
||||||
|
for (const command of commands) {
|
||||||
|
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
||||||
|
seenRun.add(command.run);
|
||||||
|
seenLabel.add(command.label);
|
||||||
|
kept.push(command);
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every bound command as a candidate, in registry order. Only the label is a
|
||||||
|
* field: the chord is displayed beside the row, never matched, because
|
||||||
|
* matching it would make `>` results depend on a user's rebinds. */
|
||||||
|
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
||||||
|
return dedupeCommands(commands).map((command) => ({
|
||||||
|
kind: "command",
|
||||||
|
command,
|
||||||
|
fields: { label: command.label },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
||||||
|
* a path the operator typed or `/api/dirs` completed. */
|
||||||
|
export function expandHome(path: string, homeDir?: string): string {
|
||||||
|
if (!homeDir) return path;
|
||||||
|
if (path === "~") return homeDir;
|
||||||
|
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
||||||
|
const absolute = expandHome(path, homeDir);
|
||||||
|
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
||||||
|
* first mention of each — recent dirs (newest first) ahead of the project
|
||||||
|
* list, which is the empty-query order the contract asks for. */
|
||||||
|
export function directoryBarCandidates(
|
||||||
|
paths: readonly string[],
|
||||||
|
homeDir?: string,
|
||||||
|
): DirectoryBarCandidate[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: DirectoryBarCandidate[] = [];
|
||||||
|
for (const path of paths) {
|
||||||
|
if (path === "") continue;
|
||||||
|
const candidate = directoryCandidate(path, homeDir);
|
||||||
|
if (seen.has(candidate.path)) continue;
|
||||||
|
seen.add(candidate.path);
|
||||||
|
out.push(candidate);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The directory list the bar starts from: what the spawn form already knows —
|
||||||
|
* `GET /api/recent-dirs` and the project list — either alone if the other
|
||||||
|
* fails. */
|
||||||
|
export async function loadBarDirectories(): Promise<string[]> {
|
||||||
|
const [recents, discovered] = await Promise.all([
|
||||||
|
recentDirs()
|
||||||
|
.then((r) => r.dirs.map((d) => d.cwd))
|
||||||
|
.catch(() => [] as string[]),
|
||||||
|
fetchProjects()
|
||||||
|
.then((list) => list.map((p) => p.path))
|
||||||
|
.catch(() => [] as string[]),
|
||||||
|
]);
|
||||||
|
return [...recents, ...discovered];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- grouping ----
|
// ---- grouping ----
|
||||||
|
|
||||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||||
* and groups appear in the order their best-scoring member does — so the first
|
* and groups appear in the order their best-scoring member does — so the first
|
||||||
* row under the first header is the globally best result. */
|
* row under the first header is the globally best result. */
|
||||||
export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
export function barGroups<C extends BarCandidate>(
|
||||||
const groups: BarGroup[] = [];
|
results: readonly MatchResult<C>[],
|
||||||
|
): BarGroup<C>[] {
|
||||||
|
const groups: BarGroup<C>[] = [];
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const kind = result.candidate.kind;
|
const kind = result.candidate.kind;
|
||||||
let group = groups.find((g) => g.kind === kind);
|
let group = groups.find((g) => g.kind === kind);
|
||||||
@@ -122,7 +282,10 @@ export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
|||||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||||
* this list. */
|
* this list. */
|
||||||
export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] {
|
export function barRows<C extends BarCandidate>(
|
||||||
|
query: string,
|
||||||
|
candidates: readonly C[],
|
||||||
|
): MatchResult<C>[] {
|
||||||
return matchAndRank(query, candidates);
|
return matchAndRank(query, candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +308,25 @@ export function renderHighlighted(text: string, ranges: readonly MatchRange[]):
|
|||||||
return frag;
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The secondary line under a row's name: the field the match was found in when
|
/** The name line of a row: the session's name, the command's label, or the
|
||||||
* that was not the name, so a session found through its directory says so. */
|
* directory's path. */
|
||||||
|
function rowName(candidate: BarCandidate): string {
|
||||||
|
switch (candidate.kind) {
|
||||||
|
case "session":
|
||||||
|
return candidate.fields.name;
|
||||||
|
case "command":
|
||||||
|
return candidate.fields.label;
|
||||||
|
case "directory":
|
||||||
|
return candidate.fields.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The secondary line under a session row: the field the match was found in
|
||||||
|
* when that was not the name, so a session found through its directory says
|
||||||
|
* so. Only sessions have one — a command shows keycaps instead, and a
|
||||||
|
* directory's path is already the name. */
|
||||||
function metaText(result: BarResult): string | undefined {
|
function metaText(result: BarResult): string | undefined {
|
||||||
|
if (result.candidate.kind !== "session") return undefined;
|
||||||
const fields = result.candidate.fields;
|
const fields = result.candidate.fields;
|
||||||
switch (result.field) {
|
switch (result.field) {
|
||||||
case "directory":
|
case "directory":
|
||||||
@@ -162,22 +341,46 @@ function metaText(result: BarResult): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(result: BarResult, index: number): HTMLElement {
|
function renderRow(result: BarResult, index: number): HTMLElement {
|
||||||
|
const candidate = result.candidate;
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "cmdbar-row";
|
row.className = "cmdbar-row";
|
||||||
row.setAttribute("role", "option");
|
row.setAttribute("role", "option");
|
||||||
row.id = `cmdbar-row-${index}`;
|
row.id = `cmdbar-row-${index}`;
|
||||||
row.dataset.index = String(index);
|
row.dataset.index = String(index);
|
||||||
row.dataset.sessionId = result.candidate.session.id;
|
row.dataset.kind = candidate.kind;
|
||||||
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
|
if (candidate.kind === "session") {
|
||||||
|
row.dataset.sessionId = candidate.session.id;
|
||||||
|
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||||
|
} else if (isActionCandidate(candidate)) {
|
||||||
|
row.dataset.actionId = candidate.action.id;
|
||||||
|
} else if (candidate.kind === "command") {
|
||||||
|
row.dataset.commandId = candidate.command.id;
|
||||||
|
} else {
|
||||||
|
row.dataset.path = candidate.path;
|
||||||
|
}
|
||||||
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.className = "cmdbar-row-name";
|
name.className = "cmdbar-row-name";
|
||||||
const nameText = result.candidate.fields.name;
|
const nameText = rowName(candidate);
|
||||||
name.append(
|
const nameMatched =
|
||||||
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
|
result.field === "name" || result.field === "label" || result.field === "path";
|
||||||
);
|
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
|
||||||
row.appendChild(name);
|
row.appendChild(name);
|
||||||
|
|
||||||
|
// The chord is teaching material, not a match target: it is rendered from
|
||||||
|
// the live registry entry and never fed to the matcher.
|
||||||
|
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
|
||||||
|
const keys = document.createElement("span");
|
||||||
|
keys.className = "cmdbar-row-keys";
|
||||||
|
for (const cap of keyCaps(candidate.command.keys[0])) {
|
||||||
|
const kbd = document.createElement("kbd");
|
||||||
|
kbd.textContent = cap;
|
||||||
|
keys.appendChild(kbd);
|
||||||
|
}
|
||||||
|
row.appendChild(keys);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
const meta = metaText(result);
|
const meta = metaText(result);
|
||||||
if (meta) {
|
if (meta) {
|
||||||
const el = document.createElement("span");
|
const el = document.createElement("span");
|
||||||
@@ -189,6 +392,12 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||||
|
mixed: "Go to a session…",
|
||||||
|
">": "Run a command…",
|
||||||
|
"~": "Spawn in a directory…",
|
||||||
|
};
|
||||||
|
|
||||||
// ---- the overlay ----
|
// ---- the overlay ----
|
||||||
|
|
||||||
export interface CommandBarOptions {
|
export interface CommandBarOptions {
|
||||||
@@ -200,6 +409,25 @@ export interface CommandBarOptions {
|
|||||||
* sidebar's click event — which is what the contract requires; tests pass
|
* sidebar's click event — which is what the contract requires; tests pass
|
||||||
* their own. */
|
* their own. */
|
||||||
onOpenSession?: (sessionId: string) => void;
|
onOpenSession?: (sessionId: string) => void;
|
||||||
|
/** The command registry to list under `>`. Defaults to every bound command,
|
||||||
|
* in registry order. */
|
||||||
|
commands?: readonly Command[];
|
||||||
|
/** The directory list behind `~`, fetched once on first use. */
|
||||||
|
loadDirectories?: () => Promise<string[]>;
|
||||||
|
/** One directory's subdirectories, for filling the list in as a path is
|
||||||
|
* typed. Defaults to `GET /api/dirs`. */
|
||||||
|
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||||
|
/** Spawn a session in the highlighted directory. Defaults to raising
|
||||||
|
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
||||||
|
* spawn. */
|
||||||
|
onSpawnDirectory?: (path: string) => void;
|
||||||
|
/** The action page's three actions (#151). Each defaults to raising the
|
||||||
|
* event the flow that already implements it listens on — the sidebar's
|
||||||
|
* confirm-and-blockers kill, its inline rename prompt, the spawn form
|
||||||
|
* seeded from the session. The bar picks the action and nothing more. */
|
||||||
|
onKillSession?: (session: Session) => void;
|
||||||
|
onRenameSession?: (session: Session) => void;
|
||||||
|
onSpawnSimilar?: (session: Session) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenBar {
|
interface OpenBar {
|
||||||
@@ -228,8 +456,29 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
const openSession =
|
const openSession =
|
||||||
opts.onOpenSession ??
|
opts.onOpenSession ??
|
||||||
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
||||||
|
const spawnDirectory =
|
||||||
|
opts.onSpawnDirectory ??
|
||||||
|
((path: string) =>
|
||||||
|
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
||||||
|
const sessionEvent =
|
||||||
|
(name: string) =>
|
||||||
|
(session: Session): void => {
|
||||||
|
document.dispatchEvent(new CustomEvent(name, { detail: session }));
|
||||||
|
};
|
||||||
|
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
|
||||||
|
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
|
||||||
|
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
|
||||||
|
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
||||||
|
const listEntries =
|
||||||
|
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
||||||
|
|
||||||
const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir);
|
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
|
||||||
|
opts.sessions,
|
||||||
|
opts.homeDir,
|
||||||
|
);
|
||||||
|
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
|
||||||
|
opts.commands ?? publishedCommands(),
|
||||||
|
);
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "cmdbar-overlay";
|
el.className = "cmdbar-overlay";
|
||||||
@@ -246,10 +495,10 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
input.setAttribute("role", "combobox");
|
input.setAttribute("role", "combobox");
|
||||||
input.setAttribute("aria-expanded", "true");
|
input.setAttribute("aria-expanded", "true");
|
||||||
input.setAttribute("aria-controls", "cmdbar-results");
|
input.setAttribute("aria-controls", "cmdbar-results");
|
||||||
input.setAttribute("aria-label", "Search sessions");
|
input.setAttribute("aria-label", "Search sessions, commands and directories");
|
||||||
input.setAttribute("autocomplete", "off");
|
input.setAttribute("autocomplete", "off");
|
||||||
input.spellcheck = false;
|
input.spellcheck = false;
|
||||||
input.placeholder = "Go to a session…";
|
input.placeholder = PLACEHOLDERS.mixed;
|
||||||
|
|
||||||
const results = document.createElement("div");
|
const results = document.createElement("div");
|
||||||
results.className = "cmdbar-results";
|
results.className = "cmdbar-results";
|
||||||
@@ -263,6 +512,98 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
let rows: BarResult[] = [];
|
let rows: BarResult[] = [];
|
||||||
let highlighted = 0;
|
let highlighted = 0;
|
||||||
|
|
||||||
|
// The action page (#151). `page` is the session being acted on, `stashed`
|
||||||
|
// the result-list query it was drilled into from — backing out restores it,
|
||||||
|
// so the round trip costs the operator nothing.
|
||||||
|
let page: Session | null = null;
|
||||||
|
let stashed = "";
|
||||||
|
let header: HTMLElement | null = null;
|
||||||
|
|
||||||
|
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||||
|
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||||
|
let directories: DirectoryBarCandidate[] = [];
|
||||||
|
let directoriesRequested = false;
|
||||||
|
let probed: DirectoryBarCandidate[] = [];
|
||||||
|
let probedDir: string | null = null;
|
||||||
|
const listings = new Map<string, string[]>();
|
||||||
|
|
||||||
|
function alive(): boolean {
|
||||||
|
return current?.el === el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the recents-and-projects list the first time `~` is used. */
|
||||||
|
function ensureDirectories(): void {
|
||||||
|
if (directoriesRequested) return;
|
||||||
|
directoriesRequested = true;
|
||||||
|
loadDirectories()
|
||||||
|
.then((paths) => {
|
||||||
|
directories = directoryBarCandidates(paths, opts.homeDir);
|
||||||
|
if (alive()) render();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* no directories is a shorter list, not an error state */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
|
||||||
|
* the directory it names is listed and its children join the candidates, so
|
||||||
|
* `~` reaches a directory that was never spawned in. */
|
||||||
|
function probeDirectories(query: string): void {
|
||||||
|
const { dir } = splitPath(query);
|
||||||
|
if (dir === "") {
|
||||||
|
probed = [];
|
||||||
|
probedDir = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dir === probedDir) return;
|
||||||
|
probedDir = dir;
|
||||||
|
const cached = listings.get(dir);
|
||||||
|
if (cached) {
|
||||||
|
probed = directoryBarCandidates(
|
||||||
|
cached.map((entry) => `${dir}${entry}`),
|
||||||
|
opts.homeDir,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probed = [];
|
||||||
|
listEntries(dir)
|
||||||
|
.then((entries) => {
|
||||||
|
listings.set(dir, entries);
|
||||||
|
if (!alive() || probedDir !== dir) return;
|
||||||
|
probed = directoryBarCandidates(
|
||||||
|
entries.map((entry) => `${dir}${entry}`),
|
||||||
|
opts.homeDir,
|
||||||
|
);
|
||||||
|
render();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* an unreadable directory contributes nothing */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||||
|
* list is mixed — sessions and commands together — except on an empty query,
|
||||||
|
* where the bar is a session switcher. */
|
||||||
|
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
||||||
|
if (page) return sessionActionCandidates();
|
||||||
|
switch (parsed.sigil) {
|
||||||
|
case ">":
|
||||||
|
return [...commandCandidates];
|
||||||
|
case "~": {
|
||||||
|
ensureDirectories();
|
||||||
|
probeDirectories(parsed.query);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return [...directories, ...probed].filter((c) =>
|
||||||
|
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return parsed.query === ""
|
||||||
|
? [...sessionCandidates]
|
||||||
|
: [...sessionCandidates, ...commandCandidates];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function paintHighlight(): void {
|
function paintHighlight(): void {
|
||||||
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
||||||
const active = Number(row.dataset.index) === highlighted;
|
const active = Number(row.dataset.index) === highlighted;
|
||||||
@@ -276,8 +617,28 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The line naming the session being acted on, so the target is never
|
||||||
|
* ambiguous. Present only while the action page is up. */
|
||||||
|
function paintHeader(): void {
|
||||||
|
header?.remove();
|
||||||
|
header = null;
|
||||||
|
if (!page) return;
|
||||||
|
header = document.createElement("div");
|
||||||
|
header.className = "cmdbar-page-header";
|
||||||
|
header.setAttribute("role", "presentation");
|
||||||
|
header.textContent = `Actions for "${sessionName(page)}"`;
|
||||||
|
panel.insertBefore(header, results);
|
||||||
|
}
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
rows = barRows(input.value, candidates);
|
// On the action page the query line is over the three actions, so there is
|
||||||
|
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||||
|
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
|
||||||
|
input.placeholder = page
|
||||||
|
? `Act on ${sessionName(page)}…`
|
||||||
|
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||||
|
paintHeader();
|
||||||
|
rows = barRows(parsed.query, candidatesFor(parsed));
|
||||||
// The highlight starts on the globally best row every time the list
|
// The highlight starts on the globally best row every time the list
|
||||||
// changes: an old index would point at a different result.
|
// changes: an old index would point at a different result.
|
||||||
highlighted = 0;
|
highlighted = 0;
|
||||||
@@ -294,11 +655,15 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
for (const group of barGroups(rows)) {
|
for (const group of barGroups(rows)) {
|
||||||
const header = document.createElement("div");
|
// While a sigil is in force there is one kind, so the header is
|
||||||
header.className = "cmdbar-group-title";
|
// dropped — and the action page is one kind too.
|
||||||
header.setAttribute("role", "presentation");
|
if (parsed.sigil === null && !page) {
|
||||||
header.textContent = group.title;
|
const header = document.createElement("div");
|
||||||
results.appendChild(header);
|
header.className = "cmdbar-group-title";
|
||||||
|
header.setAttribute("role", "presentation");
|
||||||
|
header.textContent = group.title;
|
||||||
|
results.appendChild(header);
|
||||||
|
}
|
||||||
for (const result of group.results) {
|
for (const result of group.results) {
|
||||||
const at = index;
|
const at = index;
|
||||||
const row = renderRow(result, at);
|
const row = renderRow(result, at);
|
||||||
@@ -317,19 +682,82 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
paintHighlight();
|
paintHighlight();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||||
|
* other kind, and none on the page itself, which has no session rows. */
|
||||||
|
function drillIn(): void {
|
||||||
|
const candidate = rows[highlighted]?.candidate;
|
||||||
|
if (page || candidate?.kind !== "session") return;
|
||||||
|
page = candidate.session;
|
||||||
|
stashed = input.value;
|
||||||
|
input.value = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape, or backspace at an empty query: back to the result list with the
|
||||||
|
* query the drill-in was made from. */
|
||||||
|
function leavePage(): void {
|
||||||
|
page = null;
|
||||||
|
input.value = stashed;
|
||||||
|
stashed = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action: SessionAction, session: Session): void {
|
||||||
|
close();
|
||||||
|
switch (action.id) {
|
||||||
|
case "kill":
|
||||||
|
killSession(session);
|
||||||
|
return;
|
||||||
|
case "rename":
|
||||||
|
renameSession(session);
|
||||||
|
return;
|
||||||
|
case "spawn-similar":
|
||||||
|
spawnSimilar(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function activate(index: number): void {
|
function activate(index: number): void {
|
||||||
const result = rows[index];
|
const result = rows[index];
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const sessionId = result.candidate.session.id;
|
const candidate = result.candidate;
|
||||||
|
if (isActionCandidate(candidate)) {
|
||||||
|
// The page cannot be up without a session to act on.
|
||||||
|
if (page) runAction(candidate.action, page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
openSession(sessionId);
|
switch (candidate.kind) {
|
||||||
|
case "session":
|
||||||
|
openSession(candidate.session.id);
|
||||||
|
return;
|
||||||
|
case "command":
|
||||||
|
candidate.command.run();
|
||||||
|
return;
|
||||||
|
case "directory":
|
||||||
|
spawnDirectory(candidate.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent): void {
|
function onKeydown(e: KeyboardEvent): void {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "Escape":
|
case "Escape":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
close();
|
// On an action page Escape is one level up, not the way out.
|
||||||
|
if (page) leavePage();
|
||||||
|
else close();
|
||||||
|
return;
|
||||||
|
case "Tab":
|
||||||
|
e.preventDefault();
|
||||||
|
drillIn();
|
||||||
|
return;
|
||||||
|
case "Backspace":
|
||||||
|
// Ordinary editing, except at an empty query on the action page.
|
||||||
|
if (page && input.value === "") {
|
||||||
|
e.preventDefault();
|
||||||
|
leavePage();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case "Enter":
|
case "Enter":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
COMMAND_SPECS,
|
COMMAND_SPECS,
|
||||||
CommandSet,
|
CommandSet,
|
||||||
|
commandSpecs,
|
||||||
DEFAULT_LEADER,
|
DEFAULT_LEADER,
|
||||||
canonicalizeKey,
|
canonicalizeKey,
|
||||||
|
controlByte,
|
||||||
defineCommands,
|
defineCommands,
|
||||||
|
isBarTypingTarget,
|
||||||
|
isTerminalTextarea,
|
||||||
isTypingTarget,
|
isTypingTarget,
|
||||||
keyCaps,
|
keyCaps,
|
||||||
publishedCommands,
|
publishedCommands,
|
||||||
|
type Command,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
|
|
||||||
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||||
@@ -161,6 +166,37 @@ describe("the leader", () => {
|
|||||||
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #159: the leader is the operator's, served on `/api/info`. Every chord
|
||||||
|
// behind it is built from it, so a rebound leader carries its escape hatches
|
||||||
|
// with it instead of leaving them on a stale ctrl-a.
|
||||||
|
it("builds the chords behind it from the configured leader", () => {
|
||||||
|
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
||||||
|
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
||||||
|
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
||||||
|
// Commands that do not hang off the leader are untouched.
|
||||||
|
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches a configured leader's chord and not the default's", () => {
|
||||||
|
const literalCtrlK = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
|
||||||
|
leader: "ctrl-b",
|
||||||
|
});
|
||||||
|
set.handle(key("b", { ctrlKey: true }));
|
||||||
|
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
|
||||||
|
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The old leader is an ordinary key again: it holds nothing, and the chord
|
||||||
|
// that used to follow it runs nothing.
|
||||||
|
set.handle(key("a", { ctrlKey: true }));
|
||||||
|
set.handle(key("k", { ctrlKey: true }));
|
||||||
|
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the configured leader's own byte for the doubled chord", () => {
|
||||||
|
expect(controlByte("ctrl-b")).toBe("\x02");
|
||||||
|
});
|
||||||
|
|
||||||
it("holds the leader as a partial chord instead of selecting the page", () => {
|
it("holds the leader as a partial chord instead of selecting the page", () => {
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||||
leader: DEFAULT_LEADER,
|
leader: DEFAULT_LEADER,
|
||||||
@@ -234,3 +270,133 @@ describe("isTypingTarget", () => {
|
|||||||
expect(isTypingTarget(null)).toBe(false);
|
expect(isTypingTarget(null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isTerminalTextarea", () => {
|
||||||
|
it("recognises xterm's offscreen textarea, and nothing else", () => {
|
||||||
|
expect(isTerminalTextarea(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(true);
|
||||||
|
expect(isTerminalTextarea(el("<textarea></textarea>"))).toBe(false);
|
||||||
|
expect(isTerminalTextarea(el("<input>"))).toBe(false);
|
||||||
|
expect(isTerminalTextarea(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isBarTypingTarget", () => {
|
||||||
|
it("holds the bar's keys out of real form fields", () => {
|
||||||
|
expect(isBarTypingTarget(el("<input>"))).toBe(true);
|
||||||
|
expect(isBarTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => {
|
||||||
|
expect(isBarTypingTarget(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(false);
|
||||||
|
expect(isBarTypingTarget(el("<button></button>"))).toBe(false);
|
||||||
|
expect(isBarTypingTarget(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctrl-k over a focused pane (#152)", () => {
|
||||||
|
const terminal = () => el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
|
||||||
|
/** The global listener's routing: the bar's set only sees a key that is not
|
||||||
|
* aimed at a real form field. */
|
||||||
|
function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) {
|
||||||
|
if (isBarTypingTarget(target)) return false;
|
||||||
|
return pressOn(set, target, k, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("opens the bar when the terminal has the keyboard", () => {
|
||||||
|
const open = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
||||||
|
expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the spawn form's inputs their own Ctrl-K", () => {
|
||||||
|
const open = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
||||||
|
expect(route(set, el("<input>"), "k", { ctrlKey: true })).toBe(false);
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the literal 0x0B after the leader, without opening the bar", () => {
|
||||||
|
const open = vi.fn();
|
||||||
|
const literal = vi.fn();
|
||||||
|
const bar = new CommandSet(defineCommands({ "bar.open": open }));
|
||||||
|
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), {
|
||||||
|
leader: DEFAULT_LEADER,
|
||||||
|
});
|
||||||
|
const target = terminal();
|
||||||
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||||
|
const e = key("k", { ctrlKey: true });
|
||||||
|
Object.defineProperty(e, "target", { value: target });
|
||||||
|
expect(set.handle(e)).toBe(true);
|
||||||
|
expect(literal).toHaveBeenCalledTimes(1);
|
||||||
|
// The leader's set consumed the key, so the bar's set never sees it.
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds the escape hatch behind the default leader", () => {
|
||||||
|
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK");
|
||||||
|
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("leader pressed twice (#158)", () => {
|
||||||
|
it("sends one literal leader byte and clears the pending chord", () => {
|
||||||
|
const literal = vi.fn();
|
||||||
|
const run = vi.fn();
|
||||||
|
const set = new CommandSet(
|
||||||
|
defineCommands({ "pane.literalLeader": literal, "spawn.open": run }),
|
||||||
|
{ leader: DEFAULT_LEADER },
|
||||||
|
);
|
||||||
|
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(literal).toHaveBeenCalledTimes(1);
|
||||||
|
// The chord is cleared, not still held: the next key is ordinary again.
|
||||||
|
expect(set.handle(key("n"))).toBe(true);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds the escape hatch behind the default leader", () => {
|
||||||
|
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalLeader");
|
||||||
|
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ${DEFAULT_LEADER}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not just the default: the chord is resolved from whatever leader this
|
||||||
|
// set was constructed with, so a rebound leader carries the escape hatch
|
||||||
|
// with it — the same proof #156 wrote for the TUI. The web has no config
|
||||||
|
// for this yet (#159), but the mechanism must not be hardcoded to ctrl-a.
|
||||||
|
it("follows a rebound leader, not just the default", () => {
|
||||||
|
const literal = vi.fn();
|
||||||
|
const rebound: Command = {
|
||||||
|
id: "pane.literalLeader",
|
||||||
|
label: "Send a literal leader key to the pane",
|
||||||
|
group: "Pane",
|
||||||
|
scope: "global",
|
||||||
|
keys: ["ctrl-b ctrl-b"],
|
||||||
|
run: literal,
|
||||||
|
};
|
||||||
|
const set = new CommandSet([rebound], { leader: "ctrl-b" });
|
||||||
|
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(literal).toHaveBeenCalledTimes(1);
|
||||||
|
// The old leader no longer does anything special.
|
||||||
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("controlByte", () => {
|
||||||
|
it("computes the raw control byte from a plain ctrl chord", () => {
|
||||||
|
expect(controlByte("ctrl-a")).toBe("\x01");
|
||||||
|
expect(controlByte("ctrl-k")).toBe("\x0b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects anything that isn't a single plain ctrl chord", () => {
|
||||||
|
expect(() => controlByte("ctrl-shift-a")).toThrow();
|
||||||
|
expect(() => controlByte("a")).toThrow();
|
||||||
|
expect(() => controlByte("ctrl-a ctrl-a")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+65
-12
@@ -32,9 +32,26 @@ export interface Command extends CommandSpec {
|
|||||||
run: () => void;
|
run: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every command the web client has. The single list this issue exists to
|
/** Leader used when the daemon does not name one — an older daemon, or an
|
||||||
* create: labels, chords and grouping live here and nowhere else. */
|
* unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a`
|
||||||
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
* is select-all in a browser, so a set that owns the leader
|
||||||
|
* `preventDefault`s it. */
|
||||||
|
export const DEFAULT_LEADER = "ctrl-a";
|
||||||
|
|
||||||
|
/** Every command the web client has, for one leader. The single list: labels,
|
||||||
|
* chords and grouping live here and nowhere else. Chords behind the leader are
|
||||||
|
* built from it (#159), so the configured leader — served on `/api/info` as
|
||||||
|
* `leader` — is what the overlay renders and the sets dispatch. */
|
||||||
|
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
|
||||||
|
return SPECS.map(({ leaderKeys, ...spec }) =>
|
||||||
|
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
|
||||||
|
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
|
||||||
|
|
||||||
|
const SPECS: readonly SpecSource[] = [
|
||||||
{
|
{
|
||||||
id: "spawn.open",
|
id: "spawn.open",
|
||||||
label: "Open the spawn form",
|
label: "Open the spawn form",
|
||||||
@@ -56,6 +73,22 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
|||||||
keys: ["ctrl-k"],
|
keys: ["ctrl-k"],
|
||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "pane.literalCtrlK",
|
||||||
|
label: "Send a literal Ctrl-K to the pane",
|
||||||
|
group: "Pane",
|
||||||
|
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
||||||
|
leaderKeys: (leader) => [`${leader} ctrl-k`],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane.literalLeader",
|
||||||
|
label: "Send a literal leader key to the pane",
|
||||||
|
group: "Pane",
|
||||||
|
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
||||||
|
leaderKeys: (leader) => [`${leader} ${leader}`],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
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",
|
||||||
@@ -142,13 +175,20 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s]));
|
/** The command list under the default leader. Iteration order everywhere else
|
||||||
|
* (the help overlay, the bar) comes from here. */
|
||||||
|
export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs();
|
||||||
|
|
||||||
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
||||||
* test failure rather than a key that silently does nothing. */
|
* test failure rather than a key that silently does nothing. `leader` picks
|
||||||
export function defineCommands(handlers: Record<string, () => void>): Command[] {
|
* the chords behind the leader; omit it for a set that has none. */
|
||||||
|
export function defineCommands(
|
||||||
|
handlers: Record<string, () => void>,
|
||||||
|
leader: string = DEFAULT_LEADER,
|
||||||
|
): Command[] {
|
||||||
|
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
|
||||||
return Object.entries(handlers).map(([id, run]) => {
|
return Object.entries(handlers).map(([id, run]) => {
|
||||||
const spec = SPEC_BY_ID.get(id);
|
const spec = byId.get(id);
|
||||||
if (!spec) throw new Error(`unknown command id: ${id}`);
|
if (!spec) throw new Error(`unknown command id: ${id}`);
|
||||||
return { ...spec, run };
|
return { ...spec, run };
|
||||||
});
|
});
|
||||||
@@ -216,6 +256,15 @@ export function canonicalizeKey(e: KeyboardEvent): string | null {
|
|||||||
return out + e.key.toLowerCase();
|
return out + e.key.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The raw control byte for a canonical `ctrl-<letter>` chord — `ctrl-a` is
|
||||||
|
* `0x01` — mirroring the TUI's `encode_key`. Built from the chord string
|
||||||
|
* itself so a different leader sends its own byte, not a hardcoded one. */
|
||||||
|
export function controlByte(chord: string): string {
|
||||||
|
const m = /^ctrl-([a-z])$/.exec(chord);
|
||||||
|
if (!m) throw new Error(`not a plain ctrl chord: ${chord}`);
|
||||||
|
return String.fromCharCode(m[1].charCodeAt(0) - 96);
|
||||||
|
}
|
||||||
|
|
||||||
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
|
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
|
||||||
|
|
||||||
/** True when a chord carries a modifier or more than one key. Plain single-key
|
/** True when a chord carries a modifier or more than one key. Plain single-key
|
||||||
@@ -241,11 +290,19 @@ export function isTypingTarget(target: EventTarget | null): boolean {
|
|||||||
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
||||||
* also where the leader has to work — a pane is exactly where the operator
|
* also where the leader has to work — a pane is exactly where the operator
|
||||||
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
||||||
function isTerminalTextarea(target: EventTarget | null): boolean {
|
export function isTerminalTextarea(target: EventTarget | null): boolean {
|
||||||
const el = target as HTMLElement | null;
|
const el = target as HTMLElement | null;
|
||||||
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True where the command bar's own keys must stay out of the way: a real form
|
||||||
|
* field, and not the pane. `ctrl-k` is intercepted ahead of xterm
|
||||||
|
* (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while
|
||||||
|
* the spawn form's inputs keep their own Ctrl-K. */
|
||||||
|
export function isBarTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
return isTypingTarget(target) && !isTerminalTextarea(target);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- display ----
|
// ---- display ----
|
||||||
|
|
||||||
const CAPS: Record<string, string> = {
|
const CAPS: Record<string, string> = {
|
||||||
@@ -292,10 +349,6 @@ export function keyCaps(chord: string): string[] {
|
|||||||
|
|
||||||
// ---- dispatch ----
|
// ---- dispatch ----
|
||||||
|
|
||||||
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
|
||||||
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
|
||||||
export const DEFAULT_LEADER = "ctrl-a";
|
|
||||||
|
|
||||||
/** How long a partial chord waits for its next key — the TUI's
|
/** How long a partial chord waits for its next key — the TUI's
|
||||||
* `chord_timeout_ms` default. */
|
* `chord_timeout_ms` default. */
|
||||||
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { ConnectionPool } from "./connections.js";
|
import { ConnectionPool } from "./connections.js";
|
||||||
|
|
||||||
|
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
||||||
|
// the pool reaches the focused connection with them.
|
||||||
|
const sent: { id: string; data: string }[] = [];
|
||||||
|
vi.mock("./attach.js", () => ({
|
||||||
|
createAttachConnection: (id: string) => ({
|
||||||
|
sessionId: id,
|
||||||
|
el: document.createElement("div"),
|
||||||
|
onMount() {},
|
||||||
|
onUnmount() {},
|
||||||
|
focusTerminal() {},
|
||||||
|
sendInput(data: string) {
|
||||||
|
sent.push({ id, data });
|
||||||
|
},
|
||||||
|
dispose() {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -85,3 +102,26 @@ describe("ConnectionPool.setFocus", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ConnectionPool.sendToFocused", () => {
|
||||||
|
it("types the bytes into the focused session's PTY", () => {
|
||||||
|
mockFetch();
|
||||||
|
sent.length = 0;
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
pool.mount(A, pane);
|
||||||
|
pool.setFocus(A);
|
||||||
|
|
||||||
|
expect(pool.sendToFocused("\x0b")).toBe(true);
|
||||||
|
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op with nothing focused", () => {
|
||||||
|
mockFetch();
|
||||||
|
sent.length = 0;
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
|
||||||
|
expect(pool.sendToFocused("\x0b")).toBe(false);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -159,6 +159,18 @@ export class ConnectionPool {
|
|||||||
this.cache.get(this.focusedId)?.focusTerminal();
|
this.cache.get(this.focusedId)?.focusTerminal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Type raw bytes into the focused session's PTY. Returns false when there is
|
||||||
|
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
|
||||||
|
* is a no-op over the welcome pane rather than an error
|
||||||
|
* (docs/COMMAND-BAR.md). */
|
||||||
|
sendToFocused(data: string): boolean {
|
||||||
|
if (this.focusedId === null) return false;
|
||||||
|
const conn = this.cache.get(this.focusedId);
|
||||||
|
if (!conn) return false;
|
||||||
|
conn.sendInput(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fully disconnect + forget a session. */
|
/** Fully disconnect + forget a session. */
|
||||||
close(id: Uuid): void {
|
close(id: Uuid): void {
|
||||||
const conn = this.cache.get(id);
|
const conn = this.cache.get(id);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-1
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { keyCaps, publishedCommands } from "./commands.js";
|
import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
|
||||||
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,6 +44,33 @@ describe("help overlay", () => {
|
|||||||
.toEqual(publishedCommands().map((c) => c.id));
|
.toEqual(publishedCommands().map((c) => c.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #159: the leader comes from the daemon, so the overlay must render the
|
||||||
|
// configured chord — a hardcoded `Ctrl A` is the visible half of the bug.
|
||||||
|
it("renders the configured leader's chord, not a hardcoded Ctrl A", () => {
|
||||||
|
const bound = defineCommands(
|
||||||
|
{ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} },
|
||||||
|
"ctrl-b",
|
||||||
|
);
|
||||||
|
publishCommands(bound);
|
||||||
|
try {
|
||||||
|
openHelp();
|
||||||
|
const keysOf = (id: string) =>
|
||||||
|
[
|
||||||
|
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
|
||||||
|
.querySelectorAll("kbd"),
|
||||||
|
]
|
||||||
|
.map((k) => k.textContent)
|
||||||
|
.join(" ");
|
||||||
|
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
|
||||||
|
expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B");
|
||||||
|
} finally {
|
||||||
|
// Leave the registry as the rest of the suite expects it.
|
||||||
|
publishCommands(
|
||||||
|
defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("opens on ? and closes on a second ?", () => {
|
it("opens on ? and closes on a second ?", () => {
|
||||||
press("?", { shiftKey: true });
|
press("?", { shiftKey: true });
|
||||||
expect(isHelpOpen()).toBe(true);
|
expect(isHelpOpen()).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
closeForceKillDialog,
|
||||||
|
openForceKillDialog,
|
||||||
|
openKillConfirmDialog,
|
||||||
|
} from "./killDialog.js";
|
||||||
|
|
||||||
|
describe("kill dialogs", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
closeForceKillDialog();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the kill button, so Enter confirms", () => {
|
||||||
|
const el = openKillConfirmDialog({
|
||||||
|
name: "alpha",
|
||||||
|
blockers: null,
|
||||||
|
onKill: () => Promise.resolve(),
|
||||||
|
});
|
||||||
|
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the force button too", () => {
|
||||||
|
const el = openForceKillDialog({
|
||||||
|
name: "alpha",
|
||||||
|
blockers: null,
|
||||||
|
reason: "worktree is dirty.",
|
||||||
|
onForce: () => Promise.resolve(),
|
||||||
|
});
|
||||||
|
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
|
||||||
|
});
|
||||||
|
});
|
||||||
+84
-23
@@ -1,14 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* Forced-kill prompt (#81).
|
* The two kill prompts (#81, #160).
|
||||||
*
|
*
|
||||||
* The daemon refuses to kill a session whose worktree still holds uncommitted
|
* A kill is destructive and unattended agents live in these sessions, so both
|
||||||
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
* clients ask before one goes through — the TUI's `KillConfirm`, and
|
||||||
* refusal gets its own dialog: it names what is in the way and offers the
|
* `openKillConfirmDialog` here (#160). Where the session's worktree still
|
||||||
* override the daemon's `force=true` already implements. A kill the daemon
|
* holds uncommitted or unpushed work, the confirmation says so up front, the
|
||||||
* accepts never reaches here — there is nothing to warn about.
|
* same way the TUI's does.
|
||||||
|
*
|
||||||
|
* The daemon decides the rest: it refuses a kill over such a worktree, and
|
||||||
|
* that refusal needs an answer the confirmation cannot give — a forced kill.
|
||||||
|
* `openForceKillDialog` is that second prompt. A kill the daemon accepts never
|
||||||
|
* reaches it.
|
||||||
*/
|
*/
|
||||||
import { formatInTheWay, type InTheWay } from "./types.js";
|
import { formatInTheWay, type InTheWay } from "./types.js";
|
||||||
|
|
||||||
|
/** What both prompts need: a target to name and a button that acts on it. */
|
||||||
|
interface KillDialogSpec {
|
||||||
|
/** Session name, as the sidebar shows it. */
|
||||||
|
name: string;
|
||||||
|
/** Sentence under the title. */
|
||||||
|
body: string;
|
||||||
|
/** Label on the acting button. */
|
||||||
|
confirmLabel: string;
|
||||||
|
/** Extra class on the acting button, so tests and styles can find it. */
|
||||||
|
confirmClass: string;
|
||||||
|
/** Run it. A rejection is rendered in the dialog, which stays open so the
|
||||||
|
* press can be retried. */
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillConfirmPrompt {
|
||||||
|
name: string;
|
||||||
|
/** What the card last saw in the way, when anything was. Shown before the
|
||||||
|
* attempt, so "this will be refused" is not a surprise. */
|
||||||
|
blockers: InTheWay | null;
|
||||||
|
/** Run the plain kill. Opening a further dialog from here is allowed: the
|
||||||
|
* confirmation only closes itself if it is still the one on screen. */
|
||||||
|
onKill: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ForceKillPrompt {
|
export interface ForceKillPrompt {
|
||||||
/** Session name, as the sidebar shows it. */
|
/** Session name, as the sidebar shows it. */
|
||||||
name: string;
|
name: string;
|
||||||
@@ -30,9 +60,9 @@ export function closeForceKillDialog(): void {
|
|||||||
open = null;
|
open = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
/** Mount one of the prompts. Opening while one is up replaces it, so the
|
||||||
* can never stack two dialogs. */
|
* confirmation and a refusal that follows it can never stack. */
|
||||||
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
function mount(p: KillDialogSpec): HTMLElement {
|
||||||
closeForceKillDialog();
|
closeForceKillDialog();
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
@@ -50,9 +80,7 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
|
|
||||||
const body = document.createElement("p");
|
const body = document.createElement("p");
|
||||||
body.className = "kill-dialog-body";
|
body.className = "kill-dialog-body";
|
||||||
body.textContent = p.blockers
|
body.textContent = p.body;
|
||||||
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
|
||||||
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
|
|
||||||
|
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
error.className = "kill-dialog-error";
|
error.className = "kill-dialog-error";
|
||||||
@@ -65,22 +93,27 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
||||||
cancel.textContent = "Cancel";
|
cancel.textContent = "Cancel";
|
||||||
cancel.addEventListener("click", closeForceKillDialog);
|
cancel.addEventListener("click", closeForceKillDialog);
|
||||||
const force = document.createElement("button");
|
const confirm = document.createElement("button");
|
||||||
force.type = "button";
|
confirm.type = "button";
|
||||||
force.className = "btn btn-danger kill-dialog-force";
|
confirm.className = `btn btn-danger ${p.confirmClass}`;
|
||||||
force.textContent = "Force kill";
|
confirm.textContent = p.confirmLabel;
|
||||||
force.addEventListener("click", () => {
|
confirm.addEventListener("click", () => {
|
||||||
force.disabled = true;
|
confirm.disabled = true;
|
||||||
void p
|
void p
|
||||||
.onForce()
|
.onConfirm()
|
||||||
.then(closeForceKillDialog)
|
.then(() => {
|
||||||
|
// A handler that opened the refusal prompt has already replaced this
|
||||||
|
// dialog; closing here would take that one down with it.
|
||||||
|
if (open?.el === el) closeForceKillDialog();
|
||||||
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
force.disabled = false;
|
if (open?.el !== el) return;
|
||||||
|
confirm.disabled = false;
|
||||||
error.hidden = false;
|
error.hidden = false;
|
||||||
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
actions.append(cancel, force);
|
actions.append(cancel, confirm);
|
||||||
|
|
||||||
panel.append(title, body, error, actions);
|
panel.append(title, body, error, actions);
|
||||||
|
|
||||||
@@ -99,6 +132,34 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeydown, true);
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
open = { el, onKeydown };
|
open = { el, onKeydown };
|
||||||
cancel.focus();
|
// The acting button takes the focus in both prompts: the operator already
|
||||||
|
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
|
||||||
|
confirm.focus();
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ask before a kill the daemon has not seen yet (#160). */
|
||||||
|
export function openKillConfirmDialog(p: KillConfirmPrompt): HTMLElement {
|
||||||
|
return mount({
|
||||||
|
name: p.name,
|
||||||
|
body: p.blockers
|
||||||
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. The kill will be refused unless it is forced.`
|
||||||
|
: "The session and everything running in it go away. Its worktree stays on disk.",
|
||||||
|
confirmLabel: "Kill",
|
||||||
|
confirmClass: "kill-dialog-kill",
|
||||||
|
onConfirm: p.onKill,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offer the override after the daemon refuses a guarded kill (#81). */
|
||||||
|
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||||
|
return mount({
|
||||||
|
name: p.name,
|
||||||
|
body: p.blockers
|
||||||
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||||
|
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`,
|
||||||
|
confirmLabel: "Force kill",
|
||||||
|
confirmClass: "kill-dialog-force",
|
||||||
|
onConfirm: p.onForce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+130
-44
@@ -7,8 +7,8 @@ import { ConnectionPool } from "./connections.js";
|
|||||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||||
import { installHudKeybind } from "./debug.js";
|
import { installHudKeybind } from "./debug.js";
|
||||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js";
|
import { CommandSet, DEFAULT_LEADER, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
|
||||||
import { isCommandBarOpen, openCommandBar } from "./commandBar.js";
|
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
|
||||||
import { DaemonLink } from "./daemonLink.js";
|
import { DaemonLink } from "./daemonLink.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import { installRestartOverlay } from "./restartOverlay.js";
|
import { installRestartOverlay } from "./restartOverlay.js";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
fetchNames,
|
fetchNames,
|
||||||
getDaemonInfo,
|
getDaemonInfo,
|
||||||
|
gitRoot,
|
||||||
projects as fetchProjects,
|
projects as fetchProjects,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ import { createPreviewSchedule } from "./previewSchedule.js";
|
|||||||
import {
|
import {
|
||||||
FOCUS_PANE_EVENT,
|
FOCUS_PANE_EVENT,
|
||||||
FOCUS_SIDEBAR_EVENT,
|
FOCUS_SIDEBAR_EVENT,
|
||||||
|
OPEN_COMMAND_BAR_EVENT,
|
||||||
OPEN_SPAWN_EVENT,
|
OPEN_SPAWN_EVENT,
|
||||||
SESSION_SELECT_EVENT,
|
SESSION_SELECT_EVENT,
|
||||||
SPAWN_SIMILAR_EVENT,
|
SPAWN_SIMILAR_EVENT,
|
||||||
@@ -85,9 +87,15 @@ const store = new CatalogStore();
|
|||||||
// Absolute paths are a fine fallback until it lands.
|
// Absolute paths are a fine fallback until it lands.
|
||||||
let homeDir: string | undefined;
|
let homeDir: string | undefined;
|
||||||
void getDaemonInfo()
|
void getDaemonInfo()
|
||||||
.then((info) => (homeDir = info.home_dir))
|
.then((info) => {
|
||||||
|
homeDir = info.home_dir;
|
||||||
|
// The leader is the operator's, not this module's (#159): a daemon that
|
||||||
|
// names one rebinds the chords hanging off it, and the `?` overlay and the
|
||||||
|
// command bar re-render from the rebuilt set.
|
||||||
|
if (info.leader) useLeader(info.leader);
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* absolute paths are a fine fallback */
|
/* absolute paths and the default leader are fine fallbacks */
|
||||||
});
|
});
|
||||||
|
|
||||||
// The daemon's identity and liveness: names the release in the sidebar's status
|
// The daemon's identity and liveness: names the release in the sidebar's status
|
||||||
@@ -337,21 +345,25 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void
|
|||||||
// a new branch named after that name. Anything unresolvable (no projects
|
// a new branch named after that name. Anything unresolvable (no projects
|
||||||
// known yet, no command, no generated name) opens the form pre-filled with
|
// known yet, no command, no generated name) opens the form pre-filled with
|
||||||
// what did resolve instead.
|
// what did resolve instead.
|
||||||
|
async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||||
|
try {
|
||||||
|
const info = await getDaemonInfo();
|
||||||
|
if (info.presets?.length) return info.presets;
|
||||||
|
} catch {
|
||||||
|
/* keep fallbacks */
|
||||||
|
}
|
||||||
|
return FALLBACK_PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
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),
|
||||||
]);
|
]);
|
||||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
const presets = await spawnPresets();
|
||||||
try {
|
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
|
||||||
const info = await getDaemonInfo();
|
|
||||||
if (info.presets?.length) presets = info.presets;
|
|
||||||
} catch {
|
|
||||||
/* keep fallbacks */
|
|
||||||
}
|
|
||||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
|
||||||
const req = buildFastForwardRequest(resolved);
|
const req = buildFastForwardRequest(resolved);
|
||||||
if (req) {
|
if (req) {
|
||||||
try {
|
try {
|
||||||
@@ -372,13 +384,54 @@ async function requestFastForward(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
||||||
|
// fast-forward spawn, aimed at the chosen directory instead of the most recent
|
||||||
|
// project. A directory the project scan never saw stands in as its own project;
|
||||||
|
// one outside any repo spawns without a worktree, since there is no repo to cut
|
||||||
|
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||||
|
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||||
|
const [discovered, handle] = await Promise.all([
|
||||||
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
|
fetchNames(1)
|
||||||
|
.then((batch) => batch.names[0])
|
||||||
|
.catch(() => undefined),
|
||||||
|
]);
|
||||||
|
const presets = await spawnPresets();
|
||||||
|
const known = discovered.find((p) => p.path === path);
|
||||||
|
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||||
|
const resolved = resolveFastForward([project], presets, handle, path);
|
||||||
|
const req = buildFastForwardRequest(resolved);
|
||||||
|
if (req) {
|
||||||
|
const inRepo =
|
||||||
|
known !== undefined ||
|
||||||
|
(await gitRoot(path)
|
||||||
|
.then((r) => !!r.root)
|
||||||
|
.catch(() => false));
|
||||||
|
if (!inRepo) delete req.worktree;
|
||||||
|
try {
|
||||||
|
mountSpawned(await createSession(req));
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// A failed POST degrades to the pre-filled form, never a dead row.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openSpawnModal({
|
||||||
|
seed: fastForwardSeed(resolved),
|
||||||
|
onSuccess: (session) => mountSpawned(session),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
|
||||||
|
void requestSpawnInDirectory((ev as CustomEvent<string>).detail);
|
||||||
|
});
|
||||||
|
|
||||||
// The global half of the command registry (#148). Two sets, because they
|
// The global half of the command registry (#148). Two sets, because they
|
||||||
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
||||||
// the plain keys go deaf behind it while the focus chord does not — an
|
// the plain keys go deaf behind it while the focus chord does not — an
|
||||||
// attached terminal must never be able to trap the focus keys.
|
// attached terminal must never be able to trap the focus keys.
|
||||||
//
|
//
|
||||||
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
||||||
// keys, so `isTypingTarget` keeps them out of form fields and the terminal's
|
// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's
|
||||||
// hidden textarea.
|
// hidden textarea.
|
||||||
const spawnKeys = new CommandSet(
|
const spawnKeys = new CommandSet(
|
||||||
defineCommands({
|
defineCommands({
|
||||||
@@ -392,36 +445,68 @@ const spawnKeys = new CommandSet(
|
|||||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
||||||
// the focus back out of it.
|
// the focus back out of it.
|
||||||
//
|
//
|
||||||
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the
|
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
||||||
// key as a partial chord and takes select-all off it, so the escape hatch
|
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
||||||
// docs/COMMAND-BAR.md describes has somewhere to land (#149).
|
// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
|
||||||
const focusKeys = new CommandSet(
|
// (#149).
|
||||||
defineCommands({
|
let currentLeader = DEFAULT_LEADER;
|
||||||
"focus.toggle": () => {
|
|
||||||
if (sidebar.hasListFocus()) {
|
function buildFocusKeys(leader: string): CommandSet {
|
||||||
pool.focusTerminal();
|
return new CommandSet(
|
||||||
} else {
|
defineCommands(
|
||||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
{
|
||||||
sidebar.focusList();
|
"focus.toggle": () => {
|
||||||
}
|
if (sidebar.hasListFocus()) {
|
||||||
},
|
pool.focusTerminal();
|
||||||
}),
|
} else {
|
||||||
{ leader: DEFAULT_LEADER },
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||||
);
|
sidebar.focusList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// The escape hatch: binding ctrl-k takes readline's
|
||||||
|
// kill-to-end-of-line away from every pane, and leader ctrl-k gives it
|
||||||
|
// back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
|
||||||
|
// this set, ahead of the bar's, so the chord never opens the bar.
|
||||||
|
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
||||||
|
// The leader itself, pressed twice, tmux-style: sends one literal
|
||||||
|
// leader byte and clears the pending chord instead of resolving as a
|
||||||
|
// dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
|
||||||
|
// hardcoded byte, so a rebound leader sends its own.
|
||||||
|
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
|
||||||
|
},
|
||||||
|
leader,
|
||||||
|
),
|
||||||
|
{ leader },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
|
||||||
|
|
||||||
|
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
|
||||||
|
* same command ids replaces them, so the help overlay and the command bar —
|
||||||
|
* both of which render the published registry when they open — show the
|
||||||
|
* configured chord rather than the default. */
|
||||||
|
function useLeader(leader: string): void {
|
||||||
|
if (leader === currentLeader) return;
|
||||||
|
currentLeader = leader;
|
||||||
|
focusKeys = buildFocusKeys(leader);
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
||||||
// like any other, so the `?` overlay lists it without a second list.
|
// like any other, so the `?` overlay lists it without a second list.
|
||||||
const barKeys = new CommandSet(
|
const openBar = () => {
|
||||||
defineCommands({
|
// Opening while a session is previewed discards the preview first, like
|
||||||
"bar.open": () => {
|
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||||
// Opening while a session is previewed discards the preview first, like
|
schedulePreview.cancel();
|
||||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
mv?.previewSession(null);
|
||||||
schedulePreview.cancel();
|
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||||
mv?.previewSession(null);
|
};
|
||||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
|
||||||
},
|
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
|
||||||
}),
|
|
||||||
);
|
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
|
||||||
|
// phone, so the field the operator already reaches for opens the same bar.
|
||||||
|
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
|
||||||
|
|
||||||
// Capture phase: an attached xterm swallows every keystroke it is given, so the
|
// Capture phase: an attached xterm swallows every keystroke it is given, so the
|
||||||
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
|
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
|
||||||
@@ -433,9 +518,10 @@ window.addEventListener(
|
|||||||
if (focusKeys.handle(e)) return;
|
if (focusKeys.handle(e)) return;
|
||||||
// The help overlay owns the keyboard while it is up.
|
// The help overlay owns the keyboard while it is up.
|
||||||
if (isHelpOpen()) return;
|
if (isHelpOpen()) return;
|
||||||
// Opening the bar over a focused terminal is #152; until then the pane's
|
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
|
||||||
// hidden textarea keeps its Ctrl+K, like any other typing target.
|
// seen ahead of xterm and opens the bar (#152). A real form field — the
|
||||||
if (!isTypingTarget(e.target) && barKeys.handle(e)) return;
|
// spawn form's inputs — keeps its own Ctrl+K.
|
||||||
|
if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return;
|
||||||
spawnKeys.handle(e);
|
spawnKeys.handle(e);
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
|
|||||||
+17
-3
@@ -215,6 +215,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return fl?.sessionId ?? null;
|
return fl?.sessionId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The session the operator actually picked — what `focusedLeafSession()`
|
||||||
|
* would return with the preview undone. A preview moves the active tab and
|
||||||
|
* its focus, but it is a view: the accent stays on the row the operator
|
||||||
|
* chose until they choose another (docs/PREVIEW.md). */
|
||||||
|
function realFocusedSession(): string | null {
|
||||||
|
const p = preview;
|
||||||
|
if (!p) return focusedLeafSession();
|
||||||
|
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
|
||||||
|
const leafId =
|
||||||
|
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
|
||||||
|
const fl = leafId ? findLeaf(tab.root, leafId) : null;
|
||||||
|
return fl?.sessionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
||||||
* the DOM (cheap path for click-to-focus). */
|
* the DOM (cheap path for click-to-focus). */
|
||||||
function syncFocus(): void {
|
function syncFocus(): void {
|
||||||
@@ -233,7 +247,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (!preview) pool.setFocus(focusedLeafSession());
|
if (!preview) pool.setFocus(focusedLeafSession());
|
||||||
sidebar.setHighlights({
|
sidebar.setHighlights({
|
||||||
open: realOpenSessions(),
|
open: realOpenSessions(),
|
||||||
focused: focusedLeafSession(),
|
focused: realFocusedSession(),
|
||||||
});
|
});
|
||||||
// The row the preview came from wears the same colour, so the sidebar and
|
// The row the preview came from wears the same colour, so the sidebar and
|
||||||
// the pane read as one thing rather than two unrelated highlights.
|
// the pane read as one thing rather than two unrelated highlights.
|
||||||
@@ -608,7 +622,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
{ focus: !preview && !quietRender },
|
{ focus: !preview && !quietRender },
|
||||||
);
|
);
|
||||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||||
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
|
||||||
sidebar.setPreview(preview?.sessionId ?? null);
|
sidebar.setPreview(preview?.sessionId ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,7 +831,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
openSessions: realOpenSessions,
|
openSessions: realOpenSessions,
|
||||||
beginSessionDrag,
|
beginSessionDrag,
|
||||||
render,
|
render,
|
||||||
focusedSessionId: focusedLeafSession,
|
focusedSessionId: realFocusedSession,
|
||||||
previewSession,
|
previewSession,
|
||||||
previewedSessionId: () => preview?.sessionId ?? null,
|
previewedSessionId: () => preview?.sessionId ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-1
@@ -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);
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("x kills the highlighted session", async () => {
|
it("x kills the highlighted session, once confirmed (#160)", async () => {
|
||||||
const { el } = twoFolders();
|
const { el } = twoFolders();
|
||||||
const killed: string[] = [];
|
const killed: string[] = [];
|
||||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
@@ -153,6 +153,8 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
|
|
||||||
press(tree(el), "j");
|
press(tree(el), "j");
|
||||||
press(tree(el), "x");
|
press(tree(el), "x");
|
||||||
|
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
//
|
||||||
|
// The sidebar's search field is the touch entry point to the command bar
|
||||||
|
// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the
|
||||||
|
// shell to open the bar with an empty query, instead of filtering the tree.
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
|
||||||
|
import { sessionBarCandidates } from "./commandBar.js";
|
||||||
|
import { matchAndRank } from "./matcher.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
window.fetch = () => Promise.reject(new Error("offline"));
|
||||||
|
});
|
||||||
|
|
||||||
|
function session(id: string, cwd: string): Session {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
cwd,
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
} as Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWith(sessions: Session[]): CatalogStore {
|
||||||
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||||
|
return {
|
||||||
|
snapshot: () => state,
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
fn(state);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
|
||||||
|
const handle = renderSidebar(storeWith(sessions));
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
const search = handle.el.querySelector<HTMLInputElement>(".search")!;
|
||||||
|
const opens: number[] = [];
|
||||||
|
const listener = () => opens.push(1);
|
||||||
|
document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener);
|
||||||
|
return {
|
||||||
|
handle,
|
||||||
|
search,
|
||||||
|
opens,
|
||||||
|
dispose: () => {
|
||||||
|
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, listener);
|
||||||
|
handle.dispose();
|
||||||
|
handle.el.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar search field opens the command bar (#157)", () => {
|
||||||
|
it("asks the shell to open the bar when the field is focused", () => {
|
||||||
|
const t = mount();
|
||||||
|
t.search.dispatchEvent(new FocusEvent("focus"));
|
||||||
|
expect(t.opens).toHaveLength(1);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on a tap, where a focus event may never arrive", () => {
|
||||||
|
const t = mount();
|
||||||
|
t.search.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||||
|
expect(t.opens).toHaveLength(1);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trap the keyboard in the field: it gives focus up on open", () => {
|
||||||
|
const t = mount();
|
||||||
|
t.search.focus();
|
||||||
|
expect(document.activeElement).not.toBe(t.search);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the bar with an empty query — the field never holds one", () => {
|
||||||
|
const t = mount();
|
||||||
|
t.search.focus();
|
||||||
|
expect(t.search.value).toBe("");
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is readonly: no on-screen keyboard over a field nothing is typed into", () => {
|
||||||
|
const t = mount();
|
||||||
|
expect(t.search.readOnly).toBe(true);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no longer filters the tree in place", () => {
|
||||||
|
const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]);
|
||||||
|
t.search.value = "alpha";
|
||||||
|
t.search.dispatchEvent(new Event("input"));
|
||||||
|
expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
|
||||||
|
const t = mount([]);
|
||||||
|
expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet");
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The inline filter matched a session on its name and its directory, case- and
|
||||||
|
// whitespace-insensitively. That behaviour did not go away with the field — it
|
||||||
|
// moved into the bar, which ranks the same two fields (docs/COMMAND-BAR.md).
|
||||||
|
describe("what the retired inline filter did, the bar now does", () => {
|
||||||
|
it("matches a session on both its name and its directory", () => {
|
||||||
|
const sessions = [session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")];
|
||||||
|
const candidates = sessionBarCandidates(sessions, "/home/n");
|
||||||
|
|
||||||
|
const byName = matchAndRank("alpha", candidates).map((r) => r.candidate.session.id);
|
||||||
|
expect(byName).toEqual(["alpha"]);
|
||||||
|
|
||||||
|
const byDir = matchAndRank("beta", candidates).map((r) => r.candidate.session.id);
|
||||||
|
expect(byDir).toEqual(["beta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores case and surrounding whitespace, and shows nothing on no match", () => {
|
||||||
|
const candidates = sessionBarCandidates([session("alpha", "/home/n/Alpha")], "/home/n");
|
||||||
|
|
||||||
|
expect(matchAndRank(" ALPHA ", candidates)).toHaveLength(1);
|
||||||
|
expect(matchAndRank("zzz", candidates)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+49
-4
@@ -1202,8 +1202,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
||||||
session that is already open still reads as provisional. */
|
session that is already open still reads as provisional — except the session
|
||||||
.tree-leaf:where(.is-previewing) {
|
already mounted in the pane (tier 4). Previewing that one changes nothing on
|
||||||
|
screen, so its accent stays put instead of blinking out under the pointer. */
|
||||||
|
.tree-leaf:where(.is-previewing):where(:not(.is-active)) {
|
||||||
background: var(--preview-tint);
|
background: var(--preview-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,6 +1474,15 @@ input.leaf-rename:focus {
|
|||||||
padding: var(--sp-2) 0;
|
padding: var(--sp-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The action page's header (#151): names the session being acted on, so the
|
||||||
|
target of kill/rename/spawn-similar is never ambiguous. */
|
||||||
|
.cmdbar-page-header {
|
||||||
|
padding: var(--sp-2) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.cmdbar-group-title {
|
.cmdbar-group-title {
|
||||||
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
@@ -1517,6 +1528,25 @@ input.leaf-rename:focus {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A command row's chord, shown so the bar teaches the keymap. */
|
||||||
|
.cmdbar-row-keys {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmdbar-row-keys kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
padding: 0 var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
.cmdbar-hit {
|
.cmdbar-hit {
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -1530,7 +1560,10 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Phone: the bottom-sheet treatment the spawn modal already uses. */
|
/* Phone: the bottom-sheet treatment the spawn modal already uses. */
|
||||||
@media (max-width: 600px) {
|
/* Bottom sheet at phone width, the same treatment (and the same 599px break)
|
||||||
|
the spawn modal uses: the bar is reached by thumb here, through the sidebar's
|
||||||
|
search field (#157), so it rides the bottom of the screen. */
|
||||||
|
@media (max-width: 599px) {
|
||||||
.cmdbar-overlay {
|
.cmdbar-overlay {
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1539,9 +1572,21 @@ input.leaf-rename:focus {
|
|||||||
.cmdbar-panel {
|
.cmdbar-panel {
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 70vh;
|
max-height: 70dvh;
|
||||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumb-sized rows and a field that clears the on-screen keyboard's
|
||||||
|
zoom-on-focus threshold (16px). */
|
||||||
|
.cmdbar-input {
|
||||||
|
font-size: var(--fs-17);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmdbar-row {
|
||||||
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-26
@@ -26,7 +26,6 @@ describe("buildTree", () => {
|
|||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("a", "/home/n/work"), session("b", "/home/n/work")],
|
[session("a", "/home/n/work"), session("b", "/home/n/work")],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(names(root)).toEqual(["~/work"]);
|
expect(names(root)).toEqual(["~/work"]);
|
||||||
@@ -35,7 +34,7 @@ describe("buildTree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("collapses a linear chain into one folder", () => {
|
it("collapses a linear chain into one folder", () => {
|
||||||
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n", "") as TreeFolder;
|
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n") as TreeFolder;
|
||||||
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ describe("buildTree", () => {
|
|||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
||||||
@@ -53,7 +51,6 @@ describe("buildTree", () => {
|
|||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(names(root)).toEqual(["~/git"]);
|
expect(names(root)).toEqual(["~/git"]);
|
||||||
@@ -65,30 +62,11 @@ describe("buildTree", () => {
|
|||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("loose", ""), session("nested", "/home/n/work")],
|
[session("loose", ""), session("nested", "/home/n/work")],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(root.children[0].kind).toBe("folder");
|
expect(root.children[0].kind).toBe("folder");
|
||||||
expect(root.children[1].kind).toBe("leaf");
|
expect(root.children[1].kind).toBe("leaf");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters on both session name and directory", () => {
|
|
||||||
const sessions = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
|
|
||||||
|
|
||||||
const byDir = buildTree(sessions, "/home/n", "alpha") as TreeFolder;
|
|
||||||
expect(names(byDir)).toEqual(["~/alpha"]);
|
|
||||||
|
|
||||||
const byCommand = buildTree(sessions, "/home/n", "claude") as TreeFolder;
|
|
||||||
expect(names(byCommand)).toEqual(["~/alpha"]);
|
|
||||||
|
|
||||||
const noMatch = buildTree(sessions, "/home/n", "zzz") as TreeFolder;
|
|
||||||
expect(noMatch.children).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores case and surrounding whitespace in the filter", () => {
|
|
||||||
const root = buildTree([session("a", "/home/n/Alpha")], "/home/n", " ALPHA ") as TreeFolder;
|
|
||||||
expect(root.children).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mergeLinear", () => {
|
describe("mergeLinear", () => {
|
||||||
@@ -127,7 +105,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
@@ -147,7 +124,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
@@ -157,7 +133,7 @@ describe("worktree sessions group under their repository (#99)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a session without a worktree where it is", () => {
|
it("leaves a session without a worktree where it is", () => {
|
||||||
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
|
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n") as TreeFolder;
|
||||||
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-6
@@ -25,19 +25,14 @@ export function compareNodes(a: TreeNode, b: TreeNode): number {
|
|||||||
return an.localeCompare(bn);
|
return an.localeCompare(bn);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
|
export function buildTree(sessions: Session[], home: string | undefined): TreeNode {
|
||||||
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
|
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
|
||||||
const lc = filter.trim().toLowerCase();
|
|
||||||
|
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
// Grouped by repository, so a worktree session lands under its repo
|
// Grouped by repository, so a worktree session lands under its repo
|
||||||
// rather than in a folder of its own (#99).
|
// rather than in a folder of its own (#99).
|
||||||
const cwd = sessionTreePath(s);
|
const cwd = sessionTreePath(s);
|
||||||
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
||||||
if (lc) {
|
|
||||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
|
||||||
if (!haystack.includes(lc)) continue;
|
|
||||||
}
|
|
||||||
const parts = dir ? dir.split("/").filter(Boolean) : [];
|
const parts = dir ? dir.split("/").filter(Boolean) : [];
|
||||||
let cursor = root;
|
let cursor = root;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ export interface DaemonInfo {
|
|||||||
* value across a reconnect means the daemon restarted. Absent on older
|
* value across a reconnect means the daemon restarted. Absent on older
|
||||||
* daemons, which the restart flow treats as "cannot tell". */
|
* daemons, which the restart flow treats as "cannot tell". */
|
||||||
instance?: string;
|
instance?: string;
|
||||||
|
/** Canonical leader key the operator configured (`[tui].leader`, default
|
||||||
|
* `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or
|
||||||
|
* empty on an older daemon; the client then keeps its own default. */
|
||||||
|
leader?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build provenance of the running daemon: what the status indicator names in
|
/** Build provenance of the running daemon: what the status indicator names in
|
||||||
|
|||||||
Reference in New Issue
Block a user