Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
999dbe3ea3 | ||
|
|
c9d089534b | ||
|
|
573a2b4a07 | ||
|
|
9ef3547a00 | ||
|
|
2ab07c2343 | ||
|
|
342e732ef0 | ||
|
|
3205270b51 | ||
|
|
d8e31f2760 | ||
|
|
fdf9ea1b31 | ||
|
|
22cbfbc9bc | ||
|
|
8157793282 | ||
|
|
839dd27925 | ||
|
|
a1816a0838 | ||
|
|
e01b0616b1 | ||
|
|
74d8d3c37a | ||
|
|
cc9b027bd1 | ||
|
|
10a25e5b83 | ||
|
|
4c3f65027d | ||
|
|
76eb3d1386 | ||
|
|
877f409194 | ||
|
|
39d4014dcb | ||
|
|
f02510465c | ||
|
|
8e251bf8be | ||
|
|
bc95d16a98 | ||
|
|
975f3ef0c5 | ||
|
|
f0108c5645 | ||
|
|
c534cee7cc | ||
|
|
c2ac3f7eb6 | ||
|
|
c118020b29 | ||
|
|
7cb14c7aae | ||
|
|
80a9399905 | ||
|
|
9d9a2e723d | ||
|
|
bbafff5a98 | ||
|
|
17e1dc4049 | ||
|
|
436cb194c3 | ||
|
|
6471da2624 | ||
|
|
2a43c76176 | ||
|
|
f689c916bf | ||
|
|
88233cb96d | ||
|
|
54d84e3eaa | ||
|
|
efbccb4e11 | ||
|
|
41c498401b | ||
|
|
6ca347b657 | ||
|
|
1b4981f9ce | ||
|
|
1b289a0450 | ||
|
|
3c5bb51e88 | ||
|
|
f60587a6d6 | ||
|
|
43946c3c46 | ||
|
|
eb605a3d05 | ||
|
|
4c9409a785 | ||
|
|
c24a2133d4 | ||
|
|
75ccfe10d2 | ||
|
|
ea3e18d1f2 | ||
|
|
4f0e887a50 | ||
|
|
cfebfd441b | ||
|
|
81312aa0c5 | ||
|
|
52be48b0a6 | ||
|
|
202b5008b8 | ||
|
|
015f2be5fb | ||
|
|
5a594b2b7f | ||
|
|
8370ddf066 | ||
|
|
5c4e083c2d | ||
|
|
71add0215a | ||
|
|
766d256970 | ||
|
|
78cddec730 | ||
|
|
4ff31a4f79 | ||
|
|
abab7dcb63 | ||
|
|
c7c5b2e0ec | ||
|
|
f6a97c4695 | ||
|
|
323ead5a83 | ||
|
|
5be3e33c94 | ||
|
|
002c128ac8 | ||
|
|
bb28fe1d6e | ||
|
|
d7b15a131f | ||
|
|
581ec29492 | ||
|
|
90b7953b17 | ||
|
|
26e7992c8d | ||
|
|
60515dd8c6 | ||
|
|
b3c73fd2cb | ||
|
|
7d06bec21e | ||
|
|
1a0d1284e1 | ||
|
|
fc6f3469b0 | ||
|
|
b95b5c04c1 | ||
|
|
6af8abccd3 | ||
|
|
ad2e700391 | ||
|
|
2dcef860a6 | ||
|
|
0bb5df7245 | ||
|
|
cb070e4f84 | ||
|
|
8fde211348 | ||
|
|
31cdbd4f0e | ||
|
|
0d06827b69 | ||
|
|
8a6cb81d3a | ||
|
|
52f9d0679e | ||
|
|
e6ef236f89 | ||
|
|
5747c71800 | ||
|
|
5b0a0c2001 | ||
|
|
df9013ffdf | ||
|
|
39341b278c | ||
|
|
b646cc5adf | ||
|
|
cfc125144f | ||
|
|
cd66dbf7d8 | ||
|
|
6bb812f6bd | ||
|
|
d3098a9400 | ||
|
|
5de04af36a | ||
|
|
36cb03961c | ||
|
|
5e34ce7a61 | ||
|
|
1fa9ce798e | ||
|
|
2c89902829 | ||
|
|
8949fdfdf6 | ||
|
|
9b0e9751ec | ||
|
|
d1ed4f1fdc | ||
|
|
2c8758ed14 |
@@ -97,3 +97,34 @@ jobs:
|
|||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: pnpm -C web build
|
run: pnpm -C web build
|
||||||
|
|
||||||
|
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
|
||||||
|
# change invalidates the pin silently elsewhere, so the packaged build has to
|
||||||
|
# fail here too.
|
||||||
|
flake:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The runner container is root with no `nixbld` group, but the installer
|
||||||
|
# writes `build-users-group = nixbld` anyway and then fails on it, so the
|
||||||
|
# config goes in first. Flakes are off by default and `nix build .#…`
|
||||||
|
# needs them. Written again afterwards in case the installer replaced it.
|
||||||
|
- name: Install nix
|
||||||
|
run: |
|
||||||
|
mkdir -p /etc/nix
|
||||||
|
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||||
|
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
|
||||||
|
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||||
|
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
# The installer does not enable flakes, so `nix build .#…` needs the
|
||||||
|
# features passed explicitly — a local machine that has them in
|
||||||
|
# nix.conf will build fine while CI fails without this.
|
||||||
|
- name: build flake packages
|
||||||
|
run: |
|
||||||
|
nix --extra-experimental-features "nix-command flakes" build \
|
||||||
|
.#webFrontend \
|
||||||
|
.#maestro \
|
||||||
|
.#maestro-web \
|
||||||
|
--no-link
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ See `docs/INSTALL.md` for end-user install instructions.
|
|||||||
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
||||||
touches. Read it before changing anything that creates or removes one.
|
touches. Read it before changing anything that creates or removes one.
|
||||||
|
|
||||||
|
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
|
||||||
|
sidebars — line layout, activity colours, duration format, state precedence.
|
||||||
|
Read it before changing sidebar rendering.
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
Generated
+11
@@ -1065,6 +1065,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"libc",
|
"libc",
|
||||||
"maestro-config",
|
"maestro-config",
|
||||||
|
"maestro-hooks",
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
"maestro-testing",
|
"maestro-testing",
|
||||||
"nix",
|
"nix",
|
||||||
@@ -1102,6 +1103,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"libc",
|
"libc",
|
||||||
"maestro-config",
|
"maestro-config",
|
||||||
|
"maestro-hooks",
|
||||||
"maestro-names",
|
"maestro-names",
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1137,6 +1139,15 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maestro-hooks"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maestro-installer"
|
name = "maestro-installer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dirs = "5"
|
|||||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
maestro-config = { path = "crates/maestro-config" }
|
maestro-config = { path = "crates/maestro-config" }
|
||||||
|
maestro-hooks = { path = "crates/maestro-hooks" }
|
||||||
maestro-names = { path = "crates/maestro-names" }
|
maestro-names = { path = "crates/maestro-names" }
|
||||||
maestro-protocol = { path = "crates/maestro-protocol" }
|
maestro-protocol = { path = "crates/maestro-protocol" }
|
||||||
maestro-testing = { path = "crates/maestro-testing" }
|
maestro-testing = { path = "crates/maestro-testing" }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dirs.workspace = true
|
|||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
|
maestro-hooks.workspace = true
|
||||||
maestro-protocol = { workspace = true, features = ["client"] }
|
maestro-protocol = { workspace = true, features = ["client"] }
|
||||||
nix.workspace = true
|
nix.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ pub enum Command {
|
|||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// List retained worktrees, or reclaim one by ID (#86).
|
/// List retained worktrees, or reclaim one by ID (#86).
|
||||||
Worktrees(WorktreesArgs),
|
Worktrees(WorktreesArgs),
|
||||||
|
/// Install or update the agent activity hook (#106).
|
||||||
|
Hooks(HooksArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `maestro hooks <install>`
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct HooksArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: HooksCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum HooksCommand {
|
||||||
|
/// Write the Claude Code activity hook into ~/.claude/settings.json.
|
||||||
|
/// Idempotent: an older maestro hook is updated in place, every other
|
||||||
|
/// hook is left untouched.
|
||||||
|
Install,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `maestro worktrees <list|reclaim>`
|
/// `maestro worktrees <list|reclaim>`
|
||||||
@@ -275,6 +292,23 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("KEY=VALUE"));
|
assert!(err.to_string().contains("KEY=VALUE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_hooks_install() {
|
||||||
|
let cli = Cli::try_parse_from(["maestro", "hooks", "install"]).unwrap();
|
||||||
|
match cli.command {
|
||||||
|
Command::Hooks(h) => {
|
||||||
|
assert!(matches!(h.command, HooksCommand::Install));
|
||||||
|
}
|
||||||
|
_ => panic!("wrong subcommand"),
|
||||||
|
}
|
||||||
|
// No subcommand → help, like `worktrees`.
|
||||||
|
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err.kind(),
|
||||||
|
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_worktrees_list() {
|
fn parses_worktrees_list() {
|
||||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ pub enum CliError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Attach(#[from] crate::attach::AttachError),
|
Attach(#[from] crate::attach::AttachError),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
|
Hooks(#[from] maestro_hooks::HooksError),
|
||||||
|
#[error("could not determine the home directory ($HOME unset?)")]
|
||||||
|
NoHome,
|
||||||
|
#[error("{0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +44,15 @@ pub async fn dispatch(
|
|||||||
None => default_config_path()?,
|
None => default_config_path()?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `hooks install` is purely local: it must work with the daemon down, so
|
||||||
|
// it never touches the client.
|
||||||
|
if let Command::Hooks(args) = &command {
|
||||||
|
match args.command {
|
||||||
|
crate::cli::HooksCommand::Install => run_hooks_install()?,
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let daemon = client(&config_path, daemon_url_override)?;
|
let daemon = client(&config_path, daemon_url_override)?;
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
@@ -77,6 +90,7 @@ pub async fn dispatch(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
||||||
Command::Worktrees(args) => match args.command {
|
Command::Worktrees(args) => match args.command {
|
||||||
WorktreesCommand::List(list_args) => {
|
WorktreesCommand::List(list_args) => {
|
||||||
let mut stdout = std::io::stdout().lock();
|
let mut stdout = std::io::stdout().lock();
|
||||||
@@ -101,6 +115,25 @@ pub async fn dispatch(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||||
|
/// user's data dir and register it in their Claude Code settings. Idempotent
|
||||||
|
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
fn run_hooks_install() -> Result<(), CliError> {
|
||||||
|
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||||
|
let changed = maestro_hooks::install_for_home(&home)?;
|
||||||
|
if changed {
|
||||||
|
println!(
|
||||||
|
"installed claude-code activity hook: {} → {}",
|
||||||
|
maestro_hooks::script_path(&home).display(),
|
||||||
|
maestro_hooks::settings_path(&home).display()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("claude-code activity hook already up to date");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
||||||
const CTRL_Q: u8 = 0x11;
|
const CTRL_Q: u8 = 0x11;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
||||||
SessionStatus, WorktreeSpec,
|
SessionStatus, WorktreeSpec,
|
||||||
@@ -108,6 +109,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
ActivityState::AwaitingInput => "awaiting",
|
ActivityState::AwaitingInput => "awaiting",
|
||||||
|
ActivityState::Error => "error",
|
||||||
};
|
};
|
||||||
// Show the live foreground command alongside the spawn command when
|
// Show the live foreground command alongside the spawn command when
|
||||||
// the daemon has observed one that differs (NAP-340).
|
// the daemon has observed one that differs (NAP-340).
|
||||||
@@ -137,21 +139,6 @@ pub(crate) fn blocked_label(s: &Session) -> String {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
|
||||||
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
|
||||||
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
|
||||||
let p = path.to_string_lossy();
|
|
||||||
if !home.is_empty() {
|
|
||||||
if p == home {
|
|
||||||
return "~".into();
|
|
||||||
}
|
|
||||||
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
|
|
||||||
return format!("~/{rest}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -175,7 +162,9 @@ mod tests {
|
|||||||
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
|
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||||
|
|
||||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||||
use crate::sessions::abbreviate_dir;
|
|
||||||
|
|
||||||
/// `maestro worktrees list`
|
/// `maestro worktrees list`
|
||||||
pub async fn run_list(
|
pub async fn run_list(
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ use std::collections::BTreeMap;
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::de::Error as _;
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
|
||||||
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
||||||
pub const DIR_NAME: &str = "maestro";
|
pub const DIR_NAME: &str = "maestro";
|
||||||
@@ -64,6 +65,24 @@ pub struct MaestroConfigFile {
|
|||||||
pub tui: TuiSection,
|
pub tui: TuiSection,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub spawn: SpawnSection,
|
pub spawn: SpawnSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hooks: HooksSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[hooks]` — agent-hook installation settings (#106).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
|
pub struct HooksSection {
|
||||||
|
/// Whether the daemon installs the Claude Code activity hook into
|
||||||
|
/// `~/.claude/settings.json` on first start when no maestro hook is
|
||||||
|
/// there yet. Default `true`; set `auto_install = false` to opt out.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_install: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HooksSection {
|
||||||
|
pub fn auto_install(&self) -> bool {
|
||||||
|
self.auto_install.unwrap_or(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
@@ -128,11 +147,14 @@ pub struct WebSection {
|
|||||||
|
|
||||||
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
|
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
|
||||||
///
|
///
|
||||||
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
|
/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI
|
||||||
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
|
/// ships with a default keymap; entries in `keymap_normal` / `keymap_insert`
|
||||||
/// applied **over** the defaults, so a partial override is enough.
|
/// are applied **over** the defaults, so a partial override is enough.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
pub struct TuiSection {
|
pub struct TuiSection {
|
||||||
|
/// Named palette preset. Defaults to the dark preset.
|
||||||
|
#[serde(default, deserialize_with = "deserialize_theme")]
|
||||||
|
pub theme: Option<TuiTheme>,
|
||||||
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub leader: Option<String>,
|
pub leader: Option<String>,
|
||||||
@@ -149,6 +171,75 @@ pub struct TuiSection {
|
|||||||
pub keymap_insert: BTreeMap<String, String>,
|
pub keymap_insert: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TuiTheme {
|
||||||
|
/// The terminal-native dark palette and the default.
|
||||||
|
Dark,
|
||||||
|
/// A high-contrast light palette.
|
||||||
|
Light,
|
||||||
|
/// Named ANSI colours, allowing the terminal to provide the colour set.
|
||||||
|
Ansi,
|
||||||
|
/// Solarized Dark.
|
||||||
|
SolarizedDark,
|
||||||
|
/// Solarized Light.
|
||||||
|
SolarizedLight,
|
||||||
|
/// Catppuccin Mocha.
|
||||||
|
Catppuccin,
|
||||||
|
/// GitHub Dark.
|
||||||
|
GithubDark,
|
||||||
|
/// GitHub Light.
|
||||||
|
GithubLight,
|
||||||
|
/// Tokyo Night.
|
||||||
|
TokyoNight,
|
||||||
|
/// Gruvbox Dark.
|
||||||
|
Gruvbox,
|
||||||
|
/// Nord.
|
||||||
|
Nord,
|
||||||
|
/// Dracula.
|
||||||
|
Dracula,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `[tui].theme` name and its variant, in documentation order.
|
||||||
|
pub const TUI_THEMES: &[(&str, TuiTheme)] = &[
|
||||||
|
("dark", TuiTheme::Dark),
|
||||||
|
("light", TuiTheme::Light),
|
||||||
|
("ansi", TuiTheme::Ansi),
|
||||||
|
("solarized-dark", TuiTheme::SolarizedDark),
|
||||||
|
("solarized-light", TuiTheme::SolarizedLight),
|
||||||
|
("catppuccin", TuiTheme::Catppuccin),
|
||||||
|
("github-dark", TuiTheme::GithubDark),
|
||||||
|
("github-light", TuiTheme::GithubLight),
|
||||||
|
("tokyo-night", TuiTheme::TokyoNight),
|
||||||
|
("gruvbox", TuiTheme::Gruvbox),
|
||||||
|
("nord", TuiTheme::Nord),
|
||||||
|
("dracula", TuiTheme::Dracula),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn deserialize_theme<'de, D>(deserializer: D) -> Result<Option<TuiTheme>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = Option::<String>::deserialize(deserializer)?;
|
||||||
|
raw.map(|name| {
|
||||||
|
TUI_THEMES
|
||||||
|
.iter()
|
||||||
|
.find(|(n, _)| *n == name)
|
||||||
|
.map(|(_, theme)| *theme)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let known = TUI_THEMES
|
||||||
|
.iter()
|
||||||
|
.map(|(n, _)| format!("{n:?}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
D::Error::custom(format!(
|
||||||
|
"invalid [tui].theme {name:?}; expected one of {known}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
|
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
|
||||||
///
|
///
|
||||||
/// Defined in shared config so every binary sees the same list: the web reads
|
/// Defined in shared config so every binary sees the same list: the web reads
|
||||||
@@ -375,6 +466,26 @@ mod tests {
|
|||||||
assert_eq!(cfg, MaestroConfigFile::default());
|
assert_eq!(cfg, MaestroConfigFile::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||||
|
// Absent file / absent [hooks] ⇒ auto_install on.
|
||||||
|
assert!(HooksSection::default().auto_install());
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
std::fs::write(&path, "").unwrap();
|
||||||
|
assert!(MaestroConfigFile::load(&path).unwrap().hooks.auto_install());
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[hooks]
|
||||||
|
auto_install = false
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!MaestroConfigFile::load(&path).unwrap().hooks.auto_install());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_presets_default_to_builtins_plus_custom() {
|
fn spawn_presets_default_to_builtins_plus_custom() {
|
||||||
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
|
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
|
||||||
@@ -558,6 +669,29 @@ bind_addr = "127.0.0.1:9090"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
|
||||||
|
assert_eq!(TuiSection::default().theme, None);
|
||||||
|
for (name, theme) in TUI_THEMES {
|
||||||
|
let cfg = file_with(&format!("[tui]\ntheme = \"{name}\"\n"));
|
||||||
|
assert_eq!(cfg.tui.theme, Some(*theme));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_theme_rejects_unknown_names_with_the_field_name() {
|
||||||
|
let err = toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"glorb\"\n").unwrap_err();
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains("[tui].theme"),
|
||||||
|
"diagnostic should name the field: {message}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
message.contains("glorb"),
|
||||||
|
"diagnostic should name the invalid value: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn file_with(body: &str) -> MaestroConfigFile {
|
fn file_with(body: &str) -> MaestroConfigFile {
|
||||||
toml::from_str(body).expect("parse")
|
toml::from_str(body).expect("parse")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dirs.workspace = true
|
|||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
|
maestro-hooks.workspace = true
|
||||||
maestro-names.workspace = true
|
maestro-names.workspace = true
|
||||||
maestro-protocol.workspace = true
|
maestro-protocol.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -15,6 +15,44 @@ use maestro_protocol::ActivityState;
|
|||||||
/// Active↔Idle. This window *is* the debounce the issue calls for.
|
/// Active↔Idle. This window *is* the debounce the issue calls for.
|
||||||
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(3);
|
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// How long an agent-reported state (#105) stays in force before inference
|
||||||
|
/// resumes. Long enough for a working agent's heartbeat to hold between
|
||||||
|
/// reports, short enough that an agent dying mid-run cannot pin its session
|
||||||
|
/// at `Error` forever.
|
||||||
|
pub const REPORT_TTL: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Server-side cap (#105) on a reported `detail`, in characters. The meta
|
||||||
|
/// line renders it inline, so an unbounded string would own the whole card.
|
||||||
|
pub const MAX_REPORT_DETAIL_CHARS: usize = 256;
|
||||||
|
|
||||||
|
/// An agent-reported activity state (#105) still within its TTL. While one
|
||||||
|
/// is [`Self::in_force`], it outranks [`decide`]'s inference; when it
|
||||||
|
/// lapses, inference resumes and any reported detail is cleared with it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ReportedActivity {
|
||||||
|
pub state: ActivityState,
|
||||||
|
pub expires_at: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportedActivity {
|
||||||
|
pub fn new(state: ActivityState, ttl: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
expires_at: std::time::Instant::now() + ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn in_force(&self, now: std::time::Instant) -> bool {
|
||||||
|
now < self.expires_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap a reported detail to [`MAX_REPORT_DETAIL_CHARS`] characters,
|
||||||
|
/// char-boundary safe.
|
||||||
|
pub fn truncate_detail(detail: Option<String>) -> Option<String> {
|
||||||
|
detail.map(|d| d.chars().take(MAX_REPORT_DETAIL_CHARS).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Combine the observed signals into an [`ActivityState`]:
|
/// Combine the observed signals into an [`ActivityState`]:
|
||||||
/// recent output ⇒ `Active`; else blocked on a terminal read ⇒ `AwaitingInput`;
|
/// recent output ⇒ `Active`; else blocked on a terminal read ⇒ `AwaitingInput`;
|
||||||
/// else `Idle`.
|
/// else `Idle`.
|
||||||
@@ -59,4 +97,25 @@ mod tests {
|
|||||||
ActivityState::Idle
|
ActivityState::Idle
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_is_in_force_until_exactly_its_expiry() {
|
||||||
|
let ttl = Duration::from_secs(300);
|
||||||
|
let report = ReportedActivity::new(ActivityState::Error, ttl);
|
||||||
|
assert!(report.in_force(report.expires_at - Duration::from_millis(1)));
|
||||||
|
assert!(!report.in_force(report.expires_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detail_truncates_to_the_cap_without_panic_on_multibyte_input() {
|
||||||
|
let long = "é".repeat(MAX_REPORT_DETAIL_CHARS + 10);
|
||||||
|
let capped = truncate_detail(Some(long)).unwrap();
|
||||||
|
assert_eq!(capped.chars().count(), MAX_REPORT_DETAIL_CHARS);
|
||||||
|
|
||||||
|
assert_eq!(truncate_detail(None), None);
|
||||||
|
assert_eq!(
|
||||||
|
truncate_detail(Some("short".into())).as_deref(),
|
||||||
|
Some("short")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
let _ = default_state_dir();
|
let _ = default_state_dir();
|
||||||
std::fs::create_dir_all(config.sidecar_dir())?;
|
std::fs::create_dir_all(config.sidecar_dir())?;
|
||||||
std::fs::create_dir_all(config.fifo_dir())?;
|
std::fs::create_dir_all(config.fifo_dir())?;
|
||||||
|
|
||||||
|
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||||
|
// session spawned right away should already carry the hook.
|
||||||
|
if config.hooks_auto_install {
|
||||||
|
match crate::hooks::auto_install(&config.home_dir) {
|
||||||
|
crate::hooks::AutoInstallOutcome::Installed => {
|
||||||
|
tracing::info!("installed claude-code activity hook (#106)");
|
||||||
|
}
|
||||||
|
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
|
||||||
// under their base repos, created on demand (#56).
|
// under their base repos, created on demand (#56).
|
||||||
|
|
||||||
@@ -73,6 +87,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
// One store instance shared with the API handlers: forced kills (#81)
|
// One store instance shared with the API handlers: forced kills (#81)
|
||||||
// record here, `GET /api/worktrees` (#80) reads back from here.
|
// record here, `GET /api/worktrees` (#80) reads back from here.
|
||||||
.with_retained_store((*retained).clone());
|
.with_retained_store((*retained).clone());
|
||||||
|
// Spawned sessions learn where to report back (#103). Derived from the
|
||||||
|
// configured bind address; set before any spawn can happen.
|
||||||
|
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
||||||
|
|
||||||
let recovered = manager.recover().await?;
|
let recovered = manager.recover().await?;
|
||||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ pub struct DaemonConfig {
|
|||||||
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
||||||
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
||||||
pub project_scan_depth: u32,
|
pub project_scan_depth: u32,
|
||||||
|
/// Whether the daemon installs the Claude Code activity hook on first
|
||||||
|
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||||
|
/// `true`.
|
||||||
|
pub hooks_auto_install: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonConfig {
|
impl DaemonConfig {
|
||||||
@@ -197,6 +201,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
spawn_presets,
|
spawn_presets,
|
||||||
project_roots,
|
project_roots,
|
||||||
project_scan_depth,
|
project_scan_depth,
|
||||||
|
hooks_auto_install: file.hooks.auto_install(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +238,27 @@ pub fn system_hostname() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||||
|
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!(cfg.hooks_auto_install);
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[hooks]
|
||||||
|
auto_install = false
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
|
assert!(!cfg.hooks_auto_install);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_file_defaults_everything() {
|
fn empty_file_defaults_everything() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! First-start hook auto-install (#106).
|
||||||
|
//!
|
||||||
|
//! The daemon runs [`auto_install`] once at startup: when the user's
|
||||||
|
//! `~/.claude/settings.json` carries no maestro hook yet (and
|
||||||
|
//! `[hooks].auto_install` hasn't opted out — checked by the caller), the same
|
||||||
|
//! installer `maestro hooks install` uses writes it. Best-effort: a failure is
|
||||||
|
//! warned about and never blocks startup. The same global hook serves every
|
||||||
|
//! Claude Code session on the machine, so nothing here targets repositories
|
||||||
|
//! or worktrees.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Install the Claude Code activity hook for `home_dir` unless one is already
|
||||||
|
/// present or the home dir is unresolvable (`""`). Returns what happened.
|
||||||
|
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
||||||
|
if home_dir.is_empty() {
|
||||||
|
return AutoInstallOutcome::Skipped;
|
||||||
|
}
|
||||||
|
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
||||||
|
if maestro_hooks::is_installed(&settings) {
|
||||||
|
return AutoInstallOutcome::AlreadyPresent;
|
||||||
|
}
|
||||||
|
match maestro_hooks::install_for_home(Path::new(home_dir)) {
|
||||||
|
Ok(_) => AutoInstallOutcome::Installed,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
|
||||||
|
AutoInstallOutcome::Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`auto_install`] did, so callers can log and tests can assert.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AutoInstallOutcome {
|
||||||
|
Installed,
|
||||||
|
AlreadyPresent,
|
||||||
|
Failed,
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Everything happens under a temp `$HOME`; the developer's own
|
||||||
|
/// `~/.claude/settings.json` is never read or written.
|
||||||
|
#[test]
|
||||||
|
fn installs_when_settings_carry_no_maestro_hook() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let outcome = auto_install(dir.path().to_str().unwrap());
|
||||||
|
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
||||||
|
let settings = dir.path().join(".claude/settings.json");
|
||||||
|
assert!(maestro_hooks::is_installed(&settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_start_is_a_noop() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let home = dir.path().to_str().unwrap();
|
||||||
|
auto_install(home);
|
||||||
|
// A foreign edit between starts survives; presence alone skips.
|
||||||
|
let settings = dir.path().join(".claude/settings.json");
|
||||||
|
let before = std::fs::read_to_string(&settings).unwrap();
|
||||||
|
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
||||||
|
assert_eq!(
|
||||||
|
before,
|
||||||
|
std::fs::read_to_string(&settings).unwrap(),
|
||||||
|
"already-installed settings must not be rewritten"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unresolvable_home_is_skipped() {
|
||||||
|
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod cli;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dirlist;
|
pub mod dirlist;
|
||||||
pub mod gitroot;
|
pub mod gitroot;
|
||||||
|
pub mod hooks;
|
||||||
pub mod local_api;
|
pub mod local_api;
|
||||||
pub mod panes;
|
pub mod panes;
|
||||||
pub mod proc;
|
pub mod proc;
|
||||||
@@ -27,5 +28,6 @@ pub mod tmux;
|
|||||||
pub mod worktree;
|
pub mod worktree;
|
||||||
pub mod worktree_probe;
|
pub mod worktree_probe;
|
||||||
|
|
||||||
|
pub use activity::MAX_REPORT_DETAIL_CHARS;
|
||||||
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
|
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
|
||||||
pub use session::{SessionError, SessionManager};
|
pub use session::{SessionError, SessionManager};
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ use std::sync::Arc;
|
|||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get};
|
use axum::routing::{any, delete, get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use maestro_config::SpawnPreset;
|
use maestro_config::SpawnPreset;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
PROTOCOL_VERSION,
|
ReportActivityRequest, Session, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -46,6 +46,7 @@ pub struct LocalApiState {
|
|||||||
pub fn router(state: LocalApiState) -> Router {
|
pub fn router(state: LocalApiState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
|
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -318,6 +319,29 @@ async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
||||||
|
/// The report holds above the daemon's inference for a TTL, then inference
|
||||||
|
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
||||||
|
/// forever. Unknown session is 404; an unknown state value is rejected by the
|
||||||
|
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
|
||||||
|
async fn report_activity(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<ReportActivityRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s.manager.report_activity(id, body.state, body.detail).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(
|
||||||
|
session_error_status(&info.code),
|
||||||
|
info.code.as_str(),
|
||||||
|
info.message.as_str(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||||
(status, Json(ApiError::new(code, message))).into_response()
|
(status, Json(ApiError::new(code, message))).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ pub struct SessionRecord {
|
|||||||
/// fall back to a probe.
|
/// fall back to a probe.
|
||||||
pub worktree_branch: Option<String>,
|
pub worktree_branch: Option<String>,
|
||||||
pub bridge: PtyBridge,
|
pub bridge: PtyBridge,
|
||||||
|
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||||
|
/// outranks the activity pass's own inference; when it lapses, inference
|
||||||
|
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||||
|
/// field is daemon-local bookkeeping only — it never rides the wire.
|
||||||
|
pub reported_activity: Option<crate::activity::ReportedActivity>,
|
||||||
|
/// Flipped once the natural-exit settle step (#82) has been applied or
|
||||||
|
/// refused for this session. `Dead` is observable before settling ends
|
||||||
|
/// (#115), so this is the observable that says its outcomes are safe to
|
||||||
|
/// assert on. Daemon-local bookkeeping; never rides the wire.
|
||||||
|
pub settled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -131,6 +141,14 @@ struct Inner {
|
|||||||
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
||||||
/// kill (#81) without one fails rather than destroying unrecorded work.
|
/// kill (#81) without one fails rather than destroying unrecorded work.
|
||||||
retained: Option<crate::retained_store::RetainedStore>,
|
retained: Option<crate::retained_store::RetainedStore>,
|
||||||
|
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
|
||||||
|
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
|
||||||
|
/// the variable. A mutex because the runner sets it after construction,
|
||||||
|
/// possibly while clones are already serving.
|
||||||
|
daemon_url: std::sync::Mutex<Option<String>>,
|
||||||
|
/// TTL an agent-reported activity state (#105) stays in force. A const in
|
||||||
|
/// production; overridable for tests that watch the lapse happen.
|
||||||
|
report_ttl: Duration,
|
||||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||||
events: broadcast::Sender<CatalogEvent>,
|
events: broadcast::Sender<CatalogEvent>,
|
||||||
}
|
}
|
||||||
@@ -170,6 +188,8 @@ impl SessionManager {
|
|||||||
replay_bytes,
|
replay_bytes,
|
||||||
spawn_history: None,
|
spawn_history: None,
|
||||||
retained: None,
|
retained: None,
|
||||||
|
daemon_url: std::sync::Mutex::new(None),
|
||||||
|
report_ttl: crate::activity::REPORT_TTL,
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
events,
|
events,
|
||||||
}),
|
}),
|
||||||
@@ -202,6 +222,23 @@ impl SessionManager {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Override the agent-report TTL (#105). Tests pass a short one to watch
|
||||||
|
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
|
||||||
|
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
|
||||||
|
let inner = Arc::get_mut(&mut self.inner)
|
||||||
|
.expect("with_activity_report_ttl must be called before the SessionManager is cloned");
|
||||||
|
inner.report_ttl = ttl;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the daemon's own HTTP base URL (#103), exported to every session
|
||||||
|
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
|
||||||
|
/// is a plain setter: the daemon runner learns its bound address after
|
||||||
|
/// construction, and clones may already exist by then.
|
||||||
|
pub fn set_daemon_url(&self, url: String) {
|
||||||
|
*self.inner.daemon_url.lock().expect("daemon_url mutex") = Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
/// Recent working dirs for the spawn form's picker (#24), newest-first and
|
/// Recent working dirs for the spawn form's picker (#24), newest-first and
|
||||||
/// deduped by dir. Empty when no history is wired or it's disabled.
|
/// deduped by dir. Empty when no history is wired or it's disabled.
|
||||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||||
@@ -262,6 +299,17 @@ impl SessionManager {
|
|||||||
sessions.get(&id).map(|r| r.session.status)
|
sessions.get(&id).map(|r| r.session.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the natural-exit settle step (#82) has finished for this
|
||||||
|
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
||||||
|
/// still pending, `None` if the session is unknown. `Dead` is observable
|
||||||
|
/// before settling ends (#115), so anything asserting on a dead
|
||||||
|
/// session's worktree waits on this rather than inferring settle
|
||||||
|
/// completion from its side effects.
|
||||||
|
pub async fn is_settled(&self, id: Uuid) -> Option<bool> {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
sessions.get(&id).map(|r| r.settled)
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||||
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
||||||
@@ -278,7 +326,11 @@ impl SessionManager {
|
|||||||
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
||||||
}
|
}
|
||||||
.map_err(SessionError::Worktree)?;
|
.map_err(SessionError::Worktree)?;
|
||||||
(path.clone(), Some(path), Some(base_repo.clone()))
|
// Record the repository, not whatever the request named: a
|
||||||
|
// spawn from inside a linked worktree passes that worktree as
|
||||||
|
// `base_repo`, and clients group sessions by this path (#99).
|
||||||
|
let repo = crate::worktree::main_checkout_of(base_repo);
|
||||||
|
(path.clone(), Some(path), Some(repo))
|
||||||
}
|
}
|
||||||
None => (req.cwd.clone(), None, None),
|
None => (req.cwd.clone(), None, None),
|
||||||
};
|
};
|
||||||
@@ -300,7 +352,7 @@ impl SessionManager {
|
|||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let session_env = match sanitize_env(&req.env) {
|
let mut session_env = match sanitize_env(&req.env) {
|
||||||
Ok(env) => env,
|
Ok(env) => env,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = std::fs::remove_file(&fifo_path);
|
let _ = std::fs::remove_file(&fifo_path);
|
||||||
@@ -308,6 +360,19 @@ impl SessionManager {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Identity + reporting target for the in-session hook (#106). Ours
|
||||||
|
// win over whatever the request carried, so a client cannot spoof
|
||||||
|
// another session's identity (#103).
|
||||||
|
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
|
||||||
|
if let Some(url) = self
|
||||||
|
.inner
|
||||||
|
.daemon_url
|
||||||
|
.lock()
|
||||||
|
.expect("daemon_url mutex")
|
||||||
|
.clone()
|
||||||
|
{
|
||||||
|
session_env.insert("MAESTRO_URL".into(), url);
|
||||||
|
}
|
||||||
let spawned = {
|
let spawned = {
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let cwd = effective_cwd.clone();
|
let cwd = effective_cwd.clone();
|
||||||
@@ -348,7 +413,9 @@ impl SessionManager {
|
|||||||
// activity watcher refines `foreground`/`activity` on its next tick.
|
// activity watcher refines `foreground`/`activity` on its next tick.
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: ActivityState::Active,
|
activity: ActivityState::Active,
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
// The watcher fills this in on its first blocked-work pass; a
|
// The watcher fills this in on its first blocked-work pass; a
|
||||||
@@ -377,6 +444,8 @@ impl SessionManager {
|
|||||||
worktree_base_repo,
|
worktree_base_repo,
|
||||||
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
||||||
bridge,
|
bridge,
|
||||||
|
reported_activity: None,
|
||||||
|
settled: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -565,9 +634,14 @@ impl SessionManager {
|
|||||||
status: SessionStatus::Running,
|
status: SessionStatus::Running,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
// Activity is unknown on adoption; the watcher fills it in.
|
// Activity is unknown on adoption; the watcher fills it in.
|
||||||
|
// #104: the restart also resets the activity clock to now —
|
||||||
|
// accepted, since the daemon cannot know how long a
|
||||||
|
// recovered session had already been sitting idle.
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: ActivityState::Idle,
|
activity: ActivityState::Idle,
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
@@ -599,6 +673,8 @@ impl SessionManager {
|
|||||||
// recovered session probes git for it at retain time.
|
// recovered session probes git for it at retain time.
|
||||||
worktree_branch: None,
|
worktree_branch: None,
|
||||||
bridge,
|
bridge,
|
||||||
|
reported_activity: None,
|
||||||
|
settled: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -639,6 +715,47 @@ impl SessionManager {
|
|||||||
.await
|
.await
|
||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accept an agent-reported activity state (#105), the only path that can
|
||||||
|
/// produce `ActivityState::Error`. The report outranks the activity pass's
|
||||||
|
/// own inference until its TTL lapses — so an agent that dies mid-run
|
||||||
|
/// cannot pin its session at `Error` forever. An unknown session is
|
||||||
|
/// [`SessionError::NotFound`]; the reported `detail` is capped
|
||||||
|
/// server-side.
|
||||||
|
///
|
||||||
|
/// A report that changes the effective state restarts `activity_since`
|
||||||
|
/// (#104's clock); re-reporting an unchanged state only extends the TTL
|
||||||
|
/// and stays silent on the event stream at steady state.
|
||||||
|
pub async fn report_activity(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
state: ActivityState,
|
||||||
|
detail: Option<String>,
|
||||||
|
) -> Result<(), SessionError> {
|
||||||
|
let updated = {
|
||||||
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
|
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
let detail = crate::activity::truncate_detail(detail);
|
||||||
|
let changed = rec.session.activity != state || rec.session.activity_detail != detail;
|
||||||
|
if rec.session.activity != state {
|
||||||
|
rec.session.activity_since = Utc::now();
|
||||||
|
}
|
||||||
|
rec.session.activity = state;
|
||||||
|
rec.session.activity_detail = detail;
|
||||||
|
rec.reported_activity = Some(crate::activity::ReportedActivity::new(
|
||||||
|
state,
|
||||||
|
self.inner.report_ttl,
|
||||||
|
));
|
||||||
|
changed.then(|| rec.session.clone())
|
||||||
|
};
|
||||||
|
if let Some(session) = updated {
|
||||||
|
let _ = self
|
||||||
|
.inner
|
||||||
|
.events
|
||||||
|
.send(CatalogEvent::SessionUpdated { session });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
|
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
|
||||||
@@ -809,8 +926,22 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
let Some(rec) = sessions.get_mut(&id) else {
|
let Some(rec) = sessions.get_mut(&id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// #105: an agent report lapses at its TTL — drop it and its
|
||||||
|
// detail so this tick's inference takes over again.
|
||||||
|
if let Some(report) = &rec.reported_activity {
|
||||||
|
if !report.in_force(std::time::Instant::now()) {
|
||||||
|
rec.reported_activity = None;
|
||||||
|
rec.session.activity_detail = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
let idle = rec.bridge.idle_for();
|
let idle = rec.bridge.idle_for();
|
||||||
let activity = crate::activity::decide(idle, awaiting);
|
// #105: while a report is in force it supplies the state and
|
||||||
|
// nothing here may overwrite it; only an unreported session is
|
||||||
|
// decided by inference.
|
||||||
|
let activity = match &rec.reported_activity {
|
||||||
|
Some(report) => report.state,
|
||||||
|
None => crate::activity::decide(idle, awaiting),
|
||||||
|
};
|
||||||
let foreground = Some(pane.command.clone());
|
let foreground = Some(pane.command.clone());
|
||||||
// The pane's live cwd, drives the dir-based session tree; empty means
|
// The pane's live cwd, drives the dir-based session tree; empty means
|
||||||
// tmux reported none.
|
// tmux reported none.
|
||||||
@@ -824,6 +955,12 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
{
|
{
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
// #104: stamp only when the state itself changed — never on
|
||||||
|
// foreground/cwd-only diffs, and never every tick, or the
|
||||||
|
// sidebar's duration would reset continuously and read zero.
|
||||||
|
if rec.session.activity != activity {
|
||||||
|
rec.session.activity_since = Utc::now();
|
||||||
|
}
|
||||||
rec.session.activity = activity;
|
rec.session.activity = activity;
|
||||||
rec.session.foreground = foreground;
|
rec.session.foreground = foreground;
|
||||||
rec.session.current_cwd = current_cwd;
|
rec.session.current_cwd = current_cwd;
|
||||||
@@ -950,6 +1087,14 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Settle has now been applied or refused either way (#115): from here on
|
||||||
|
// its outcomes are safe to observe.
|
||||||
|
{
|
||||||
|
let mut sessions = inner.sessions.lock().await;
|
||||||
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
|
record.settled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _ = inner.sidecar.write(&session);
|
let _ = inner.sidecar.write(&session);
|
||||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
|
|||||||
@@ -264,6 +264,25 @@ impl Tmux {
|
|||||||
check(out)
|
check(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `tmux show-environment -t maestro-<id>`, parsed into KEY=VALUE pairs.
|
||||||
|
/// Test-facing view of what a spawned session actually inherits.
|
||||||
|
pub fn show_environment(&self, id: Uuid) -> Result<BTreeMap<String, String>, TmuxError> {
|
||||||
|
let name = session_name(id);
|
||||||
|
let out = self
|
||||||
|
.cmd()
|
||||||
|
.args(["show-environment", "-t", &name])
|
||||||
|
.output()?;
|
||||||
|
check(out.clone())?;
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let mut env = BTreeMap::new();
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if let Some((k, v)) = line.split_once('=') {
|
||||||
|
env.insert(k.to_string(), v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(env)
|
||||||
|
}
|
||||||
|
|
||||||
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
|
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
|
||||||
pub fn kill_server(&self) -> Result<(), TmuxError> {
|
pub fn kill_server(&self) -> Result<(), TmuxError> {
|
||||||
let out = self.cmd().arg("kill-server").output()?;
|
let out = self.cmd().arg("kill-server").output()?;
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ impl WorktreeManager {
|
|||||||
/// The repository's main checkout. `--git-common-dir` points at the shared
|
/// The repository's main checkout. `--git-common-dir` points at the shared
|
||||||
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
||||||
/// back to the input when git can't answer.
|
/// back to the input when git can't answer.
|
||||||
|
/// The repository a path belongs to: the main checkout, even when `path` is a
|
||||||
|
/// linked worktree. Clients group sessions by this (#99), and placement uses it
|
||||||
|
/// so a worktree is never nested inside another.
|
||||||
|
pub fn main_checkout_of(path: &Path) -> PathBuf {
|
||||||
|
main_checkout(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn main_checkout(path: &Path) -> PathBuf {
|
fn main_checkout(path: &Path) -> PathBuf {
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
|
|||||||
@@ -189,8 +189,23 @@ fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
|
|||||||
"never observed a foreground group whose leader had exited"
|
"never observed a foreground group whose leader had exited"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(
|
// The leader can be gone while the surviving stage is still setting up:
|
||||||
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
|
// `true` exits as soon as both stages are forked, before the reader has
|
||||||
"a surviving pipeline member still holds the tty"
|
// opened the tty and parked. Poll for the answer, but only accept it
|
||||||
);
|
// while the state this test exists for still holds — otherwise a shell
|
||||||
|
// that regained the terminal would satisfy it vacuously.
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut holds = false;
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
let groups = ProcGroups::snapshot();
|
||||||
|
let still_leaderless = pgrp_and_tpgid(pane.pane_pid).is_some_and(|(pgrp, tpgid)| {
|
||||||
|
tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none()
|
||||||
|
});
|
||||||
|
if still_leaderless && awaiting_input(pane.pane_pid, &pane.tty, &groups) {
|
||||||
|
holds = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
assert!(holds, "a surviving pipeline member still holds the tty");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Agent-reported activity (#105): `POST /api/sessions/:id/activity`
|
||||||
|
//! semantics at the SessionManager level — precedence over the daemon's own
|
||||||
|
//! inference, detail handling, and lapse back to inference at the TTL.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::TmuxFixture;
|
||||||
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
|
use maestro_daemon::SessionManager;
|
||||||
|
use maestro_protocol::{ActivityState, CreateSessionRequest};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
manager: SessionManager,
|
||||||
|
_tmux: TmuxFixture,
|
||||||
|
_state_dir: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fixture(ttl: Duration) -> Fixture {
|
||||||
|
let tmux = TmuxFixture::new();
|
||||||
|
let state_dir = TempDir::new().unwrap();
|
||||||
|
let manager = SessionManager::new(
|
||||||
|
Arc::new(tmux.tmux.clone()),
|
||||||
|
SidecarStore::new(state_dir.path().join("sessions")),
|
||||||
|
WorktreeManager::new(None),
|
||||||
|
state_dir.path().join("fifos"),
|
||||||
|
)
|
||||||
|
.with_activity_report_ttl(ttl);
|
||||||
|
Fixture {
|
||||||
|
manager,
|
||||||
|
_tmux: tmux,
|
||||||
|
_state_dir: state_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
||||||
|
manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "sleep 60".into(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata: Default::default(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observed(manager: &SessionManager, id: Uuid) -> maestro_protocol::Session {
|
||||||
|
manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.expect("session still tracked")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn report_on_unknown_session_is_not_found() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let err = fx
|
||||||
|
.manager
|
||||||
|
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn report_sets_error_state_and_detail() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(
|
||||||
|
id,
|
||||||
|
ActivityState::Error,
|
||||||
|
Some("cargo build failed: link error".into()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
assert_eq!(s.activity, ActivityState::Error);
|
||||||
|
assert_eq!(
|
||||||
|
s.activity_detail.as_deref(),
|
||||||
|
Some("cargo build failed: link error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replacing_a_report_replaces_both_state_and_detail() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Error, Some("first failure".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fx.manager
|
||||||
|
.report_activity(
|
||||||
|
id,
|
||||||
|
ActivityState::AwaitingInput,
|
||||||
|
Some("needs approval".into()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
assert_eq!(s.activity, ActivityState::AwaitingInput);
|
||||||
|
assert_eq!(s.activity_detail.as_deref(), Some("needs approval"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn detail_is_capped_server_side() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
let long = "x".repeat(10_000);
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Error, Some(long))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
let detail = s.activity_detail.expect("detail present");
|
||||||
|
assert_eq!(
|
||||||
|
detail.chars().count(),
|
||||||
|
maestro_daemon::MAX_REPORT_DETAIL_CHARS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resumes() {
|
||||||
|
let fx = fixture(Duration::from_millis(150)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Several watcher ticks inside the TTL: nothing infers `Error`, so the
|
||||||
|
// report must hold above inference.
|
||||||
|
sleep(Duration::from_millis(80)).await;
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
assert_eq!(s.activity, ActivityState::Error);
|
||||||
|
assert_eq!(s.activity_detail.as_deref(), Some("agent died mid-run"));
|
||||||
|
|
||||||
|
// After the TTL lapses, inference resumes and the detail goes with it.
|
||||||
|
common::wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
s.activity != ActivityState::Error && s.activity_detail.is_none()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -346,6 +346,109 @@ async fn delete_owned_session_kills_it() {
|
|||||||
assert!(!h.manager.has(session.id).await);
|
assert!(!h.manager.has(session.id).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn a session through the manager and return its id.
|
||||||
|
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||||
|
h.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "sleep 60".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agent activity endpoint on the wire (#105): a report lands in the
|
||||||
|
/// session snapshot with its detail.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_activity_reports_error_with_detail() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let id = spawn_via_manager(&h).await;
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"state": "Error",
|
||||||
|
"detail": "cargo build failed"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
||||||
|
assert_eq!(s.activity, maestro_protocol::ActivityState::Error);
|
||||||
|
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_activity_to_unknown_session_returns_404() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{}/activity", Uuid::new_v4()),
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({ "state": "Idle" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown state values are rejected rather than coerced (#105).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_activity_rejects_an_unknown_state_value() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let id = spawn_via_manager(&h).await;
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||||
|
.json(&serde_json::json!({ "state": "Teleported" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(resp.status().is_client_error(), "got {}", resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_activity_caps_an_overlong_detail_server_side() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let id = spawn_via_manager(&h).await;
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"state": "Error",
|
||||||
|
"detail": "x".repeat(10_000)
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.activity_detail.as_ref().map(|d| d.chars().count()),
|
||||||
|
Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
|
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
|
||||||
/// with the structured breakdown, and the session keeps running.
|
/// with the structured breakdown, and the session keeps running.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ impl Fx {
|
|||||||
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
|
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Variant whose manager knows the daemon's own base URL (#103), the way
|
||||||
|
/// the production runner wires it from `[daemon].bind_addr`.
|
||||||
|
fn with_daemon_url(url: &str) -> Self {
|
||||||
|
let fx = Self::new();
|
||||||
|
fx.manager.set_daemon_url(url.to_string());
|
||||||
|
fx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The env map of the (single, expected) spawn call.
|
||||||
|
fn spawned_env(&self) -> BTreeMap<String, String> {
|
||||||
|
let calls = self.fake.calls();
|
||||||
|
let Some(Call::NewSessionPiped { env, .. }) = calls.first() else {
|
||||||
|
panic!("expected a spawn call, got {calls:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(calls.len(), 1, "expected exactly one spawn: {calls:?}");
|
||||||
|
env.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
|
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
|
||||||
let tmp = TempDir::new().expect("tempdir");
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
let fake = Arc::new(FakePanes::new());
|
let fake = Arc::new(FakePanes::new());
|
||||||
@@ -261,16 +279,23 @@ async fn requested_env_reaches_tmux() {
|
|||||||
|
|
||||||
fx.manager.create(req).await.expect("create");
|
fx.manager.create(req).await.expect("create");
|
||||||
|
|
||||||
|
let spawned = match fx.fake.calls().first() {
|
||||||
|
Some(Call::NewSessionPiped { env, .. }) => env.clone(),
|
||||||
|
other => panic!("expected a spawn call, got {other:?}"),
|
||||||
|
};
|
||||||
|
let id = spawned
|
||||||
|
.get("MAESTRO_SESSION_ID")
|
||||||
|
.expect("spawn must export MAESTRO_SESSION_ID (#103)")
|
||||||
|
.clone();
|
||||||
|
// The daemon-owned variable rides alongside whatever was requested.
|
||||||
let want: BTreeMap<String, String> = [
|
let want: BTreeMap<String, String> = [
|
||||||
("EMPTY".to_string(), String::new()),
|
("EMPTY".to_string(), String::new()),
|
||||||
("FOO".to_string(), "bar".to_string()),
|
("FOO".to_string(), "bar".to_string()),
|
||||||
|
("MAESTRO_SESSION_ID".to_string(), id),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect();
|
.collect();
|
||||||
match fx.fake.calls().first() {
|
assert_eq!(&spawned, &want);
|
||||||
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
|
|
||||||
other => panic!("expected a spawn call, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -338,6 +363,9 @@ async fn activity_pass_emits_updated_only_when_something_changed() {
|
|||||||
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
|
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
|
||||||
// change here is `foreground`, not the activity state.
|
// change here is `foreground`, not the activity state.
|
||||||
assert_eq!(updated.activity, ActivityState::Active);
|
assert_eq!(updated.activity, ActivityState::Active);
|
||||||
|
// A foreground/cwd-only change is not a state transition (#104): the
|
||||||
|
// sidebar's duration must keep counting from the original stamp.
|
||||||
|
assert_eq!(updated.activity_since, session.activity_since);
|
||||||
|
|
||||||
// Drain anything already queued, then confirm a steady pane stays quiet.
|
// Drain anything already queued, then confirm a steady pane stays quiet.
|
||||||
// Stays well inside the 3s window, so no Active -> Idle transition either.
|
// Stays well inside the 3s window, so no Active -> Idle transition either.
|
||||||
@@ -349,6 +377,70 @@ async fn activity_pass_emits_updated_only_when_something_changed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sidebar's duration (#104) is time in the *current* state, so
|
||||||
|
/// `activity_since` is stamped exactly when the decided state differs from
|
||||||
|
/// the stored one — once per transition. Stamping it every tick would make
|
||||||
|
/// the duration permanently read zero.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn activity_transition_stamps_activity_since_once_per_transition() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let session = fx
|
||||||
|
.manager
|
||||||
|
.create(fx.request("sleep 300"))
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
fx.fake.set_pane_info(
|
||||||
|
session.id,
|
||||||
|
PaneInfo {
|
||||||
|
// Nothing reads from this pid: `awaiting_input` is deterministically
|
||||||
|
// false, so past `ACTIVE_WINDOW` the session decides as Idle.
|
||||||
|
pane_pid: i32::MAX,
|
||||||
|
command: "sleep".into(),
|
||||||
|
tty: "/dev/null".into(),
|
||||||
|
current_path: String::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
|
|
||||||
|
let idle = wait_for_activity(&fx.manager, session.id, ActivityState::Idle).await;
|
||||||
|
assert!(
|
||||||
|
idle.activity_since >= session.activity_since + chrono::Duration::seconds(2),
|
||||||
|
"the Active -> Idle transition must stamp a fresh activity_since \
|
||||||
|
(spawn stamp {:?}, transition stamp {:?})",
|
||||||
|
session.activity_since,
|
||||||
|
idle.activity_since
|
||||||
|
);
|
||||||
|
|
||||||
|
// Steady Idle across many more ticks must not re-stamp — no further
|
||||||
|
// SessionUpdated at all, or the sidebar clock would keep resetting.
|
||||||
|
while events.try_recv().is_ok() {}
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
assert!(
|
||||||
|
matches!(events.try_recv(), Err(TryRecvError::Empty)),
|
||||||
|
"a steady state must not re-emit (and re-stamp) on every tick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_activity(
|
||||||
|
manager: &SessionManager,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
want: ActivityState,
|
||||||
|
) -> maestro_protocol::Session {
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(6);
|
||||||
|
loop {
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
panic!("timed out waiting for {id} to become {want:?}");
|
||||||
|
}
|
||||||
|
if let Some(s) = manager.snapshot().await.into_iter().find(|s| s.id == id) {
|
||||||
|
if s.activity == want {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_updated(
|
async fn wait_for_updated(
|
||||||
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
|
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
|
||||||
) -> maestro_protocol::Session {
|
) -> maestro_protocol::Session {
|
||||||
@@ -396,3 +488,70 @@ fn attaching_spawn_history_after_cloning_panics() {
|
|||||||
7,
|
7,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every tmux-spawned session learns who it is (#103): its own UUID under
|
||||||
|
/// `MAESTRO_SESSION_ID`, so the reporting hook in #106 can name the session
|
||||||
|
/// it is running inside.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_exports_maestro_session_id() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
|
||||||
|
let env = fx.spawned_env();
|
||||||
|
assert_eq!(
|
||||||
|
env.get("MAESTRO_SESSION_ID").map(String::as_str),
|
||||||
|
Some(&s.id.to_string()[..]),
|
||||||
|
"MAESTRO_SESSION_ID must be the session's UUID, got {env:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hook also needs to know where to report to: `MAESTRO_URL` carries the
|
||||||
|
/// daemon's base URL derived from `[daemon].bind_addr` (#103).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_exports_maestro_url_when_the_daemon_is_wired() {
|
||||||
|
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
|
||||||
|
fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
|
||||||
|
let env = fx.spawned_env();
|
||||||
|
assert_eq!(
|
||||||
|
env.get("MAESTRO_URL").map(String::as_str),
|
||||||
|
Some("http://127.0.0.1:7070"),
|
||||||
|
"MAESTRO_URL must be the daemon's base URL, got {env:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without a wired URL (tests, or a caller that never set one) only the
|
||||||
|
/// session id is injected — an empty or bogus `MAESTRO_URL` would send the
|
||||||
|
/// #106 hook's reports nowhere useful.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_omits_maestro_url_when_not_wired() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
|
||||||
|
let env = fx.spawned_env();
|
||||||
|
assert!(
|
||||||
|
!env.contains_key("MAESTRO_URL"),
|
||||||
|
"MAESTRO_URL must be absent when no daemon URL is known, got {env:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The daemon-owned variables win over anything the spawner sent: a client
|
||||||
|
/// cannot spoof another session's identity.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_overrides_client_supplied_maestro_variables() {
|
||||||
|
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
|
||||||
|
let mut req = fx.request("bash");
|
||||||
|
req.env.insert(
|
||||||
|
"MAESTRO_SESSION_ID".into(),
|
||||||
|
"00000000-0000-0000-0000-000000000000".into(),
|
||||||
|
);
|
||||||
|
req.env.insert("MAESTRO_URL".into(), "http://evil:1".into());
|
||||||
|
let s = fx.manager.create(req).await.expect("create");
|
||||||
|
|
||||||
|
let env = fx.spawned_env();
|
||||||
|
assert_eq!(env.get("MAESTRO_SESSION_ID"), Some(&s.id.to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
env.get("MAESTRO_URL"),
|
||||||
|
Some(&"http://127.0.0.1:7070".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -397,6 +397,12 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
init_bare_repo(origin.path());
|
init_bare_repo(origin.path());
|
||||||
push_all_to_origin(repo.path(), origin.path());
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
|
// Subscribe before the watcher starts so every pass's emission is
|
||||||
|
// captured — otherwise the breakdown event can land between the
|
||||||
|
// snapshot observation below and the drain, and the quiet window
|
||||||
|
// would see it as an illegitimate re-emit.
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
|
||||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||||
Duration::from_millis(50),
|
Duration::from_millis(50),
|
||||||
Duration::from_millis(100),
|
Duration::from_millis(100),
|
||||||
@@ -418,14 +424,38 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// A steady state must not re-emit every tick (#83 change-detection).
|
// Consume through the emission that carried the breakdown (#83
|
||||||
let mut events = fx.manager.events();
|
// change-detection): the quiet window below must start after the last
|
||||||
|
// legitimate change, not race it into the channel.
|
||||||
|
loop {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(5), events.recv()).await {
|
||||||
|
Ok(Ok(CatalogEvent::SessionUpdated { session }))
|
||||||
|
if matches!(
|
||||||
|
&session.blocked_work,
|
||||||
|
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => continue,
|
||||||
|
_ => panic!("never saw the SessionUpdated carrying the breakdown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
while events.try_recv().is_ok() {}
|
while events.try_recv().is_ok() {}
|
||||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
assert!(
|
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
|
||||||
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
|
// The activity pass may still legitimately announce e.g. Active→Idle
|
||||||
"an unchanged worktree must not re-emit SessionUpdated"
|
// (its debounce is time-based, not worktree-based), and its payload
|
||||||
);
|
// carries today's blocked_work unchanged.
|
||||||
|
while let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&session.blocked_work,
|
||||||
|
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||||
|
),
|
||||||
|
"an unchanged worktree must not re-emit a changed indicator"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||||
@@ -769,6 +799,14 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
|||||||
"a dirty worktree must be retained on natural exit"
|
"a dirty worktree must be retained on natural exit"
|
||||||
);
|
);
|
||||||
// ...in the store, under the exit reason.
|
// ...in the store, under the exit reason.
|
||||||
|
// (`Dead` is observable a moment before the settle step's store write lands.)
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
fx.retained_store()
|
||||||
|
.load_all()
|
||||||
|
.map(|all| all.len() == 1)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
let all = fx.retained_store().load_all().expect("load_all");
|
let all = fx.retained_store().load_all().expect("load_all");
|
||||||
assert_eq!(all.len(), 1);
|
assert_eq!(all.len(), 1);
|
||||||
assert_eq!(all[0].path, wt_path);
|
assert_eq!(all[0].path, wt_path);
|
||||||
@@ -840,6 +878,17 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// (`Dead` is observable a moment before the settle step removes the tree.)
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
!wt_path.exists()
|
||||||
|
&& fx
|
||||||
|
.retained_store()
|
||||||
|
.load_all()
|
||||||
|
.map(|all| all.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!wt_path.exists(),
|
!wt_path.exists(),
|
||||||
"a pristine worktree leaves nothing behind"
|
"a pristine worktree leaves nothing behind"
|
||||||
@@ -897,6 +946,14 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// The settle step has been applied or refused either way — here refused,
|
||||||
|
// since there is no store to record the tree into. Only from this point
|
||||||
|
// is "the tree survived" a settled fact rather than a race (#115).
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
manager.is_settled(s.id).await == Some(true)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"nothing may be destroyed when it cannot be recorded"
|
"nothing may be destroyed when it cannot be recorded"
|
||||||
@@ -947,3 +1004,101 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
|
|
||||||
fx.manager.kill(s.id, false).await.unwrap();
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #99: clients group a session under `worktree.base_repo`, so that field has
|
||||||
|
/// to name the repository even when the spawn request pointed at a linked
|
||||||
|
/// worktree. Recording the request verbatim put the session in a folder of its
|
||||||
|
/// own — `<repo>/worktrees/<branch>` — instead of under the repo.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
|
||||||
|
// A linked worktree of that repo, then a spawn whose `base_repo` names it
|
||||||
|
// rather than the main checkout — what happens when the operator spawns
|
||||||
|
// from inside a worktree.
|
||||||
|
let first = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "feat/one".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create first");
|
||||||
|
let inner_worktree = first.worktree.expect("first has a worktree").path;
|
||||||
|
|
||||||
|
let second = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: inner_worktree.clone(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: inner_worktree.clone(),
|
||||||
|
branch: "feat/two".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create second");
|
||||||
|
|
||||||
|
let wt = second.worktree.expect("second has a worktree");
|
||||||
|
assert_eq!(
|
||||||
|
wt.base_repo,
|
||||||
|
repo.path()
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| repo.path().to_path_buf()),
|
||||||
|
"base_repo must be the repository, not the worktree the request named",
|
||||||
|
);
|
||||||
|
assert_ne!(wt.base_repo, inner_worktree, "the worktree is not the repo");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #103: every tmux-spawned session carries the daemon's identity variables in
|
||||||
|
/// its environment — its UUID under `MAESTRO_SESSION_ID` and the daemon's base
|
||||||
|
/// URL under `MAESTRO_URL` — so the in-session reporting hook (#106) can find
|
||||||
|
/// both without any out-of-band channel.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawned_sessions_export_maestro_session_id_and_url() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
fx.manager
|
||||||
|
.set_daemon_url("http://127.0.0.1:7071".to_string());
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
let env = fx
|
||||||
|
.tmux
|
||||||
|
.tmux
|
||||||
|
.show_environment(s.id)
|
||||||
|
.expect("show-environment");
|
||||||
|
assert_eq!(
|
||||||
|
env.get("MAESTRO_SESSION_ID"),
|
||||||
|
Some(&s.id.to_string()),
|
||||||
|
"session env: {env:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
env.get("MAESTRO_URL"),
|
||||||
|
Some(&"http://127.0.0.1:7071".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
fx.manager.kill(s.id, false).await.expect("kill");
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ fn sample(id: Uuid) -> Session {
|
|||||||
.with_timezone(&Utc),
|
.with_timezone(&Utc),
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "maestro-hooks"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "Agent activity hooks (#106): shipped script + idempotent settings.json installer"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//! Claude Code activity hook (#106).
|
||||||
|
//!
|
||||||
|
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
|
||||||
|
//! the daemon's first-start auto-install:
|
||||||
|
//!
|
||||||
|
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
||||||
|
//! posts an [`ActivityState`](maestro-protocol) to
|
||||||
|
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
|
||||||
|
//! global hook runs in every Claude Code session on the machine, so it exits
|
||||||
|
//! 0 and silently when either #103 env var is unset, and it never lets a
|
||||||
|
//! daemon failure escape (short curl timeout, status ignored).
|
||||||
|
//! * [`install`] — idempotent registration of that script in
|
||||||
|
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
|
||||||
|
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
|
||||||
|
//! in the file is preserved verbatim.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Marker substring every maestro-installed hook command carries. The
|
||||||
|
/// installer rewrites or removes exactly these entries — nothing else in the
|
||||||
|
/// user's settings file.
|
||||||
|
pub const HOOK_MARKER: &str = "maestro-claude-hook";
|
||||||
|
|
||||||
|
/// File name of the installed script (carries [`HOOK_MARKER`]).
|
||||||
|
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||||
|
|
||||||
|
/// Claude Code hook event → `ActivityState` spelling posted to the daemon.
|
||||||
|
///
|
||||||
|
/// `PreToolUse` is what makes a session `Active` *during* a long tool call —
|
||||||
|
/// `PostToolUse` alone only fires once it ends (#114). `StopFailure` is
|
||||||
|
/// Claude Code's own name for "the turn ended on an API error"; there is no
|
||||||
|
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||||
|
/// `Error`.
|
||||||
|
pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||||
|
// Tool starting: the agent is demonstrably producing work, even if the
|
||||||
|
// tool itself runs for minutes.
|
||||||
|
("PreToolUse", "Active"),
|
||||||
|
// Tool finished successfully.
|
||||||
|
("PostToolUse", "Active"),
|
||||||
|
// Permission / idle notices: Claude is blocked on the user.
|
||||||
|
("Notification", "AwaitingInput"),
|
||||||
|
// Turn ended: waiting on the user again.
|
||||||
|
("Stop", "AwaitingInput"),
|
||||||
|
// Turn ended on an API error; the payload's `error` field is forwarded
|
||||||
|
// as the report's `detail` (#114).
|
||||||
|
("StopFailure", "Error"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||||
|
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||||
|
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
||||||
|
# Exits silently unless running inside a session maestro spawned (#103 env).
|
||||||
|
# Event -> ActivityState mapping:
|
||||||
|
# PreToolUse -> Active (tool started; agent is working)
|
||||||
|
# PostToolUse -> Active (tool finished)
|
||||||
|
# Notification -> AwaitingInput (permission / idle notice)
|
||||||
|
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||||
|
# StopFailure -> Error (turn ended on an API error)
|
||||||
|
state="$1"
|
||||||
|
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||||
|
[ -n "$MAESTRO_URL" ] || exit 0
|
||||||
|
payload=$(cat 2>/dev/null) || true
|
||||||
|
body="{\"state\":\"$state\"}"
|
||||||
|
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
||||||
|
# spelling is reported as the detail — anything else (quotes, backslashes,
|
||||||
|
# prose) would risk breaking the JSON body, and the spelling already names
|
||||||
|
# the failure.
|
||||||
|
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
||||||
|
if [ -n "$detail" ]; then
|
||||||
|
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
|
||||||
|
fi
|
||||||
|
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body" \
|
||||||
|
"$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
exit 0
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum HooksError {
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("json: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the script is written for a given home directory:
|
||||||
|
/// `<home>/.local/share/maestro/maestro-claude-hook.sh`.
|
||||||
|
pub fn script_path(home: &Path) -> std::path::PathBuf {
|
||||||
|
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user-level Claude Code settings file for a given home directory.
|
||||||
|
pub fn settings_path(home: &Path) -> std::path::PathBuf {
|
||||||
|
home.join(".claude/settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `settings_path` already carries a maestro hook (#106).
|
||||||
|
pub fn is_installed(settings_path: &Path) -> bool {
|
||||||
|
settings_json(settings_path)
|
||||||
|
.map(|root| find_maestro_commands(&root).next().is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write [`HOOK_SCRIPT`] to `path`, executable. Parent directories are
|
||||||
|
/// created as needed — this is always under the caller's chosen data dir,
|
||||||
|
/// never inside a repository.
|
||||||
|
pub fn write_script(path: &Path) -> Result<(), HooksError> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, HOOK_SCRIPT)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = std::fs::metadata(path)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(path, perms)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the full hook setup for `home`: write the script under
|
||||||
|
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
|
||||||
|
/// Idempotent; returns whether anything changed on disk.
|
||||||
|
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
|
||||||
|
let script = script_path(home);
|
||||||
|
let settings = settings_path(home);
|
||||||
|
let before = std::fs::read_to_string(&settings).unwrap_or_default();
|
||||||
|
write_script(&script)?;
|
||||||
|
install(&settings, &script)?;
|
||||||
|
let after = std::fs::read_to_string(&settings).unwrap_or_default();
|
||||||
|
Ok(before != after)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
|
||||||
|
/// settings file at `settings_path`. Idempotent: existing maestro entries are
|
||||||
|
/// updated in place wherever they sit, stale ones removed, duplicates folded;
|
||||||
|
/// every non-maestro entry and every unrelated top-level field survives.
|
||||||
|
///
|
||||||
|
/// Never inspects or writes anywhere but `settings_path` — callers pass the
|
||||||
|
/// user's real home or a temp one in tests, never a repository.
|
||||||
|
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
|
||||||
|
let mut root = settings_json(settings_path)?;
|
||||||
|
|
||||||
|
// Strip every maestro entry wherever it currently sits, so an older
|
||||||
|
// install under different events is replaced rather than duplicated.
|
||||||
|
strip_maestro_entries(&mut root);
|
||||||
|
|
||||||
|
let hooks = ensure_object(&mut root, "hooks");
|
||||||
|
for (event, state) in HOOK_EVENTS {
|
||||||
|
let groups = ensure_array(hooks, event);
|
||||||
|
let mut group = serde_json::Map::new();
|
||||||
|
group.insert(
|
||||||
|
"hooks".into(),
|
||||||
|
serde_json::json!([{
|
||||||
|
"type": "command",
|
||||||
|
"command": format!("{} {state}", script_path.display()),
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
groups.push(serde_json::Value::Object(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_atomic(settings_path, &root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_json(path: &Path) -> Result<serde_json::Value, HooksError> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(s) => Ok(serde_json::from_str(&s)?),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
Ok(serde_json::Value::Object(Default::default()))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every command string in the file belonging to a maestro hook.
|
||||||
|
fn find_maestro_commands(root: &serde_json::Value) -> impl Iterator<Item = &str> {
|
||||||
|
root.get("hooks")
|
||||||
|
.and_then(|h| h.as_object())
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|events| events.values())
|
||||||
|
.filter_map(|groups| groups.as_array())
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|group| group.get("hooks"))
|
||||||
|
.filter_map(|inner| inner.as_array())
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|hook| hook.get("command"))
|
||||||
|
.filter_map(|cmd| cmd.as_str())
|
||||||
|
.filter(|cmd| cmd.contains(HOOK_MARKER))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all maestro inner-hook entries from every event group, dropping
|
||||||
|
/// groups left empty. Non-maestro siblings survive untouched.
|
||||||
|
fn strip_maestro_entries(root: &mut serde_json::Value) {
|
||||||
|
let Some(events) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (_, groups) in events.iter_mut() {
|
||||||
|
let Some(list) = groups.as_array_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for group in list.iter_mut() {
|
||||||
|
let Some(inner) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
inner.retain(|hook| {
|
||||||
|
!hook
|
||||||
|
.get("command")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.is_some_and(|c| c.contains(HOOK_MARKER))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
list.retain(|group| {
|
||||||
|
group
|
||||||
|
.get("hooks")
|
||||||
|
.and_then(|h| h.as_array())
|
||||||
|
.is_some_and(|h| !h.is_empty())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// An event left with no groups at all (ours emptied it, or the user's
|
||||||
|
// file carried an empty list) reads better gone.
|
||||||
|
events.retain(|_, groups| groups.as_array().is_some_and(|g| !g.is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_object<'a>(
|
||||||
|
root: &'a mut serde_json::Value,
|
||||||
|
key: &str,
|
||||||
|
) -> &'a mut serde_json::Map<String, serde_json::Value> {
|
||||||
|
if !root.get(key).map(|v| v.is_object()).unwrap_or(false) {
|
||||||
|
root[key] = serde_json::json!({});
|
||||||
|
}
|
||||||
|
root.get_mut(key).unwrap().as_object_mut().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_array<'a>(
|
||||||
|
parent: &'a mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
key: &str,
|
||||||
|
) -> &'a mut Vec<serde_json::Value> {
|
||||||
|
let v = parent
|
||||||
|
.entry(key.to_string())
|
||||||
|
.or_insert_with(|| serde_json::json!([]));
|
||||||
|
if !v.is_array() {
|
||||||
|
*v = serde_json::json!([]);
|
||||||
|
}
|
||||||
|
v.as_array_mut().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &Path, value: &serde_json::Value) -> Result<(), HooksError> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.maestro-tmp");
|
||||||
|
std::fs::write(&tmp, serde_json::to_string_pretty(value)? + "\n")?;
|
||||||
|
std::fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
||||||
|
//! and the settings-file installer. Everything happens under a temp `$HOME`;
|
||||||
|
//! the developer's own `~/.claude/settings.json` is never touched.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||||
|
let mut cmd = Command::new("/bin/sh");
|
||||||
|
// `sh -c CMD NAME ARGS…`: NAME fills $0, so the real arguments start at $1.
|
||||||
|
let mut argv = vec!["-c", script, HOOK_MARKER];
|
||||||
|
argv.extend(args.iter().copied());
|
||||||
|
cmd.args(&argv);
|
||||||
|
// A clean env: only what the test passes, plus PATH so curl can spawn.
|
||||||
|
cmd.env_clear();
|
||||||
|
cmd.env("PATH", std::env::var("PATH").unwrap_or_default());
|
||||||
|
for (k, v) in envs {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
let mut child = cmd
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn /bin/sh");
|
||||||
|
// Feed a Claude-Code-style payload on stdin; the script must drain it.
|
||||||
|
child.stdin.take().unwrap().write_all(payload).ok();
|
||||||
|
child.wait_with_output().expect("wait")
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION: &str = "01234567-89ab-cdef-0123-456789abcdef";
|
||||||
|
|
||||||
|
/// Accept exactly one HTTP request on a throwaway listener, answer 204, and
|
||||||
|
/// hand back the raw request text. Runs `run` with the base URL once the
|
||||||
|
/// acceptor thread is in place.
|
||||||
|
fn capture_request(run: impl FnOnce(String) -> std::process::Output) -> String {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let (mut sock, _) = listener.accept().unwrap();
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let n = sock.read(&mut buf).unwrap();
|
||||||
|
sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8_lossy(&buf[..n]).to_string()
|
||||||
|
});
|
||||||
|
let out = run(format!("http://{addr}"));
|
||||||
|
assert!(out.status.success(), "{:?}", out.stderr);
|
||||||
|
handle.join().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_envs(url: &str) -> Vec<(&'static str, String)> {
|
||||||
|
vec![
|
||||||
|
("MAESTRO_URL", url.to_string()),
|
||||||
|
("MAESTRO_SESSION_ID", SESSION.to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
|
||||||
|
envs.iter().map(|(k, v)| (*k, v.as_str())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hook must be invisible outside maestro: no env vars (#103 unset) ⇒
|
||||||
|
/// exit 0, no output, stdin drained.
|
||||||
|
#[test]
|
||||||
|
fn script_exits_silently_without_maestro_env() {
|
||||||
|
for (event, _) in HOOK_EVENTS {
|
||||||
|
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||||
|
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||||
|
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
||||||
|
assert!(out.stderr.is_empty(), "{event:?}: {:?}", out.stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With both env vars set the script POSTs the mapped state to the daemon's
|
||||||
|
/// activity endpoint and still exits 0.
|
||||||
|
#[test]
|
||||||
|
fn script_posts_mapped_state_to_the_activity_endpoint() {
|
||||||
|
// The installer bakes the mapped state into the command's argv; the
|
||||||
|
// script relays whatever it is given.
|
||||||
|
let request = capture_request(|url| {
|
||||||
|
sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["Active"],
|
||||||
|
&owned_envs(&session_envs(&url)),
|
||||||
|
br#"{"session_id":"x"}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(request.starts_with("POST "), "{request}");
|
||||||
|
assert!(
|
||||||
|
request.contains(&format!("/api/sessions/{SESSION}/activity")),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every event maps to the state the contract specifies.
|
||||||
|
#[test]
|
||||||
|
fn event_state_mapping_matches_the_contract() {
|
||||||
|
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
|
||||||
|
assert_eq!(map["PreToolUse"], "Active");
|
||||||
|
assert_eq!(map["PostToolUse"], "Active");
|
||||||
|
assert_eq!(map["Notification"], "AwaitingInput");
|
||||||
|
assert_eq!(map["Stop"], "AwaitingInput");
|
||||||
|
assert_eq!(map["StopFailure"], "Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `StopFailure` payload names the API error that ended the turn; the
|
||||||
|
/// script reports its `error` spelling as the report's `detail` so a card
|
||||||
|
/// can show why (#114).
|
||||||
|
#[test]
|
||||||
|
fn script_reports_the_error_spelling_as_detail() {
|
||||||
|
let request = capture_request(|url| {
|
||||||
|
sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["Error"],
|
||||||
|
&owned_envs(&session_envs(&url)),
|
||||||
|
br#"{"session_id":"abc","hook_event_name":"StopFailure","error":"rate_limit","error_details":"429 Too Many Requests"}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||||
|
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `error` value that is not a plain enum spelling (or a payload without
|
||||||
|
/// one at all) must never produce a broken body — no detail is posted.
|
||||||
|
#[test]
|
||||||
|
fn script_posts_no_detail_for_an_unexpected_error_value() {
|
||||||
|
let request = capture_request(|url| {
|
||||||
|
sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["Error"],
|
||||||
|
&owned_envs(&session_envs(&url)),
|
||||||
|
br#"{"hook_event_name":"StopFailure","error":"429 \"Too Many\" \\ requests"}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||||
|
assert!(!request.contains("detail"), "{request}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events whose payload carries no error fields post a bare body — never an
|
||||||
|
/// empty or stale detail.
|
||||||
|
#[test]
|
||||||
|
fn script_posts_no_detail_for_events_without_an_error() {
|
||||||
|
let request = capture_request(|url| {
|
||||||
|
sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["AwaitingInput"],
|
||||||
|
&owned_envs(&session_envs(&url)),
|
||||||
|
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(request.contains(r#""state":"AwaitingInput""#), "{request}");
|
||||||
|
assert!(!request.contains("detail"), "{request}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dead or unreachable daemon must never fail — or slow down — a session.
|
||||||
|
#[test]
|
||||||
|
fn script_ignores_daemon_failure() {
|
||||||
|
// Port 1 on localhost is closed in practice; curl fails fast.
|
||||||
|
let out = sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["Stop"],
|
||||||
|
&[
|
||||||
|
("MAESTRO_URL", "http://127.0.0.1:1"),
|
||||||
|
("MAESTRO_SESSION_ID", "00000000-0000-0000-0000-000000000000"),
|
||||||
|
],
|
||||||
|
br#"{"session_id":"x"}"#,
|
||||||
|
);
|
||||||
|
assert!(out.status.success());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- installer -------------------------------------------------------------
|
||||||
|
|
||||||
|
fn installed_commands(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()
|
||||||
|
.flat_map(|events| events.values())
|
||||||
|
.filter_map(|g| g.as_array())
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|g| g.get("hooks"))
|
||||||
|
.filter_map(|h| h.as_array())
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|h| h.get("command"))
|
||||||
|
.filter_map(|c| c.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh install registers exactly one command per event, all pointing at the
|
||||||
|
/// script with the right state argument.
|
||||||
|
#[test]
|
||||||
|
fn fresh_install_registers_every_event() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let home = dir.path();
|
||||||
|
write_script(&script_path(home)).unwrap();
|
||||||
|
install(&settings_path(home), &script_path(home)).unwrap();
|
||||||
|
|
||||||
|
let cmds = installed_commands(&settings_path(home));
|
||||||
|
assert_eq!(cmds.len(), HOOK_EVENTS.len());
|
||||||
|
let joined = cmds.join("\n");
|
||||||
|
assert_eq!(joined.matches("Active").count(), 2);
|
||||||
|
assert!(joined.contains("Error"));
|
||||||
|
assert_eq!(joined.matches("AwaitingInput").count(), 2);
|
||||||
|
for cmd in &cmds {
|
||||||
|
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running install twice changes nothing further — no duplicates.
|
||||||
|
#[test]
|
||||||
|
fn install_is_idempotent() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let home = dir.path();
|
||||||
|
install(&settings_path(home), &script_path(home)).unwrap();
|
||||||
|
let once = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||||
|
|
||||||
|
install(&settings_path(home), &script_path(home)).unwrap();
|
||||||
|
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||||
|
assert_eq!(once, twice);
|
||||||
|
assert_eq!(
|
||||||
|
installed_commands(&settings_path(home)).len(),
|
||||||
|
HOOK_EVENTS.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An older maestro entry under the same events is updated in place, not
|
||||||
|
/// duplicated next to the new one.
|
||||||
|
#[test]
|
||||||
|
fn install_updates_an_older_maestro_hook_in_place() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings = dir.path().join(".claude/settings.json");
|
||||||
|
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
|
||||||
|
let old_script = "/old/location/maestro-claude-hook.sh Active";
|
||||||
|
std::fs::write(
|
||||||
|
&settings,
|
||||||
|
serde_json::json!({
|
||||||
|
"model": "sonnet",
|
||||||
|
"hooks": {
|
||||||
|
"Stop": [{"hooks": [{"type": "command", "command": old_script}]}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let new_script = dir
|
||||||
|
.path()
|
||||||
|
.join(".local/share/maestro")
|
||||||
|
.join(SCRIPT_FILE_NAME);
|
||||||
|
install(&settings, &new_script).unwrap();
|
||||||
|
|
||||||
|
let cmds = installed_commands(&settings);
|
||||||
|
assert_eq!(
|
||||||
|
cmds.len(),
|
||||||
|
HOOK_EVENTS.len(),
|
||||||
|
"old entry replaced, not kept: {cmds:?}"
|
||||||
|
);
|
||||||
|
assert!(!cmds.iter().any(|c| c == old_script));
|
||||||
|
let root: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||||
|
assert_eq!(root["model"], "sonnet", "unrelated fields survive");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stale maestro entry under an event we no longer use is cleaned up too;
|
||||||
|
/// the user's own hooks are never touched.
|
||||||
|
#[test]
|
||||||
|
fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings = dir.path().join(".claude/settings.json");
|
||||||
|
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&settings,
|
||||||
|
serde_json::json!({
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [{"matcher": "Bash", "hooks": [
|
||||||
|
{"type": "command", "command": "my-own-linter.sh"},
|
||||||
|
]}],
|
||||||
|
"UserPromptSubmit": [{"hooks": [
|
||||||
|
{"type": "command", "command": "/old/maestro-claude-hook.sh Active"},
|
||||||
|
]}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let new_script = dir
|
||||||
|
.path()
|
||||||
|
.join(".local/share/maestro")
|
||||||
|
.join(SCRIPT_FILE_NAME);
|
||||||
|
install(&settings, &new_script).unwrap();
|
||||||
|
|
||||||
|
let root: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||||
|
// Foreign hook untouched.
|
||||||
|
let pre = root["hooks"]["PreToolUse"].as_array().unwrap();
|
||||||
|
assert_eq!(pre[0]["hooks"][0]["command"], "my-own-linter.sh");
|
||||||
|
// Stale maestro event gone.
|
||||||
|
assert!(root["hooks"].get("UserPromptSubmit").is_none());
|
||||||
|
// Our events present, nothing else maestro-related anywhere.
|
||||||
|
let cmds = installed_commands(&settings);
|
||||||
|
assert_eq!(
|
||||||
|
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||||
|
HOOK_EVENTS.len(),
|
||||||
|
"{cmds:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `is_installed` flips only once a maestro entry exists.
|
||||||
|
#[test]
|
||||||
|
fn is_installed_tracks_reality() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let home = dir.path();
|
||||||
|
assert!(!is_installed(&settings_path(home)));
|
||||||
|
install_for_home(home).unwrap();
|
||||||
|
assert!(is_installed(&settings_path(home)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full home-level install leaves the script executable.
|
||||||
|
#[test]
|
||||||
|
fn install_for_home_writes_an_executable_script() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let home = dir.path();
|
||||||
|
install_for_home(home).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(script_path(home))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o111, 0o111);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ use uuid::Uuid;
|
|||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
KillOutcome, Project, RecentDirs, RetainedWorktree, Session,
|
KillOutcome, Project, RecentDirs, ReportActivityRequest, RetainedWorktree, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -134,6 +134,21 @@ impl DaemonClient {
|
|||||||
json(resp).await
|
json(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
||||||
|
/// state. The daemon holds it above its own inference for a TTL, then
|
||||||
|
/// resumes inference. Answers `204 No Content`.
|
||||||
|
pub async fn report_activity(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
req: &ReportActivityRequest,
|
||||||
|
) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(Method::POST, &routes::session_activity(id), &[], Some(req))
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||||
self.get(routes::INFO, &[]).await
|
self.get(routes::INFO, &[]).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! Path-abbreviation helpers shared by every client that renders a session's
|
||||||
|
//! directory (CLI, TUI, web (#117)). Ports of `web/src/types.ts` and
|
||||||
|
//! `web/src/tree.ts`'s `abbreviateCwd`, `abbreviateFolderName`, `mergeLinear`,
|
||||||
|
//! and `sessionTreePath` — keep them in sync.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::Session;
|
||||||
|
|
||||||
|
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
|
||||||
|
/// untouched. Mirrors the web's `abbreviateCwd`.
|
||||||
|
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
|
||||||
|
let p = path.to_string_lossy();
|
||||||
|
if !home.is_empty() {
|
||||||
|
if p == home {
|
||||||
|
return "~".into();
|
||||||
|
}
|
||||||
|
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
|
||||||
|
return format!("~/{rest}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of [`abbreviate_folder_name`]: the last path segment kept verbatim,
|
||||||
|
/// and — when there was something to collapse — the shortened lead-in a
|
||||||
|
/// caller can render dimmed (the web's `.folder-stem`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AbbreviatedFolderName {
|
||||||
|
/// The collapsed earlier segments, joined by `/`, with no trailing
|
||||||
|
/// slash. `None` when `name` had two or fewer segments — nothing to
|
||||||
|
/// gain from collapsing, so `last` carries the name unabbreviated.
|
||||||
|
pub stem: Option<String>,
|
||||||
|
pub last: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/git.naps.pt/naps62/maestro` → stem `~/g/n`, last `maestro`: keep the
|
||||||
|
/// last segment verbatim, collapse every earlier one to its first character.
|
||||||
|
/// Two-or-fewer segments are left alone. Mirrors the web's
|
||||||
|
/// `abbreviateFolderName`, minus the HTML escaping/wrapping — a ratatui
|
||||||
|
/// caller styles `stem` dim itself rather than parsing markup out of it.
|
||||||
|
pub fn abbreviate_folder_name(name: &str) -> AbbreviatedFolderName {
|
||||||
|
let parts: Vec<&str> = name.split('/').collect();
|
||||||
|
if parts.len() <= 2 {
|
||||||
|
return AbbreviatedFolderName {
|
||||||
|
stem: None,
|
||||||
|
last: name.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let last = parts[parts.len() - 1].to_string();
|
||||||
|
let stem = parts[..parts.len() - 1]
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
if *p == "~" {
|
||||||
|
"~".to_string()
|
||||||
|
} else {
|
||||||
|
p.chars().next().map(String::from).unwrap_or_default()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
AbbreviatedFolderName {
|
||||||
|
stem: Some(stem),
|
||||||
|
last,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory a session lives in: its live `current_cwd` if the daemon
|
||||||
|
/// has observed one, else the spawn `cwd`. Mirrors the web's `sessionCwd`.
|
||||||
|
fn session_cwd(s: &Session) -> Option<PathBuf> {
|
||||||
|
if let Some(cur) = &s.current_cwd {
|
||||||
|
if !cur.as_os_str().is_empty() {
|
||||||
|
return Some(cur.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !s.cwd.as_os_str().is_empty() {
|
||||||
|
return Some(s.cwd.clone());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a session sits in the directory tree. A worktree session belongs to
|
||||||
|
/// its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
||||||
|
/// grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
||||||
|
/// session of the same name, and the repository stops being where you look
|
||||||
|
/// for the work. Mirrors the web's `sessionTreePath`.
|
||||||
|
pub fn session_tree_path(s: &Session) -> Option<PathBuf> {
|
||||||
|
if let Some(wt) = &s.worktree {
|
||||||
|
return Some(wt.base_repo.clone());
|
||||||
|
}
|
||||||
|
session_cwd(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One node of a directory tree grouping sessions by path, generic over the
|
||||||
|
/// leaf payload since building the tree itself (grouping key, sort order,
|
||||||
|
/// filtering) is not this crate's job — only [`merge_linear`] is.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TreeNode<T> {
|
||||||
|
Folder(TreeFolder<T>),
|
||||||
|
Leaf(T),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TreeFolder<T> {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub children: Vec<TreeNode<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold a folder with a single folder-child into one node (`a` + `b` →
|
||||||
|
/// `a/b`), repeating down the chain, then recurse into the surviving
|
||||||
|
/// branches. The merged node keeps the deepest path so collapse/expand state
|
||||||
|
/// stays stable as it grows. Mirrors the web's `mergeLinear`.
|
||||||
|
pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
||||||
|
let mut cur = folder;
|
||||||
|
loop {
|
||||||
|
let single_folder_child =
|
||||||
|
cur.children.len() == 1 && matches!(cur.children[0], TreeNode::Folder(_));
|
||||||
|
if !single_folder_child {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let child = match cur.children.pop().expect("checked len == 1 above") {
|
||||||
|
TreeNode::Folder(f) => f,
|
||||||
|
TreeNode::Leaf(_) => unreachable!("checked matches! above"),
|
||||||
|
};
|
||||||
|
cur = TreeFolder {
|
||||||
|
name: format!("{}/{}", cur.name, child.name),
|
||||||
|
path: child.path,
|
||||||
|
children: child.children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
cur.children = cur
|
||||||
|
.children
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| match c {
|
||||||
|
TreeNode::Folder(f) => TreeNode::Folder(merge_linear(f)),
|
||||||
|
leaf => leaf,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
cur
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{SessionStatus, SessionWorktree};
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn session(cwd: &str) -> Session {
|
||||||
|
Session {
|
||||||
|
id: Uuid::from_u128(1),
|
||||||
|
cwd: PathBuf::from(cwd),
|
||||||
|
command: "bash".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
status: SessionStatus::Running,
|
||||||
|
created_at: Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap(),
|
||||||
|
foreground: None,
|
||||||
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abbreviate_dir_collapses_home() {
|
||||||
|
assert_eq!(
|
||||||
|
abbreviate_dir(Path::new("/home/naps/maestro"), "/home/naps"),
|
||||||
|
"~/maestro"
|
||||||
|
);
|
||||||
|
assert_eq!(abbreviate_dir(Path::new("/home/naps"), "/home/naps"), "~");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abbreviate_dir_leaves_paths_outside_home_untouched() {
|
||||||
|
assert_eq!(
|
||||||
|
abbreviate_dir(Path::new("/srv/work/api"), "/home/naps"),
|
||||||
|
"/srv/work/api"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
abbreviate_dir(Path::new("/srv/work/api"), ""),
|
||||||
|
"/srv/work/api"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abbreviate_folder_name_collapses_the_stem() {
|
||||||
|
let got = abbreviate_folder_name("~/git.naps.pt/naps62/maestro");
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
AbbreviatedFolderName {
|
||||||
|
stem: Some("~/g/n".into()),
|
||||||
|
last: "maestro".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-or-fewer segments are left alone — nothing to gain from collapsing.
|
||||||
|
#[test]
|
||||||
|
fn abbreviate_folder_name_two_segment_no_op() {
|
||||||
|
assert_eq!(
|
||||||
|
abbreviate_folder_name("~/work"),
|
||||||
|
AbbreviatedFolderName {
|
||||||
|
stem: None,
|
||||||
|
last: "~/work".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
abbreviate_folder_name("work"),
|
||||||
|
AbbreviatedFolderName {
|
||||||
|
stem: None,
|
||||||
|
last: "work".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_tree_path_prefers_worktree_base_repo() {
|
||||||
|
let mut s = session("/home/n/tea/agent-skills/worktrees/largo-clarinet");
|
||||||
|
s.worktree = Some(SessionWorktree {
|
||||||
|
path: PathBuf::from("/home/n/tea/agent-skills/worktrees/largo-clarinet"),
|
||||||
|
base_repo: PathBuf::from("/home/n/tea/agent-skills"),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
session_tree_path(&s),
|
||||||
|
Some(PathBuf::from("/home/n/tea/agent-skills"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_tree_path_falls_back_to_cwd_without_a_worktree() {
|
||||||
|
let s = session("/home/n/tea/arr");
|
||||||
|
assert_eq!(
|
||||||
|
session_tree_path(&s),
|
||||||
|
Some(PathBuf::from("/home/n/tea/arr"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_tree_path_prefers_live_current_cwd_over_spawn_cwd() {
|
||||||
|
let mut s = session("/home/n/tea/arr");
|
||||||
|
s.current_cwd = Some(PathBuf::from("/home/n/tea/arr/crates"));
|
||||||
|
assert_eq!(
|
||||||
|
session_tree_path(&s),
|
||||||
|
Some(PathBuf::from("/home/n/tea/arr/crates"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn leaf(name: &str) -> TreeNode<String> {
|
||||||
|
TreeNode::Leaf(name.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder(name: &str, path: &str, children: Vec<TreeNode<String>>) -> TreeFolder<String> {
|
||||||
|
TreeFolder {
|
||||||
|
name: name.into(),
|
||||||
|
path: path.into(),
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The web's mergeLinear test: keeps the deepest path so collapse state
|
||||||
|
/// survives growth.
|
||||||
|
#[test]
|
||||||
|
fn merge_linear_folds_a_chain_and_keeps_the_deepest_path() {
|
||||||
|
let tree = folder("a", "a", vec![TreeNode::Folder(folder("b", "a/b", vec![]))]);
|
||||||
|
let merged = merge_linear(tree);
|
||||||
|
assert_eq!(merged.name, "a/b");
|
||||||
|
assert_eq!(merged.path, "a/b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_linear_stops_where_a_directory_branches() {
|
||||||
|
let tree = folder(
|
||||||
|
"git",
|
||||||
|
"git",
|
||||||
|
vec![
|
||||||
|
TreeNode::Folder(folder("one", "git/one", vec![])),
|
||||||
|
TreeNode::Folder(folder("two", "git/two", vec![])),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let merged = merge_linear(tree);
|
||||||
|
assert_eq!(merged.name, "git");
|
||||||
|
assert_eq!(merged.children.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_linear_stops_at_a_leaf() {
|
||||||
|
let tree = folder("a", "a", vec![leaf("session")]);
|
||||||
|
let merged = merge_linear(tree);
|
||||||
|
assert_eq!(merged.name, "a");
|
||||||
|
assert_eq!(merged.children, vec![leaf("session")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_linear_recurses_into_surviving_branches() {
|
||||||
|
let tree = folder(
|
||||||
|
"root",
|
||||||
|
"root",
|
||||||
|
vec![
|
||||||
|
TreeNode::Folder(folder(
|
||||||
|
"a",
|
||||||
|
"root/a",
|
||||||
|
vec![TreeNode::Folder(folder("b", "root/a/b", vec![leaf("s")]))],
|
||||||
|
)),
|
||||||
|
leaf("other"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let merged = merge_linear(tree);
|
||||||
|
assert_eq!(merged.children.len(), 2);
|
||||||
|
let TreeNode::Folder(nested) = &merged.children[0] else {
|
||||||
|
panic!("expected folder")
|
||||||
|
};
|
||||||
|
assert_eq!(nested.name, "a/b");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#[cfg(feature = "client")]
|
#[cfg(feature = "client")]
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod display;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
#[cfg(feature = "client")]
|
||||||
@@ -75,11 +76,24 @@ pub struct Session {
|
|||||||
/// byte stream.
|
/// byte stream.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub activity: ActivityState,
|
pub activity: ActivityState,
|
||||||
|
/// Short human-readable string an agent reported alongside its activity
|
||||||
|
/// (#105) — e.g. why it is in `Error`. Shown on a sidebar card's meta line
|
||||||
|
/// while the report is in force; cleared when the report lapses or is
|
||||||
|
/// replaced. `None` unless an agent reported one.
|
||||||
|
#[serde(default)]
|
||||||
|
pub activity_detail: Option<String>,
|
||||||
/// Wall-clock time of the session's most recent observed PTY output.
|
/// Wall-clock time of the session's most recent observed PTY output.
|
||||||
/// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads
|
/// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads
|
||||||
/// from daemons that predate the field.
|
/// from daemons that predate the field.
|
||||||
#[serde(default = "epoch")]
|
#[serde(default = "epoch")]
|
||||||
pub last_activity_at: DateTime<Utc>,
|
pub last_activity_at: DateTime<Utc>,
|
||||||
|
/// Wall-clock time at which the session entered its current
|
||||||
|
/// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or
|
||||||
|
/// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only
|
||||||
|
/// tracks the last observed PTY output. Populated by #104; defaults to the
|
||||||
|
/// Unix epoch on payloads from daemons that predate the field.
|
||||||
|
#[serde(default = "epoch")]
|
||||||
|
pub activity_since: DateTime<Utc>,
|
||||||
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
|
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
|
||||||
/// from [`Session::cwd`], the directory it was spawned in. The activity
|
/// from [`Session::cwd`], the directory it was spawned in. The activity
|
||||||
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
|
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
|
||||||
@@ -110,7 +124,12 @@ pub enum SessionStatus {
|
|||||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||||
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
||||||
/// inspection — never by parsing the terminal byte stream.
|
/// inspection — never by parsing the terminal byte stream.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
///
|
||||||
|
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
||||||
|
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
||||||
|
/// whole `Session`. Non-string values still error — only variant names are
|
||||||
|
/// forgiven.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||||
pub enum ActivityState {
|
pub enum ActivityState {
|
||||||
/// Producing output, or the foreground process is busy.
|
/// Producing output, or the foreground process is busy.
|
||||||
Active,
|
Active,
|
||||||
@@ -119,6 +138,64 @@ pub enum ActivityState {
|
|||||||
Idle,
|
Idle,
|
||||||
/// Blocked on a `read()` from the pty — waiting on the user.
|
/// Blocked on a `read()` from the pty — waiting on the user.
|
||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
|
/// The agent running in the session reported a failure (#102). Never
|
||||||
|
/// inferred by the daemon; only set when an agent reports it (#105).
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ActivityState {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ok(match s.as_str() {
|
||||||
|
"Active" => Self::Active,
|
||||||
|
"AwaitingInput" => Self::AwaitingInput,
|
||||||
|
"Error" => Self::Error,
|
||||||
|
// Known or not, any other spelling degrades to the neutral default
|
||||||
|
_ => Self::Idle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP `POST /api/sessions/:id/activity` body (#105): an agent's report of
|
||||||
|
/// its own state. The daemon holds the reported state above its own inference
|
||||||
|
/// for a fixed TTL, then resumes inference.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ReportActivityRequest {
|
||||||
|
/// Parsed strictly, unlike [`ActivityState`]'s own lenient `Deserialize`
|
||||||
|
/// (#110). Leniency exists so a client reading a *newer* daemon's snapshot
|
||||||
|
/// degrades an unrecognised state to `Idle` instead of dropping the whole
|
||||||
|
/// session. A report travelling the other way has no such excuse: an agent
|
||||||
|
/// posting a state this daemon does not know is a bug in the agent, and
|
||||||
|
/// silently recording it as `Idle` would hide that behind a 204.
|
||||||
|
#[serde(deserialize_with = "strict_activity_state")]
|
||||||
|
pub state: ActivityState,
|
||||||
|
/// Short human string shown next to the state (e.g. an error message).
|
||||||
|
/// The daemon caps its length server-side.
|
||||||
|
#[serde(default)]
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
||||||
|
/// `Idle`. Used only by [`ReportActivityRequest`]; see the note there.
|
||||||
|
fn strict_activity_state<'de, D>(deserializer: D) -> Result<ActivityState, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::de::Error as _;
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
match s.as_str() {
|
||||||
|
"Active" => Ok(ActivityState::Active),
|
||||||
|
"Idle" => Ok(ActivityState::Idle),
|
||||||
|
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||||
|
"Error" => Ok(ActivityState::Error),
|
||||||
|
other => Err(D::Error::unknown_variant(
|
||||||
|
other,
|
||||||
|
&["Active", "Idle", "AwaitingInput", "Error"],
|
||||||
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional worktree request attached to a `CreateSessionRequest`.
|
/// Optional worktree request attached to a `CreateSessionRequest`.
|
||||||
@@ -534,9 +611,13 @@ mod tests {
|
|||||||
.with_timezone(&Utc),
|
.with_timezone(&Utc),
|
||||||
foreground: Some("claude".into()),
|
foreground: Some("claude".into()),
|
||||||
activity: ActivityState::Active,
|
activity: ActivityState::Active,
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_timezone(&Utc),
|
.with_timezone(&Utc),
|
||||||
|
activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Utc),
|
||||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
@@ -588,6 +669,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activity_since_defaults_to_epoch_on_legacy_payload() {
|
||||||
|
// A daemon predating #102 omits `activity_since`; it must deserialize
|
||||||
|
// to the forward-compat default rather than failing, and a present
|
||||||
|
// value round-trips.
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": Uuid::from_u128(1),
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"command": "bash",
|
||||||
|
"status": "Running",
|
||||||
|
"created_at": "2026-05-24T12:00:00Z"
|
||||||
|
});
|
||||||
|
let s: Session = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(s.activity_since, epoch());
|
||||||
|
|
||||||
|
let full = sample_session();
|
||||||
|
assert_eq!(full, round_trip(&full));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #102: nothing infers `Error`; only #105's agent-reported failure sets it.
|
||||||
|
#[test]
|
||||||
|
fn error_activity_state_serializes_pascal_case_and_idle_stays_default() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(ActivityState::Error).unwrap(),
|
||||||
|
serde_json::json!("Error")
|
||||||
|
);
|
||||||
|
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_state_serializes_pascal_case_and_defaults_idle() {
|
fn activity_state_serializes_pascal_case_and_defaults_idle() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -597,6 +707,78 @@ mod tests {
|
|||||||
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #110 / SIDEBAR.md Precedence: a future daemon may send an
|
||||||
|
// `ActivityState` variant this client does not know; it must degrade to
|
||||||
|
// the neutral default instead of failing the whole `Session`.
|
||||||
|
#[test]
|
||||||
|
fn unknown_activity_state_variant_deserializes_to_idle() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<ActivityState>(serde_json::json!("Teleported")).unwrap(),
|
||||||
|
ActivityState::Idle
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut future = serde_json::to_value(sample_session()).unwrap();
|
||||||
|
future["activity"] = serde_json::json!("QuantumEntangled");
|
||||||
|
let s: Session = serde_json::from_value(future).unwrap();
|
||||||
|
assert_eq!(s.activity, ActivityState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_string_activity_payload_still_fails_deserialization() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": Uuid::from_u128(1),
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"command": "bash",
|
||||||
|
"status": "Running",
|
||||||
|
"created_at": "2026-05-24T12:00:00Z",
|
||||||
|
"activity": 42,
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<Session>(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activity_detail_defaults_to_none_on_legacy_payload() {
|
||||||
|
// #105: a daemon predating the detail field omits it; it must
|
||||||
|
// deserialize to `None` rather than failing, and a present value
|
||||||
|
// round-trips.
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": Uuid::from_u128(1),
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"command": "bash",
|
||||||
|
"status": "Running",
|
||||||
|
"created_at": "2026-05-24T12:00:00Z"
|
||||||
|
});
|
||||||
|
let s: Session = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(s.activity_detail, None);
|
||||||
|
|
||||||
|
let mut reported = sample_session();
|
||||||
|
reported.activity = ActivityState::Error;
|
||||||
|
reported.activity_detail = Some("build failed".into());
|
||||||
|
assert_eq!(reported.clone(), round_trip(&reported));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_activity_request_defaults_detail_and_rejects_unknown_state() {
|
||||||
|
let bare: ReportActivityRequest =
|
||||||
|
serde_json::from_value(serde_json::json!({ "state": "Error" })).unwrap();
|
||||||
|
assert_eq!(bare.state, ActivityState::Error);
|
||||||
|
assert_eq!(bare.detail, None);
|
||||||
|
|
||||||
|
let full = ReportActivityRequest {
|
||||||
|
state: ActivityState::AwaitingInput,
|
||||||
|
detail: Some("waiting on permission".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(full, round_trip(&full));
|
||||||
|
|
||||||
|
// Unknown state values are rejected rather than coerced (#105).
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<ReportActivityRequest>(serde_json::json!({
|
||||||
|
"state": "Teleported"
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_round_trips() {
|
fn session_round_trips() {
|
||||||
let s = sample_session();
|
let s = sample_session();
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ pub fn session(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}")
|
format!("{SESSIONS}/{id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
||||||
|
pub fn session_activity(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/activity")
|
||||||
|
}
|
||||||
|
|
||||||
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
|
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
|
||||||
pub fn worktree(id: Uuid) -> String {
|
pub fn worktree(id: Uuid) -> String {
|
||||||
format!("{WORKTREES}/{id}")
|
format!("{WORKTREES}/{id}")
|
||||||
@@ -61,6 +66,7 @@ pub fn attach(id: Uuid) -> String {
|
|||||||
/// registers the same paths [`session`] and [`attach`] build.
|
/// registers the same paths [`session`] and [`attach`] build.
|
||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
pub const SESSION: &str = "/api/sessions/:id";
|
pub const SESSION: &str = "/api/sessions/:id";
|
||||||
|
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
||||||
pub const ATTACH: &str = "/api/attach/:id";
|
pub const ATTACH: &str = "/api/attach/:id";
|
||||||
pub const WORKTREE: &str = "/api/worktrees/:id";
|
pub const WORKTREE: &str = "/api/worktrees/:id";
|
||||||
}
|
}
|
||||||
@@ -169,6 +175,10 @@ mod tests {
|
|||||||
session(id()),
|
session(id()),
|
||||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session_activity(id()),
|
||||||
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
attach(id()),
|
attach(id()),
|
||||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
@@ -191,6 +201,10 @@ mod tests {
|
|||||||
patterns::SESSION.trim_end_matches(":id"),
|
patterns::SESSION.trim_end_matches(":id"),
|
||||||
session(id()).trim_end_matches(&id().to_string())
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
|
||||||
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
patterns::ATTACH.trim_end_matches(":id"),
|
patterns::ATTACH.trim_end_matches(":id"),
|
||||||
attach(id()).trim_end_matches(&id().to_string())
|
attach(id()).trim_end_matches(&id().to_string())
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ pub enum Action {
|
|||||||
FocusSidebar,
|
FocusSidebar,
|
||||||
FocusPane(Direction),
|
FocusPane(Direction),
|
||||||
|
|
||||||
|
// Session scrollback
|
||||||
|
ScrollPanePageUp,
|
||||||
|
ScrollPanePageDown,
|
||||||
|
|
||||||
// Sidebar highlight movement
|
// Sidebar highlight movement
|
||||||
SidebarNext,
|
SidebarNext,
|
||||||
SidebarPrev,
|
SidebarPrev,
|
||||||
|
ToggleSidebarGroup,
|
||||||
|
|
||||||
// Catalog actions on the highlighted sidebar row
|
// Catalog actions on the highlighted sidebar row
|
||||||
AttachHighlighted,
|
AttachHighlighted,
|
||||||
@@ -46,13 +51,6 @@ pub enum Action {
|
|||||||
/// Open the retained-worktrees view (#85).
|
/// Open the retained-worktrees view (#85).
|
||||||
ShowWorktrees,
|
ShowWorktrees,
|
||||||
|
|
||||||
// Tabs
|
|
||||||
NewTab,
|
|
||||||
CloseTab,
|
|
||||||
NextTab,
|
|
||||||
PrevTab,
|
|
||||||
GotoTab(u8),
|
|
||||||
|
|
||||||
// Chrome
|
// Chrome
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
ShowHelp,
|
ShowHelp,
|
||||||
@@ -70,8 +68,39 @@ pub enum ActionParseError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Action {
|
impl Action {
|
||||||
/// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`,
|
/// Plain-language label for the in-app help menu. Configuration keeps the
|
||||||
/// or `"GotoTab(3)"`.
|
/// stable enum spelling; operators should not have to decode it.
|
||||||
|
pub fn help_label(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Action::Noop => "Do nothing".into(),
|
||||||
|
Action::EnterInsert => "Send keys to session".into(),
|
||||||
|
Action::LeaveInsert => "Return to sidebar".into(),
|
||||||
|
Action::FocusSidebar => "Focus sidebar".into(),
|
||||||
|
Action::FocusPane(direction) => {
|
||||||
|
format!(
|
||||||
|
"Focus session pane ({})",
|
||||||
|
format!("{direction:?}").to_lowercase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Action::ScrollPanePageUp => "Scroll session up one page".into(),
|
||||||
|
Action::ScrollPanePageDown => "Scroll session down one page".into(),
|
||||||
|
Action::SidebarNext => "Select next sidebar item".into(),
|
||||||
|
Action::SidebarPrev => "Select previous sidebar item".into(),
|
||||||
|
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
|
||||||
|
Action::AttachHighlighted => "Open selected item".into(),
|
||||||
|
Action::ToggleAttachHighlighted => "Open selected item".into(),
|
||||||
|
Action::NewDraftPane => "Create a session".into(),
|
||||||
|
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||||
|
Action::KillHighlighted => "Kill selected session".into(),
|
||||||
|
Action::ShowWorktrees => "Show retained worktrees".into(),
|
||||||
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
|
Action::ShowHelp => "Show help".into(),
|
||||||
|
Action::Quit => "Quit Maestro".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a canonical action string like `"EnterInsert"` or
|
||||||
|
/// `"FocusPane(Left)"`.
|
||||||
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
|
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
match s {
|
match s {
|
||||||
@@ -79,18 +108,17 @@ impl Action {
|
|||||||
"EnterInsert" => return Ok(Action::EnterInsert),
|
"EnterInsert" => return Ok(Action::EnterInsert),
|
||||||
"LeaveInsert" => return Ok(Action::LeaveInsert),
|
"LeaveInsert" => return Ok(Action::LeaveInsert),
|
||||||
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
||||||
|
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
|
||||||
|
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
|
||||||
"SidebarNext" => return Ok(Action::SidebarNext),
|
"SidebarNext" => return Ok(Action::SidebarNext),
|
||||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||||
|
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
|
||||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
||||||
"NewTab" => return Ok(Action::NewTab),
|
|
||||||
"CloseTab" => return Ok(Action::CloseTab),
|
|
||||||
"NextTab" => return Ok(Action::NextTab),
|
|
||||||
"PrevTab" => return Ok(Action::PrevTab),
|
|
||||||
"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),
|
||||||
@@ -111,13 +139,6 @@ impl Action {
|
|||||||
})?;
|
})?;
|
||||||
Ok(Action::FocusPane(dir))
|
Ok(Action::FocusPane(dir))
|
||||||
}
|
}
|
||||||
"GotoTab" => {
|
|
||||||
let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg {
|
|
||||||
action: name.to_string(),
|
|
||||||
detail: format!("expected u8 0..=255, got {arg:?}"),
|
|
||||||
})?;
|
|
||||||
Ok(Action::GotoTab(n))
|
|
||||||
}
|
|
||||||
_ => Err(ActionParseError::Unknown(name.to_string())),
|
_ => Err(ActionParseError::Unknown(name.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +158,6 @@ impl fmt::Display for Action {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
|
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
|
||||||
Action::GotoTab(n) => write!(f, "GotoTab({n})"),
|
|
||||||
other => write!(f, "{other:?}"),
|
other => write!(f, "{other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +174,13 @@ mod tests {
|
|||||||
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
|
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_labels_are_plain_language() {
|
||||||
|
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
|
||||||
|
assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item");
|
||||||
|
assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_focus_pane_directions() {
|
fn parses_focus_pane_directions() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -166,12 +193,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_goto_tab() {
|
|
||||||
assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3));
|
|
||||||
assert_eq!(Action::parse("GotoTab(0)").unwrap(), Action::GotoTab(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_action() {
|
fn rejects_unknown_action() {
|
||||||
let err = Action::parse("Frobnicate").unwrap_err();
|
let err = Action::parse("Frobnicate").unwrap_err();
|
||||||
@@ -186,22 +207,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_non_numeric_tab_arg() {
|
|
||||||
let err = Action::parse("GotoTab(abc)").unwrap_err();
|
|
||||||
assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "GotoTab"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_overflow_tab_arg() {
|
|
||||||
let err = Action::parse("GotoTab(9999)").unwrap_err();
|
|
||||||
assert!(matches!(err, ActionParseError::BadArg { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unclosed_paren() {
|
fn rejects_unclosed_paren() {
|
||||||
let err = Action::parse("GotoTab(3").unwrap_err();
|
let err = Action::parse("FocusPane(Left").unwrap_err();
|
||||||
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
|
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fast-forward pair sits next to the draft-pane key (#67).
|
/// The fast-forward pair sits next to the draft-pane key (#67).
|
||||||
@@ -220,8 +229,8 @@ mod tests {
|
|||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
Action::NewDraftPane,
|
Action::NewDraftPane,
|
||||||
|
Action::ScrollPanePageDown,
|
||||||
Action::FocusPane(Direction::Right),
|
Action::FocusPane(Direction::Right),
|
||||||
Action::GotoTab(7),
|
|
||||||
] {
|
] {
|
||||||
let s = a.to_string();
|
let s = a.to_string();
|
||||||
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
||||||
|
|||||||
+434
-54
@@ -30,15 +30,16 @@ use crate::catalog::CatalogStore;
|
|||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
use crate::draft::DraftInputResult;
|
use crate::draft::DraftInputResult;
|
||||||
use crate::emulators::SessionEmulators;
|
use crate::emulators::SessionEmulators;
|
||||||
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
use crate::input::{
|
||||||
|
canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region,
|
||||||
|
};
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::HelpOverlay;
|
use crate::overlays::HelpOverlay;
|
||||||
use crate::pane::{Pane, PaneStatus};
|
use crate::pane::{Pane, PaneStatus};
|
||||||
use crate::render::{draw, RegionMap, RenderInputs};
|
use crate::render::{draw, RegionMap, RenderInputs};
|
||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
||||||
use crate::tabs::TabBar;
|
|
||||||
use crate::worktrees::WorktreesView;
|
use crate::worktrees::WorktreesView;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ enum OverlayDecision {
|
|||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
cfg: TuiConfig,
|
cfg: TuiConfig,
|
||||||
|
home_dir: String,
|
||||||
catalog: CatalogStore,
|
catalog: CatalogStore,
|
||||||
emulators: SessionEmulators,
|
emulators: SessionEmulators,
|
||||||
state: AppState,
|
state: AppState,
|
||||||
@@ -137,6 +139,7 @@ impl App {
|
|||||||
.expect("build reqwest client");
|
.expect("build reqwest client");
|
||||||
Self {
|
Self {
|
||||||
cfg,
|
cfg,
|
||||||
|
home_dir: std::env::var("HOME").unwrap_or_default(),
|
||||||
catalog,
|
catalog,
|
||||||
emulators: SessionEmulators::default(),
|
emulators: SessionEmulators::default(),
|
||||||
state: AppState::new(),
|
state: AppState::new(),
|
||||||
@@ -158,6 +161,9 @@ impl App {
|
|||||||
mut self,
|
mut self,
|
||||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
) -> Result<(), TuiError> {
|
) -> Result<(), TuiError> {
|
||||||
|
if let Ok(info) = self.daemon.daemon_info().await {
|
||||||
|
self.home_dir = info.home_dir;
|
||||||
|
}
|
||||||
let mut events = EventStream::new();
|
let mut events = EventStream::new();
|
||||||
loop {
|
loop {
|
||||||
self.redraw(terminal)?;
|
self.redraw(terminal)?;
|
||||||
@@ -197,6 +203,15 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.handle_sidebar_mouse(&key) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(effect) = self.handle_pane_scroll(&key) {
|
||||||
|
self.perform(effect.into_iter().collect()).await;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
||||||
match self.chord.feed(&key, &known) {
|
match self.chord.feed(&key, &known) {
|
||||||
ChordResult::Pending => Ok(false),
|
ChordResult::Pending => Ok(false),
|
||||||
@@ -224,6 +239,34 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clicks select rendered sidebar items directly. Double-clicks continue
|
||||||
|
/// through the keymap so the configured attach binding remains intact.
|
||||||
|
fn handle_sidebar_mouse(&mut self, key: &InputKey) -> bool {
|
||||||
|
let InputKey::Mouse {
|
||||||
|
kind: MouseKind::Click,
|
||||||
|
region: Region::SidebarRow,
|
||||||
|
row,
|
||||||
|
..
|
||||||
|
} = key
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(item) = self.region.sidebar_item_at(*row) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
self.state.focus = Focus::Sidebar;
|
||||||
|
match item {
|
||||||
|
SidebarItem::Group(key) => {
|
||||||
|
self.state.sidebar_highlight = Some(SidebarItem::Group(key.clone()));
|
||||||
|
self.toggle_sidebar_group(key);
|
||||||
|
}
|
||||||
|
SidebarItem::Session(id) => {
|
||||||
|
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||||
/// the daemon.
|
/// the daemon.
|
||||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||||
@@ -322,32 +365,37 @@ impl App {
|
|||||||
match act {
|
match act {
|
||||||
Action::Noop | Action::Quit => {}
|
Action::Noop | Action::Quit => {}
|
||||||
Action::EnterInsert => self.state.mode = Mode::Insert,
|
Action::EnterInsert => self.state.mode = Mode::Insert,
|
||||||
Action::LeaveInsert => self.state.mode = Mode::Normal,
|
Action::LeaveInsert => {
|
||||||
|
self.state.mode = Mode::Normal;
|
||||||
|
self.state.focus = Focus::Sidebar;
|
||||||
|
}
|
||||||
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
|
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
|
||||||
Action::FocusPane(_) => {
|
Action::FocusPane(_) => {
|
||||||
// v1 has one pane per tab; geometric focus motion lands
|
// v1 has one pane per tab; geometric focus motion lands
|
||||||
// either in the sidebar or back in the pane.
|
// either in the sidebar or back in the pane.
|
||||||
self.state.focus = Focus::Pane;
|
self.state.focus = Focus::Pane;
|
||||||
}
|
}
|
||||||
|
Action::ScrollPanePageUp => self.scroll_active_pane(self.pane_page_rows() as isize),
|
||||||
|
Action::ScrollPanePageDown => {
|
||||||
|
self.scroll_active_pane(-(self.pane_page_rows() as isize))
|
||||||
|
}
|
||||||
Action::SidebarNext => {
|
Action::SidebarNext => {
|
||||||
let order = visible_sidebar_order(&self.catalog.snapshot());
|
let order = visible_sidebar_order(
|
||||||
|
&self.catalog.snapshot(),
|
||||||
|
&self.state.collapsed_sidebar_groups,
|
||||||
|
);
|
||||||
self.state.move_sidebar_highlight(&order, 1);
|
self.state.move_sidebar_highlight(&order, 1);
|
||||||
self.state.focus = Focus::Sidebar;
|
self.state.focus = Focus::Sidebar;
|
||||||
}
|
}
|
||||||
Action::SidebarPrev => {
|
Action::SidebarPrev => {
|
||||||
let order = visible_sidebar_order(&self.catalog.snapshot());
|
let order = visible_sidebar_order(
|
||||||
|
&self.catalog.snapshot(),
|
||||||
|
&self.state.collapsed_sidebar_groups,
|
||||||
|
);
|
||||||
self.state.move_sidebar_highlight(&order, -1);
|
self.state.move_sidebar_highlight(&order, -1);
|
||||||
self.state.focus = Focus::Sidebar;
|
self.state.focus = Focus::Sidebar;
|
||||||
}
|
}
|
||||||
Action::NewTab => self.state.new_tab(),
|
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
|
||||||
Action::CloseTab => {
|
|
||||||
if let Some(sid) = self.state.close_tab() {
|
|
||||||
self.release_session(sid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Action::NextTab => self.state.next_tab(),
|
|
||||||
Action::PrevTab => self.state.prev_tab(),
|
|
||||||
Action::GotoTab(n) => self.state.goto_tab(n),
|
|
||||||
Action::ToggleSidebar => self.state.toggle_sidebar(),
|
Action::ToggleSidebar => self.state.toggle_sidebar(),
|
||||||
Action::ShowHelp => {
|
Action::ShowHelp => {
|
||||||
self.help = HelpOverlay {
|
self.help = HelpOverlay {
|
||||||
@@ -356,13 +404,24 @@ impl App {
|
|||||||
self.state.show_overlay(OverlayKind::Help);
|
self.state.show_overlay(OverlayKind::Help);
|
||||||
}
|
}
|
||||||
Action::NewDraftPane => {
|
Action::NewDraftPane => {
|
||||||
|
if let Some(sid) = self.state.active_session() {
|
||||||
|
self.release_session(sid);
|
||||||
|
}
|
||||||
self.state.open_draft_pane();
|
self.state.open_draft_pane();
|
||||||
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
||||||
}
|
}
|
||||||
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
||||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(SidebarItem::Group(key)) = self.state.sidebar_highlight.clone() {
|
||||||
effects.push(Effect::Attach(sid));
|
self.toggle_sidebar_group(key);
|
||||||
|
} else if let Some(sid) = self.pick_sidebar_target() {
|
||||||
|
self.state.sidebar_highlight = Some(SidebarItem::Session(sid));
|
||||||
|
if self.state.active_session() != Some(sid) {
|
||||||
|
if let Some(previous) = self.state.active_session() {
|
||||||
|
self.release_session(previous);
|
||||||
|
}
|
||||||
|
effects.push(Effect::Attach(sid));
|
||||||
|
}
|
||||||
self.state.attach_to_focused(sid);
|
self.state.attach_to_focused(sid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,22 +462,127 @@ impl App {
|
|||||||
/// Return the session under the sidebar highlight, falling back to the
|
/// Return the session under the sidebar highlight, falling back to the
|
||||||
/// first listed session so the user can attach without arrowing first.
|
/// first listed session so the user can attach without arrowing first.
|
||||||
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
||||||
if let Some(sid) = self.state.sidebar_highlight {
|
if let Some(SidebarItem::Session(sid)) = self.state.sidebar_highlight {
|
||||||
return Some(sid);
|
return Some(sid);
|
||||||
}
|
}
|
||||||
|
if self.state.sidebar_highlight.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let snap = self.catalog.snapshot();
|
let snap = self.catalog.snapshot();
|
||||||
snap.sessions.first().map(|s| s.id)
|
snap.sessions.first().map(|s| s.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_highlighted_sidebar_group(&mut self) {
|
||||||
|
let key = match self.state.sidebar_highlight.clone() {
|
||||||
|
Some(SidebarItem::Group(key)) => Some(key),
|
||||||
|
Some(SidebarItem::Session(id)) => self
|
||||||
|
.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.and_then(|s| maestro_protocol::display::session_tree_path(&s)),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if let Some(key) = key {
|
||||||
|
self.toggle_sidebar_group(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_sidebar_group(&mut self, key: PathBuf) {
|
||||||
|
let contains_highlight = match self.state.sidebar_highlight {
|
||||||
|
Some(SidebarItem::Session(id)) => {
|
||||||
|
self.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.and_then(maestro_protocol::display::session_tree_path)
|
||||||
|
.as_ref()
|
||||||
|
== Some(&key)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
self.state.toggle_sidebar_group(key, contains_highlight);
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
||||||
/// attached to send it to.
|
/// attached to send it to.
|
||||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
let sid = self.state.active_session()?;
|
let sid = self.state.active_session()?;
|
||||||
self.emulators.get(sid)?;
|
let handle = self.emulators.get(sid)?;
|
||||||
let bytes = encode_key(key)?;
|
let bytes = encode_key(key)?;
|
||||||
|
handle
|
||||||
|
.screen
|
||||||
|
.lock()
|
||||||
|
.expect("vt100 parser mutex")
|
||||||
|
.set_scrollback(0);
|
||||||
Some(Effect::Forward { id: sid, bytes })
|
Some(Effect::Forward { id: sid, bytes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pane_page_rows(&self) -> usize {
|
||||||
|
self.region
|
||||||
|
.pane
|
||||||
|
.map_or(1, |area| usize::from(area.height.saturating_sub(1).max(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Positive rows move into older history; negative rows return toward the
|
||||||
|
/// live bottom. vt100 clamps both ends to the retained buffer.
|
||||||
|
fn scroll_active_pane(&mut self, rows: isize) {
|
||||||
|
let Some(sid) = self.state.active_session() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(handle) = self.emulators.get(sid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||||
|
let current = parser.screen().scrollback();
|
||||||
|
let next = if rows >= 0 {
|
||||||
|
current.saturating_add(rows as usize)
|
||||||
|
} else {
|
||||||
|
current.saturating_sub(rows.unsigned_abs())
|
||||||
|
};
|
||||||
|
parser.set_scrollback(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wheel input belongs to the terminal surface in every mode. Normal
|
||||||
|
/// shells move through Maestro's retained history; full-screen programs
|
||||||
|
/// that enabled terminal mouse reporting receive a real wheel event and
|
||||||
|
/// scroll their own alternate-screen content.
|
||||||
|
fn handle_pane_scroll(&mut self, key: &InputKey) -> Option<Option<Effect>> {
|
||||||
|
let InputKey::Mouse {
|
||||||
|
kind,
|
||||||
|
region: Region::Pane,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
} = key
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let lines = match kind {
|
||||||
|
MouseKind::ScrollUp => 3,
|
||||||
|
MouseKind::ScrollDown => -3,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let sid = self.state.active_session()?;
|
||||||
|
let handle = self.emulators.get(sid)?;
|
||||||
|
let pane = self.region.pane?;
|
||||||
|
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||||
|
if parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None {
|
||||||
|
let bytes = encode_mouse_wheel(
|
||||||
|
*kind,
|
||||||
|
col.saturating_sub(pane.x).saturating_add(1),
|
||||||
|
row.saturating_sub(pane.y).saturating_add(1),
|
||||||
|
parser.screen().mouse_protocol_encoding(),
|
||||||
|
);
|
||||||
|
drop(parser);
|
||||||
|
return Some(Some(Effect::Forward { id: sid, bytes }));
|
||||||
|
}
|
||||||
|
drop(parser);
|
||||||
|
self.scroll_active_pane(lines);
|
||||||
|
Some(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||||
/// effect when the user accepted a valid form; an invalid one names the
|
/// effect when the user accepted a valid form; an invalid one names the
|
||||||
/// missing field inside the form (#64) instead of posting.
|
/// missing field inside the form (#64) instead of posting.
|
||||||
@@ -704,11 +868,6 @@ impl App {
|
|||||||
let resolved =
|
let resolved =
|
||||||
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
||||||
if let Some(req) = resolved.build_request() {
|
if let Some(req) = resolved.build_request() {
|
||||||
// Never eat a pane already showing something: the spawn takes a
|
|
||||||
// fresh tab, same as opening a draft over a session would.
|
|
||||||
if self.state.active_tab().pane.is_some() {
|
|
||||||
self.state.new_tab();
|
|
||||||
}
|
|
||||||
self.submit_spawn(req).await;
|
self.submit_spawn(req).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -734,6 +893,9 @@ impl App {
|
|||||||
// Fill the pane in place with the new session (web parity,
|
// Fill the pane in place with the new session (web parity,
|
||||||
// #57) and attach to it right away.
|
// #57) and attach to it right away.
|
||||||
let sid = session.id;
|
let sid = session.id;
|
||||||
|
if let Some(previous) = self.state.active_session() {
|
||||||
|
self.release_session(previous);
|
||||||
|
}
|
||||||
self.state.complete_spawn(sid);
|
self.state.complete_spawn(sid);
|
||||||
self.attach(sid);
|
self.attach(sid);
|
||||||
}
|
}
|
||||||
@@ -811,13 +973,9 @@ impl App {
|
|||||||
.collect();
|
.collect();
|
||||||
let sidebar = Sidebar {
|
let sidebar = Sidebar {
|
||||||
sessions: snap.sessions.clone(),
|
sessions: snap.sessions.clone(),
|
||||||
highlight: self.state.sidebar_highlight,
|
highlight: self.state.sidebar_highlight.clone(),
|
||||||
attached,
|
attached,
|
||||||
};
|
};
|
||||||
let tab_bar = TabBar {
|
|
||||||
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
|
||||||
active_idx: self.state.active_tab_idx,
|
|
||||||
};
|
|
||||||
let active_sid = self.state.active_session();
|
let active_sid = self.state.active_session();
|
||||||
let pane = active_sid.and_then(|sid| {
|
let pane = active_sid.and_then(|sid| {
|
||||||
self.emulators.get(sid).map(|h| Pane {
|
self.emulators.get(sid).map(|h| Pane {
|
||||||
@@ -830,10 +988,21 @@ impl App {
|
|||||||
Some(PaneRef::Draft(d)) => Some(d),
|
Some(PaneRef::Draft(d)) => Some(d),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let pane_title = match self.state.active_tab().pane.as_ref() {
|
||||||
|
Some(PaneRef::Session(sid)) => snap
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|session| session.id == *sid)
|
||||||
|
.map(crate::sidebar::name_for)
|
||||||
|
.unwrap_or_else(|| "session".into()),
|
||||||
|
Some(PaneRef::Draft(_)) => "new session".into(),
|
||||||
|
None => "session".into(),
|
||||||
|
};
|
||||||
|
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
tab_bar: &tab_bar,
|
home_dir: &self.home_dir,
|
||||||
|
pane_title: &pane_title,
|
||||||
pane: pane.as_ref(),
|
pane: pane.as_ref(),
|
||||||
state: &self.state,
|
state: &self.state,
|
||||||
draft,
|
draft,
|
||||||
@@ -881,8 +1050,16 @@ impl App {
|
|||||||
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
|
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
|
||||||
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
|
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
|
||||||
/// visual sync with what the user sees.
|
/// visual sync with what the user sees.
|
||||||
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
fn visible_sidebar_order(
|
||||||
snap.sessions.iter().map(|s| s.id).collect()
|
snap: &crate::catalog::CatalogSnapshot,
|
||||||
|
collapsed: &std::collections::HashSet<PathBuf>,
|
||||||
|
) -> Vec<SidebarItem> {
|
||||||
|
Sidebar {
|
||||||
|
sessions: snap.sessions.clone(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
}
|
||||||
|
.rendered_items_with_collapsed(collapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
||||||
@@ -916,6 +1093,8 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
|||||||
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
||||||
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||||
InputKey::End => Some(b"\x1b[F".to_vec()),
|
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||||
|
InputKey::PageUp => Some(b"\x1b[5~".to_vec()),
|
||||||
|
InputKey::PageDown => Some(b"\x1b[6~".to_vec()),
|
||||||
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||||
InputKey::Paste(text) => {
|
InputKey::Paste(text) => {
|
||||||
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
||||||
@@ -931,6 +1110,39 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encode_mouse_wheel(
|
||||||
|
kind: MouseKind,
|
||||||
|
col: u16,
|
||||||
|
row: u16,
|
||||||
|
encoding: vt100::MouseProtocolEncoding,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let button = match kind {
|
||||||
|
MouseKind::ScrollUp => 64,
|
||||||
|
MouseKind::ScrollDown => 65,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
match encoding {
|
||||||
|
vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{col};{row}M").into_bytes(),
|
||||||
|
vt100::MouseProtocolEncoding::Default => vec![
|
||||||
|
0x1b,
|
||||||
|
b'[',
|
||||||
|
b'M',
|
||||||
|
(button + 32) as u8,
|
||||||
|
col.min(223).saturating_add(32) as u8,
|
||||||
|
row.min(223).saturating_add(32) as u8,
|
||||||
|
],
|
||||||
|
vt100::MouseProtocolEncoding::Utf8 => {
|
||||||
|
let mut out = b"\x1b[M".to_vec();
|
||||||
|
for value in [button + 32, u32::from(col) + 32, u32::from(row) + 32] {
|
||||||
|
let ch = char::from_u32(value).expect("mouse coordinate is a valid codepoint");
|
||||||
|
let mut bytes = [0; 4];
|
||||||
|
out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the lines shown by the help overlay from the live keymap, so the
|
/// Build the lines shown by the help overlay from the live keymap, so the
|
||||||
/// user always sees the bindings that are actually active (defaults + their
|
/// user always sees the bindings that are actually active (defaults + their
|
||||||
/// own `[tui.keymap.*]` overrides).
|
/// own `[tui.keymap.*]` overrides).
|
||||||
@@ -938,14 +1150,14 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
|||||||
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
|
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
|
||||||
let mut out = vec![format!("{label} mode"), String::new()];
|
let mut out = vec![format!("{label} mode"), String::new()];
|
||||||
for (input, action) in cfg.keymap.entries(mode) {
|
for (input, action) in cfg.keymap.entries(mode) {
|
||||||
out.push(format!(" {input:<24} {action}"));
|
out.push(format!(" {input:<24} {}", action.help_label()));
|
||||||
}
|
}
|
||||||
out.push(String::new());
|
out.push(String::new());
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
let leader = cfg.leader.canonical();
|
let leader = cfg.leader.canonical();
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
format!("Leader: {leader} (Insert → Normal)"),
|
format!("Leader: {leader} (return to sidebar)"),
|
||||||
String::new(),
|
String::new(),
|
||||||
];
|
];
|
||||||
lines.extend(section(Mode::Normal, "Normal", cfg));
|
lines.extend(section(Mode::Normal, "Normal", cfg));
|
||||||
@@ -1057,19 +1269,11 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
assert_eq!(app.state.mode, Mode::Normal);
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
app.dispatch(Action::EnterInsert);
|
app.dispatch(Action::EnterInsert);
|
||||||
|
app.state.focus = Focus::Pane;
|
||||||
assert_eq!(app.state.mode, Mode::Insert);
|
assert_eq!(app.state.mode, Mode::Insert);
|
||||||
app.dispatch(Action::LeaveInsert);
|
app.dispatch(Action::LeaveInsert);
|
||||||
assert_eq!(app.state.mode, Mode::Normal);
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
}
|
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_tab_action_extends_tabs() {
|
|
||||||
let mut app = make_app();
|
|
||||||
app.dispatch(Action::NewTab);
|
|
||||||
app.dispatch(Action::NewTab);
|
|
||||||
assert_eq!(app.state.tabs.len(), 3);
|
|
||||||
app.dispatch(Action::CloseTab);
|
|
||||||
assert_eq!(app.state.tabs.len(), 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1079,6 +1283,18 @@ mod tests {
|
|||||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_uses_plain_language_instead_of_action_names() {
|
||||||
|
let app = make_app();
|
||||||
|
let help = render_help_lines(&app.cfg).join("\n");
|
||||||
|
assert!(help.contains("Send keys to session"));
|
||||||
|
assert!(help.contains("Create a session"));
|
||||||
|
assert!(help.contains("Expand or collapse directory"));
|
||||||
|
assert!(!help.contains("EnterInsert"));
|
||||||
|
assert!(!help.contains("NewDraftPane"));
|
||||||
|
assert!(!help.contains("ToggleSidebarGroup"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
@@ -1092,21 +1308,19 @@ mod tests {
|
|||||||
assert!(app.state.overlay().is_none(), "no overlay involved");
|
assert!(app.state.overlay().is_none(), "no overlay involved");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tab already showing a session gets a new tab for the draft.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_draft_pane_over_a_session_opens_a_new_tab() {
|
fn new_draft_pane_over_a_session_reuses_the_pane() {
|
||||||
let sid = Uuid::from_u128(10);
|
let sid = Uuid::from_u128(10);
|
||||||
let mut app = app_with_highlighted_session(sid);
|
let mut app = app_with_highlighted_session(sid);
|
||||||
app.dispatch(Action::AttachHighlighted);
|
app.dispatch(Action::AttachHighlighted);
|
||||||
|
|
||||||
app.dispatch(Action::NewDraftPane);
|
app.dispatch(Action::NewDraftPane);
|
||||||
assert_eq!(app.state.tabs.len(), 2);
|
assert_eq!(app.state.tabs.len(), 1);
|
||||||
assert_eq!(app.state.active_tab_idx, 1);
|
assert_eq!(app.state.active_tab_idx, 0);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.state.active_tab().pane,
|
app.state.active_tab().pane,
|
||||||
Some(PaneRef::Draft(_))
|
Some(PaneRef::Draft(_))
|
||||||
));
|
));
|
||||||
assert_eq!(app.state.tabs[0].session(), Some(sid));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seed the catalog with one session and highlight it.
|
/// Seed the catalog with one session and highlight it.
|
||||||
@@ -1126,15 +1340,40 @@ mod tests {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_session(sid: Uuid, cwd: &str) -> maestro_protocol::Session {
|
||||||
|
use chrono::Utc;
|
||||||
|
use maestro_protocol::{Session, SessionStatus};
|
||||||
|
Session {
|
||||||
|
id: sid,
|
||||||
|
command: "bash".into(),
|
||||||
|
cwd: cwd.into(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata: Default::default(),
|
||||||
|
worktree: None,
|
||||||
|
status: SessionStatus::Running,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
foreground: None,
|
||||||
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_highlighted_asks_for_an_attach() {
|
fn attach_highlighted_asks_for_an_attach() {
|
||||||
let sid = Uuid::from_u128(11);
|
let sid = Uuid::from_u128(11);
|
||||||
@@ -1144,6 +1383,104 @@ mod tests {
|
|||||||
vec![Effect::Attach(sid)]
|
vec![Effect::Attach(sid)]
|
||||||
);
|
);
|
||||||
assert_eq!(app.state.active_tab().session(), Some(sid));
|
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||||
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_a_group_toggles_it_without_targeting_a_session() {
|
||||||
|
use maestro_protocol::CatalogEvent;
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(14);
|
||||||
|
let mut app = make_app();
|
||||||
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
|
sessions: vec![test_session(sid, "/repo")],
|
||||||
|
});
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
assert_eq!(
|
||||||
|
app.state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group(PathBuf::from("/repo")))
|
||||||
|
);
|
||||||
|
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||||
|
assert!(app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/repo")));
|
||||||
|
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||||
|
assert!(!app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/repo")));
|
||||||
|
assert!(app.dispatch(Action::KillHighlighted).is_empty());
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsing_the_group_containing_a_card_highlight_keeps_a_visible_group_highlight() {
|
||||||
|
let sid = Uuid::from_u128(15);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||||
|
assert!(app.dispatch(Action::ToggleSidebarGroup).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
app.state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group(PathBuf::from("/")))
|
||||||
|
);
|
||||||
|
assert!(app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/")));
|
||||||
|
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicking_a_group_toggles_it_and_selects_the_group() {
|
||||||
|
let sid = Uuid::from_u128(16);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10));
|
||||||
|
app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))];
|
||||||
|
let click = InputKey::Mouse {
|
||||||
|
kind: MouseKind::Click,
|
||||||
|
region: Region::SidebarRow,
|
||||||
|
col: 3,
|
||||||
|
row: 1,
|
||||||
|
};
|
||||||
|
assert!(app.handle_sidebar_mouse(&click));
|
||||||
|
assert_eq!(
|
||||||
|
app.state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group(PathBuf::from("/")))
|
||||||
|
);
|
||||||
|
assert!(app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/")));
|
||||||
|
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sidebar_click_dismisses_a_toast_before_handling_the_click() {
|
||||||
|
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(17);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
app.state.toast("notice");
|
||||||
|
app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10));
|
||||||
|
app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))];
|
||||||
|
|
||||||
|
let quit = app
|
||||||
|
.handle_event(CtEvent::Mouse(MouseEvent {
|
||||||
|
kind: MouseEventKind::Down(MouseButton::Left),
|
||||||
|
column: 3,
|
||||||
|
row: 1,
|
||||||
|
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!quit);
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
assert!(app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1349,9 +1686,7 @@ mod tests {
|
|||||||
for c in "crimson-otter".chars() {
|
for c in "crimson-otter".chars() {
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
}
|
}
|
||||||
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
|
||||||
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
|
||||||
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
|
||||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||||
|
|
||||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||||
@@ -1553,8 +1888,8 @@ mod tests {
|
|||||||
panic!("expected a draft pane");
|
panic!("expected a draft pane");
|
||||||
};
|
};
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
assert_eq!(draft.rows.len(), 1);
|
assert_eq!(draft.groups.len(), 1);
|
||||||
assert_eq!(draft.rows[0].project.name, "maestro");
|
assert_eq!(draft.groups[0].main.name, "maestro");
|
||||||
assert_eq!(draft.home_dir, "/home/u");
|
assert_eq!(draft.home_dir, "/home/u");
|
||||||
assert_eq!(draft.name.value(), "crimson-otter");
|
assert_eq!(draft.name.value(), "crimson-otter");
|
||||||
}
|
}
|
||||||
@@ -1602,6 +1937,49 @@ mod tests {
|
|||||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
|
||||||
|
use crate::session_task::{SessionHandle, SessionState};
|
||||||
|
use std::sync::atomic::AtomicU8;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(91);
|
||||||
|
let mut parser = vt100::Parser::new(2, 20, 20);
|
||||||
|
parser.process(b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
|
||||||
|
let screen = Arc::new(Mutex::new(parser));
|
||||||
|
let handle = SessionHandle {
|
||||||
|
tx: mpsc::channel(1).0,
|
||||||
|
screen: Arc::clone(&screen),
|
||||||
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||||
|
};
|
||||||
|
let mut app = make_app();
|
||||||
|
app.state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
app.emulators.insert(sid, handle);
|
||||||
|
|
||||||
|
app.scroll_active_pane(3);
|
||||||
|
assert!(screen.lock().unwrap().screen().scrollback() > 0);
|
||||||
|
assert!(app.forward_to_session(&InputKey::Enter).is_some());
|
||||||
|
assert_eq!(screen.lock().unwrap().screen().scrollback(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_wheel_encoding_supports_sgr_terminals() {
|
||||||
|
assert_eq!(
|
||||||
|
encode_mouse_wheel(MouseKind::ScrollUp, 7, 4, vt100::MouseProtocolEncoding::Sgr,),
|
||||||
|
b"\x1b[<64;7;4M"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_mouse_wheel(
|
||||||
|
MouseKind::ScrollDown,
|
||||||
|
7,
|
||||||
|
4,
|
||||||
|
vt100::MouseProtocolEncoding::Sgr,
|
||||||
|
),
|
||||||
|
b"\x1b[<65;7;4M"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
||||||
/// paste into an attached session vanished once bracketed paste was
|
/// paste into an attached session vanished once bracketed paste was
|
||||||
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
||||||
@@ -1645,7 +2023,9 @@ mod tests {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
|
|||||||
@@ -193,7 +193,9 @@ mod tests {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,15 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use maestro_config::TuiSection;
|
use maestro_config::{TuiSection, TuiTheme};
|
||||||
|
|
||||||
use crate::input::InputKey;
|
use crate::input::InputKey;
|
||||||
use crate::keymap::{Keymap, KeymapError, Mode};
|
use crate::keymap::{Keymap, KeymapError, Mode};
|
||||||
|
use crate::palette::Palette;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TuiConfig {
|
pub struct TuiConfig {
|
||||||
|
pub palette: Palette,
|
||||||
pub leader: InputKey,
|
pub leader: InputKey,
|
||||||
pub chord_timeout: Duration,
|
pub chord_timeout: Duration,
|
||||||
pub keymap: Keymap,
|
pub keymap: Keymap,
|
||||||
@@ -38,11 +40,12 @@ pub enum TuiConfigError {
|
|||||||
const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||||
// Mode
|
// Mode
|
||||||
("i", "EnterInsert"),
|
("i", "EnterInsert"),
|
||||||
// Sidebar nav
|
// Sidebar navigation
|
||||||
("j", "SidebarNext"),
|
("j", "SidebarNext"),
|
||||||
("k", "SidebarPrev"),
|
("k", "SidebarPrev"),
|
||||||
("down", "SidebarNext"),
|
("down", "SidebarNext"),
|
||||||
("up", "SidebarPrev"),
|
("up", "SidebarPrev"),
|
||||||
|
("c", "ToggleSidebarGroup"),
|
||||||
// Catalog actions
|
// Catalog actions
|
||||||
("enter", "AttachHighlighted"),
|
("enter", "AttachHighlighted"),
|
||||||
("s", "NewDraftPane"),
|
("s", "NewDraftPane"),
|
||||||
@@ -51,19 +54,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("S", "FastForwardSpawn"),
|
("S", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
("w", "ShowWorktrees"),
|
("w", "ShowWorktrees"),
|
||||||
// Tabs
|
|
||||||
("ctrl-t", "NewTab"),
|
|
||||||
("g t", "NextTab"),
|
|
||||||
("g T", "PrevTab"),
|
|
||||||
// Focus
|
// Focus
|
||||||
("ctrl-w h", "FocusSidebar"),
|
("ctrl-w h", "FocusSidebar"),
|
||||||
|
// Session scrollback
|
||||||
|
("pageup", "ScrollPanePageUp"),
|
||||||
|
("pagedown", "ScrollPanePageDown"),
|
||||||
// Chrome
|
// Chrome
|
||||||
("ctrl-w b", "ToggleSidebar"),
|
("ctrl-w b", "ToggleSidebar"),
|
||||||
("?", "ShowHelp"),
|
("?", "ShowHelp"),
|
||||||
("ctrl-w q", "Quit"),
|
("ctrl-w q", "Quit"),
|
||||||
// Mouse
|
// Mouse
|
||||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||||
("mouse:click(tab)", "NextTab"),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
||||||
@@ -76,6 +77,20 @@ impl TuiConfig {
|
|||||||
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
|
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
|
||||||
/// applied first, then user overrides on top.
|
/// applied first, then user overrides on top.
|
||||||
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
|
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
|
||||||
|
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
|
||||||
|
TuiTheme::Dark => Palette::DARK,
|
||||||
|
TuiTheme::Light => Palette::LIGHT,
|
||||||
|
TuiTheme::Ansi => Palette::ANSI,
|
||||||
|
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
|
||||||
|
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
|
||||||
|
TuiTheme::Catppuccin => Palette::CATPPUCCIN,
|
||||||
|
TuiTheme::GithubDark => Palette::GITHUB_DARK,
|
||||||
|
TuiTheme::GithubLight => Palette::GITHUB_LIGHT,
|
||||||
|
TuiTheme::TokyoNight => Palette::TOKYO_NIGHT,
|
||||||
|
TuiTheme::Gruvbox => Palette::GRUVBOX,
|
||||||
|
TuiTheme::Nord => Palette::NORD,
|
||||||
|
TuiTheme::Dracula => Palette::DRACULA,
|
||||||
|
};
|
||||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
||||||
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()))?;
|
||||||
@@ -131,6 +146,7 @@ impl TuiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(TuiConfig {
|
Ok(TuiConfig {
|
||||||
|
palette,
|
||||||
leader,
|
leader,
|
||||||
chord_timeout,
|
chord_timeout,
|
||||||
keymap,
|
keymap,
|
||||||
@@ -143,6 +159,37 @@ impl TuiConfig {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
|
use maestro_config::{TuiSection, TuiTheme};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_theme_selects_the_dark_default() {
|
||||||
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
assert_eq!(cfg.palette, Palette::DARK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_themes_select_the_matching_complete_palette() {
|
||||||
|
for (theme, expected) in [
|
||||||
|
(TuiTheme::Dark, Palette::DARK),
|
||||||
|
(TuiTheme::Light, Palette::LIGHT),
|
||||||
|
(TuiTheme::Ansi, Palette::ANSI),
|
||||||
|
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
|
||||||
|
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
|
||||||
|
(TuiTheme::Catppuccin, Palette::CATPPUCCIN),
|
||||||
|
(TuiTheme::GithubDark, Palette::GITHUB_DARK),
|
||||||
|
(TuiTheme::GithubLight, Palette::GITHUB_LIGHT),
|
||||||
|
(TuiTheme::TokyoNight, Palette::TOKYO_NIGHT),
|
||||||
|
(TuiTheme::Gruvbox, Palette::GRUVBOX),
|
||||||
|
(TuiTheme::Nord, Palette::NORD),
|
||||||
|
(TuiTheme::Dracula, Palette::DRACULA),
|
||||||
|
] {
|
||||||
|
let section = TuiSection {
|
||||||
|
theme: Some(theme),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(TuiConfig::load(§ion).unwrap().palette, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_cover_core_actions() {
|
fn defaults_cover_core_actions() {
|
||||||
@@ -155,10 +202,6 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "enter"),
|
cfg.keymap.resolve(Mode::Normal, "enter"),
|
||||||
Some(Action::AttachHighlighted),
|
Some(Action::AttachHighlighted),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
cfg.keymap.resolve(Mode::Normal, "g t"),
|
|
||||||
Some(Action::NextTab),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||||
Some(Action::NewDraftPane),
|
Some(Action::NewDraftPane),
|
||||||
|
|||||||
+837
-162
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,8 @@ pub enum InputKey {
|
|||||||
Right,
|
Right,
|
||||||
Home,
|
Home,
|
||||||
End,
|
End,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
Delete,
|
Delete,
|
||||||
/// Bracketed paste: crossterm delivers the whole payload as one event, so
|
/// Bracketed paste: crossterm delivers the whole payload as one event, so
|
||||||
/// text fields can insert it as a single operation.
|
/// text fields can insert it as a single operation.
|
||||||
@@ -63,7 +65,6 @@ pub enum MouseKind {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Region {
|
pub enum Region {
|
||||||
SidebarRow,
|
SidebarRow,
|
||||||
Tab,
|
|
||||||
Pane,
|
Pane,
|
||||||
Modal,
|
Modal,
|
||||||
Unknown,
|
Unknown,
|
||||||
@@ -136,6 +137,8 @@ impl InputKey {
|
|||||||
InputKey::Right => "right".into(),
|
InputKey::Right => "right".into(),
|
||||||
InputKey::Home => "home".into(),
|
InputKey::Home => "home".into(),
|
||||||
InputKey::End => "end".into(),
|
InputKey::End => "end".into(),
|
||||||
|
InputKey::PageUp => "pageup".into(),
|
||||||
|
InputKey::PageDown => "pagedown".into(),
|
||||||
InputKey::Delete => "delete".into(),
|
InputKey::Delete => "delete".into(),
|
||||||
// Pastes are not bindable; the canonical form exists only to
|
// Pastes are not bindable; the canonical form exists only to
|
||||||
// satisfy exhaustiveness and never round-trips through `parse`.
|
// satisfy exhaustiveness and never round-trips through `parse`.
|
||||||
@@ -150,7 +153,6 @@ impl InputKey {
|
|||||||
};
|
};
|
||||||
let r = match region {
|
let r = match region {
|
||||||
Region::SidebarRow => "sidebar-row",
|
Region::SidebarRow => "sidebar-row",
|
||||||
Region::Tab => "tab",
|
|
||||||
Region::Pane => "pane",
|
Region::Pane => "pane",
|
||||||
Region::Modal => "modal",
|
Region::Modal => "modal",
|
||||||
Region::Unknown => "unknown",
|
Region::Unknown => "unknown",
|
||||||
@@ -182,7 +184,6 @@ impl InputKey {
|
|||||||
};
|
};
|
||||||
let region = match r {
|
let region = match r {
|
||||||
"sidebar-row" => Region::SidebarRow,
|
"sidebar-row" => Region::SidebarRow,
|
||||||
"tab" => Region::Tab,
|
|
||||||
"pane" => Region::Pane,
|
"pane" => Region::Pane,
|
||||||
"modal" => Region::Modal,
|
"modal" => Region::Modal,
|
||||||
"unknown" => Region::Unknown,
|
"unknown" => Region::Unknown,
|
||||||
@@ -207,6 +208,8 @@ impl InputKey {
|
|||||||
"right" => return Ok(InputKey::Right),
|
"right" => return Ok(InputKey::Right),
|
||||||
"home" => return Ok(InputKey::Home),
|
"home" => return Ok(InputKey::Home),
|
||||||
"end" => return Ok(InputKey::End),
|
"end" => return Ok(InputKey::End),
|
||||||
|
"pageup" => return Ok(InputKey::PageUp),
|
||||||
|
"pagedown" => return Ok(InputKey::PageDown),
|
||||||
"delete" => return Ok(InputKey::Delete),
|
"delete" => return Ok(InputKey::Delete),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -281,6 +284,8 @@ where
|
|||||||
KeyCode::Right => InputKey::Right,
|
KeyCode::Right => InputKey::Right,
|
||||||
KeyCode::Home => InputKey::Home,
|
KeyCode::Home => InputKey::Home,
|
||||||
KeyCode::End => InputKey::End,
|
KeyCode::End => InputKey::End,
|
||||||
|
KeyCode::PageUp => InputKey::PageUp,
|
||||||
|
KeyCode::PageDown => InputKey::PageDown,
|
||||||
KeyCode::Delete => InputKey::Delete,
|
KeyCode::Delete => InputKey::Delete,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
@@ -617,6 +622,8 @@ mod tests {
|
|||||||
"right",
|
"right",
|
||||||
"home",
|
"home",
|
||||||
"end",
|
"end",
|
||||||
|
"pageup",
|
||||||
|
"pagedown",
|
||||||
"delete",
|
"delete",
|
||||||
] {
|
] {
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_chord() {
|
fn resolves_chord() {
|
||||||
let k = build(&[(Mode::Normal, "g t", "NextTab")]);
|
let k = build(&[(Mode::Normal, "g h", "FocusSidebar")]);
|
||||||
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
|
assert_eq!(k.resolve(Mode::Normal, "g h"), Some(Action::FocusSidebar));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -123,11 +123,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn known_bindings_sees_everything() {
|
fn known_bindings_sees_everything() {
|
||||||
let k = build(&[
|
let k = build(&[
|
||||||
(Mode::Normal, "g t", "NextTab"),
|
(Mode::Normal, "g h", "FocusSidebar"),
|
||||||
(Mode::Normal, "i", "EnterInsert"),
|
(Mode::Normal, "i", "EnterInsert"),
|
||||||
]);
|
]);
|
||||||
let kb = k.known_bindings(Mode::Normal);
|
let kb = k.known_bindings(Mode::Normal);
|
||||||
assert!(kb.contains("g t"));
|
assert!(kb.contains("g h"));
|
||||||
assert!(kb.contains("i"));
|
assert!(kb.contains("i"));
|
||||||
assert_eq!(kb.len(), 2);
|
assert_eq!(kb.len(), 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ pub mod fast_forward;
|
|||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
|
pub mod palette;
|
||||||
pub mod pane;
|
pub mod pane;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod session_task;
|
pub mod session_task;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod tabs;
|
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod text_input;
|
pub mod text_input;
|
||||||
pub mod worktrees;
|
pub mod worktrees;
|
||||||
@@ -89,6 +89,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
|||||||
Err(_) => maestro_config::MaestroConfigFile::default(),
|
Err(_) => maestro_config::MaestroConfigFile::default(),
|
||||||
};
|
};
|
||||||
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
|
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
|
||||||
|
palette::set_active(tui_cfg.palette);
|
||||||
|
|
||||||
let catalog = CatalogStore::default();
|
let catalog = CatalogStore::default();
|
||||||
let ws_url = daemon
|
let ws_url = daemon
|
||||||
|
|||||||
@@ -155,12 +155,12 @@ mod tests {
|
|||||||
fn help_overlay_lists_lines() {
|
fn help_overlay_lists_lines() {
|
||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
lines: vec![
|
lines: vec![
|
||||||
"i EnterInsert".into(),
|
"i Send keys to session".into(),
|
||||||
"s NewDraftPane".into(),
|
"s Create a session".into(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
||||||
assert!(rendered.contains("EnterInsert"));
|
assert!(rendered.contains("Send keys to session"));
|
||||||
assert!(rendered.contains("NewDraftPane"));
|
assert!(rendered.contains("Create a session"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,851 @@
|
|||||||
|
//! Named colour roles for the TUI.
|
||||||
|
//!
|
||||||
|
//! Every colour the TUI paints on its own chrome comes from here, named for
|
||||||
|
//! what it means rather than what it looks like: the card surface, the
|
||||||
|
//! selection surface, the panel border in its focused and unfocused forms,
|
||||||
|
//! the text weights (`fg` / `muted` / `dim`), and one role per
|
||||||
|
//! [`ActivityState`]. Call sites ask the palette for a role; they never spell
|
||||||
|
//! a colour out.
|
||||||
|
//!
|
||||||
|
//! Values are [`Color::Rgb`], so contrast is a property of the palette rather
|
||||||
|
//! than of the user's terminal theme — and can therefore be asserted (see
|
||||||
|
//! [`contrast_ratio`] and this module's tests).
|
||||||
|
//!
|
||||||
|
//! Each surface carries its own activity colours. A state colour picked for
|
||||||
|
//! the dark card background is not legible on the selection background, so
|
||||||
|
//! the selected card uses the selection surface's own state colours; the dot
|
||||||
|
//! keeps carrying the state either way (docs/SIDEBAR.md).
|
||||||
|
//!
|
||||||
|
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
|
||||||
|
//! [`Palette::ANSI`] uses terminal-provided ANSI colours. The remaining
|
||||||
|
//! presets ([`Palette::presets`]) adapt well-known editor themes — Solarized,
|
||||||
|
//! Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord, Dracula — with individual
|
||||||
|
//! colours lifted or darkened where the originals fall short of the contrast
|
||||||
|
//! floors asserted below.
|
||||||
|
//!
|
||||||
|
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
||||||
|
//! pane and pass through `pane::translate_color` verbatim.
|
||||||
|
|
||||||
|
use maestro_protocol::ActivityState;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
|
||||||
|
/// A background and the foreground roles legible on it.
|
||||||
|
///
|
||||||
|
/// The sidebar has two: the card surface every card sits on, and the
|
||||||
|
/// selection surface the highlighted card swaps in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Surface {
|
||||||
|
/// The surface itself.
|
||||||
|
pub bg: Color,
|
||||||
|
/// Primary text on this surface.
|
||||||
|
pub fg: Color,
|
||||||
|
/// Secondary text — the blocked-work, exited, detail and command
|
||||||
|
/// fragments of a card's meta line.
|
||||||
|
pub muted: Color,
|
||||||
|
/// The quietest legible text on this surface, for decoration that must
|
||||||
|
/// not compete with content.
|
||||||
|
pub dim: Color,
|
||||||
|
/// `ActivityState::Active`.
|
||||||
|
pub active: Color,
|
||||||
|
/// `ActivityState::Idle`.
|
||||||
|
pub idle: Color,
|
||||||
|
/// `ActivityState::AwaitingInput`.
|
||||||
|
pub awaiting: Color,
|
||||||
|
/// `ActivityState::Error`.
|
||||||
|
pub error: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Surface {
|
||||||
|
/// The bare surface: a background with no foreground override.
|
||||||
|
pub fn base(&self) -> Style {
|
||||||
|
Style::default().bg(self.bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Primary text on this surface.
|
||||||
|
pub fn text_style(&self) -> Style {
|
||||||
|
Style::default().bg(self.bg).fg(self.fg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary text on this surface.
|
||||||
|
pub fn muted_style(&self) -> Style {
|
||||||
|
Style::default().bg(self.bg).fg(self.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quietest text on this surface.
|
||||||
|
pub fn dim_style(&self) -> Style {
|
||||||
|
Style::default().bg(self.bg).fg(self.dim)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour carrying `state` on this surface.
|
||||||
|
pub fn activity(&self, state: ActivityState) -> Color {
|
||||||
|
match state {
|
||||||
|
ActivityState::Active => self.active,
|
||||||
|
ActivityState::Idle => self.idle,
|
||||||
|
ActivityState::AwaitingInput => self.awaiting,
|
||||||
|
ActivityState::Error => self.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every foreground role on this surface, paired with its role name.
|
||||||
|
/// Used by the contrast tests, and by anything that needs to reason about
|
||||||
|
/// the surface as a whole.
|
||||||
|
pub fn roles(&self) -> [(&'static str, Color); 7] {
|
||||||
|
[
|
||||||
|
("fg", self.fg),
|
||||||
|
("muted", self.muted),
|
||||||
|
("dim", self.dim),
|
||||||
|
("active", self.active),
|
||||||
|
("idle", self.idle),
|
||||||
|
("awaiting", self.awaiting),
|
||||||
|
("error", self.error),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full set of colour roles the TUI paints with.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Palette {
|
||||||
|
/// The surface an unselected card sits on.
|
||||||
|
pub card: Surface,
|
||||||
|
/// The surface the highlighted card swaps in.
|
||||||
|
pub selection: Surface,
|
||||||
|
/// Background for TUI-owned chrome outside cards. Fixed-RGB chrome must
|
||||||
|
/// paint this background too; otherwise its contrast depends on the
|
||||||
|
/// operator's terminal theme.
|
||||||
|
pub chrome_bg: Color,
|
||||||
|
/// Subtle full-panel background while the sidebar owns focus.
|
||||||
|
pub sidebar_focus_bg: Color,
|
||||||
|
/// Panel border while that panel holds focus.
|
||||||
|
pub border_focused: Color,
|
||||||
|
/// Panel border while it does not.
|
||||||
|
pub border_unfocused: Color,
|
||||||
|
/// Focus affordances that are not the border itself — markers, titles and
|
||||||
|
/// the like on the panel that holds focus.
|
||||||
|
pub focus_accent: Color,
|
||||||
|
/// Sidebar group headings.
|
||||||
|
pub group_row: Color,
|
||||||
|
/// Status-bar mode indicator, normal mode.
|
||||||
|
pub mode_normal_bg: Color,
|
||||||
|
pub mode_normal_fg: Color,
|
||||||
|
/// Status-bar mode indicator, insert mode.
|
||||||
|
pub mode_insert_bg: Color,
|
||||||
|
pub mode_insert_fg: Color,
|
||||||
|
/// Error text outside a card — form errors, failed actions.
|
||||||
|
pub error_text: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Palette {
|
||||||
|
/// The dark terminal-native palette and the default preset.
|
||||||
|
pub const DARK: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
fg: Color::Rgb(0xe5, 0xe7, 0xeb),
|
||||||
|
muted: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||||
|
dim: Color::Rgb(0x85, 0x8f, 0x9e),
|
||||||
|
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
|
idle: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||||
|
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||||
|
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x3d, 0x51, 0x44),
|
||||||
|
fg: Color::Rgb(0xf3, 0xf7, 0xf4),
|
||||||
|
muted: Color::Rgb(0xd0, 0xd9, 0xd3),
|
||||||
|
dim: Color::Rgb(0xa1, 0xb0, 0xa6),
|
||||||
|
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||||
|
idle: Color::Rgb(0xc4, 0xcf, 0xc7),
|
||||||
|
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||||
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x28, 0x30, 0x2a),
|
||||||
|
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
|
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
|
||||||
|
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
|
group_row: Color::Rgb(0xb6, 0xc1, 0xd3),
|
||||||
|
mode_normal_bg: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
|
mode_normal_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||||
|
mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A high-contrast light peer to [`Self::DARK`].
|
||||||
|
pub const LIGHT: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
fg: Color::Rgb(0x27, 0x31, 0x3d),
|
||||||
|
muted: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||||
|
dim: Color::Rgb(0x68, 0x78, 0x8a),
|
||||||
|
active: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
|
idle: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||||
|
awaiting: Color::Rgb(0x8f, 0x63, 0x00),
|
||||||
|
error: Color::Rgb(0xb4, 0x23, 0x18),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0xb5, 0xcf, 0xba),
|
||||||
|
fg: Color::Rgb(0x1d, 0x2a, 0x24),
|
||||||
|
muted: Color::Rgb(0x3f, 0x55, 0x45),
|
||||||
|
dim: Color::Rgb(0x52, 0x68, 0x5a),
|
||||||
|
active: Color::Rgb(0x15, 0x5b, 0x2e),
|
||||||
|
idle: Color::Rgb(0x3f, 0x55, 0x45),
|
||||||
|
awaiting: Color::Rgb(0x69, 0x46, 0x00),
|
||||||
|
error: Color::Rgb(0xa5, 0x1d, 0x14),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0xee, 0xf5, 0xef),
|
||||||
|
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
|
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
|
||||||
|
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
|
group_row: Color::Rgb(0x3f, 0x55, 0x45),
|
||||||
|
mode_normal_bg: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
|
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
mode_insert_bg: Color::Rgb(0xb6, 0x81, 0x08),
|
||||||
|
mode_insert_fg: Color::Rgb(0x0b, 0x12, 0x0e),
|
||||||
|
error_text: Color::Rgb(0xb4, 0x23, 0x18),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A palette of named ANSI colours for terminals without truecolour.
|
||||||
|
pub const ANSI: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Black,
|
||||||
|
fg: Color::White,
|
||||||
|
muted: Color::Gray,
|
||||||
|
dim: Color::DarkGray,
|
||||||
|
active: Color::Green,
|
||||||
|
idle: Color::Gray,
|
||||||
|
awaiting: Color::Yellow,
|
||||||
|
error: Color::Red,
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Blue,
|
||||||
|
fg: Color::White,
|
||||||
|
muted: Color::White,
|
||||||
|
dim: Color::LightBlue,
|
||||||
|
active: Color::LightGreen,
|
||||||
|
idle: Color::White,
|
||||||
|
awaiting: Color::LightYellow,
|
||||||
|
error: Color::LightRed,
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Black,
|
||||||
|
sidebar_focus_bg: Color::DarkGray,
|
||||||
|
border_focused: Color::LightGreen,
|
||||||
|
border_unfocused: Color::DarkGray,
|
||||||
|
focus_accent: Color::LightGreen,
|
||||||
|
group_row: Color::LightCyan,
|
||||||
|
mode_normal_bg: Color::Green,
|
||||||
|
mode_normal_fg: Color::Black,
|
||||||
|
mode_insert_bg: Color::Yellow,
|
||||||
|
mode_insert_fg: Color::Black,
|
||||||
|
error_text: Color::LightRed,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Solarized Dark — Ethan Schoonover's low-contrast classic,
|
||||||
|
/// accents lifted where the originals fall short of WCAG AA.
|
||||||
|
pub const SOLARIZED_DARK: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
fg: Color::Rgb(0x93, 0xa1, 0xa1),
|
||||||
|
muted: Color::Rgb(0x83, 0x94, 0x96),
|
||||||
|
dim: Color::Rgb(0x65, 0x7b, 0x83),
|
||||||
|
active: Color::Rgb(0x85, 0x99, 0x00),
|
||||||
|
idle: Color::Rgb(0x83, 0x94, 0x96),
|
||||||
|
awaiting: Color::Rgb(0xb5, 0x89, 0x00),
|
||||||
|
error: Color::Rgb(0xe5, 0x68, 0x65),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x10, 0x53, 0x62),
|
||||||
|
fg: Color::Rgb(0xb6, 0xc0, 0xc0),
|
||||||
|
muted: Color::Rgb(0xb5, 0xc0, 0xc1),
|
||||||
|
dim: Color::Rgb(0x8b, 0x9f, 0xa6),
|
||||||
|
active: Color::Rgb(0xaf, 0xca, 0x00),
|
||||||
|
idle: Color::Rgb(0xb5, 0xc0, 0xc1),
|
||||||
|
awaiting: Color::Rgb(0xef, 0xb5, 0x00),
|
||||||
|
error: Color::Rgb(0xf1, 0xad, 0xab),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x00, 0x1a, 0x21),
|
||||||
|
border_focused: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||||
|
border_unfocused: Color::Rgb(0x5e, 0x76, 0x7e),
|
||||||
|
focus_accent: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||||
|
group_row: Color::Rgb(0x93, 0xa1, 0xa1),
|
||||||
|
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||||
|
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
mode_insert_bg: Color::Rgb(0xb5, 0x89, 0x00),
|
||||||
|
mode_insert_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
error_text: Color::Rgb(0xe5, 0x68, 0x65),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Solarized Light — the warm cream-paper peer of Solarized Dark.
|
||||||
|
pub const SOLARIZED_LIGHT: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||||
|
fg: Color::Rgb(0x58, 0x6e, 0x75),
|
||||||
|
muted: Color::Rgb(0x5e, 0x73, 0x7a),
|
||||||
|
dim: Color::Rgb(0x7e, 0x8f, 0x92),
|
||||||
|
active: Color::Rgb(0x67, 0x76, 0x00),
|
||||||
|
idle: Color::Rgb(0x5e, 0x73, 0x7a),
|
||||||
|
awaiting: Color::Rgb(0x8d, 0x6a, 0x00),
|
||||||
|
error: Color::Rgb(0xd7, 0x27, 0x24),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0xcb, 0xbd, 0x93),
|
||||||
|
fg: Color::Rgb(0x3f, 0x4e, 0x53),
|
||||||
|
muted: Color::Rgb(0x40, 0x4e, 0x53),
|
||||||
|
dim: Color::Rgb(0x59, 0x67, 0x69),
|
||||||
|
active: Color::Rgb(0x46, 0x50, 0x00),
|
||||||
|
idle: Color::Rgb(0x40, 0x4e, 0x53),
|
||||||
|
awaiting: Color::Rgb(0x60, 0x48, 0x00),
|
||||||
|
error: Color::Rgb(0x94, 0x1b, 0x19),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0xf7, 0xf4, 0xed),
|
||||||
|
border_focused: Color::Rgb(0x29, 0x9b, 0x93),
|
||||||
|
border_unfocused: Color::Rgb(0x7f, 0x8f, 0x8f),
|
||||||
|
focus_accent: Color::Rgb(0x29, 0x9b, 0x93),
|
||||||
|
group_row: Color::Rgb(0x58, 0x6e, 0x75),
|
||||||
|
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||||
|
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
mode_insert_bg: Color::Rgb(0x8d, 0x6a, 0x00),
|
||||||
|
mode_insert_fg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||||
|
error_text: Color::Rgb(0xd7, 0x27, 0x24),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Catppuccin Mocha — soothing pastels on a soft dark base.
|
||||||
|
pub const CATPPUCCIN: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||||
|
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
|
||||||
|
muted: Color::Rgb(0xa6, 0xad, 0xc8),
|
||||||
|
dim: Color::Rgb(0x7f, 0x84, 0x9c),
|
||||||
|
active: Color::Rgb(0xa6, 0xe3, 0xa1),
|
||||||
|
idle: Color::Rgb(0xa6, 0xad, 0xc8),
|
||||||
|
awaiting: Color::Rgb(0xf9, 0xe2, 0xaf),
|
||||||
|
error: Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x45, 0x47, 0x5a),
|
||||||
|
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
|
||||||
|
muted: Color::Rgb(0xb2, 0xb8, 0xcf),
|
||||||
|
dim: Color::Rgb(0x92, 0x97, 0xab),
|
||||||
|
active: Color::Rgb(0xb5, 0xe8, 0xb1),
|
||||||
|
idle: Color::Rgb(0xb2, 0xb8, 0xcf),
|
||||||
|
awaiting: Color::Rgb(0xfa, 0xe9, 0xc2),
|
||||||
|
error: Color::Rgb(0xf5, 0xa1, 0xb8),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x2a, 0x2a, 0x3c),
|
||||||
|
border_focused: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||||
|
border_unfocused: Color::Rgb(0x6c, 0x70, 0x86),
|
||||||
|
focus_accent: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||||
|
group_row: Color::Rgb(0xb4, 0xbe, 0xfe),
|
||||||
|
mode_normal_bg: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||||
|
mode_normal_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||||
|
mode_insert_bg: Color::Rgb(0xf9, 0xe2, 0xaf),
|
||||||
|
mode_insert_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||||
|
error_text: Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GitHub Dark — the github.com dark default.
|
||||||
|
pub const GITHUB_DARK: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||||
|
fg: Color::Rgb(0xe6, 0xed, 0xf3),
|
||||||
|
muted: Color::Rgb(0x91, 0x98, 0xa1),
|
||||||
|
dim: Color::Rgb(0x6e, 0x76, 0x81),
|
||||||
|
active: Color::Rgb(0x3f, 0xb9, 0x50),
|
||||||
|
idle: Color::Rgb(0x91, 0x98, 0xa1),
|
||||||
|
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
|
||||||
|
error: Color::Rgb(0xf8, 0x51, 0x49),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x1f, 0x3a, 0x5f),
|
||||||
|
fg: Color::Rgb(0xe6, 0xed, 0xf3),
|
||||||
|
muted: Color::Rgb(0x9f, 0xa5, 0xad),
|
||||||
|
dim: Color::Rgb(0x7e, 0x86, 0x91),
|
||||||
|
active: Color::Rgb(0x40, 0xbc, 0x51),
|
||||||
|
idle: Color::Rgb(0x9f, 0xa5, 0xad),
|
||||||
|
awaiting: Color::Rgb(0xd4, 0x9b, 0x22),
|
||||||
|
error: Color::Rgb(0xfa, 0x81, 0x7b),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x16, 0x1b, 0x26),
|
||||||
|
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
|
||||||
|
border_unfocused: Color::Rgb(0x58, 0x64, 0x70),
|
||||||
|
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
|
||||||
|
group_row: Color::Rgb(0x91, 0x98, 0xa1),
|
||||||
|
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
|
||||||
|
mode_normal_fg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||||
|
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
|
||||||
|
mode_insert_fg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||||
|
error_text: Color::Rgb(0xf8, 0x51, 0x49),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GitHub Light — the github.com light default.
|
||||||
|
pub const GITHUB_LIGHT: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
fg: Color::Rgb(0x1f, 0x23, 0x28),
|
||||||
|
muted: Color::Rgb(0x59, 0x63, 0x6e),
|
||||||
|
dim: Color::Rgb(0x81, 0x8b, 0x98),
|
||||||
|
active: Color::Rgb(0x1a, 0x7f, 0x37),
|
||||||
|
idle: Color::Rgb(0x59, 0x63, 0x6e),
|
||||||
|
awaiting: Color::Rgb(0x9a, 0x67, 0x00),
|
||||||
|
error: Color::Rgb(0xd1, 0x24, 0x2f),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0xa5, 0xcd, 0xf0),
|
||||||
|
fg: Color::Rgb(0x1f, 0x23, 0x28),
|
||||||
|
muted: Color::Rgb(0x4b, 0x53, 0x5d),
|
||||||
|
dim: Color::Rgb(0x64, 0x6d, 0x7a),
|
||||||
|
active: Color::Rgb(0x14, 0x5f, 0x29),
|
||||||
|
idle: Color::Rgb(0x4b, 0x53, 0x5d),
|
||||||
|
awaiting: Color::Rgb(0x71, 0x4b, 0x00),
|
||||||
|
error: Color::Rgb(0xa1, 0x1c, 0x24),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0xf0, 0xf6, 0xfc),
|
||||||
|
border_focused: Color::Rgb(0x09, 0x69, 0xda),
|
||||||
|
border_unfocused: Color::Rgb(0x89, 0x93, 0x9d),
|
||||||
|
focus_accent: Color::Rgb(0x09, 0x69, 0xda),
|
||||||
|
group_row: Color::Rgb(0x59, 0x63, 0x6e),
|
||||||
|
mode_normal_bg: Color::Rgb(0x09, 0x69, 0xda),
|
||||||
|
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
mode_insert_bg: Color::Rgb(0x9a, 0x67, 0x00),
|
||||||
|
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
error_text: Color::Rgb(0xd1, 0x24, 0x2f),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tokyo Night — neon-tinted blues of the VS Code / vim favourite.
|
||||||
|
pub const TOKYO_NIGHT: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||||
|
fg: Color::Rgb(0xc0, 0xca, 0xf5),
|
||||||
|
muted: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||||
|
dim: Color::Rgb(0x5d, 0x67, 0x95),
|
||||||
|
active: Color::Rgb(0x9e, 0xce, 0x6a),
|
||||||
|
idle: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||||
|
awaiting: Color::Rgb(0xe0, 0xaf, 0x68),
|
||||||
|
error: Color::Rgb(0xf7, 0x76, 0x8e),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x33, 0x46, 0x7c),
|
||||||
|
fg: Color::Rgb(0xc0, 0xca, 0xf5),
|
||||||
|
muted: Color::Rgb(0xb0, 0xb8, 0xd9),
|
||||||
|
dim: Color::Rgb(0x8e, 0x96, 0xb8),
|
||||||
|
active: Color::Rgb(0xa8, 0xd3, 0x79),
|
||||||
|
idle: Color::Rgb(0xb0, 0xb8, 0xd9),
|
||||||
|
awaiting: Color::Rgb(0xe1, 0xb1, 0x6b),
|
||||||
|
error: Color::Rgb(0xf9, 0xa0, 0xb0),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x24, 0x28, 0x3b),
|
||||||
|
border_focused: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||||
|
border_unfocused: Color::Rgb(0x5d, 0x67, 0x95),
|
||||||
|
focus_accent: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||||
|
group_row: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||||
|
mode_normal_bg: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||||
|
mode_normal_fg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||||
|
mode_insert_bg: Color::Rgb(0xe0, 0xaf, 0x68),
|
||||||
|
mode_insert_fg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||||
|
error_text: Color::Rgb(0xf7, 0x76, 0x8e),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Gruvbox Dark — retro warm earth tones.
|
||||||
|
pub const GRUVBOX: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x28, 0x28, 0x28),
|
||||||
|
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
|
||||||
|
muted: Color::Rgb(0xbd, 0xae, 0x93),
|
||||||
|
dim: Color::Rgb(0x92, 0x83, 0x74),
|
||||||
|
active: Color::Rgb(0xb8, 0xbb, 0x26),
|
||||||
|
idle: Color::Rgb(0xbd, 0xae, 0x93),
|
||||||
|
awaiting: Color::Rgb(0xfa, 0xbd, 0x2f),
|
||||||
|
error: Color::Rgb(0xfb, 0x58, 0x44),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x50, 0x49, 0x45),
|
||||||
|
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
|
||||||
|
muted: Color::Rgb(0xc7, 0xbb, 0xa4),
|
||||||
|
dim: Color::Rgb(0xa4, 0x98, 0x8b),
|
||||||
|
active: Color::Rgb(0xbf, 0xc3, 0x28),
|
||||||
|
idle: Color::Rgb(0xc7, 0xbb, 0xa4),
|
||||||
|
awaiting: Color::Rgb(0xfa, 0xc3, 0x43),
|
||||||
|
error: Color::Rgb(0xfd, 0xa5, 0x9a),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x28, 0x28, 0x28),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x32, 0x30, 0x2f),
|
||||||
|
border_focused: Color::Rgb(0xfe, 0x80, 0x19),
|
||||||
|
border_unfocused: Color::Rgb(0x7e, 0x71, 0x67),
|
||||||
|
focus_accent: Color::Rgb(0xfe, 0x80, 0x19),
|
||||||
|
group_row: Color::Rgb(0xd5, 0xc4, 0xa1),
|
||||||
|
mode_normal_bg: Color::Rgb(0xfe, 0x80, 0x19),
|
||||||
|
mode_normal_fg: Color::Rgb(0x28, 0x28, 0x28),
|
||||||
|
mode_insert_bg: Color::Rgb(0xfa, 0xbd, 0x2f),
|
||||||
|
mode_insert_fg: Color::Rgb(0x28, 0x28, 0x28),
|
||||||
|
error_text: Color::Rgb(0xfb, 0x58, 0x44),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Nord — arctic, bluish and calm; the red lifted to stay legible.
|
||||||
|
pub const NORD: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||||
|
fg: Color::Rgb(0xec, 0xef, 0xf4),
|
||||||
|
muted: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||||
|
dim: Color::Rgb(0x9a, 0xa5, 0xb5),
|
||||||
|
active: Color::Rgb(0xa3, 0xbe, 0x8c),
|
||||||
|
idle: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||||
|
awaiting: Color::Rgb(0xeb, 0xcb, 0x8b),
|
||||||
|
error: Color::Rgb(0xd0, 0x8b, 0x91),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x4c, 0x56, 0x6a),
|
||||||
|
fg: Color::Rgb(0xec, 0xef, 0xf4),
|
||||||
|
muted: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||||
|
dim: Color::Rgb(0x9f, 0xaa, 0xb9),
|
||||||
|
active: Color::Rgb(0xc0, 0xd3, 0xb0),
|
||||||
|
idle: Color::Rgb(0xe5, 0xe9, 0xf0),
|
||||||
|
awaiting: Color::Rgb(0xee, 0xd3, 0x9c),
|
||||||
|
error: Color::Rgb(0xe7, 0xc5, 0xc8),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x35, 0x3b, 0x49),
|
||||||
|
border_focused: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||||
|
border_unfocused: Color::Rgb(0x72, 0x80, 0x9a),
|
||||||
|
focus_accent: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||||
|
group_row: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||||
|
mode_normal_bg: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||||
|
mode_normal_fg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||||
|
mode_insert_bg: Color::Rgb(0xeb, 0xcb, 0x8b),
|
||||||
|
mode_insert_fg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||||
|
error_text: Color::Rgb(0xd0, 0x8b, 0x91),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Dracula — high-saturation accents on a dark violet-grey base.
|
||||||
|
pub const DRACULA: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||||
|
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
|
||||||
|
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||||
|
dim: Color::Rgb(0x65, 0x74, 0xa6),
|
||||||
|
active: Color::Rgb(0x50, 0xfa, 0x7b),
|
||||||
|
idle: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||||
|
awaiting: Color::Rgb(0xf1, 0xfa, 0x8c),
|
||||||
|
error: Color::Rgb(0xff, 0x59, 0x59),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x4a, 0x4e, 0x62),
|
||||||
|
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
|
||||||
|
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||||
|
dim: Color::Rgb(0x94, 0x9f, 0xc1),
|
||||||
|
active: Color::Rgb(0x64, 0xfb, 0x8a),
|
||||||
|
idle: Color::Rgb(0xcc, 0xcf, 0xe1),
|
||||||
|
awaiting: Color::Rgb(0xf3, 0xfb, 0xa0),
|
||||||
|
error: Color::Rgb(0xff, 0xad, 0xad),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x31, 0x33, 0x42),
|
||||||
|
border_focused: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||||
|
border_unfocused: Color::Rgb(0x65, 0x74, 0xa6),
|
||||||
|
focus_accent: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||||
|
group_row: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||||
|
mode_normal_bg: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||||
|
mode_normal_fg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||||
|
mode_insert_bg: Color::Rgb(0xf1, 0xfa, 0x8c),
|
||||||
|
mode_insert_fg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||||
|
error_text: Color::Rgb(0xff, 0x59, 0x59),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The default preset, retained as a named compatibility constant.
|
||||||
|
pub const DEFAULT: Palette = Self::DARK;
|
||||||
|
|
||||||
|
/// All preset names and their complete palettes.
|
||||||
|
pub fn presets() -> [(&'static str, Palette); 12] {
|
||||||
|
[
|
||||||
|
("dark", Self::DARK),
|
||||||
|
("light", Self::LIGHT),
|
||||||
|
("ansi", Self::ANSI),
|
||||||
|
("solarized-dark", Self::SOLARIZED_DARK),
|
||||||
|
("solarized-light", Self::SOLARIZED_LIGHT),
|
||||||
|
("catppuccin", Self::CATPPUCCIN),
|
||||||
|
("github-dark", Self::GITHUB_DARK),
|
||||||
|
("github-light", Self::GITHUB_LIGHT),
|
||||||
|
("tokyo-night", Self::TOKYO_NIGHT),
|
||||||
|
("gruvbox", Self::GRUVBOX),
|
||||||
|
("nord", Self::NORD),
|
||||||
|
("dracula", Self::DRACULA),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a config name to a palette.
|
||||||
|
pub fn named(name: &str) -> Option<Palette> {
|
||||||
|
Self::presets()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(n, _)| *n == name)
|
||||||
|
.map(|(_, p)| p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every role, including the backgrounds it is painted against.
|
||||||
|
pub fn roles(&self) -> [(&'static str, Color); 27] {
|
||||||
|
[
|
||||||
|
("card.bg", self.card.bg),
|
||||||
|
("card.fg", self.card.fg),
|
||||||
|
("card.muted", self.card.muted),
|
||||||
|
("card.dim", self.card.dim),
|
||||||
|
("card.active", self.card.active),
|
||||||
|
("card.idle", self.card.idle),
|
||||||
|
("card.awaiting", self.card.awaiting),
|
||||||
|
("card.error", self.card.error),
|
||||||
|
("selection.bg", self.selection.bg),
|
||||||
|
("selection.fg", self.selection.fg),
|
||||||
|
("selection.muted", self.selection.muted),
|
||||||
|
("selection.dim", self.selection.dim),
|
||||||
|
("selection.active", self.selection.active),
|
||||||
|
("selection.idle", self.selection.idle),
|
||||||
|
("selection.awaiting", self.selection.awaiting),
|
||||||
|
("selection.error", self.selection.error),
|
||||||
|
("chrome_bg", self.chrome_bg),
|
||||||
|
("sidebar_focus_bg", self.sidebar_focus_bg),
|
||||||
|
("border_focused", self.border_focused),
|
||||||
|
("border_unfocused", self.border_unfocused),
|
||||||
|
("focus_accent", self.focus_accent),
|
||||||
|
("group_row", self.group_row),
|
||||||
|
("mode_normal_bg", self.mode_normal_bg),
|
||||||
|
("mode_normal_fg", self.mode_normal_fg),
|
||||||
|
("mode_insert_bg", self.mode_insert_bg),
|
||||||
|
("mode_insert_fg", self.mode_insert_fg),
|
||||||
|
("error_text", self.error_text),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The surface a card sits on: the selection surface when it is the
|
||||||
|
/// highlighted card, the card surface otherwise.
|
||||||
|
pub fn surface(&self, selected: bool) -> &Surface {
|
||||||
|
if selected {
|
||||||
|
&self.selection
|
||||||
|
} else {
|
||||||
|
&self.card
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border style for a panel, by whether it holds focus.
|
||||||
|
pub fn border_style(&self, focused: bool) -> Style {
|
||||||
|
if focused {
|
||||||
|
Style::default().bg(self.chrome_bg).fg(self.border_focused)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
.bg(self.chrome_bg)
|
||||||
|
.fg(self.border_unfocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style for a sidebar group heading.
|
||||||
|
pub fn group_row_style(&self) -> Style {
|
||||||
|
Style::default()
|
||||||
|
.bg(self.chrome_bg)
|
||||||
|
.fg(self.group_row)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style for the status bar's mode indicator.
|
||||||
|
pub fn mode_style(&self, insert: bool) -> Style {
|
||||||
|
if insert {
|
||||||
|
Style::default()
|
||||||
|
.fg(self.mode_insert_fg)
|
||||||
|
.bg(self.mode_insert_bg)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
.fg(self.mode_normal_fg)
|
||||||
|
.bg(self.mode_normal_bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style for error text outside a card.
|
||||||
|
pub fn error_style(&self) -> Style {
|
||||||
|
Style::default().bg(self.chrome_bg).fg(self.error_text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Palette {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_slot() -> &'static std::sync::RwLock<Palette> {
|
||||||
|
static ACTIVE: std::sync::OnceLock<std::sync::RwLock<Palette>> = std::sync::OnceLock::new();
|
||||||
|
ACTIVE.get_or_init(|| std::sync::RwLock::new(Palette::DEFAULT))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the palette used by the running TUI.
|
||||||
|
pub(crate) fn set_active(palette: Palette) {
|
||||||
|
*active_slot().write().expect("active palette lock poisoned") = palette;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The palette in force. Config-driven preset selection lands here at TUI
|
||||||
|
/// startup, before the first frame is drawn.
|
||||||
|
pub fn active() -> Palette {
|
||||||
|
*active_slot().read().expect("active palette lock poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
||||||
|
/// luminance — they are whatever the terminal theme says — so they return
|
||||||
|
/// `None` and cannot be contrast-checked.
|
||||||
|
pub fn relative_luminance(color: Color) -> Option<f64> {
|
||||||
|
let Color::Rgb(r, g, b) = color else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
fn channel(v: u8) -> f64 {
|
||||||
|
let v = v as f64 / 255.0;
|
||||||
|
if v <= 0.03928 {
|
||||||
|
v / 12.92
|
||||||
|
} else {
|
||||||
|
((v + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG contrast ratio between two RGB roles, 1.0 (identical) to 21.0
|
||||||
|
/// (black on white). `None` if either colour is not RGB.
|
||||||
|
pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
||||||
|
let (la, lb) = (relative_luminance(a)?, relative_luminance(b)?);
|
||||||
|
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||||
|
Some((hi + 0.05) / (lo + 0.05))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const STATES: [ActivityState; 4] = [
|
||||||
|
ActivityState::Active,
|
||||||
|
ActivityState::Idle,
|
||||||
|
ActivityState::AwaitingInput,
|
||||||
|
ActivityState::Error,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every preset except `ansi`, whose colours the terminal owns and which
|
||||||
|
/// therefore has no fixed luminance to assert against.
|
||||||
|
fn rgb_presets() -> impl Iterator<Item = (&'static str, Palette)> {
|
||||||
|
Palette::presets()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, p)| relative_luminance(p.card.bg).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
|
||||||
|
/// decoration and only has to clear the 3:1 non-text threshold.
|
||||||
|
#[test]
|
||||||
|
fn every_role_is_legible_on_its_own_surface() {
|
||||||
|
for (preset, p) in rgb_presets() {
|
||||||
|
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
|
||||||
|
for (role, color) in surface.roles() {
|
||||||
|
let ratio = contrast_ratio(surface.bg, color)
|
||||||
|
.unwrap_or_else(|| panic!("{preset}.{surface_name}.{role} must be RGB"));
|
||||||
|
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
||||||
|
assert!(
|
||||||
|
ratio >= floor,
|
||||||
|
"{preset}.{surface_name}.{role} contrast {ratio:.2} is below {floor}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_preset_supplies_every_role() {
|
||||||
|
for (name, p) in Palette::presets() {
|
||||||
|
assert_eq!(p.roles().len(), 27, "{name} role set is incomplete");
|
||||||
|
assert_eq!(Palette::named(name), Some(p));
|
||||||
|
}
|
||||||
|
assert_eq!(Palette::named("unknown"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The selection background exists to be seen as a different surface, not
|
||||||
|
/// merely a slightly different shade of the card.
|
||||||
|
#[test]
|
||||||
|
fn selection_background_is_distinguishable_from_the_card_background() {
|
||||||
|
for (name, p) in rgb_presets() {
|
||||||
|
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
|
||||||
|
assert!(
|
||||||
|
ratio >= 1.6,
|
||||||
|
"{name} card and selection backgrounds differ by only {ratio:.2}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug this palette fixes: a state colour chosen for the dark card
|
||||||
|
/// background is not legible on the selection background, so the
|
||||||
|
/// selection surface carries its own — distinct — state colours.
|
||||||
|
#[test]
|
||||||
|
fn selection_state_colours_are_not_the_card_state_colours() {
|
||||||
|
for (name, p) in Palette::presets() {
|
||||||
|
for state in STATES {
|
||||||
|
assert_ne!(
|
||||||
|
p.card.activity(state),
|
||||||
|
p.selection.activity(state),
|
||||||
|
"{name} {state:?} must have a selection-specific colour"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Other panels' roles are RGB too, so a preset swap moves all of them.
|
||||||
|
/// Foregrounds are checked against the chrome background they are
|
||||||
|
/// actually painted on; they must never rely on the terminal default.
|
||||||
|
#[test]
|
||||||
|
fn chrome_roles_are_rgb_values() {
|
||||||
|
for (preset, p) in rgb_presets() {
|
||||||
|
for (name, color) in p.roles().into_iter().skip(16) {
|
||||||
|
assert!(
|
||||||
|
relative_luminance(color).is_some(),
|
||||||
|
"{preset}.{name} must be an RGB value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The mode indicator paints fg on bg; both pairs must stay readable.
|
||||||
|
for (name, fg, bg) in [
|
||||||
|
("normal", p.mode_normal_fg, p.mode_normal_bg),
|
||||||
|
("insert", p.mode_insert_fg, p.mode_insert_bg),
|
||||||
|
] {
|
||||||
|
let ratio = contrast_ratio(fg, bg).unwrap();
|
||||||
|
assert!(
|
||||||
|
ratio >= 4.5,
|
||||||
|
"{preset} mode {name} contrast {ratio:.2} is below 4.5"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (name, fg, floor) in [
|
||||||
|
("border_focused", p.border_focused, 3.0),
|
||||||
|
("border_unfocused", p.border_unfocused, 3.0),
|
||||||
|
("focus_accent", p.focus_accent, 3.0),
|
||||||
|
("group_row", p.group_row, 4.5),
|
||||||
|
("error_text", p.error_text, 4.5),
|
||||||
|
] {
|
||||||
|
let ratio = contrast_ratio(fg, p.chrome_bg).unwrap();
|
||||||
|
assert!(
|
||||||
|
ratio >= floor,
|
||||||
|
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (name, fg) in [
|
||||||
|
("card.fg", p.card.fg),
|
||||||
|
("card.muted", p.card.muted),
|
||||||
|
("group_row", p.group_row),
|
||||||
|
] {
|
||||||
|
let ratio = contrast_ratio(fg, p.sidebar_focus_bg).unwrap();
|
||||||
|
assert!(
|
||||||
|
ratio >= 4.5,
|
||||||
|
"{preset}.{name} contrast {ratio:.2} is below 4.5 on sidebar_focus_bg"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contrast_ratio_matches_known_values() {
|
||||||
|
let black = Color::Rgb(0, 0, 0);
|
||||||
|
let white = Color::Rgb(255, 255, 255);
|
||||||
|
assert!((contrast_ratio(black, white).unwrap() - 21.0).abs() < 0.01);
|
||||||
|
assert!((contrast_ratio(white, white).unwrap() - 1.0).abs() < 0.01);
|
||||||
|
assert_eq!(contrast_ratio(Color::Cyan, white), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ impl Pane {
|
|||||||
|
|
||||||
// Place the terminal cursor at vt100's cursor position when we own
|
// Place the terminal cursor at vt100's cursor position when we own
|
||||||
// focus and the cursor is on-screen. Terminals blink it on their own.
|
// focus and the cursor is on-screen. Terminals blink it on their own.
|
||||||
if self.focused && !screen.hide_cursor() {
|
if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() {
|
||||||
let (cr, cc) = screen.cursor_position();
|
let (cr, cc) = screen.cursor_position();
|
||||||
if cr < rows && cc < cols {
|
if cr < rows && cc < cols {
|
||||||
f.set_cursor_position((area.x + cc, area.y + cr));
|
f.set_cursor_position((area.x + cc, area.y + cr));
|
||||||
@@ -106,6 +106,9 @@ fn translate_cell_style(cell: &vt100::Cell) -> Style {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PTY cell colours pass through verbatim: they belong to the program
|
||||||
|
/// running in the pane, not to the TUI's own chrome, so they are deliberately
|
||||||
|
/// not palette roles (see `crate::palette`).
|
||||||
fn translate_color(c: vt100::Color) -> Option<Color> {
|
fn translate_color(c: vt100::Color) -> Option<Color> {
|
||||||
match c {
|
match c {
|
||||||
vt100::Color::Default => None,
|
vt100::Color::Default => None,
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
//! Top-level draw orchestration. Composes sidebar / tab bar / pane / overlays
|
//! Top-level draw orchestration. Composes sidebar / pane / overlays
|
||||||
//! into one frame, and returns a [`RegionMap`] that the input router uses to
|
//! into one frame, and returns a [`RegionMap`] that the input router uses to
|
||||||
//! hit-test mouse clicks.
|
//! hit-test mouse clicks.
|
||||||
|
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::Modifier;
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||||
|
use crate::palette;
|
||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
use crate::tabs::TabBar;
|
|
||||||
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||||
|
|
||||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||||
@@ -22,7 +22,7 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
|||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct RegionMap {
|
pub struct RegionMap {
|
||||||
pub sidebar: Option<Rect>,
|
pub sidebar: Option<Rect>,
|
||||||
pub tab_bar: Option<Rect>,
|
pub sidebar_items: Vec<SidebarItem>,
|
||||||
pub pane: Option<Rect>,
|
pub pane: Option<Rect>,
|
||||||
pub modal: Option<Rect>,
|
pub modal: Option<Rect>,
|
||||||
}
|
}
|
||||||
@@ -34,11 +34,6 @@ impl RegionMap {
|
|||||||
return Region::Modal;
|
return Region::Modal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(r) = self.tab_bar {
|
|
||||||
if inside(r, col, row) {
|
|
||||||
return Region::Tab;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(r) = self.sidebar {
|
if let Some(r) = self.sidebar {
|
||||||
if inside(r, col, row) {
|
if inside(r, col, row) {
|
||||||
return Region::SidebarRow;
|
return Region::SidebarRow;
|
||||||
@@ -51,6 +46,24 @@ impl RegionMap {
|
|||||||
}
|
}
|
||||||
Region::Unknown
|
Region::Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a mouse row against the rendered sidebar sequence. The title
|
||||||
|
/// occupies one inner row; groups occupy one row and visible cards two.
|
||||||
|
pub fn sidebar_item_at(&self, row: u16) -> Option<SidebarItem> {
|
||||||
|
let area = self.sidebar?;
|
||||||
|
let mut cursor = area.y.saturating_add(1);
|
||||||
|
for item in &self.sidebar_items {
|
||||||
|
let height = match item {
|
||||||
|
SidebarItem::Group(_) => 1,
|
||||||
|
SidebarItem::Session(_) => 2,
|
||||||
|
};
|
||||||
|
if row >= cursor && row < cursor.saturating_add(height) {
|
||||||
|
return Some(item.clone());
|
||||||
|
}
|
||||||
|
cursor = cursor.saturating_add(height);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inside(r: Rect, col: u16, row: u16) -> bool {
|
fn inside(r: Rect, col: u16, row: u16) -> bool {
|
||||||
@@ -62,9 +75,10 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RenderInputs<'a> {
|
pub struct RenderInputs<'a> {
|
||||||
pub sidebar: &'a Sidebar,
|
pub sidebar: &'a Sidebar,
|
||||||
pub tab_bar: &'a TabBar,
|
pub home_dir: &'a str,
|
||||||
|
pub pane_title: &'a str,
|
||||||
pub pane: Option<&'a Pane>,
|
pub pane: Option<&'a Pane>,
|
||||||
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
/// Inline spawn form when the pane is a draft (issue #63).
|
||||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
@@ -101,43 +115,36 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
let right = cols[1];
|
let right = cols[1];
|
||||||
|
|
||||||
if inputs.state.sidebar_visible {
|
if inputs.state.sidebar_visible {
|
||||||
inputs
|
inputs.sidebar.render_with_focus_and_collapsed_home(
|
||||||
.sidebar
|
f,
|
||||||
.render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
|
sidebar_area,
|
||||||
|
inputs.state.focus == Focus::Sidebar,
|
||||||
|
&inputs.state.collapsed_sidebar_groups,
|
||||||
|
inputs.home_dir,
|
||||||
|
);
|
||||||
map.sidebar = Some(sidebar_area);
|
map.sidebar = Some(sidebar_area);
|
||||||
|
map.sidebar_items = inputs
|
||||||
|
.sidebar
|
||||||
|
.rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1-row tab bar + 1-row horizontal rule (doubles as the pane's focus
|
|
||||||
// indicator) + flex pane area below. Saves the 4 rows the old
|
|
||||||
// `Length(3) + Borders::ALL` layout was eating before any content showed.
|
|
||||||
let rows = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Min(0),
|
|
||||||
])
|
|
||||||
.split(right);
|
.split(right);
|
||||||
let tab_area = rows[0];
|
let title_area = rows[0];
|
||||||
let separator_area = rows[1];
|
let pane_area = rows[1];
|
||||||
let pane_area = rows[2];
|
|
||||||
|
|
||||||
inputs.tab_bar.render(f, tab_area);
|
|
||||||
map.tab_bar = Some(tab_area);
|
|
||||||
|
|
||||||
let pane_focused = inputs.state.focus == Focus::Pane;
|
let pane_focused = inputs.state.focus == Focus::Pane;
|
||||||
f.render_widget(
|
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||||
Block::default()
|
|
||||||
.borders(Borders::TOP)
|
|
||||||
.border_style(focus_style(pane_focused)),
|
|
||||||
separator_area,
|
|
||||||
);
|
|
||||||
if let Some(p) = inputs.pane {
|
if let Some(p) = inputs.pane {
|
||||||
p.render(f, pane_area);
|
p.render(f, pane_area);
|
||||||
}
|
}
|
||||||
if let Some(d) = inputs.draft {
|
if let Some(d) = inputs.draft {
|
||||||
d.render(f, pane_area, pane_focused);
|
d.render(f, pane_area, pane_focused);
|
||||||
}
|
}
|
||||||
|
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||||
|
// chrome; every remaining cell belongs to the emulator.
|
||||||
map.pane = Some(pane_area);
|
map.pane = Some(pane_area);
|
||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
@@ -193,14 +200,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Border style for a panel based on whether it currently has focus.
|
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
||||||
/// Focused = cyan; unfocused = the terminal default.
|
let palette = palette::active();
|
||||||
fn focus_style(focused: bool) -> Style {
|
let style = if focused {
|
||||||
if focused {
|
palette.mode_style(false)
|
||||||
Style::default().fg(Color::Cyan)
|
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
palette.card.text_style()
|
||||||
}
|
}
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
f.render_widget(Paragraph::new(format!(" {title}")).style(style), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||||
@@ -217,12 +225,9 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
};
|
};
|
||||||
let hint = match state.mode {
|
let hint = match state.mode {
|
||||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||||
Mode::Insert => "^a: back to normal",
|
Mode::Insert => "^a: return to sidebar",
|
||||||
};
|
|
||||||
let mode_style = match state.mode {
|
|
||||||
Mode::Normal => Style::default().fg(Color::Black).bg(Color::Cyan),
|
|
||||||
Mode::Insert => Style::default().fg(Color::Black).bg(Color::Yellow),
|
|
||||||
};
|
};
|
||||||
|
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||||
let line = ratatui::text::Line::from(vec![
|
let line = ratatui::text::Line::from(vec![
|
||||||
Span::styled(format!(" {mode_label} "), mode_style),
|
Span::styled(format!(" {mode_label} "), mode_style),
|
||||||
Span::raw(format!(" {focus_label} • {hint}")),
|
Span::raw(format!(" {focus_label} • {hint}")),
|
||||||
@@ -244,22 +249,16 @@ mod tests {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 20,
|
height: 20,
|
||||||
}),
|
}),
|
||||||
tab_bar: Some(Rect {
|
sidebar_items: Vec::new(),
|
||||||
|
pane: Some(Rect {
|
||||||
x: 10,
|
x: 10,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 3,
|
height: 20,
|
||||||
}),
|
|
||||||
pane: Some(Rect {
|
|
||||||
x: 10,
|
|
||||||
y: 3,
|
|
||||||
width: 30,
|
|
||||||
height: 17,
|
|
||||||
}),
|
}),
|
||||||
modal: None,
|
modal: None,
|
||||||
};
|
};
|
||||||
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
|
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
|
||||||
assert_eq!(map.region_at(15, 1), Region::Tab);
|
|
||||||
assert_eq!(map.region_at(15, 5), Region::Pane);
|
assert_eq!(map.region_at(15, 5), Region::Pane);
|
||||||
assert_eq!(map.region_at(50, 50), Region::Unknown);
|
assert_eq!(map.region_at(50, 50), Region::Unknown);
|
||||||
}
|
}
|
||||||
@@ -273,17 +272,12 @@ mod tests {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 20,
|
height: 20,
|
||||||
}),
|
}),
|
||||||
tab_bar: Some(Rect {
|
sidebar_items: Vec::new(),
|
||||||
|
pane: Some(Rect {
|
||||||
x: 10,
|
x: 10,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 3,
|
height: 20,
|
||||||
}),
|
|
||||||
pane: Some(Rect {
|
|
||||||
x: 10,
|
|
||||||
y: 3,
|
|
||||||
width: 30,
|
|
||||||
height: 17,
|
|
||||||
}),
|
}),
|
||||||
modal: Some(Rect {
|
modal: Some(Rect {
|
||||||
x: 5,
|
x: 5,
|
||||||
@@ -297,6 +291,40 @@ mod tests {
|
|||||||
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidebar_mouse_rows_follow_group_and_two_line_card_heights() {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
let group_a = SidebarItem::Group(PathBuf::from("/a"));
|
||||||
|
let card_a = SidebarItem::Session(Uuid::from_u128(1));
|
||||||
|
let group_b = SidebarItem::Group(PathBuf::from("/b"));
|
||||||
|
let card_b = SidebarItem::Session(Uuid::from_u128(2));
|
||||||
|
let map = RegionMap {
|
||||||
|
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||||
|
sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b],
|
||||||
|
..RegionMap::default()
|
||||||
|
};
|
||||||
|
assert_eq!(map.sidebar_item_at(1), Some(group_a));
|
||||||
|
assert_eq!(map.sidebar_item_at(2), Some(card_a.clone()));
|
||||||
|
assert_eq!(map.sidebar_item_at(3), Some(card_a));
|
||||||
|
assert_eq!(map.sidebar_item_at(4), Some(group_b));
|
||||||
|
|
||||||
|
let collapsed = RegionMap {
|
||||||
|
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||||
|
sidebar_items: vec![
|
||||||
|
SidebarItem::Group(PathBuf::from("/a")),
|
||||||
|
SidebarItem::Group(PathBuf::from("/b")),
|
||||||
|
SidebarItem::Session(Uuid::from_u128(2)),
|
||||||
|
],
|
||||||
|
..RegionMap::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
collapsed.sidebar_item_at(2),
|
||||||
|
Some(SidebarItem::Group(PathBuf::from("/b")))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||||
/// form where the terminal would otherwise be.
|
/// form where the terminal would otherwise be.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -316,14 +344,11 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: HashSet::new(),
|
attached: HashSet::new(),
|
||||||
};
|
};
|
||||||
let tab_bar = TabBar {
|
|
||||||
tab_names: vec!["1".into()],
|
|
||||||
active_idx: 0,
|
|
||||||
};
|
|
||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
tab_bar: &tab_bar,
|
home_dir: "",
|
||||||
|
pane_title: "new session",
|
||||||
pane: None,
|
pane: None,
|
||||||
draft: Some(draft),
|
draft: Some(draft),
|
||||||
state: &state,
|
state: &state,
|
||||||
@@ -347,7 +372,7 @@ mod tests {
|
|||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
for s in [
|
for s in [
|
||||||
"draft",
|
"new session",
|
||||||
"cmd:",
|
"cmd:",
|
||||||
"dir:",
|
"dir:",
|
||||||
"name:",
|
"name:",
|
||||||
@@ -357,4 +382,89 @@ mod tests {
|
|||||||
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_rectangles_are_stable_when_focus_moves() {
|
||||||
|
use crate::overlays::HelpOverlay;
|
||||||
|
use crate::sidebar::Sidebar;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
};
|
||||||
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
|
let draw_map = |focus| {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = focus;
|
||||||
|
let inputs = RenderInputs {
|
||||||
|
sidebar: &sidebar,
|
||||||
|
home_dir: "",
|
||||||
|
pane_title: "session",
|
||||||
|
pane: None,
|
||||||
|
draft: None,
|
||||||
|
state: &state,
|
||||||
|
help: &help,
|
||||||
|
worktrees: None,
|
||||||
|
};
|
||||||
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
let mut map = None;
|
||||||
|
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
|
||||||
|
map.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sidebar_focus = draw_map(Focus::Sidebar);
|
||||||
|
let pane_focus = draw_map(Focus::Pane);
|
||||||
|
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
|
||||||
|
assert_eq!(sidebar_focus.pane, pane_focus.pane);
|
||||||
|
assert_eq!(sidebar_focus.pane, Some(Rect::new(28, 1, 52, 22)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_focus_uses_backgrounds_without_borders() {
|
||||||
|
use crate::overlays::HelpOverlay;
|
||||||
|
use crate::sidebar::Sidebar;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
};
|
||||||
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
|
let render = |focus| {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = focus;
|
||||||
|
let inputs = RenderInputs {
|
||||||
|
sidebar: &sidebar,
|
||||||
|
home_dir: "",
|
||||||
|
pane_title: "session",
|
||||||
|
pane: None,
|
||||||
|
draft: None,
|
||||||
|
state: &state,
|
||||||
|
help: &help,
|
||||||
|
worktrees: None,
|
||||||
|
};
|
||||||
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|f| {
|
||||||
|
draw(f, inputs);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
terminal.backend().buffer().clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sidebar_focused = render(Focus::Sidebar);
|
||||||
|
let pane_focused = render(Focus::Pane);
|
||||||
|
let p = palette::active();
|
||||||
|
assert_eq!(sidebar_focused[(0, 0)].style().bg, Some(p.sidebar_focus_bg));
|
||||||
|
assert_eq!(pane_focused[(0, 0)].style().bg, Some(p.chrome_bg));
|
||||||
|
assert_eq!(sidebar_focused[(28, 0)].style().bg, Some(p.card.bg));
|
||||||
|
assert_eq!(pane_focused[(28, 0)].style().bg, Some(p.mode_normal_bg));
|
||||||
|
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
|
||||||
|
assert!(title.contains("session"), "missing title: {title:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ use maestro_protocol::ClientControl;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
/// Match the browser terminal's retained history without letting long-running
|
||||||
|
/// sessions grow the client without bound.
|
||||||
|
pub const SCROLLBACK_ROWS: usize = 5_000;
|
||||||
|
|
||||||
/// Input the dispatcher (or the resize tracker) pushes to a session task.
|
/// Input the dispatcher (or the resize tracker) pushes to a session task.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum SessionInput {
|
pub enum SessionInput {
|
||||||
@@ -103,7 +107,7 @@ impl SessionTask {
|
|||||||
/// up with `SessionInput::Resize` on every frame whose allocation changes.
|
/// up with `SessionInput::Resize` on every frame whose allocation changes.
|
||||||
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
|
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
|
||||||
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
||||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
|
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
|
||||||
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
||||||
|
|
||||||
let parser_bg = parser.clone();
|
let parser_bg = parser.clone();
|
||||||
|
|||||||
+1103
-89
File diff suppressed because it is too large
Load Diff
+74
-182
@@ -1,13 +1,15 @@
|
|||||||
//! `AppState` — the top-level mutable state of the running TUI.
|
//! `AppState` — the top-level mutable state of the running TUI.
|
||||||
//!
|
//!
|
||||||
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
//! v1 keeps it small on purpose: one pane containing a session or draft form,
|
||||||
//! session or a draft form), an active tab index, focus split between sidebar
|
//! focus split between sidebar and pane, the current input mode, and an optional overlay (kill confirm,
|
||||||
//! and pane, the current input mode, and an optional overlay (kill confirm,
|
|
||||||
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
||||||
//! pane is a draft pane (issue #63).
|
//! pane is a draft pane (issue #63).
|
||||||
//!
|
//!
|
||||||
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::draft::DraftPane;
|
use crate::draft::DraftPane;
|
||||||
@@ -19,8 +21,18 @@ pub enum Focus {
|
|||||||
Pane,
|
Pane,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One selectable item in the rendered sidebar sequence.
|
||||||
|
///
|
||||||
|
/// Group paths, unlike a session's position in the catalog, survive catalog
|
||||||
|
/// redraws and make a highlighted collapsed group unambiguous.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum SidebarItem {
|
||||||
|
Group(PathBuf),
|
||||||
|
Session(Uuid),
|
||||||
|
}
|
||||||
|
|
||||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||||
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
// The draft variant dwarfs `Session(Uuid)`; the pane is moved rarely enough
|
||||||
// that boxing every access costs more than the copy does.
|
// that boxing every access costs more than the copy does.
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -70,7 +82,10 @@ pub struct AppState {
|
|||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub sidebar_visible: bool,
|
pub sidebar_visible: bool,
|
||||||
pub sidebar_highlight: Option<Uuid>,
|
pub sidebar_highlight: Option<SidebarItem>,
|
||||||
|
/// Group paths whose cards are hidden. This is UI state rather than
|
||||||
|
/// catalog state, so it survives every catalog redraw.
|
||||||
|
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
||||||
/// Private: every transition goes through [`AppState::show_overlay`],
|
/// Private: every transition goes through [`AppState::show_overlay`],
|
||||||
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
|
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
|
||||||
/// from four call sites is what left a toast on screen with no way to
|
/// from four call sites is what left a toast on screen with no way to
|
||||||
@@ -96,6 +111,7 @@ impl AppState {
|
|||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
sidebar_visible: true,
|
sidebar_visible: true,
|
||||||
sidebar_highlight: None,
|
sidebar_highlight: None,
|
||||||
|
collapsed_sidebar_groups: HashSet::new(),
|
||||||
overlay: None,
|
overlay: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,57 +142,6 @@ impl AppState {
|
|||||||
&mut self.tabs[self.active_tab_idx]
|
&mut self.tabs[self.active_tab_idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a new tab and switch to it. Sidebar regains focus so the user
|
|
||||||
/// can pick something to attach.
|
|
||||||
pub fn new_tab(&mut self) {
|
|
||||||
let name = format!("{}", self.tabs.len() + 1);
|
|
||||||
self.tabs.push(Tab { name, pane: None });
|
|
||||||
self.active_tab_idx = self.tabs.len() - 1;
|
|
||||||
self.focus = Focus::Sidebar;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop the active tab. No-op if it's the only one — there must always
|
|
||||||
/// be at least one tab.
|
|
||||||
///
|
|
||||||
/// Returns the session that tab was showing, if any, so the caller can
|
|
||||||
/// release its emulator. Without that the tab's WebSocket task and vt100
|
|
||||||
/// parser outlive the tab forever. A draft pane holds no session and
|
|
||||||
/// releases nothing.
|
|
||||||
pub fn close_tab(&mut self) -> Option<Uuid> {
|
|
||||||
if self.tabs.len() <= 1 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let closed = self.tabs.remove(self.active_tab_idx);
|
|
||||||
if self.active_tab_idx >= self.tabs.len() {
|
|
||||||
self.active_tab_idx = self.tabs.len() - 1;
|
|
||||||
}
|
|
||||||
let sid = closed.session()?;
|
|
||||||
// Only release it if no other tab still shows the same session.
|
|
||||||
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
|
|
||||||
(!still_shown).then_some(sid)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_tab(&mut self) {
|
|
||||||
if self.tabs.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_tab(&mut self) {
|
|
||||||
if self.tabs.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn goto_tab(&mut self, n: u8) {
|
|
||||||
let idx = (n as usize)
|
|
||||||
.saturating_sub(1)
|
|
||||||
.min(self.tabs.len().saturating_sub(1));
|
|
||||||
self.active_tab_idx = idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind a session to the active tab, move focus into the pane, and
|
/// Bind a session to the active tab, move focus into the pane, and
|
||||||
/// drop straight into Insert mode so keystrokes go to the session
|
/// drop straight into Insert mode so keystrokes go to the session
|
||||||
/// without an extra `i` press. The leader returns to Normal.
|
/// without an extra `i` press. The leader returns to Normal.
|
||||||
@@ -186,14 +151,10 @@ impl AppState {
|
|||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
|
/// Replace the pane with a draft form. Focus lands in the pane, Insert
|
||||||
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
|
/// mode on, so typing starts immediately, same flow as
|
||||||
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
|
/// [`Self::attach_to_focused`].
|
||||||
/// starts immediately, same flow as [`Self::attach_to_focused`].
|
|
||||||
pub fn open_draft_pane(&mut self) {
|
pub fn open_draft_pane(&mut self) {
|
||||||
if self.active_tab().pane.is_some() {
|
|
||||||
self.new_tab();
|
|
||||||
}
|
|
||||||
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
||||||
self.focus = Focus::Pane;
|
self.focus = Focus::Pane;
|
||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
@@ -213,19 +174,32 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
/// rendered item sequence. Wraps. No-op on empty.
|
||||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
|
||||||
if order.is_empty() {
|
if order.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let len = order.len() as i32;
|
let len = order.len() as i32;
|
||||||
let cur = self
|
let cur = self
|
||||||
.sidebar_highlight
|
.sidebar_highlight
|
||||||
.and_then(|id| order.iter().position(|x| *x == id))
|
.as_ref()
|
||||||
|
.and_then(|id| order.iter().position(|x| x == id))
|
||||||
.map(|i| i as i32)
|
.map(|i| i as i32)
|
||||||
.unwrap_or(-1);
|
.unwrap_or(-1);
|
||||||
let next = (cur + delta).rem_euclid(len) as usize;
|
let next = (cur + delta).rem_euclid(len) as usize;
|
||||||
self.sidebar_highlight = Some(order[next]);
|
self.sidebar_highlight = Some(order[next].clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a group. When collapsing a group containing the current card,
|
||||||
|
/// leave the highlight on the group row that remains visible.
|
||||||
|
pub fn toggle_sidebar_group(&mut self, key: PathBuf, contains_highlight: bool) {
|
||||||
|
if self.collapsed_sidebar_groups.remove(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.collapsed_sidebar_groups.insert(key.clone());
|
||||||
|
if contains_highlight {
|
||||||
|
self.sidebar_highlight = Some(SidebarItem::Group(key));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle sidebar visibility.
|
/// Toggle sidebar visibility.
|
||||||
@@ -252,88 +226,36 @@ mod tests {
|
|||||||
assert!(state.overlay.is_none());
|
assert!(state.overlay.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_tab_extends_and_focuses() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.new_tab();
|
|
||||||
assert_eq!(state.tabs.len(), 2);
|
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
|
||||||
assert_eq!(state.focus, Focus::Sidebar);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_tab_keeps_at_least_one() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.close_tab();
|
|
||||||
assert_eq!(state.tabs.len(), 1);
|
|
||||||
state.new_tab();
|
|
||||||
state.close_tab();
|
|
||||||
assert_eq!(state.tabs.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_tab_advances_when_last_is_closed() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.new_tab();
|
|
||||||
state.new_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 2);
|
|
||||||
state.close_tab();
|
|
||||||
assert_eq!(state.tabs.len(), 2);
|
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_prev_tab_wraps() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.new_tab();
|
|
||||||
state.new_tab();
|
|
||||||
state.active_tab_idx = 0;
|
|
||||||
state.next_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
|
||||||
state.next_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 2);
|
|
||||||
state.next_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 0);
|
|
||||||
state.prev_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn goto_tab_uses_one_based_clamp() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.new_tab();
|
|
||||||
state.new_tab();
|
|
||||||
state.goto_tab(1);
|
|
||||||
assert_eq!(state.active_tab_idx, 0);
|
|
||||||
state.goto_tab(2);
|
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
|
||||||
state.goto_tab(99);
|
|
||||||
// Clamped to last available tab.
|
|
||||||
assert_eq!(state.active_tab_idx, state.tabs.len() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_sidebar_highlight_walks_order_and_wraps() {
|
fn move_sidebar_highlight_walks_order_and_wraps() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
let a = Uuid::from_u128(1);
|
let a = Uuid::from_u128(1);
|
||||||
let b = Uuid::from_u128(2);
|
let b = Uuid::from_u128(2);
|
||||||
let c = Uuid::from_u128(3);
|
let c = Uuid::from_u128(3);
|
||||||
let order = vec![a, b, c];
|
let order = vec![
|
||||||
|
SidebarItem::Session(a),
|
||||||
|
SidebarItem::Group("repo".into()),
|
||||||
|
SidebarItem::Session(b),
|
||||||
|
SidebarItem::Session(c),
|
||||||
|
];
|
||||||
|
|
||||||
// No prior highlight + delta +1 lands on first item.
|
// No prior highlight + delta +1 lands on first item.
|
||||||
state.move_sidebar_highlight(&order, 1);
|
state.move_sidebar_highlight(&order, 1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(a));
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||||
|
|
||||||
state.move_sidebar_highlight(&order, 1);
|
state.move_sidebar_highlight(&order, 1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(b));
|
assert_eq!(
|
||||||
|
state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group("repo".into()))
|
||||||
|
);
|
||||||
state.move_sidebar_highlight(&order, 1);
|
state.move_sidebar_highlight(&order, 1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(c));
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||||
// Wraps forward.
|
// Wraps forward.
|
||||||
state.move_sidebar_highlight(&order, 1);
|
state.move_sidebar_highlight(&order, 1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(a));
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
|
||||||
// Wraps backward.
|
// Wraps backward.
|
||||||
state.move_sidebar_highlight(&order, -1);
|
state.move_sidebar_highlight(&order, -1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(c));
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -343,6 +265,22 @@ mod tests {
|
|||||||
assert_eq!(state.sidebar_highlight, None);
|
assert_eq!(state.sidebar_highlight, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsing_highlighted_group_moves_a_card_highlight_to_the_group() {
|
||||||
|
let key = PathBuf::from("/repo");
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.sidebar_highlight = Some(SidebarItem::Session(Uuid::from_u128(9)));
|
||||||
|
state.toggle_sidebar_group(key.clone(), true);
|
||||||
|
assert_eq!(
|
||||||
|
state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group(key.clone()))
|
||||||
|
);
|
||||||
|
assert!(state.collapsed_sidebar_groups.contains(&key));
|
||||||
|
state.toggle_sidebar_group(key.clone(), false);
|
||||||
|
assert!(!state.collapsed_sidebar_groups.contains(&key));
|
||||||
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Group(key)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_binds_focuses_pane_and_enters_insert_mode() {
|
fn attach_binds_focuses_pane_and_enters_insert_mode() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
@@ -367,38 +305,6 @@ mod tests {
|
|||||||
assert_eq!(state.focus, Focus::Pane);
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The emulator leak: a closed tab's session must come back so the caller
|
|
||||||
/// can abort its WebSocket task.
|
|
||||||
#[test]
|
|
||||||
fn close_tab_reports_the_session_it_dropped() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
let sid = Uuid::from_u128(3);
|
|
||||||
state.new_tab();
|
|
||||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
||||||
|
|
||||||
assert_eq!(state.close_tab(), Some(sid));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ...but not while another tab still shows it.
|
|
||||||
#[test]
|
|
||||||
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
let sid = Uuid::from_u128(4);
|
|
||||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
||||||
state.new_tab();
|
|
||||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
||||||
|
|
||||||
assert_eq!(state.close_tab(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_tab_is_a_no_op_on_the_last_tab() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
|
|
||||||
assert_eq!(state.close_tab(), None);
|
|
||||||
assert_eq!(state.tabs.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dismiss_overlay_reports_whether_one_was_showing() {
|
fn dismiss_overlay_reports_whether_one_was_showing() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
@@ -420,18 +326,14 @@ mod tests {
|
|||||||
assert_eq!(state.tabs.len(), 1);
|
assert_eq!(state.tabs.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tab already showing a session gets a fresh tab for the draft; v1
|
|
||||||
/// has no split tree, so a new tab is the "split".
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_draft_over_a_session_opens_a_new_tab() {
|
fn open_draft_over_a_session_reuses_the_pane() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
state.attach_to_focused(Uuid::from_u128(9));
|
state.attach_to_focused(Uuid::from_u128(9));
|
||||||
state.open_draft_pane();
|
state.open_draft_pane();
|
||||||
assert_eq!(state.tabs.len(), 2);
|
assert_eq!(state.tabs.len(), 1);
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
assert_eq!(state.active_tab_idx, 0);
|
||||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||||
// The session tab is untouched.
|
|
||||||
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web parity (#57): submitting fills the pane in place.
|
/// Web parity (#57): submitting fills the pane in place.
|
||||||
@@ -445,14 +347,4 @@ mod tests {
|
|||||||
assert_eq!(state.focus, Focus::Pane);
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
assert_eq!(state.mode, Mode::Insert);
|
assert_eq!(state.mode, Mode::Insert);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A closed draft tab holds no session, so nothing is released.
|
|
||||||
#[test]
|
|
||||||
fn close_tab_reports_nothing_for_a_draft_pane() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.open_draft_pane();
|
|
||||||
state.new_tab();
|
|
||||||
state.active_tab_idx = 0;
|
|
||||||
assert_eq!(state.close_tab(), None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
//! Tab-bar widget — top row of the main area.
|
|
||||||
|
|
||||||
use ratatui::layout::Rect;
|
|
||||||
use ratatui::style::{Modifier, Style};
|
|
||||||
use ratatui::text::Line;
|
|
||||||
use ratatui::widgets::Tabs;
|
|
||||||
use ratatui::Frame;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TabBar {
|
|
||||||
pub tab_names: Vec<String>,
|
|
||||||
pub active_idx: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TabBar {
|
|
||||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
|
||||||
let titles: Vec<Line> = self
|
|
||||||
.tab_names
|
|
||||||
.iter()
|
|
||||||
.map(|n| Line::from(n.as_str()))
|
|
||||||
.collect();
|
|
||||||
let tabs = Tabs::new(titles)
|
|
||||||
.select(self.active_idx)
|
|
||||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
|
||||||
f.render_widget(tabs, area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use ratatui::backend::TestBackend;
|
|
||||||
use ratatui::Terminal;
|
|
||||||
|
|
||||||
fn render_to_string(bar: &TabBar, w: u16, h: u16) -> String {
|
|
||||||
let backend = TestBackend::new(w, h);
|
|
||||||
let mut terminal = Terminal::new(backend).unwrap();
|
|
||||||
terminal.draw(|f| bar.render(f, f.area())).unwrap();
|
|
||||||
let buf = terminal.backend().buffer().clone();
|
|
||||||
let mut out = String::new();
|
|
||||||
for y in 0..buf.area.height {
|
|
||||||
for x in 0..buf.area.width {
|
|
||||||
out.push_str(buf[(x, y)].symbol());
|
|
||||||
}
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_three_tab_names() {
|
|
||||||
let bar = TabBar {
|
|
||||||
tab_names: vec!["1".into(), "2".into(), "3".into()],
|
|
||||||
active_idx: 1,
|
|
||||||
};
|
|
||||||
let rendered = render_to_string(&bar, 40, 1);
|
|
||||||
assert!(rendered.contains("1"));
|
|
||||||
assert!(rendered.contains("2"));
|
|
||||||
assert!(rendered.contains("3"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,8 +19,7 @@ use tracing_subscriber::EnvFilter;
|
|||||||
#[command(
|
#[command(
|
||||||
name = "maestro",
|
name = "maestro",
|
||||||
version,
|
version,
|
||||||
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator",
|
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator"
|
||||||
arg_required_else_help = true
|
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Override the daemon HTTP base clients talk to (e.g.
|
/// Override the daemon HTTP base clients talk to (e.g.
|
||||||
@@ -37,8 +36,11 @@ struct Cli {
|
|||||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||||
verbose: u8,
|
verbose: u8,
|
||||||
|
|
||||||
|
/// A missing subcommand defaults to the TUI (#119): a bare `maestro` opens
|
||||||
|
/// the terminal UI. Builds without the `tui` feature have no UI surface,
|
||||||
|
/// so they print help and exit 0 instead.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Option<Command>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
@@ -55,6 +57,9 @@ enum Command {
|
|||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
Kill(maestro_cli::cli::KillArgs),
|
Kill(maestro_cli::cli::KillArgs),
|
||||||
|
/// Install or update the agent activity hook (#106).
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
Hooks(maestro_cli::cli::HooksArgs),
|
||||||
/// Per-host daemon: tmux wrapper + session API.
|
/// Per-host daemon: tmux wrapper + session API.
|
||||||
#[cfg(feature = "daemon")]
|
#[cfg(feature = "daemon")]
|
||||||
Daemon {
|
Daemon {
|
||||||
@@ -86,7 +91,22 @@ fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match cli.command {
|
let command = match cli.command {
|
||||||
|
Some(command) => command,
|
||||||
|
None => {
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
{
|
||||||
|
Command::Tui(maestro_tui::cli::TuiArgs { daemon_url: None })
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "tui"))]
|
||||||
|
{
|
||||||
|
use clap::CommandFactory;
|
||||||
|
let _ = <Cli as CommandFactory>::command().print_help();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match command {
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
Command::List(args) => {
|
Command::List(args) => {
|
||||||
maestro_cli::dispatch(
|
maestro_cli::dispatch(
|
||||||
@@ -123,6 +143,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
Command::Hooks(args) => {
|
||||||
|
maestro_cli::dispatch(
|
||||||
|
maestro_cli::cli::Command::Hooks(args),
|
||||||
|
cli.config,
|
||||||
|
cli.daemon_url,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
#[cfg(feature = "daemon")]
|
#[cfg(feature = "daemon")]
|
||||||
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
|
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
@@ -154,15 +183,19 @@ fn init_tracing(cli: &Cli) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
fn is_cli_subcommand(command: &Command) -> bool {
|
fn is_cli_subcommand(command: &Option<Command>) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
command,
|
command,
|
||||||
Command::List(_) | Command::Spawn(_) | Command::Attach(_) | Command::Kill(_)
|
Some(Command::List(_))
|
||||||
|
| Some(Command::Spawn(_))
|
||||||
|
| Some(Command::Attach(_))
|
||||||
|
| Some(Command::Kill(_))
|
||||||
|
| Some(Command::Hooks(_))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "cli"))]
|
#[cfg(not(feature = "cli"))]
|
||||||
fn is_cli_subcommand(_command: &Command) -> bool {
|
fn is_cli_subcommand(_command: &Option<Command>) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,12 +233,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse argv the way `main` does, returning just the dispatched subcommand.
|
/// Parse argv the way `main` does, returning just the dispatched subcommand.
|
||||||
fn command_of(argv: &[&str]) -> Command {
|
fn command_of(argv: &[&str]) -> Option<Command> {
|
||||||
Cli::try_parse_from(argv)
|
Cli::try_parse_from(argv)
|
||||||
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
|
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
|
||||||
.command
|
.command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_invocation_parses_to_no_subcommand() {
|
||||||
|
// The TUI-vs-help default is applied in `dispatch`; parsing must not
|
||||||
|
// reject an empty argv.
|
||||||
|
assert!(command_of(&["maestro"]).is_none());
|
||||||
|
assert!(command_of(&["maestro", "--verbose"]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
#[test]
|
#[test]
|
||||||
fn cli_subcommands_are_classified_as_cli() {
|
fn cli_subcommands_are_classified_as_cli() {
|
||||||
|
|||||||
@@ -54,11 +54,35 @@ fn version_flag_succeeds() {
|
|||||||
assert!(text.contains("maestro"), "--version output: {text}");
|
assert!(text.contains("maestro"), "--version output: {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A bare `maestro` must never fall back to help on a build that has a TUI:
|
||||||
|
/// the missing subcommand defaults to `tui` (#119). Point at a port with
|
||||||
|
/// nothing listening so the TUI's health probe fails fast instead of blocking
|
||||||
|
/// on a terminal.
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
#[test]
|
#[test]
|
||||||
fn no_args_prints_help_and_fails() {
|
fn bare_invocation_dispatches_to_the_tui() {
|
||||||
// `arg_required_else_help = true` => exit non-zero but show usage.
|
let (ok, text) = run(&["--daemon-url", "http://127.0.0.1:9"]);
|
||||||
|
assert!(
|
||||||
|
!ok,
|
||||||
|
"TUI against a dead daemon should exit non-zero:\n{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("daemon unreachable"),
|
||||||
|
"bare invocation should have run the TUI's daemon probe:\n{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("Usage"),
|
||||||
|
"bare invocation must not print help when a TUI exists:\n{text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds without the `tui` feature keep the historical behaviour: no args =>
|
||||||
|
/// help, exit 0 (#119).
|
||||||
|
#[cfg(not(feature = "tui"))]
|
||||||
|
#[test]
|
||||||
|
fn bare_invocation_prints_help_and_exits_zero() {
|
||||||
let (ok, text) = run(&[]);
|
let (ok, text) = run(&[]);
|
||||||
assert!(!ok, "bare invocation should fail:\n{text}");
|
assert!(ok, "bare invocation should exit 0:\n{text}");
|
||||||
assert!(text.contains("Usage"), "expected usage text:\n{text}");
|
assert!(text.contains("Usage"), "expected usage text:\n{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-4
@@ -119,6 +119,9 @@ The wizard always installs the default-feature build.
|
|||||||
|
|
||||||
`maestro tui` is the keyboard-and-mouse driven terminal UI client. It ships
|
`maestro tui` is the keyboard-and-mouse driven terminal UI client. It ships
|
||||||
in the default feature set, so a vanilla `cargo install maestro` builds it.
|
in the default feature set, so a vanilla `cargo install maestro` builds it.
|
||||||
|
A bare `maestro` opens the TUI too — the missing subcommand defaults to `tui`
|
||||||
|
(#119), so `maestro --daemon-url http://host:7070` works as well. Builds
|
||||||
|
without the `tui` feature have no UI to open and print help instead (exit 0).
|
||||||
Drop the `tui` feature to skip it on hosts that don't need the UI surface
|
Drop the `tui` feature to skip it on hosts that don't need the UI surface
|
||||||
(`--no-default-features --features cli,daemon`).
|
(`--no-default-features --features cli,daemon`).
|
||||||
|
|
||||||
@@ -131,24 +134,46 @@ cover every v1 action — only override what you want to change:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[tui]
|
[tui]
|
||||||
|
theme = "dark" # see the theme list below
|
||||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
leader = "ctrl-a" # one byte that escapes Insert mode
|
||||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||||
|
|
||||||
[tui.keymap.normal]
|
[tui.keymap.normal]
|
||||||
"i" = "EnterInsert"
|
"i" = "EnterInsert"
|
||||||
"s" = "OpenSpawnModal"
|
"s" = "NewDraftPane"
|
||||||
"x" = "KillHighlighted"
|
"x" = "KillHighlighted"
|
||||||
"?" = "ShowHelp"
|
"?" = "ShowHelp"
|
||||||
"ctrl-t" = "NewTab"
|
|
||||||
"g t" = "NextTab"
|
|
||||||
"ctrl-w h" = "FocusSidebar"
|
"ctrl-w h" = "FocusSidebar"
|
||||||
"enter" = "AttachHighlighted"
|
"enter" = "AttachHighlighted"
|
||||||
|
"pageup" = "ScrollPanePageUp"
|
||||||
|
"pagedown" = "ScrollPanePageDown"
|
||||||
|
|
||||||
[tui.keymap.insert]
|
[tui.keymap.insert]
|
||||||
# (leader is added automatically as LeaveInsert; only override here when you
|
# (leader is added automatically as LeaveInsert; only override here when you
|
||||||
# want extra Insert-mode bindings)
|
# want extra Insert-mode bindings)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`theme` selects the complete TUI palette. `dark` is the default and follows
|
||||||
|
Maestro's terminal-native dark product palette; `light` is its light peer;
|
||||||
|
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
|
||||||
|
The remaining presets adapt well-known editor themes, with individual colours
|
||||||
|
lifted or darkened where the originals fall short of the WCAG AA contrast
|
||||||
|
floors the palette tests assert:
|
||||||
|
|
||||||
|
| name | based on |
|
||||||
|
|-------------------|------------------|
|
||||||
|
| `solarized-dark` | Solarized Dark |
|
||||||
|
| `solarized-light` | Solarized Light |
|
||||||
|
| `catppuccin` | Catppuccin Mocha |
|
||||||
|
| `github-dark` | GitHub Dark |
|
||||||
|
| `github-light` | GitHub Light |
|
||||||
|
| `tokyo-night` | Tokyo Night |
|
||||||
|
| `gruvbox` | Gruvbox Dark |
|
||||||
|
| `nord` | Nord |
|
||||||
|
| `dracula` | Dracula |
|
||||||
|
|
||||||
|
An unknown name is rejected as an `[tui].theme` configuration error.
|
||||||
|
|
||||||
The full action vocabulary is the `Action` enum in
|
The full action vocabulary is the `Action` enum in
|
||||||
`crates/maestro-tui/src/action.rs`.
|
`crates/maestro-tui/src/action.rs`.
|
||||||
|
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
# SIDEBAR.md — the session card contract
|
||||||
|
|
||||||
|
One rule for how a session is rendered as a card in the sidebar, so the web
|
||||||
|
sidebar (#107) and the TUI sidebar (#108) describe a session the same way.
|
||||||
|
Read this before changing either. Both clients implement exactly what is
|
||||||
|
written here; if it needs to change, change it here first.
|
||||||
|
|
||||||
|
A card is always two lines: **line 1** and **line 2**.
|
||||||
|
|
||||||
|
## Line 1 — name
|
||||||
|
|
||||||
|
- Left: the session name — `metadata.name` if present, else the command line
|
||||||
|
(same fallback both clients already use).
|
||||||
|
- In the web client, **spawn similar** (`⧉`) and **kill** (`✕`) remain buttons
|
||||||
|
anchored on the right. The TUI omits those glyphs because its actions are
|
||||||
|
keyboard-driven and the icons do not identify their keys.
|
||||||
|
- The name truncates with `…` at the available width. In the web client it
|
||||||
|
must stop before the action buttons; in the TUI it may use the full row.
|
||||||
|
|
||||||
|
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
|
||||||
|
badge move off this row into line 2.
|
||||||
|
|
||||||
|
## Line 2 — meta line (adaptive)
|
||||||
|
|
||||||
|
Line 2 always renders — never an empty placeholder row either, since its
|
||||||
|
unconditional fragments (see below) always have something to show. Its
|
||||||
|
*fragments* are adaptive: some show only when their trigger holds. The line
|
||||||
|
itself is not.
|
||||||
|
|
||||||
|
Line 2 reads left to right:
|
||||||
|
|
||||||
|
```
|
||||||
|
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
Its minimum content is therefore the dot and the command.
|
||||||
|
|
||||||
|
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
||||||
|
state's colour, and `<duration>` follows in the same colour when shown. There
|
||||||
|
is no state word and no second badge: the dot exists so the state is still
|
||||||
|
legible when a row is dimmed or the terminal renders the duration in the
|
||||||
|
default fg. The dot is an unconditional part of line 2; the bracketed
|
||||||
|
fragments appear only when their trigger holds.
|
||||||
|
|
||||||
|
`<duration>` renders only once the session has been held in its current
|
||||||
|
activity state longer than **30 seconds** (`activity_since` from #102) — under
|
||||||
|
that threshold the duration is absent and the dot renders alone. A payload
|
||||||
|
that predates #102 (no usable `activity_since`) likewise contributes no
|
||||||
|
duration — the dot still renders. This rule now governs only the duration
|
||||||
|
fragment; it no longer decides whether line 2 exists.
|
||||||
|
|
||||||
|
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||||
|
`Error`. It sits right after the duration, in the muted style, and shows only
|
||||||
|
while an agent-reported state is in force (see Precedence): when the report
|
||||||
|
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||||
|
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||||
|
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||||
|
client ever lets it wrap.
|
||||||
|
|
||||||
|
`<command>` is unconditional and muted: the session's command line, shown on
|
||||||
|
every card in both clients — no omission in the TUI, no name-vs-command
|
||||||
|
comparison gating it. A session named by its command line then shows the same
|
||||||
|
string on both lines; that is accepted, the price of a row that never changes
|
||||||
|
height.
|
||||||
|
|
||||||
|
## Colour per activity state
|
||||||
|
|
||||||
|
The state word is never spelled out — the colour carries it.
|
||||||
|
|
||||||
|
| Activity state | Web token | TUI palette role |
|
||||||
|
| ---------------- | --------- | ---------------- |
|
||||||
|
| `Active` | `--live` | `active` |
|
||||||
|
| `Idle` | `--muted` | `idle` |
|
||||||
|
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||||
|
| `Error` | `--danger`| `error` |
|
||||||
|
|
||||||
|
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
|
||||||
|
and `prefers-color-scheme` variants for each). The TUI names roles, not
|
||||||
|
colours: a role's value comes from the active palette
|
||||||
|
(`maestro-tui/src/palette.rs`), never from a colour spelled out at the use
|
||||||
|
site.
|
||||||
|
|
||||||
|
A palette holds one role set per **surface** — the card surface every card
|
||||||
|
sits on, and the selection surface the highlighted card swaps in — so each
|
||||||
|
state role resolves to the value picked for the surface it is painted on. The
|
||||||
|
highlighted card therefore takes its state colours from the selection
|
||||||
|
surface; a colour chosen for the card background is never painted onto the
|
||||||
|
selection background. Contrast is a property of the palette (values are RGB)
|
||||||
|
and is asserted in the palette's tests, not left to the terminal theme.
|
||||||
|
|
||||||
|
The duration text inherits its state's colour; the blocked-work, exited and
|
||||||
|
command fragments use the surface's `muted` role, since the ⚠ glyph already
|
||||||
|
signals severity.
|
||||||
|
|
||||||
|
## Duration format
|
||||||
|
|
||||||
|
Time spent in the **current activity state**, sourced from `activity_since`
|
||||||
|
(#102) — *not* time since spawn, and *not* time since last PTY output
|
||||||
|
(`last_activity_at`). Both clients compute it identically:
|
||||||
|
|
||||||
|
- `< 60s` → whole seconds, floored: `30s`
|
||||||
|
- `< 60m` → whole minutes, floored: `4m`
|
||||||
|
- `≥ 60m` → whole hours, floored: `2h`
|
||||||
|
|
||||||
|
No days unit, no seconds inside minutes, no rounding up. Floor everywhere so
|
||||||
|
the counter never shows a duration the state hasn't actually reached yet.
|
||||||
|
Clock granularity: re-render at least once every 30s while visible so the
|
||||||
|
cutover points land within one tick.
|
||||||
|
|
||||||
|
## Precedence
|
||||||
|
|
||||||
|
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
|
||||||
|
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
|
||||||
|
When the TTL expires, inference resumes and the session's reported detail —
|
||||||
|
if any — clears with the report. Clients never blend the two: whichever source
|
||||||
|
is in force supplies both the colour and the clock for `activity_since`.
|
||||||
|
|
||||||
|
Two rulings specific to a reported `Error` (#114):
|
||||||
|
|
||||||
|
- **The five-minute TTL applies to `Error` like to any other state.** A
|
||||||
|
session that failed is still failed after five minutes, but a card pinned
|
||||||
|
red forever lies once the session moves on; when the report lapses the card
|
||||||
|
returns to the daemon's own inference — usually `Idle` for a session that
|
||||||
|
has gone quiet.
|
||||||
|
- **Only a new report clears an `Error` before its TTL.** The next
|
||||||
|
`ActivityState` of any kind replaces it (with #106's hook that is typically
|
||||||
|
`PreToolUse` → `Active` when the user prompts again). PTY output and
|
||||||
|
process changes do not clear it — only the agent itself may withdraw what
|
||||||
|
the agent itself reported. Re-reporting `Error` extends the TTL rather than
|
||||||
|
stacking reports.
|
||||||
|
|
||||||
|
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)
|
||||||
|
until the daemon sends a variant it does know. Rust clients deserialize
|
||||||
|
activity leniently for the same reason.
|
||||||
|
|
||||||
|
Leniency runs one way only. It covers a client *reading* a newer daemon, where
|
||||||
|
dropping the whole session would be worse than dimming one card. A report
|
||||||
|
arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
|
||||||
|
state is a 4xx: an agent posting a state the daemon does not know is a bug in
|
||||||
|
the agent, and answering 204 would hide it.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
activityColorSlug,
|
||||||
|
activityDurationLabel,
|
||||||
|
activityHeldMs,
|
||||||
|
} from "./activity.js";
|
||||||
|
|
||||||
|
describe("activityHeldMs", () => {
|
||||||
|
it("measures time spent in the current state", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const since = new Date(now - 90_000).toISOString();
|
||||||
|
expect(activityHeldMs(since, now)).toBe(90_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for payloads that predate #102 (epoch default)", () => {
|
||||||
|
expect(activityHeldMs(undefined, Date.now())).toBeNull();
|
||||||
|
expect(activityHeldMs(new Date(0).toISOString(), Date.now())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unparseable timestamps", () => {
|
||||||
|
expect(activityHeldMs("not-a-time", Date.now())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never goes negative", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(activityHeldMs(new Date(now + 5_000).toISOString(), now)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activityDurationLabel (#102)", () => {
|
||||||
|
it("floors whole seconds under a minute", () => {
|
||||||
|
expect(activityDurationLabel(30_400)).toBe("30s");
|
||||||
|
expect(activityDurationLabel(59_900)).toBe("59s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors whole minutes under an hour", () => {
|
||||||
|
expect(activityDurationLabel(60_000)).toBe("1m");
|
||||||
|
expect(activityDurationLabel(4 * 60_000 + 59_999)).toBe("4m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors whole hours from an hour up, with no days unit", () => {
|
||||||
|
expect(activityDurationLabel(60 * 60_000)).toBe("1h");
|
||||||
|
expect(activityDurationLabel(2 * 3_600_000 + 59 * 60_000 + 59_999)).toBe("2h");
|
||||||
|
expect(activityDurationLabel(26 * 3_600_000)).toBe("26h");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activityColorSlug", () => {
|
||||||
|
it("maps each known state to its contract slug", () => {
|
||||||
|
expect(activityColorSlug("Active")).toBe("active");
|
||||||
|
expect(activityColorSlug("Idle")).toBe("idle");
|
||||||
|
expect(activityColorSlug("AwaitingInput")).toBe("awaiting-input");
|
||||||
|
expect(activityColorSlug("Error")).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unknown variant as idle so rendering never fails", () => {
|
||||||
|
expect(activityColorSlug("Teleported")).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// docs/SIDEBAR.md: the session card's meta line — activity colour mapping and
|
||||||
|
// the shared duration format. Both clients compute it identically; the TUI
|
||||||
|
// mirrors this in crates/maestro-tui.
|
||||||
|
|
||||||
|
import type { ActivityState, IsoTimestamp } from "./types.js";
|
||||||
|
|
||||||
|
/** A state held shorter than this has no duration fragment. */
|
||||||
|
export const META_LINE_MIN_HOLD_MS = 30_000;
|
||||||
|
|
||||||
|
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
|
||||||
|
// implausibly old is treated as "never observed" rather than a huge duration.
|
||||||
|
const PLAUSIBLE_EPOCH_MS = Date.UTC(2010, 0, 1);
|
||||||
|
|
||||||
|
export function activityHeldMs(since: IsoTimestamp | undefined, now: number): number | null {
|
||||||
|
if (!since) return null;
|
||||||
|
const t = Date.parse(since);
|
||||||
|
if (Number.isNaN(t) || t < PLAUSIBLE_EPOCH_MS) return null;
|
||||||
|
return Math.max(0, now - t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<60s` → whole seconds, `<60m` → whole minutes, else whole hours. Floored
|
||||||
|
* everywhere so the counter never shows a duration the state hasn't reached. */
|
||||||
|
export function activityDurationLabel(heldMs: number): string {
|
||||||
|
const s = Math.floor(heldMs / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m`;
|
||||||
|
return `${Math.floor(m / 60)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS hook for the state's colour (`styles.css` maps slugs to tokens). An
|
||||||
|
* unknown variant degrades to idle so rendering never fails (Precedence). */
|
||||||
|
export function activityColorSlug(state: ActivityState | string): string {
|
||||||
|
switch (state) {
|
||||||
|
case "Active":
|
||||||
|
return "active";
|
||||||
|
case "AwaitingInput":
|
||||||
|
return "awaiting-input";
|
||||||
|
case "Error":
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -123,7 +123,8 @@ export function createAttachConnection(
|
|||||||
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
||||||
// scrolling *its* content (the only scroll that exists on the alt screen).
|
// scrolling *its* content (the only scroll that exists on the alt screen).
|
||||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||||||
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
|
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||||||
|
// gesture stream; only the terminal surface opts out of native page panning.
|
||||||
{
|
{
|
||||||
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
||||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||||||
|
|||||||
@@ -104,3 +104,168 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
expect(confirmations).toEqual(['Kill session "claude"?']);
|
expect(confirmations).toEqual(['Kill session "claude"?']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("agent-reported detail (#112)", () => {
|
||||||
|
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||||
|
|
||||||
|
it("renders the reported detail right after the duration on line 2", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000020",
|
||||||
|
status: "Dead",
|
||||||
|
activity: "Error",
|
||||||
|
activity_since: ago(90_000),
|
||||||
|
activity_detail: "cargo build failed",
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||||
|
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
|
||||||
|
const order = [...line2!.children].map((c) => c.className);
|
||||||
|
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
|
||||||
|
expect(order.indexOf("activity-detail")).toBeLessThan(
|
||||||
|
order.findIndex((c) => c.startsWith("card-flag")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the detail when no report is in force", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000021",
|
||||||
|
activity: "Active",
|
||||||
|
activity_since: ago(90_000),
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session cards (#107)", () => {
|
||||||
|
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||||
|
|
||||||
|
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
|
||||||
|
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
|
||||||
|
expect(line1).not.toBeNull();
|
||||||
|
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
|
||||||
|
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
|
||||||
|
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always renders line 2 for a freshly spawned session", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
||||||
|
activity: "Active",
|
||||||
|
activity_since: ago(5_000),
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||||
|
expect(line2).not.toBeNull();
|
||||||
|
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||||
|
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||||
|
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000012",
|
||||||
|
activity: "Active",
|
||||||
|
activity_since: ago(4 * 60_000 + 30_000),
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||||
|
expect(line2?.dataset.activity).toBe("active");
|
||||||
|
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||||
|
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the command when the session name equals it", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-00000000001b",
|
||||||
|
command: "claude",
|
||||||
|
metadata: { name: "claude" },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps each activity state to its contract slug", () => {
|
||||||
|
const mk = (id: string, activity: string) =>
|
||||||
|
session({
|
||||||
|
id,
|
||||||
|
activity: activity as Session["activity"],
|
||||||
|
status: "Running",
|
||||||
|
activity_since: ago(90_000),
|
||||||
|
});
|
||||||
|
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
|
||||||
|
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
|
||||||
|
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
|
||||||
|
const el = renderSidebar(storeWith([idle, wait, error])).el;
|
||||||
|
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||||
|
"awaiting-input",
|
||||||
|
);
|
||||||
|
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unknown activity variant as idle instead of failing to render", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000016",
|
||||||
|
activity: "Teleported" as Session["activity"],
|
||||||
|
activity_since: ago(120_000),
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
|
||||||
|
expect(line2?.getAttribute("data-activity")).toBe("idle");
|
||||||
|
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves blocked work onto line 2 in the shared phrasing", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000017",
|
||||||
|
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||||
|
expect(line2?.textContent).toContain("⚠");
|
||||||
|
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
|
||||||
|
// Line 1 carries no badges.
|
||||||
|
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows exited on line 2 for dead sessions", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000018",
|
||||||
|
status: "Dead",
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||||
|
expect(line2?.textContent).toContain("exited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||||
|
command: "claude",
|
||||||
|
metadata: { name: "worker" },
|
||||||
|
activity: "AwaitingInput",
|
||||||
|
activity_since: ago(90_000),
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const row = rowFor(el, s.id) as HTMLElement;
|
||||||
|
const dot = row.querySelector<HTMLElement>(".activity-dot");
|
||||||
|
expect(dot?.getAttribute("title")).toContain("Awaiting input");
|
||||||
|
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
|
||||||
|
expect(row.textContent).not.toMatch(/awaiting.?input/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the attach navigation and depth indentation", () => {
|
||||||
|
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const row = rowFor(el, s.id) as HTMLAnchorElement;
|
||||||
|
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
|
||||||
|
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+63
-15
@@ -4,6 +4,12 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
|||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
||||||
|
import {
|
||||||
|
activityColorSlug,
|
||||||
|
activityDurationLabel,
|
||||||
|
activityHeldMs,
|
||||||
|
META_LINE_MIN_HOLD_MS,
|
||||||
|
} from "./activity.js";
|
||||||
import type { TreeNode } from "./tree.js";
|
import type { TreeNode } from "./tree.js";
|
||||||
import { buildTree } from "./tree.js";
|
import { buildTree } from "./tree.js";
|
||||||
import { createThemeToggle } from "./theme.js";
|
import { createThemeToggle } from "./theme.js";
|
||||||
@@ -17,6 +23,39 @@ export function requestOpenSpawn(): void {
|
|||||||
|
|
||||||
const collapsed = new Set<string>();
|
const collapsed = new Set<string>();
|
||||||
|
|
||||||
|
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
|
||||||
|
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
||||||
|
const STATE_WORDS: Record<string, string> = {
|
||||||
|
active: "Active",
|
||||||
|
idle: "Idle",
|
||||||
|
"awaiting-input": "Awaiting input",
|
||||||
|
error: "Error",
|
||||||
|
};
|
||||||
|
|
||||||
|
function metaLine(s: Session, now: number): string {
|
||||||
|
const dead = s.status === "Dead";
|
||||||
|
const blockers = sessionBlockers(s);
|
||||||
|
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||||
|
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||||
|
const held = activityHeldMs(s.activity_since, now);
|
||||||
|
const duration =
|
||||||
|
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||||
|
|
||||||
|
const fragments = [
|
||||||
|
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||||
|
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
||||||
|
s.activity_detail
|
||||||
|
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
||||||
|
: "",
|
||||||
|
blockers
|
||||||
|
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||||
|
: "",
|
||||||
|
dead ? `<span class="card-flag">exited</span>` : "",
|
||||||
|
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
|
||||||
|
];
|
||||||
|
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||||
if (node.kind === "folder") {
|
if (node.kind === "folder") {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
@@ -52,30 +91,31 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const s = node.session;
|
const s = node.session;
|
||||||
|
const now = Date.now();
|
||||||
const row = document.createElement("a");
|
const row = document.createElement("a");
|
||||||
row.className = "tree-leaf";
|
row.className = "tree-leaf session-card";
|
||||||
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
||||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||||
row.dataset.sessionId = s.id;
|
row.dataset.sessionId = s.id;
|
||||||
const running = s.status === "Running";
|
const running = s.status === "Running";
|
||||||
row.dataset.status = running ? "running" : "dead";
|
row.dataset.status = running ? "running" : "dead";
|
||||||
const name = sessionName(s);
|
const name = sessionName(s);
|
||||||
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
|
const stateWord =
|
||||||
// The directory is the enclosing folder, so only surface the command — and
|
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
|
||||||
// only when it isn't already what the name shows.
|
row.setAttribute(
|
||||||
const showCmd = s.command !== name;
|
"aria-label",
|
||||||
// #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so
|
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||||
// "this will be refused" is visible before the kill is attempted.
|
);
|
||||||
const blockers = sessionBlockers(s);
|
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
<span class="card-line1">
|
||||||
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
|
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||||
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
<span class="leaf-actions">
|
||||||
${blockers ? `<span class="leaf-badge leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>` : ""}
|
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||||
<span class="leaf-actions">
|
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
</span>
|
||||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
|
||||||
</span>
|
</span>
|
||||||
|
${metaLine(s, now)}
|
||||||
`;
|
`;
|
||||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||||
similarBtn.addEventListener("click", (ev) => {
|
similarBtn.addEventListener("click", (ev) => {
|
||||||
@@ -395,6 +435,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
|
|
||||||
const unsubscribe = store.subscribe(refresh);
|
const unsubscribe = store.subscribe(refresh);
|
||||||
|
|
||||||
|
// Contract granularity: re-render at least every 30s while visible so the
|
||||||
|
// duration cutover points land within one tick.
|
||||||
|
const clock = setInterval(() => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
refresh();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
el,
|
el,
|
||||||
setActive(id: string | null) {
|
setActive(id: string | null) {
|
||||||
@@ -410,6 +457,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyHighlights();
|
applyHighlights();
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
|
clearInterval(clock);
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
|
|||||||
+66
-18
@@ -59,20 +59,28 @@ function leafRow(opts: {
|
|||||||
status: "running" | "dead";
|
status: "running" | "dead";
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
indent?: number;
|
indent?: number;
|
||||||
|
activity?: "active" | "idle" | "awaiting-input" | "error";
|
||||||
|
duration?: string;
|
||||||
}): HTMLElement {
|
}): HTMLElement {
|
||||||
const row = h("a", "tree-leaf");
|
const row = h("a", "tree-leaf session-card");
|
||||||
(row as HTMLAnchorElement).href = "#/kit";
|
(row as HTMLAnchorElement).href = "#/kit";
|
||||||
row.dataset.status = opts.status;
|
row.dataset.status = opts.status;
|
||||||
if (opts.active) row.classList.add("is-active");
|
if (opts.active) row.classList.add("is-active");
|
||||||
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
|
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
|
||||||
|
const flags = [
|
||||||
|
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
|
||||||
|
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
|
||||||
|
].join("");
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<span class="leaf-name">${opts.name}</span>
|
<span class="card-line1">
|
||||||
${opts.cmd !== opts.name ? `<span class="leaf-meta"><span class="leaf-cmd">${opts.cmd}</span></span>` : ""}
|
<span class="leaf-name">${opts.name}</span>
|
||||||
${opts.status === "dead" ? `<span class="leaf-badge">exited</span>` : ""}
|
<span class="leaf-actions">
|
||||||
<span class="leaf-actions">
|
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
|
||||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${opts.name}">⧉</button>
|
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
|
||||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${opts.name}">✕</button>
|
</span>
|
||||||
</span>`;
|
</span>
|
||||||
|
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
|
||||||
|
`;
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,14 +198,17 @@ export function renderKit(): HTMLElement {
|
|||||||
// --- chips & badges ---
|
// --- chips & badges ---
|
||||||
{
|
{
|
||||||
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
|
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
|
||||||
|
const activeLine = h(
|
||||||
|
"span",
|
||||||
|
"card-line2",
|
||||||
|
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
|
||||||
|
);
|
||||||
|
activeLine.setAttribute("data-activity", "active");
|
||||||
body.append(
|
body.append(
|
||||||
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
||||||
specimen("folder-count", h("span", "folder-count", "4")),
|
specimen("folder-count", h("span", "folder-count", "4")),
|
||||||
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
|
specimen("card-line2 · active", activeLine),
|
||||||
specimen(
|
specimen("attach-state · open", h("span", "attach-state", "live")),
|
||||||
"attach-state · open",
|
|
||||||
h("span", "attach-state", "live"),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
|
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
@@ -215,19 +226,56 @@ export function renderKit(): HTMLElement {
|
|||||||
{
|
{
|
||||||
const { el, body } = section(
|
const { el, body } = section(
|
||||||
"Session rows — nested by host, then directory",
|
"Session rows — nested by host, then directory",
|
||||||
"The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
|
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
|
||||||
);
|
);
|
||||||
const list = h("div", "kit-tree");
|
const list = h("div", "kit-tree");
|
||||||
list.append(
|
list.append(
|
||||||
folderHeader("~/git/maestro", 3, 0),
|
folderHeader("~/git/maestro", 3, 0),
|
||||||
folderHeader("web", 2, 1),
|
folderHeader("web", 2, 1),
|
||||||
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
|
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
|
||||||
leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }),
|
leafRow({
|
||||||
|
name: "dev-server",
|
||||||
|
cmd: "vite dev",
|
||||||
|
status: "running",
|
||||||
|
indent: 2,
|
||||||
|
active: true,
|
||||||
|
activity: "active",
|
||||||
|
duration: "4m",
|
||||||
|
}),
|
||||||
folderHeader("crates/daemon", 1, 1),
|
folderHeader("crates/daemon", 1, 1),
|
||||||
leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }),
|
leafRow({
|
||||||
|
name: "daemon-work",
|
||||||
|
cmd: "claude",
|
||||||
|
status: "running",
|
||||||
|
indent: 2,
|
||||||
|
activity: "awaiting-input",
|
||||||
|
duration: "12s",
|
||||||
|
}),
|
||||||
|
leafRow({
|
||||||
|
name: "flaky-agent",
|
||||||
|
cmd: "claude",
|
||||||
|
status: "running",
|
||||||
|
indent: 2,
|
||||||
|
activity: "error",
|
||||||
|
duration: "2h",
|
||||||
|
}),
|
||||||
folderHeader("~/work/acme", 2, 0),
|
folderHeader("~/work/acme", 2, 0),
|
||||||
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
|
leafRow({
|
||||||
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
|
name: "billing-api",
|
||||||
|
cmd: "claude",
|
||||||
|
status: "running",
|
||||||
|
indent: 1,
|
||||||
|
activity: "idle",
|
||||||
|
duration: "26m",
|
||||||
|
}),
|
||||||
|
leafRow({
|
||||||
|
name: "scratch",
|
||||||
|
cmd: "bash",
|
||||||
|
status: "dead",
|
||||||
|
indent: 1,
|
||||||
|
activity: "idle",
|
||||||
|
duration: "5h",
|
||||||
|
}),
|
||||||
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
|
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
|
||||||
);
|
);
|
||||||
body.classList.add("kit-tree-host");
|
body.classList.add("kit-tree-host");
|
||||||
|
|||||||
+5
-1
@@ -272,7 +272,11 @@ function showMultiView(): void {
|
|||||||
// cloned session's dir + command.
|
// cloned session's dir + command.
|
||||||
function requestOpenSpawn(): void {
|
function requestOpenSpawn(): void {
|
||||||
showMultiView();
|
showMultiView();
|
||||||
mv.openDraft();
|
// A new tab, not a split of the pane you were reading (#100). "Spawn
|
||||||
|
// similar" and the fast-forward key below deliberately keep the old
|
||||||
|
// behaviour: both act on the session in front of you, so a split beside it
|
||||||
|
// is the point.
|
||||||
|
mv.openDraft(undefined, { newTab: true });
|
||||||
}
|
}
|
||||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||||
|
|||||||
@@ -282,3 +282,54 @@ describe("multiview pane-drag swap (#73)", () => {
|
|||||||
expect(nameInputOf(leaf2).value).toBe("west");
|
expect(nameInputOf(leaf2).value).toBe("west");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("openDraft newTab (#100)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens its own tab instead of splitting the focused session", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
tab.focusedLeafId = first;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.openDraft(undefined, { newTab: true });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(2);
|
||||||
|
// The tab you were reading is untouched: still one pane, still its session.
|
||||||
|
const original = leaves(layout.tabs[0].root);
|
||||||
|
expect(original).toHaveLength(1);
|
||||||
|
expect(original[0].sessionId).toBe("s1");
|
||||||
|
// The draft is alone in the new, now-active tab.
|
||||||
|
const fresh = leaves(layout.tabs[1].root);
|
||||||
|
expect(fresh).toHaveLength(1);
|
||||||
|
expect(fresh[0].sessionId).toBeNull();
|
||||||
|
expect(fresh[0].draft).not.toBeNull();
|
||||||
|
expect(layout.activeTabId).toBe(layout.tabs[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still splits when newTab is not asked for", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.openDraft();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(1);
|
||||||
|
expect(leaves(tab.root)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+12
-3
@@ -57,7 +57,7 @@ export interface MultiView {
|
|||||||
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
||||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||||
* ("Spawn similar" clones a session's cwd + command). */
|
* ("Spawn similar" clones a session's cwd + command). */
|
||||||
openDraft: (seed?: Partial<Draft>) => void;
|
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
|
||||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||||
* drag shows drop zones to place it. */
|
* drag shows drop zones to place it. */
|
||||||
@@ -412,9 +412,18 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
opts.onOpenSessionsChange?.();
|
opts.onOpenSessionsChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDraft(seed?: Partial<Draft>): void {
|
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||||
const tab = activeTab(layout);
|
|
||||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||||
|
if (opts?.newTab) {
|
||||||
|
// Spawn opens its own tab rather than halving whatever you were reading
|
||||||
|
// (#100). The empty-pane reuse below still applies within a tab.
|
||||||
|
const fresh = addTab(layout);
|
||||||
|
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
|
||||||
|
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tab = activeTab(layout);
|
||||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowPaths(form: HTMLFormElement): string[] {
|
function boxPaths(form: HTMLFormElement): string[] {
|
||||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
|
||||||
(r) => r.dataset.cwd ?? "",
|
(b) => b.dataset.cwd ?? "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("spawnForm project picker", () => {
|
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
|
||||||
let handle: SpawnFormHandle | null = null;
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
|
|||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("renders one row per project, fresh before stale", async () => {
|
it("renders one box per project, fresh before stale", async () => {
|
||||||
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||||
const stale = project({
|
const stale = project({
|
||||||
name: "stale",
|
name: "stale",
|
||||||
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
|
|||||||
last_activity: "2026-01-01T00:00:00Z",
|
last_activity: "2026-01-01T00:00:00Z",
|
||||||
});
|
});
|
||||||
const form = (await mount([stale, fresh])).el;
|
const form = (await mount([stale, fresh])).el;
|
||||||
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||||
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
|
||||||
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
expect(boxes[1].classList.contains("is-stale")).toBe(true);
|
||||||
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("nests worktrees under their base repository", async () => {
|
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
||||||
|
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||||
|
const form = (await mount([p])).el;
|
||||||
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||||
|
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||||
|
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||||
|
// projects sharing a basename (#95).
|
||||||
|
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||||
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||||
const wt = project({
|
const wt = project({
|
||||||
name: "wt",
|
name: "wt",
|
||||||
path: "/elsewhere/wt",
|
path: "/home/user/maestro/worktrees/wt",
|
||||||
is_worktree: true,
|
is_worktree: true,
|
||||||
base_repo: main.path,
|
base_repo: main.path,
|
||||||
});
|
});
|
||||||
const form = (await mount([wt, main])).el;
|
const form = (await mount([wt, main])).el;
|
||||||
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||||
const [mainRow, wtRow] = [
|
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
|
||||||
...form.querySelectorAll(".project-row:not(.project-other)"),
|
|
||||||
];
|
|
||||||
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
|
||||||
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters rows as the operator types", async () => {
|
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
|
||||||
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||||
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||||
const form = (await mount([a, b])).el;
|
const wt = project({
|
||||||
|
name: "bulgarians",
|
||||||
|
path: "/home/user/maestro/worktrees/bulgarians",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: a.path,
|
||||||
|
});
|
||||||
|
const form = (await mount([a, b, wt])).el;
|
||||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||||
filter.value = "tea";
|
filter.value = "tea";
|
||||||
filter.dispatchEvent(new Event("input"));
|
filter.dispatchEvent(new Event("input"));
|
||||||
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||||
|
// A query that only matches a worktree still surfaces its repository box.
|
||||||
|
filter.value = "bulgarians";
|
||||||
|
filter.dispatchEvent(new Event("input"));
|
||||||
|
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||||
filter.value = "zzz";
|
filter.value = "zzz";
|
||||||
filter.dispatchEvent(new Event("input"));
|
filter.dispatchEvent(new Event("input"));
|
||||||
expect(rowPaths(form)).toEqual([]);
|
expect(boxPaths(form)).toEqual([]);
|
||||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
|
|||||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
onChange.mockClear();
|
onChange.mockClear();
|
||||||
|
|
||||||
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||||
row.click();
|
box.click();
|
||||||
|
|
||||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||||
const last = onChange.mock.lastCall![0];
|
const last = onChange.mock.lastCall![0];
|
||||||
expect(last.cwd).toBe("/home/user/maestro");
|
expect(last.cwd).toBe("/home/user/maestro");
|
||||||
expect(last.command).toBe("claude");
|
expect(last.command).toBe("claude");
|
||||||
expect(values).toBeTruthy();
|
expect(values).toBeTruthy();
|
||||||
// The raw input stays hidden — the picked row is the source of truth.
|
// The raw input stays hidden — the picked box is the source of truth.
|
||||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||||
expect(row.classList.contains("is-active")).toBe(true);
|
expect(box.classList.contains("is-active")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||||
|
|||||||
+269
-88
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
|
||||||
|
|
||||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||||
* from the repository's existing branches (#61). */
|
* from the repository's existing branches (#61). */
|
||||||
@@ -130,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
placeholder="bash -l" autocomplete="off" />
|
placeholder="bash -l" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Working directory</span>
|
<span class="field-label">Project</span>
|
||||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||||
autocomplete="off" />
|
autocomplete="off" />
|
||||||
<div class="picker projects"></div>
|
<div class="picker projects"></div>
|
||||||
|
<button type="button" class="project-other">Other directory…</button>
|
||||||
<div class="raw-wrap cwd-raw-wrap" hidden>
|
<div class="raw-wrap cwd-raw-wrap" hidden>
|
||||||
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field wt-step" hidden>
|
||||||
|
<span class="field-label">Worktree</span>
|
||||||
|
<div class="picker wt-choices"></div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Name</span>
|
<span class="field-label">Name</span>
|
||||||
<div class="name-row">
|
<div class="name-row">
|
||||||
@@ -147,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="worktree">
|
<fieldset class="worktree">
|
||||||
<legend>
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||||
|
<span class="chevron">▾</span>
|
||||||
|
<span class="folder-name">Advanced options</span>
|
||||||
|
<span class="wt-summary"></span>
|
||||||
|
</button>
|
||||||
|
<div class="wt-collapsible" hidden>
|
||||||
<label class="row-inline">
|
<label class="row-inline">
|
||||||
<input type="checkbox" name="worktree_enabled" />
|
<input type="checkbox" name="worktree_enabled" />
|
||||||
Create git worktree
|
Create git worktree
|
||||||
</label>
|
</label>
|
||||||
</legend>
|
<p class="wt-hint" hidden></p>
|
||||||
<p class="wt-hint" hidden></p>
|
<div class="wt-body" hidden>
|
||||||
<div class="wt-body" hidden>
|
<p class="wt-repo"></p>
|
||||||
<p class="wt-repo"></p>
|
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
<label class="row-inline">
|
||||||
<label class="row-inline">
|
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
New branch
|
||||||
New branch
|
</label>
|
||||||
|
<label class="row-inline">
|
||||||
|
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||||
|
Existing branch
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="wt-new-wrap">
|
||||||
|
New branch name
|
||||||
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label class="row-inline">
|
<label class="wt-exist-wrap" hidden>
|
||||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
Pick a branch
|
||||||
Existing branch
|
<select class="branch-select"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="wt-new-wrap">
|
|
||||||
Branch
|
|
||||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label class="wt-exist-wrap" hidden>
|
|
||||||
Branch
|
|
||||||
<select class="branch-select"></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||||
@@ -187,11 +197,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||||
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
||||||
const recentsEl = form.querySelector(".projects") as HTMLElement;
|
const projectsEl = form.querySelector(".projects") as HTMLElement;
|
||||||
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
|
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
|
||||||
|
const otherBtn = form.querySelector(".project-other") as HTMLButtonElement;
|
||||||
|
const wtStep = form.querySelector(".wt-step") as HTMLElement;
|
||||||
|
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
||||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||||
|
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||||
|
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||||
|
const wtSummary = form.querySelector(".wt-summary") as HTMLElement;
|
||||||
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
||||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||||
@@ -342,6 +358,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
||||||
|
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
||||||
|
* without touching `command`, since that was already settled. */
|
||||||
|
function reapplyActiveTile(): void {
|
||||||
|
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||||
|
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCommandTiles(): void {
|
function renderCommandTiles(): void {
|
||||||
cmdTiles.replaceChildren();
|
cmdTiles.replaceChildren();
|
||||||
for (const p of presets) {
|
for (const p of presets) {
|
||||||
@@ -350,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
tile.className = "tile";
|
tile.className = "tile";
|
||||||
tile.dataset.id = p.id;
|
tile.dataset.id = p.id;
|
||||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
||||||
tile.addEventListener("click", () => selectPreset(p.id));
|
tile.addEventListener("click", () => {
|
||||||
|
commandTouched = true;
|
||||||
|
selectPreset(p.id);
|
||||||
|
});
|
||||||
cmdTiles.appendChild(tile);
|
cmdTiles.appendChild(tile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set once the operator picks a tile or types a custom command — guards
|
||||||
|
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||||
|
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||||
|
// operator has already touched (#76).
|
||||||
|
let commandTouched = false;
|
||||||
|
|
||||||
cmdRaw.addEventListener("input", () => {
|
cmdRaw.addEventListener("input", () => {
|
||||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||||
|
commandTouched = true;
|
||||||
command = cmdRaw.value.trim();
|
command = cmdRaw.value.trim();
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
@@ -372,7 +407,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (info.presets?.length) {
|
if (info.presets?.length) {
|
||||||
presets = info.presets;
|
presets = info.presets;
|
||||||
renderCommandTiles();
|
renderCommandTiles();
|
||||||
applyInitialCommand();
|
if (commandTouched) {
|
||||||
|
reapplyActiveTile();
|
||||||
|
} else {
|
||||||
|
applyInitialCommand();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -399,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (first) selectPreset(first.id, false);
|
if (first) selectPreset(first.id, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- working-directory picker ---------------------------------------
|
// ---- working-directory picker (#95) ----------------------------------
|
||||||
|
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
||||||
|
// 1. Step one is one box per repository (name, then full path). A worktree
|
||||||
|
// is a box only when its main checkout is missing from the scan.
|
||||||
|
// 2. The filter keeps a repository when the query matches it or any of its
|
||||||
|
// worktrees, so a worktree name still finds its box.
|
||||||
|
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||||
|
// worktrees newest-first. "Other directory…" clears both steps.
|
||||||
|
// 4. "+ new" is the default selection — except on a box that is itself a
|
||||||
|
// linked worktree, which defaults to that worktree.
|
||||||
|
// 5. "+ new" means create: cwd is the repository, create-worktree on.
|
||||||
|
// An existing worktree means run in it: cwd is that worktree,
|
||||||
|
// create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
|
||||||
|
// these are separate acts — #91's rule, now an explicit choice.
|
||||||
|
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
||||||
|
// membership. Only the free-text hatch pays for a probe.
|
||||||
|
|
||||||
|
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||||
|
type WorktreeChoice = "new" | string;
|
||||||
|
|
||||||
|
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||||
|
let groups: ProjectGroup[] = []; // rebuilt on every render
|
||||||
|
let selectedGroup: ProjectGroup | null = null;
|
||||||
|
let worktreeChoice: WorktreeChoice = "new";
|
||||||
|
|
||||||
function showRawCwd(prefillValue?: string, focus = true): void {
|
function showRawCwd(prefillValue?: string, focus = true): void {
|
||||||
cwdRawWrap.hidden = false;
|
cwdRawWrap.hidden = false;
|
||||||
if (prefillValue != null) cwd = prefillValue;
|
if (prefillValue != null) cwd = prefillValue;
|
||||||
cwdRaw.value = cwd;
|
cwdRaw.value = cwd;
|
||||||
markActiveProject(null);
|
clearProjectSelection();
|
||||||
if (focus) {
|
if (focus) {
|
||||||
cwdRaw.focus();
|
cwdRaw.focus();
|
||||||
cwdRaw.select();
|
cwdRaw.select();
|
||||||
@@ -421,62 +484,95 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (wtEnabled.checked) scheduleGitRootProbe();
|
if (wtEnabled.checked) scheduleGitRootProbe();
|
||||||
});
|
});
|
||||||
|
|
||||||
function markActiveProject(activeCwd: string | null): void {
|
/** Every worktree of a group, including the group's own box when the scan
|
||||||
for (const row of recentsEl.querySelectorAll(".project-row")) {
|
* found no main checkout to nest it under. */
|
||||||
row.classList.toggle(
|
function worktreesOf(group: ProjectGroup): Project[] {
|
||||||
"is-active",
|
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
|
||||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
}
|
||||||
);
|
|
||||||
|
function markActiveBox(): void {
|
||||||
|
const active = selectedGroup?.main.path ?? null;
|
||||||
|
for (const box of projectsEl.querySelectorAll(".project-box")) {
|
||||||
|
box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectProject(project: Project): void {
|
function markActiveChoice(): void {
|
||||||
cwd = project.path;
|
for (const btn of wtChoices.querySelectorAll(".wt-choice")) {
|
||||||
|
const el = btn as HTMLElement;
|
||||||
|
const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? "");
|
||||||
|
el.classList.toggle("is-active", value === worktreeChoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProjectSelection(): void {
|
||||||
|
selectedGroup = null;
|
||||||
|
worktreeChoice = "new";
|
||||||
|
wtChoices.replaceChildren();
|
||||||
|
wtStep.hidden = true;
|
||||||
|
markActiveBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProject(group: ProjectGroup): void {
|
||||||
|
selectedGroup = group;
|
||||||
cwdRawWrap.hidden = true;
|
cwdRawWrap.hidden = true;
|
||||||
markActiveProject(project.path);
|
renderWorktreeChoices(group);
|
||||||
// The scan already knows this directory is inside a repo (#61): the
|
markActiveBox();
|
||||||
// toggle defaults back on and no /api/gitroot round trip is needed —
|
// Rule 4: a box that is itself a linked worktree defaults to running there.
|
||||||
// unless the directory *is* a linked worktree itself (#91): spawning
|
applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new");
|
||||||
// in place is the only thing that works there, so default the toggle
|
}
|
||||||
// off. Turning it back on by hand still tries (and fails with git's
|
|
||||||
// own message), which is deliberate (#91 non-goal).
|
function applyWorktreeChoice(choice: WorktreeChoice): void {
|
||||||
wtEnabled.checked = !project.is_worktree;
|
const group = selectedGroup;
|
||||||
if (project.is_worktree) {
|
if (!group) return;
|
||||||
|
const target =
|
||||||
|
choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
|
||||||
|
if (!target) return;
|
||||||
|
worktreeChoice = choice;
|
||||||
|
cwd = target.path;
|
||||||
|
branchCache = null;
|
||||||
|
markActiveChoice();
|
||||||
|
if (choice === "new") {
|
||||||
|
wtEnabled.checked = true;
|
||||||
|
updateWtSummary();
|
||||||
|
applyProjectRepo(group.main.base_repo ?? group.main.path);
|
||||||
|
} else {
|
||||||
|
wtEnabled.checked = false;
|
||||||
|
updateWtSummary();
|
||||||
inferredBaseRepo = null;
|
inferredBaseRepo = null;
|
||||||
wtBody.hidden = true;
|
wtBody.hidden = true;
|
||||||
wtHint.hidden = true;
|
wtHint.hidden = true;
|
||||||
} else {
|
|
||||||
applyProjectRepo(project.base_repo ?? project.path);
|
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
// Picking a project pre-selects the command it was last used with.
|
// The chosen directory pre-selects the command it was last used with.
|
||||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
|
||||||
if (match) selectPreset(match.id);
|
if (match) selectPreset(match.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
||||||
|
function visibleGroups(query: string): ProjectGroup[] {
|
||||||
|
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function renderProjects(): void {
|
function renderProjects(): void {
|
||||||
recentsEl.replaceChildren();
|
projectsEl.replaceChildren();
|
||||||
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
|
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||||
for (const group of groupProjects(sorted)) {
|
const visible = visibleGroups(filterInput.value);
|
||||||
recentsEl.appendChild(projectRow(group.main));
|
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
||||||
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
|
if (discovered.length > 0 && visible.length === 0) {
|
||||||
}
|
|
||||||
if (discovered.length > 0 && sorted.length === 0) {
|
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "picker-empty";
|
empty.className = "picker-empty";
|
||||||
empty.textContent = "No projects match.";
|
empty.textContent = "No projects match.";
|
||||||
recentsEl.appendChild(empty);
|
projectsEl.appendChild(empty);
|
||||||
}
|
}
|
||||||
recentsEl.appendChild(otherDirRow());
|
markActiveBox();
|
||||||
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filterInput.addEventListener("input", renderProjects);
|
filterInput.addEventListener("input", renderProjects);
|
||||||
|
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||||
|
|
||||||
async function loadProjects(): Promise<void> {
|
async function loadProjects(): Promise<void> {
|
||||||
recentsEl.replaceChildren(loadingRow());
|
projectsEl.replaceChildren(loadingRow());
|
||||||
try {
|
try {
|
||||||
discovered = await fetchProjects();
|
discovered = await fetchProjects();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -487,12 +583,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (seedCwd) {
|
if (seedCwd) {
|
||||||
// A seed sets the directory verbatim, regardless of what the scan found.
|
// A seed sets the directory verbatim, regardless of what the scan found.
|
||||||
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
||||||
// picked row: keep the picker and don't steal focus. Otherwise land
|
// picked box: keep the picker and don't steal focus. Otherwise land
|
||||||
// focused in the raw input (clone / no-match flows).
|
// focused in the raw input (clone / no-match flows).
|
||||||
if (prefill.cwdRawVisible === false) {
|
if (prefill.cwdRawVisible === false) {
|
||||||
cwd = seedCwd;
|
cwd = seedCwd;
|
||||||
cwdRaw.value = seedCwd;
|
cwdRaw.value = seedCwd;
|
||||||
markActiveProject(cwd);
|
// Restoring must not touch the worktree toggle — the seed carries it.
|
||||||
|
if (!restoreSelection(seedCwd)) clearProjectSelection();
|
||||||
} else {
|
} else {
|
||||||
showRawCwd(seedCwd, false);
|
showRawCwd(seedCwd, false);
|
||||||
}
|
}
|
||||||
@@ -509,34 +606,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectRow(project: Project): HTMLElement {
|
/** Put the two steps back on a restored draft's cwd, without re-running the
|
||||||
const row = document.createElement("button");
|
* side effects of a fresh pick. */
|
||||||
row.type = "button";
|
function restoreSelection(path: string): boolean {
|
||||||
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
|
for (const group of groups) {
|
||||||
isStale(project, new Date()) ? " is-stale" : ""
|
const wt = worktreesOf(group).find((p) => p.path === path);
|
||||||
}`;
|
const choice: WorktreeChoice | null =
|
||||||
row.dataset.cwd = project.path;
|
wt ? wt.path : group.main.path === path ? "new" : null;
|
||||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
if (choice === null) continue;
|
||||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
selectedGroup = group;
|
||||||
// and `dataset.cwd` (what's actually submitted).
|
worktreeChoice = choice;
|
||||||
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
|
renderWorktreeChoices(group);
|
||||||
row.innerHTML = `
|
markActiveChoice();
|
||||||
<span class="project-info">
|
markActiveBox();
|
||||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
return true;
|
||||||
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
}
|
||||||
</span>
|
return false;
|
||||||
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
|
||||||
row.addEventListener("click", () => selectProject(project));
|
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function otherDirRow(): HTMLElement {
|
function projectBox(group: ProjectGroup): HTMLElement {
|
||||||
const row = document.createElement("button");
|
const project = group.main;
|
||||||
row.type = "button";
|
const box = document.createElement("button");
|
||||||
row.className = "project-row project-other";
|
box.type = "button";
|
||||||
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
box.dataset.cwd = project.path;
|
||||||
return row;
|
const trees = worktreesOf(group).length;
|
||||||
|
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
||||||
|
// tells two projects sharing a basename apart (#95).
|
||||||
|
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
box.innerHTML = `
|
||||||
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||||
|
<span class="project-path">${escapeHtml(project.path)}</span>
|
||||||
|
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||||
|
box.addEventListener("click", () => selectProject(group));
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorktreeChoices(group: ProjectGroup): void {
|
||||||
|
wtChoices.replaceChildren();
|
||||||
|
wtChoices.appendChild(
|
||||||
|
choiceButton("new", "+ new", `new worktree of ${group.main.name}`),
|
||||||
|
);
|
||||||
|
for (const wt of worktreesOf(group)) {
|
||||||
|
wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path));
|
||||||
|
}
|
||||||
|
wtStep.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`;
|
||||||
|
btn.dataset.kind = choice === "new" ? "new" : "existing";
|
||||||
|
if (choice !== "new") btn.dataset.cwd = choice;
|
||||||
|
btn.innerHTML = `
|
||||||
|
<span class="wt-choice-name">${escapeHtml(label)}</span>
|
||||||
|
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
|
||||||
|
btn.addEventListener("click", () => applyWorktreeChoice(choice));
|
||||||
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadingRow(): HTMLElement {
|
function loadingRow(): HTMLElement {
|
||||||
@@ -546,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- worktree: disclosure (#93), reuses the sidebar's folder-header ---
|
||||||
|
let advancedCollapsed = true;
|
||||||
|
function setAdvancedCollapsed(collapsed: boolean): void {
|
||||||
|
advancedCollapsed = collapsed;
|
||||||
|
wtToggle.setAttribute("aria-expanded", String(!collapsed));
|
||||||
|
wtCollapsible.hidden = collapsed;
|
||||||
|
}
|
||||||
|
function updateWtSummary(): void {
|
||||||
|
wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree";
|
||||||
|
}
|
||||||
|
wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed));
|
||||||
|
|
||||||
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
||||||
// The project scan already reports repo membership, so a picked project
|
// The project scan already reports repo membership, so a picked project
|
||||||
// resolves its base repo locally (#61); only the free-text escape hatch
|
// resolves its base repo locally (#61); only the free-text escape hatch
|
||||||
@@ -626,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// Publish the untick too, or the next render seeds the box checked with
|
// Publish the untick too, or the next render seeds the box checked with
|
||||||
// no inferred repo and submitting fails.
|
// no inferred repo and submitting fails.
|
||||||
wtEnabled.checked = false;
|
wtEnabled.checked = false;
|
||||||
|
updateWtSummary();
|
||||||
emit();
|
emit();
|
||||||
wtBody.hidden = true;
|
wtBody.hidden = true;
|
||||||
wtHint.hidden = false;
|
wtHint.hidden = false;
|
||||||
@@ -699,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
branchSelect.addEventListener("change", () => emit());
|
branchSelect.addEventListener("change", () => emit());
|
||||||
|
|
||||||
wtEnabled.addEventListener("change", () => {
|
wtEnabled.addEventListener("change", () => {
|
||||||
|
updateWtSummary();
|
||||||
emit();
|
emit();
|
||||||
if (wtEnabled.checked) {
|
if (wtEnabled.checked) {
|
||||||
resolveBaseRepo();
|
resolveBaseRepo();
|
||||||
@@ -712,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// The toggle defaults ON (#61): the operator almost always wants a
|
// The toggle defaults ON (#61): the operator almost always wants a
|
||||||
// worktree, and every non-repo target unticks it via the gitroot probe.
|
// worktree, and every non-repo target unticks it via the gitroot probe.
|
||||||
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
||||||
|
updateWtSummary();
|
||||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||||
setBranchMode(branchMode);
|
setBranchMode(branchMode);
|
||||||
|
|
||||||
@@ -722,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
const autocomplete = attachDirAutocomplete(cwdRaw);
|
||||||
|
|
||||||
|
// ---- Enter submits from anywhere (#97) --------------------------------
|
||||||
|
// A project-box/wt-choice that isn't yet the active pick takes the Enter to
|
||||||
|
// choose it instead; the next Enter, now that it's active, submits.
|
||||||
|
// dirAutocomplete's own Enter (accept the highlight) already preventDefaults
|
||||||
|
// when it handles the key, which the `e.defaultPrevented` guard defers to.
|
||||||
|
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
|
||||||
|
// does the same) rather than folding it into the blanket-submit rule below.
|
||||||
|
form.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
if (target === wtEnabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
wtEnabled.checked = !wtEnabled.checked;
|
||||||
|
wtEnabled.dispatchEvent(new Event("change"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target instanceof HTMLButtonElement) {
|
||||||
|
if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!target.classList.contains("is-active")) {
|
||||||
|
target.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.requestSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
if (target === filterInput) {
|
||||||
|
const top = visibleGroups(filterInput.value)[0];
|
||||||
|
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
|
||||||
|
}
|
||||||
|
form.requestSubmit();
|
||||||
|
});
|
||||||
|
|
||||||
// ---- submit ----------------------------------------------------------
|
// ---- submit ----------------------------------------------------------
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Enter submits the draft form from any field, at any point (#97). The two
|
||||||
|
// exceptions: a picker button that isn't yet the active choice takes the
|
||||||
|
// Enter to *choose* it instead (the next Enter submits), and the project
|
||||||
|
// filter must resolve to the highlighted project rather than sending
|
||||||
|
// whatever cwd was already set.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||||
|
import type { Project } from "./types.js";
|
||||||
|
|
||||||
|
const MAESTRO: Project = {
|
||||||
|
path: "/home/user/tea/maestro",
|
||||||
|
name: "maestro",
|
||||||
|
is_worktree: false,
|
||||||
|
last_activity: "2026-08-20T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WT_A: Project = {
|
||||||
|
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
||||||
|
name: "bulgarians",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: MAESTRO.path,
|
||||||
|
last_activity: "2026-08-22T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OTHER: Project = {
|
||||||
|
path: "/home/user/tea/rev",
|
||||||
|
name: "rev",
|
||||||
|
is_worktree: false,
|
||||||
|
last_activity: "2026-08-19T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function stubFetch(projects: Project[]): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
const body = url.includes("/api/projects")
|
||||||
|
? projects
|
||||||
|
: url.includes("/api/names")
|
||||||
|
? { names: ["amber-fox"] }
|
||||||
|
: url.includes("/api/branches")
|
||||||
|
? []
|
||||||
|
: url.includes("/api/dirs")
|
||||||
|
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
|
||||||
|
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
||||||
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("spawnForm Enter-to-submit (#97)", () => {
|
||||||
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
handle?.destroy();
|
||||||
|
handle = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const drain = async () => {
|
||||||
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||||
|
stubFetch(projects);
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
return handle.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSpy(): ReturnType<typeof vi.fn> {
|
||||||
|
return vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
|
||||||
|
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
|
||||||
|
el.dispatchEvent(evt);
|
||||||
|
return { defaultPrevented: evt.defaultPrevented };
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = (form: HTMLFormElement, path: string) =>
|
||||||
|
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||||
|
|
||||||
|
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
|
||||||
|
const form = await mount([MAESTRO]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||||
|
expect(toggle.checked).toBe(true);
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(toggle);
|
||||||
|
expect(toggle.checked).toBe(false);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
pressEnter(toggle);
|
||||||
|
expect(toggle.checked).toBe(true);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("submits immediately from ordinary fields", () => {
|
||||||
|
it("name field", async () => {
|
||||||
|
const form = await mount([MAESTRO]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("worktree branch field", async () => {
|
||||||
|
const form = await mount([MAESTRO]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom command field", async () => {
|
||||||
|
const form = await mount([MAESTRO]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
||||||
|
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
|
raw.value = "bash -l";
|
||||||
|
raw.dispatchEvent(new Event("input"));
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(raw);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raw cwd field once the autocomplete dropdown is closed", async () => {
|
||||||
|
const form = await mount([MAESTRO]);
|
||||||
|
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||||
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||||
|
raw.value = "/opt/xyz-nothing-here";
|
||||||
|
raw.dispatchEvent(new Event("input"));
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(raw);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
|
||||||
|
const form = await mount([MAESTRO]);
|
||||||
|
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||||
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||||
|
raw.focus();
|
||||||
|
raw.value = "/opt/";
|
||||||
|
raw.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
const { defaultPrevented } = pressEnter(raw);
|
||||||
|
|
||||||
|
expect(defaultPrevented).toBe(true);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
expect(raw.value).toBe("/opt/nomatch-zzz/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("two-step picker: first Enter chooses, second submits", () => {
|
||||||
|
it("project box", async () => {
|
||||||
|
const form = await mount([MAESTRO]);
|
||||||
|
const b = box(form, MAESTRO.path);
|
||||||
|
b.focus();
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
const first = pressEnter(b);
|
||||||
|
expect(first.defaultPrevented).toBe(true);
|
||||||
|
expect(b.classList.contains("is-active")).toBe(true);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
pressEnter(b);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("worktree choice", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
|
||||||
|
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(choice);
|
||||||
|
expect(choice.classList.contains("is-active")).toBe(true);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
pressEnter(choice);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project filter", () => {
|
||||||
|
it("submits with the highlighted (first matching) project, not the filter text", async () => {
|
||||||
|
const form = await mount([MAESTRO, OTHER]);
|
||||||
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||||
|
filter.value = "rev";
|
||||||
|
filter.dispatchEvent(new Event("input"));
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(filter);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||||
|
filter.value = "maestro";
|
||||||
|
filter.dispatchEvent(new Event("input"));
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(filter);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
|
||||||
|
"is-active",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
||||||
|
|
||||||
|
/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
|
||||||
|
* everything else (names, projects, gitroot) answers immediately with an
|
||||||
|
* empty/default body. Lets a test pick a preset (or type a custom command)
|
||||||
|
* mid-flight, then observe what the late `/api/info` resolution does to it. */
|
||||||
|
function stubDeferredInfo(): { resolveInfo: () => void } {
|
||||||
|
let releaseInfo: () => void = () => {};
|
||||||
|
const infoGate = new Promise<void>((resolve) => {
|
||||||
|
releaseInfo = resolve;
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/api/info")) {
|
||||||
|
await infoGate;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
hostname: "h",
|
||||||
|
version: "0",
|
||||||
|
home_dir: "/home/user",
|
||||||
|
presets: [
|
||||||
|
{ id: "claude", label: "Claude", command: "claude" },
|
||||||
|
{ id: "fish", label: "Fish", command: "fish" },
|
||||||
|
{ id: "custom", label: "Custom…", command: "" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/names")) {
|
||||||
|
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify([]), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { resolveInfo: releaseInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
||||||
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
handle?.destroy();
|
||||||
|
handle = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function drain(): Promise<void> {
|
||||||
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps an operator-picked tile after /api/info resolves late", async () => {
|
||||||
|
const { resolveInfo } = stubDeferredInfo();
|
||||||
|
let values: SpawnFormValues | undefined;
|
||||||
|
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||||
|
const form = handle.el;
|
||||||
|
document.body.appendChild(form);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
||||||
|
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||||
|
(t) => t.dataset.id === "zsh",
|
||||||
|
)!;
|
||||||
|
zshTile.click();
|
||||||
|
expect(zshTile.classList.contains("is-active")).toBe(true);
|
||||||
|
|
||||||
|
// /api/info lands late with a preset list that doesn't even include "zsh".
|
||||||
|
resolveInfo();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
// The operator's pick must not be discarded back to the new list's first
|
||||||
|
// entry — `onChange` only starts firing once the initial load settles
|
||||||
|
// (`ready`), which happens right here, so this is the first place the
|
||||||
|
// published command is observable.
|
||||||
|
expect(values?.command).toBe("zsh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an operator-typed custom command after /api/info resolves late", async () => {
|
||||||
|
const { resolveInfo } = stubDeferredInfo();
|
||||||
|
let values: SpawnFormValues | undefined;
|
||||||
|
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||||
|
const form = handle.el;
|
||||||
|
document.body.appendChild(form);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||||
|
(t) => t.dataset.id === "custom",
|
||||||
|
)!;
|
||||||
|
customTile.click();
|
||||||
|
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
|
cmdRaw.value = "htop";
|
||||||
|
cmdRaw.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
|
resolveInfo();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(values?.command).toBe("htop");
|
||||||
|
expect(cmdRaw.value).toBe("htop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still applies the real presets' first entry when the operator never touched the command", async () => {
|
||||||
|
const { resolveInfo } = stubDeferredInfo();
|
||||||
|
let values: SpawnFormValues | undefined;
|
||||||
|
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||||
|
const form = handle.el;
|
||||||
|
document.body.appendChild(form);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
resolveInfo();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
// Untouched: the refined preset list is still free to settle the command.
|
||||||
|
expect(values?.command).toBe("claude");
|
||||||
|
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||||
|
(t) => t.dataset.id === "fish",
|
||||||
|
);
|
||||||
|
expect(fishTile).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,7 +101,7 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
|
|||||||
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
|
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
|
||||||
const { form, stub } = await mount();
|
const { form, stub } = await mount();
|
||||||
|
|
||||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||||
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
|
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
|
||||||
for (let i = 1; i <= "/opt/somewhere".length; i++) {
|
for (let i = 1; i <= "/opt/somewhere".length; i++) {
|
||||||
typeRaw(form, "/opt/somewhere".slice(0, i));
|
typeRaw(form, "/opt/somewhere".slice(0, i));
|
||||||
@@ -119,7 +119,7 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
|
|||||||
it("a stale probe answer cannot overwrite a newer input", async () => {
|
it("a stale probe answer cannot overwrite a newer input", async () => {
|
||||||
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
|
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
|
||||||
|
|
||||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||||
|
|
||||||
// Probe for the old target, held in flight.
|
// Probe for the old target, held in flight.
|
||||||
typeRaw(form, "/repo/old");
|
typeRaw(form, "/repo/old");
|
||||||
|
|||||||
@@ -80,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function pickProject(form: HTMLFormElement): void {
|
function pickProject(form: HTMLFormElement): void {
|
||||||
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
|
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||||
form
|
form
|
||||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||||
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
|
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
|
||||||
}
|
}
|
||||||
|
|
||||||
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
|
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
|
||||||
@@ -118,8 +118,8 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
|
||||||
handle.el
|
handle.el
|
||||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||||
.forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click());
|
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
||||||
@@ -134,7 +134,7 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
|
|
||||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
|
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
|
||||||
// fire — debounced, so the quiet period must elapse first (#74).
|
// fire — debounced, so the quiet period must elapse first (#74).
|
||||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||||
raw.value = "/opt/somewhere";
|
raw.value = "/opt/somewhere";
|
||||||
raw.dispatchEvent(new Event("input"));
|
raw.dispatchEvent(new Event("input"));
|
||||||
@@ -180,6 +180,74 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
expect(modes.at(-1)).toBe("existing");
|
expect(modes.at(-1)).toBe("existing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
|
||||||
|
const form = await mount();
|
||||||
|
pickProject(form);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
|
||||||
|
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
|
||||||
|
|
||||||
|
expect(newLabel).not.toBe("Branch");
|
||||||
|
expect(existLabel).not.toBe("Branch");
|
||||||
|
expect(newLabel).not.toBe(existLabel);
|
||||||
|
expect(newLabel).toMatch(/name/i);
|
||||||
|
expect(existLabel).toMatch(/pick|choose|select/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses the advanced (worktree) section by default (#93)", async () => {
|
||||||
|
const form = await mount();
|
||||||
|
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
|
||||||
|
const form = await mount();
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
|
||||||
|
urls = stubFetch({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
path: "/home/user/wt-91",
|
||||||
|
name: "wt-91",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: "/home/user/maestro",
|
||||||
|
last_activity: "2026-08-20T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
handle.el
|
||||||
|
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||||
|
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
|
||||||
|
// Still collapsed — picking a project must not force it open.
|
||||||
|
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
|
||||||
|
const form = await mount();
|
||||||
|
const before = [...form.querySelectorAll(".field")];
|
||||||
|
|
||||||
|
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||||
|
|
||||||
|
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
|
||||||
|
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
||||||
const gate: { release?: () => void } = {};
|
const gate: { release?: () => void } = {};
|
||||||
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
||||||
|
|||||||
+229
-87
@@ -727,10 +727,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Earlier path segments collapse to their initial; the trailing segment (the
|
/* Earlier path segments collapse to their initial; the trailing segment (the
|
||||||
one that actually names the dir) stays full. Dim the collapsed prefix. Shared
|
one that actually names the dir) stays full. Dim the collapsed prefix. */
|
||||||
by the sidebar tree and the spawn dialog's recent-directory picker. */
|
.folder-name .folder-stem {
|
||||||
.folder-name .folder-stem,
|
|
||||||
.recent-dir .folder-stem {
|
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +736,6 @@ textarea {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
min-height: 30px;
|
|
||||||
margin: 1px var(--sp-2);
|
margin: 1px var(--sp-2);
|
||||||
padding: 4px var(--sp-3);
|
padding: 4px var(--sp-3);
|
||||||
border-radius: var(--r-1);
|
border-radius: var(--r-1);
|
||||||
@@ -789,60 +786,105 @@ textarea {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #120: every session card has two lines, so its row height is stable. */
|
||||||
|
.tree-leaf.session-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1px;
|
||||||
|
/* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */
|
||||||
|
height: 49px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-line1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.leaf-name {
|
.leaf-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: var(--fs-13);
|
font-size: var(--fs-13);
|
||||||
flex: 0 1 auto;
|
/* Grows to push the actions to the right edge but shrinks first, so the name
|
||||||
overflow: hidden;
|
truncates before it can ever reach them — overlap is a defect (#107). */
|
||||||
text-overflow: ellipsis;
|
flex: 1 1 auto;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaf-meta {
|
|
||||||
font-size: var(--fs-11);
|
|
||||||
color: var(--muted);
|
|
||||||
margin-left: auto;
|
|
||||||
padding-left: var(--sp-2);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 0 1 auto;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: inline-flex;
|
}
|
||||||
align-items: baseline;
|
|
||||||
|
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
||||||
|
state word itself is never spelled out here; the colour carries it. */
|
||||||
|
.card-line2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
|
||||||
|
|
||||||
.leaf-cmd {
|
|
||||||
color: var(--muted);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaf-badge {
|
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
letter-spacing: 0.02em;
|
min-width: 0;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border: 1px solid var(--border);
|
}
|
||||||
border-radius: var(--r-pill);
|
|
||||||
padding: 1px 8px;
|
.card-line2[data-activity="active"] {
|
||||||
|
color: var(--live);
|
||||||
|
}
|
||||||
|
.card-line2[data-activity="idle"] {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.card-line2[data-activity="awaiting-input"] {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.card-line2[data-activity="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #83: the session row's blocked-work warning — an unforced kill will be
|
.activity-duration {
|
||||||
refused, and the operator should see that before pressing the button. */
|
color: inherit;
|
||||||
.leaf-badge.leaf-blocked {
|
flex: 0 0 auto;
|
||||||
color: var(--warn, #b45309);
|
|
||||||
border-color: var(--warn, #b45309);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent-reported detail (#112): sits after the duration, shrinks with an
|
||||||
|
ellipsis so it never pushes the trailing fragments off the line. */
|
||||||
|
.activity-detail {
|
||||||
|
color: var(--faint);
|
||||||
|
/* Consume only the space left after the trailing status + command. */
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
|
||||||
|
signals severity and "exited" dims with the row. */
|
||||||
|
.card-flag {
|
||||||
|
color: var(--faint);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status fragments stay intact. The unconditional command is the final
|
||||||
|
truncation target when even the complete trailing set cannot fit. */
|
||||||
|
.leaf-cmd {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions live in line 1's flow at its right edge — never overlaid on the
|
||||||
|
name (#107). They reserve their width even while hover-faded. */
|
||||||
.leaf-actions {
|
.leaf-actions {
|
||||||
position: absolute;
|
flex: 0 0 auto;
|
||||||
right: var(--sp-1);
|
margin-left: auto;
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
@@ -1100,9 +1142,11 @@ textarea {
|
|||||||
.attach-body .xterm .xterm-viewport {
|
.attach-body .xterm .xterm-viewport {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
/* We drive scrollback from touchmove (see attach.ts); claim vertical pans so
|
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
|
||||||
the browser doesn't fight the handler or trigger gestures mid-scroll. */
|
the gesture to the browser, which may cancel the event stream before the
|
||||||
touch-action: pan-y;
|
terminal sees it; `none` keeps a drag inside the terminal under our
|
||||||
|
control while the rest of the page retains native scrolling. */
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
|
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
|
||||||
@@ -1286,18 +1330,33 @@ textarea {
|
|||||||
.worktree {
|
.worktree {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
padding: var(--sp-3);
|
padding: 0 var(--sp-3);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-3);
|
|
||||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree legend {
|
.worktree .wt-toggle {
|
||||||
padding: 0 6px;
|
margin: 0 calc(var(--sp-3) * -1);
|
||||||
color: var(--ink);
|
padding-left: var(--sp-3);
|
||||||
font-size: var(--fs-13);
|
width: calc(100% + var(--sp-3) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-summary {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-collapsible {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding-bottom: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-collapsible[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
@@ -1381,8 +1440,9 @@ textarea {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Project picker rows (#59). Worktree rows are indented under their base
|
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||||
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||||
|
selectable. */
|
||||||
.picker {
|
.picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1397,73 +1457,155 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row {
|
/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so
|
||||||
|
step two and the raw-cwd hatch stayed visible while hidden. */
|
||||||
|
.wt-step[hidden],
|
||||||
|
.raw-wrap[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker.projects {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
max-height: 300px;
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
background var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-box:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-box.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-box.is-stale {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The whole path, never abbreviated — it is what tells two projects sharing a
|
||||||
|
basename apart. Wraps rather than truncating so none of it is hidden. */
|
||||||
|
.project-path {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
color: var(--faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-meta:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-other {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 4px 0;
|
||||||
|
min-height: 30px;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-other:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step two: "+ new" (create one) or an existing worktree (run in it). */
|
||||||
|
.picker.wt-choices {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-choice {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
|
min-height: 32px;
|
||||||
padding: 6px var(--sp-3);
|
padding: 6px var(--sp-3);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row:hover {
|
.wt-choice:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row.is-active {
|
.wt-choice.is-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row.is-worktree {
|
.wt-choice-name {
|
||||||
margin-left: var(--sp-4);
|
flex: 0 0 auto;
|
||||||
}
|
|
||||||
|
|
||||||
.project-row.is-stale {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-name {
|
|
||||||
font-size: var(--fs-13);
|
font-size: var(--fs-13);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-dir {
|
.wt-choice-new .wt-choice-name {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-12);
|
color: var(--accent);
|
||||||
color: var(--muted);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-cmd {
|
.wt-choice-sub {
|
||||||
flex: 0 0 auto;
|
min-width: 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-12);
|
font-size: var(--fs-12);
|
||||||
|
line-height: 1.35;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
.project-other .recent-dir {
|
|
||||||
font-family: inherit;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-empty {
|
.picker-empty {
|
||||||
|
|||||||
@@ -95,3 +95,59 @@ describe("mergeLinear", () => {
|
|||||||
expect(merged.path).toBe("a/b");
|
expect(merged.path).toBe("a/b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("worktree sessions group under their repository (#99)", () => {
|
||||||
|
/** A session the daemon spawned into a worktree of `repo`. */
|
||||||
|
function worktreeSession(id: string, repo: string, wt: string): Session {
|
||||||
|
return { ...session(id, wt), worktree: { path: wt, base_repo: repo } } as Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level folder names, after linear chains are collapsed. */
|
||||||
|
function topFolders(root: TreeFolder): string[] {
|
||||||
|
return root.children.filter((c) => c.kind === "folder").map((c) => (c as TreeFolder).name);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("nests under the repo, not the worktree directory", () => {
|
||||||
|
const root = buildTree(
|
||||||
|
[
|
||||||
|
worktreeSession(
|
||||||
|
"a",
|
||||||
|
"/home/n/tea/agent-skills",
|
||||||
|
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"/home/n",
|
||||||
|
"",
|
||||||
|
) as TreeFolder;
|
||||||
|
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
|
const repo = root.children[0] as TreeFolder;
|
||||||
|
expect(repo.children).toHaveLength(1);
|
||||||
|
expect(repo.children[0].kind).toBe("leaf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a repo's plain and worktree sessions side by side", () => {
|
||||||
|
const root = buildTree(
|
||||||
|
[
|
||||||
|
session("plain", "/home/n/tea/agent-skills"),
|
||||||
|
worktreeSession(
|
||||||
|
"wt",
|
||||||
|
"/home/n/tea/agent-skills",
|
||||||
|
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"/home/n",
|
||||||
|
"",
|
||||||
|
) as TreeFolder;
|
||||||
|
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
|
const repo = root.children[0] as TreeFolder;
|
||||||
|
expect(repo.children).toHaveLength(2);
|
||||||
|
expect(repo.children.every((c) => c.kind === "leaf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a session without a worktree where it is", () => {
|
||||||
|
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+4
-2
@@ -2,7 +2,7 @@
|
|||||||
// so it can be tested directly.
|
// so it can be tested directly.
|
||||||
|
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
|
import { abbreviateCwd, sessionName, sessionTreePath } from "./types.js";
|
||||||
|
|
||||||
export interface TreeFolder {
|
export interface TreeFolder {
|
||||||
kind: "folder";
|
kind: "folder";
|
||||||
@@ -30,7 +30,9 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
|
|||||||
const lc = filter.trim().toLowerCase();
|
const lc = filter.trim().toLowerCase();
|
||||||
|
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
const cwd = sessionCwd(s);
|
// Grouped by repository, so a worktree session lands under its repo
|
||||||
|
// rather than in a folder of its own (#99).
|
||||||
|
const cwd = sessionTreePath(s);
|
||||||
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
||||||
if (lc) {
|
if (lc) {
|
||||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
||||||
|
|||||||
+34
-3
@@ -9,8 +9,9 @@ export type IsoTimestamp = string;
|
|||||||
export type SessionStatus = "Running" | "Dead";
|
export type SessionStatus = "Running" | "Dead";
|
||||||
|
|
||||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||||
* Absent on payloads from daemons that predate the field. */
|
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput";
|
* when an agent reports failure (#105); the daemon never infers it. */
|
||||||
|
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: Uuid;
|
id: Uuid;
|
||||||
@@ -25,8 +26,17 @@ export interface Session {
|
|||||||
foreground?: string | null;
|
foreground?: string | null;
|
||||||
/** Inferred activity. Defaults to `Idle` on the wire when omitted. */
|
/** Inferred activity. Defaults to `Idle` on the wire when omitted. */
|
||||||
activity?: ActivityState;
|
activity?: ActivityState;
|
||||||
|
/** Short human string an agent reported alongside its state (#105) — e.g.
|
||||||
|
* why it is in `Error`. Shown on a sidebar card's meta line while the
|
||||||
|
* report is in force; cleared when the report lapses or is replaced. */
|
||||||
|
activity_detail?: string | null;
|
||||||
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
||||||
last_activity_at?: IsoTimestamp;
|
last_activity_at?: IsoTimestamp;
|
||||||
|
/** Wall-clock of when the session entered its current `activity` state
|
||||||
|
* (#102) — distinct from `last_activity_at`, which only tracks PTY output.
|
||||||
|
* Defaults to the Unix epoch on payloads from daemons that predate the
|
||||||
|
* field. */
|
||||||
|
activity_since?: IsoTimestamp;
|
||||||
/** The pane's *current* working directory, as the daemon's activity watcher
|
/** The pane's *current* working directory, as the daemon's activity watcher
|
||||||
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
||||||
* Drives the directory-based session tree. Absent until first observed. */
|
* Drives the directory-based session tree. Absent until first observed. */
|
||||||
@@ -36,6 +46,16 @@ export interface Session {
|
|||||||
* without a worktree; all counts default to 0 on the wire for older
|
* without a worktree; all counts default to 0 on the wire for older
|
||||||
* daemons. */
|
* daemons. */
|
||||||
blocked_work?: InTheWay | null;
|
blocked_work?: InTheWay | null;
|
||||||
|
/** The worktree the daemon created for this session, absent when it did not
|
||||||
|
* create one. `base_repo` is the repository's main checkout, which is what
|
||||||
|
* the session tree groups by (#99). */
|
||||||
|
worktree?: SessionWorktree | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||||
|
export interface SessionWorktree {
|
||||||
|
path: string;
|
||||||
|
base_repo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One directory's immediate subdirectories (NAP-329). */
|
/** One directory's immediate subdirectories (NAP-329). */
|
||||||
@@ -231,12 +251,23 @@ export function sessionName(s: Session): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
||||||
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
|
* observed one, else the spawn `cwd`. */
|
||||||
export function sessionCwd(s: Session): string | undefined {
|
export function sessionCwd(s: Session): string | undefined {
|
||||||
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
||||||
return s.cwd || undefined;
|
return s.cwd || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a session sits in the directory tree. A worktree session belongs to
|
||||||
|
* its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
||||||
|
* grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
||||||
|
* session of the same name, and the repository stops being where you look for
|
||||||
|
* the work. `base_repo` is the main checkout — the daemon resolves it. */
|
||||||
|
export function sessionTreePath(s: Session): string | undefined {
|
||||||
|
const repo = s.worktree?.base_repo;
|
||||||
|
if (typeof repo === "string" && repo) return repo;
|
||||||
|
return sessionCwd(s);
|
||||||
|
}
|
||||||
|
|
||||||
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
|
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
|
||||||
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
||||||
export function abbreviateCwd(cwd: string, home?: string): string {
|
export function abbreviateCwd(cwd: string, home?: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Second step of the working-directory picker (#95). See the interaction rules
|
||||||
|
// block in spawnForm.ts for what each choice means.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
||||||
|
import type { Project } from "./types.js";
|
||||||
|
|
||||||
|
const MAESTRO: Project = {
|
||||||
|
path: "/home/user/tea/maestro",
|
||||||
|
name: "maestro",
|
||||||
|
is_worktree: false,
|
||||||
|
last_activity: "2026-08-20T10:00:00Z",
|
||||||
|
last_command: "claude",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WT_A: Project = {
|
||||||
|
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
||||||
|
name: "bulgarians",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: MAESTRO.path,
|
||||||
|
last_activity: "2026-08-22T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WT_B: Project = {
|
||||||
|
path: "/home/user/tea/maestro/worktrees/spawn-v2",
|
||||||
|
name: "spawn-v2",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: MAESTRO.path,
|
||||||
|
last_activity: "2026-08-21T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OTHER: Project = {
|
||||||
|
path: "/home/user/tea/rev",
|
||||||
|
name: "rev",
|
||||||
|
is_worktree: false,
|
||||||
|
last_activity: "2026-08-19T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function stubFetch(projects: Project[]): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
urls.push(url);
|
||||||
|
const body = url.includes("/api/projects")
|
||||||
|
? projects
|
||||||
|
: url.includes("/api/names")
|
||||||
|
? { names: ["amber-fox"] }
|
||||||
|
: url.includes("/api/branches")
|
||||||
|
? []
|
||||||
|
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
||||||
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("spawnForm worktree step (#95)", () => {
|
||||||
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
let urls: string[] = [];
|
||||||
|
let changes: SpawnFormValues[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
||||||
|
changes = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
handle?.destroy();
|
||||||
|
handle = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const drain = async () => {
|
||||||
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||||
|
urls = stubFetch(projects);
|
||||||
|
handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
return handle.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
|
||||||
|
const choices = (form: HTMLFormElement) => [
|
||||||
|
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
|
||||||
|
];
|
||||||
|
const activeChoice = (form: HTMLFormElement) =>
|
||||||
|
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
|
||||||
|
const box = (form: HTMLFormElement, path: string) =>
|
||||||
|
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||||
|
const toggle = (form: HTMLFormElement) =>
|
||||||
|
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||||
|
|
||||||
|
it("hides the worktree step until a project is picked", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A]);
|
||||||
|
expect(step(form).hidden).toBe(true);
|
||||||
|
expect(choices(form)).toEqual([]);
|
||||||
|
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(step(form).hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
|
||||||
|
expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]);
|
||||||
|
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
||||||
|
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||||
|
expect(toggle(form).checked).toBe(true);
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
|
||||||
|
// The scan already knows the repo — no probe.
|
||||||
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
|
||||||
|
existing.click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
||||||
|
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
||||||
|
expect(toggle(form).checked).toBe(false);
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||||
|
expect(existing.classList.contains("is-active")).toBe(true);
|
||||||
|
// Still no probe: the scan reported this worktree's repo membership.
|
||||||
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||||
|
// The project box stays marked — it is what the worktree list belongs to.
|
||||||
|
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switching back to '+ new' re-enables the create toggle", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
choices(form)
|
||||||
|
.find((c) => c.dataset.cwd === WT_A.path)!
|
||||||
|
.click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
choices(form)
|
||||||
|
.find((c) => c.dataset.kind === "new")!
|
||||||
|
.click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
||||||
|
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||||
|
expect(toggle(form).checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repopulates and resets to '+ new' when another project is picked", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A, OTHER]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
choices(form)
|
||||||
|
.find((c) => c.dataset.cwd === WT_A.path)!
|
||||||
|
.click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
box(form, OTHER.path).click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
|
||||||
|
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||||
|
expect(changes.at(-1)!.cwd).toBe(OTHER.path);
|
||||||
|
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
|
||||||
|
// No main checkout in the scan, so the orphan worktree is its own box.
|
||||||
|
const form = await mount([WT_A]);
|
||||||
|
box(form, WT_A.path).click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
||||||
|
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
||||||
|
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
||||||
|
expect(toggle(form).checked).toBe(false);
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
expect(step(form).hidden).toBe(false);
|
||||||
|
|
||||||
|
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(step(form).hidden).toBe(true);
|
||||||
|
expect(choices(form)).toEqual([]);
|
||||||
|
expect(form.querySelector(".project-box.is-active")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the picked worktree from a draft seed", async () => {
|
||||||
|
urls = stubFetch([MAESTRO, WT_A, WT_B]);
|
||||||
|
handle = createSpawnForm({
|
||||||
|
onSuccess() {},
|
||||||
|
seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false },
|
||||||
|
});
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
const form = handle.el;
|
||||||
|
|
||||||
|
expect(step(form).hidden).toBe(false);
|
||||||
|
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
||||||
|
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||||
|
expect(toggle(form).checked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user