Compare commits

...
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 d032036542 feat(maestro): move worktree choice behind advanced options
ci / flake (push) Successful in 5m12s
ci / rust (push) Successful in 2m41s
ci / web (push) Successful in 35s
The worktree step ("+ new" plus the repository's existing worktrees) now
lives inside the collapsed "Advanced options" section in both the web form
and the TUI draft pane, so a plain spawn is command, project, name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 08:42:00 +01:00
Miguel PalhasandClaude Opus 5 43b90ccf0a feat(maestro): rename a session from the web sidebar
ci / web (push) Successful in 31s
ci / rust (push) Successful in 5m26s
ci / flake (push) Successful in 8m13s
Right-clicking a card opens a custom context menu (rename, spawn similar,
kill). Rename swaps the name for an inline field; Enter commits, Escape and
blur cancel. The tree stops rebuilding while the field is open, so a catalog
delta cannot replace what is being typed into.

Refs #125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:32:20 +01:00
Miguel PalhasandClaude Opus 5 bdeec6eff5 feat(maestro): rename the highlighted session from the TUI sidebar
`r` opens a prompt pre-filled with the session's own name — empty when it
has none, so Enter cannot pin the command as the name. Enter saves, Escape
cancels, and an emptied field clears the name.

Refs #125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:29:05 +01:00
Miguel PalhasandClaude Opus 5 345650172b feat(maestro): rename a session over PATCH /api/sessions/:id
The daemon trims and caps the name, persists it to the sidecar under the
sessions lock, and broadcasts SessionUpdated. A blank name clears
metadata.name so the card falls back to the command, matching the rule the
spawn form already uses.

Refs #125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:26:12 +01:00
Miguel Palhas 999dbe3ea3 Merge remote-tracking branch 'origin/main' into themes
ci / web (push) Successful in 1m27s
ci / rust (push) Successful in 5m4s
ci / flake (push) Successful in 6m57s
2026-08-25 18:46:35 +01:00
Miguel Palhas c9d089534b enable session scrolling across clients
ci / web (push) Successful in 59s
ci / rust (push) Successful in 6m8s
ci / flake (push) Successful in 9m52s
2026-08-25 18:46:26 +01:00
Miguel PalhasandClaude Fable 5 573a2b4a07 feat(maestro): add nine editor-theme TUI palettes
Solarized (dark/light), Catppuccin Mocha, GitHub (dark/light),
Tokyo Night, Gruvbox, Nord, Dracula. Colours auto-tuned from the
canonical seeds to clear the palette contrast floors; tests now
assert every RGB preset, not just dark/light.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:44:12 +01:00
Miguel Palhas 9ef3547a00 refine TUI navigation and chrome
ci / web (push) Successful in 30s
ci / rust (push) Successful in 4m41s
ci / flake (push) Successful in 7m13s
2026-08-25 15:10:47 +01:00
Miguel Palhas 2ab07c2343 fix(maestro): harden grouped sidebar interactions
ci / web (push) Successful in 1m29s
ci / rust (push) Successful in 5m25s
ci / flake (push) Successful in 7m56s
2026-08-25 13:59:38 +01:00
Miguel Palhas 342e732ef0 merge: TUI shell/124 — collapsible project groups 2026-08-25 13:54:09 +01:00
Miguel Palhas 3205270b51 feat(maestro): group TUI sidebar by project 2026-08-25 13:53:51 +01:00
Miguel Palhas d8e31f2760 fix(maestro): resize PTY to bordered pane content 2026-08-25 13:37:30 +01:00
Miguel Palhas fdf9ea1b31 merge: TUI shell/123 — unmistakable panel focus 2026-08-25 13:31:24 +01:00
Miguel Palhas 22cbfbc9bc merge: TUI shell/122 — configurable theme presets 2026-08-25 13:31:24 +01:00
Miguel Palhas 8157793282 feat(maestro): add TUI theme presets 2026-08-25 13:30:59 +01:00
Miguel Palhas 839dd27925 feat(maestro): make TUI panel focus unmistakable 2026-08-25 13:26:54 +01:00
Miguel Palhas a1816a0838 fix(maestro): preserve fixed card trailing fragments 2026-08-25 13:18:24 +01:00
Miguel Palhas e01b0616b1 merge: TUI shell/121 — TUI cards always render line two 2026-08-25 13:11:47 +01:00
Miguel Palhas 74d8d3c37a merge: TUI shell/120 — web cards always render line two 2026-08-25 13:11:47 +01:00
Miguel Palhas cc9b027bd1 feat(maestro): make TUI session cards two lines 2026-08-25 13:11:06 +01:00
Miguel Palhas 10a25e5b83 feat(maestro): keep web session cards two lines 2026-08-25 13:06:23 +01:00
Miguel Palhas 4c3f65027d fix(maestro): make TUI chrome contrast palette-controlled 2026-08-25 13:02:48 +01:00
Miguel Palhas 76eb3d1386 merge: TUI shell/119 — bare maestro opens the TUI 2026-08-25 12:55:25 +01:00
Miguel Palhas 877f409194 merge: TUI shell/117 — shared path-abbreviation helpers 2026-08-25 12:55:25 +01:00
Miguel Palhas 39d4014dcb merge: TUI shell/118 — TUI colours behind named palette roles
ci / web (push) Successful in 54s
ci / flake (push) Successful in 8m33s
ci / rust (push) Successful in 8m57s
Closes #118
2026-08-25 12:35:38 +01:00
Miguel Palhas f02510465c docs(maestro): bare maestro opens the TUI 2026-08-25 12:34:36 +01:00
Miguel Palhas 8e251bf8be feat(maestro): default a missing subcommand to the TUI 2026-08-25 12:34:36 +01:00
Miguel Palhas bc95d16a98 docs(maestro): note PTY colours are passthrough, not palette roles 2026-08-25 12:31:46 +01:00
Miguel Palhas 975f3ef0c5 feat(maestro): move path-abbreviation helpers into maestro-protocol
Port abbreviateCwd, abbreviateFolderName, mergeLinear, and
sessionTreePath from web/src/{types,tree}.ts into a maestro-protocol
display module, so the TUI sidebar can group sessions with the same
labels the web UI uses (#117). abbreviate_folder_name returns the
collapsed stem and last segment separately instead of escaped HTML,
so a ratatui caller can style the stem without parsing markup.

maestro-cli's own abbreviate_dir moves into the new module; the CLI
and the worktrees list now call the shared implementation.

Closes #117
2026-08-25 12:31:43 +01:00
Miguel Palhas f0108c5645 Merge branch 'blitz/tui-shell' into issue/118-palette-roles 2026-08-25 12:31:17 +01:00
Miguel Palhas c534cee7cc feat(maestro): palette of named colour roles for the TUI
Every colour the TUI painted on its own chrome was a named ratatui
colour spelled out at the use site, so nothing could be themed and the
selected card had broken contrast: the highlighted card's black-on-cyan
base was patched with an activity colour chosen for the dark card
background, leaving AwaitingInput yellow on light blue.

Roles now live in `palette.rs`, named for meaning. A palette holds one
role set per surface — card and selection — plus the panel border in
its focused and unfocused forms, a focus accent, a group-row style for
the sidebar grouping, the status-bar mode indicators and error text.
Each surface carries its own activity colours, so the selected card's
dot keeps carrying state without wearing a colour picked for another
background.

Values are `Color::Rgb`, so contrast is asserted rather than left to
the terminal theme: every role clears WCAG AA on its own surface, and a
sidebar test fails if a card-surface state colour is painted onto the
selection background.

PTY cell colours stay a passthrough of what the program in the pane
emits — they are not chrome and not a palette role.
2026-08-25 12:31:14 +01:00
Miguel Palhas c2ac3f7eb6 merge: TUI shell/116 — line 2 is unconditional
ci / web (push) Successful in 28s
ci / rust (push) Successful in 6m44s
ci / flake (push) Successful in 8m9s
Closes #116
2026-08-25 12:26:24 +01:00
Miguel Palhas c118020b29 docs(maestro): make sidebar card line 2 unconditional
Line 2 was rendered only past a 30s activity hold, blocked work,
exit, or reported Error, and <command> was gated behind a
metadata.name comparison and TUI-optional. Both worked against
scanability: cards changed height as sessions aged, and fresh
sessions hid their command.

Card is now always two lines. The 30s hold rule survives, demoted
to gating only the <duration> fragment. <command> is unconditional
in both clients.

Closes #116
2026-08-25 12:24:56 +01:00
Miguel Palhas 7cb14c7aae chore: empty commit to test the nix-autodeploy webhook
ci / web (push) Successful in 36s
ci / rust (push) Successful in 4m39s
ci / flake (push) Successful in 7m9s
2026-08-25 10:37:16 +01:00
Miguel Palhas 80a9399905 feat(maestro): session cards in the sidebar
ci / web (push) Successful in 29s
ci / rust (push) Successful in 4m25s
ci / flake (push) Successful in 6m34s
Turns each sidebar row into a card: name plus actions on line 1, an
adaptive meta line below carrying a colour-coded activity dot, the time
held in that state, any agent-reported detail, and the blocked-work or
exited markers that used to crowd the name row.

Renders the activity dimension the daemon already inferred and adds an
Error state fed by a Claude Code hook, which maestro installs globally
and addresses per session through MAESTRO_SESSION_ID / MAESTRO_URL.
Both clients implement one contract, docs/SIDEBAR.md.

Closes #101, #102, #103, #104, #105, #106, #107, #108, #110, #111, #112,
#113, #114, #115.
2026-08-24 23:26:59 +01:00
Miguel Palhas 9d9a2e723d merge: sidebar-cards/115-fourth-flake 2026-08-24 23:13:38 +01:00
Miguel Palhas bbafff5a98 fix(maestro): add settle-finished observable for natural-exit test
The no-retained-store test asserts a negative — the tree survives — so
waiting on that negative proves nothing: settle could still remove the
tree one instant after the assertion passes. mark_session_dead now flips
a per-record settled flag once the exit rule has been applied or refused,
exposed as SessionManager::is_settled; the test waits on it before
asserting.

Refs #115
2026-08-24 23:12:46 +01:00
Miguel Palhas 17e1dc4049 merge: sidebar-cards/114-hook-error 2026-08-24 23:01:14 +01:00
Miguel Palhas 436cb194c3 merge: sidebar-cards/113-more-flakes 2026-08-24 23:00:54 +01:00
Miguel Palhas 6471da2624 feat(maestro): report StopFailure as Error with the error spelling as detail 2026-08-24 22:54:21 +01:00
Miguel Palhas 2a43c76176 feat(maestro): report Active while a long tool call is still running 2026-08-24 22:52:51 +01:00
Miguel Palhas f689c916bf fix(maestro): poll awaiting-input until leaderless state holds
The pipeline leader exits as soon as both stages are forked, which can
beat the surviving stage opening the tty and parking on read — the one-
shot assertion then ran against a group with no parked member yet.
Poll for the answer, accepting it only while the foreground group is
still leaderless so a shell that regained the tty cannot satisfy it.

Fixes #113
2026-08-24 22:50:02 +01:00
Miguel Palhas 88233cb96d fix(maestro): sync watcher re-emit test to real event flow
Two races. First, the watcher updates the snapshot and emits
SessionUpdated as two steps, so the drain could run between them: the
legitimate breakdown emission then arrived inside the quiet window.
Subscribe before the watcher starts and consume through the breakdown
event so the window begins after the last real change.

Second, the assertion treated any SessionUpdated as a failed
suppression, but the activity pass may legitimately announce an
Active→Idle transition in that window (ACTIVE_WINDOW is 3s), carrying
today's blocked_work unchanged. Only a changed indicator counts as a
re-emit now.

Fixes #113
2026-08-24 22:50:02 +01:00
Miguel Palhas 54d84e3eaa docs(maestro): settle Error report TTL and clearing rulings 2026-08-24 22:46:46 +01:00
Miguel Palhas efbccb4e11 fix(maestro): wait for settle in pristine natural-exit test
Dead status is observable a moment before the settle step removes the
worktree, so the assertions raced the removal under parallel load. Wait
for the tree to be gone and the store to stay empty, same as the
retained-store wait cfebfd4 added to the guarded sibling.

Fixes #113
2026-08-24 22:41:54 +01:00
Miguel Palhas 41c498401b merge: sidebar-cards/112-detail-render 2026-08-24 22:30:49 +01:00
Miguel Palhas 6ca347b657 merge: sidebar-cards/106-hooks-install 2026-08-24 22:30:49 +01:00
Miguel Palhas 1b4981f9ce Merge remote-tracking branch 'origin/blitz/sidebar-cards' into sidebar-cards/106-hooks-install 2026-08-24 22:26:48 +01:00
Miguel Palhas 1b289a0450 feat(maestro): daemon auto-installs claude-code hook on first start 2026-08-24 22:25:54 +01:00
Miguel Palhas 3c5bb51e88 feat(maestro): add maestro hooks install subcommand 2026-08-24 22:22:49 +01:00
Miguel Palhas eb605a3d05 feat(maestro): ship claude-code activity hook + idempotent installer 2026-08-24 22:18:23 +01:00
Miguel Palhas 4c9409a785 feat(maestro): add [hooks] auto_install config section 2026-08-24 22:14:15 +01:00
57 changed files with 5061 additions and 857 deletions
Generated
+11
View File
@@ -1065,6 +1065,7 @@ dependencies = [
"futures-util",
"libc",
"maestro-config",
"maestro-hooks",
"maestro-protocol",
"maestro-testing",
"nix",
@@ -1102,6 +1103,7 @@ dependencies = [
"futures-util",
"libc",
"maestro-config",
"maestro-hooks",
"maestro-names",
"maestro-protocol",
"reqwest",
@@ -1137,6 +1139,15 @@ dependencies = [
"uuid",
]
[[package]]
name = "maestro-hooks"
version = "0.1.0"
dependencies = [
"serde_json",
"tempfile",
"thiserror 1.0.69",
]
[[package]]
name = "maestro-installer"
version = "0.1.0"
+1
View File
@@ -18,6 +18,7 @@ dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2"
maestro-config = { path = "crates/maestro-config" }
maestro-hooks = { path = "crates/maestro-hooks" }
maestro-names = { path = "crates/maestro-names" }
maestro-protocol = { path = "crates/maestro-protocol" }
maestro-testing = { path = "crates/maestro-testing" }
+1
View File
@@ -17,6 +17,7 @@ dirs.workspace = true
futures-util.workspace = true
libc.workspace = true
maestro-config.workspace = true
maestro-hooks.workspace = true
maestro-protocol = { workspace = true, features = ["client"] }
nix.workspace = true
serde_json.workspace = true
+34
View File
@@ -43,6 +43,23 @@ pub enum Command {
Kill(KillArgs),
/// List retained worktrees, or reclaim one by ID (#86).
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>`
@@ -275,6 +292,23 @@ mod tests {
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]
fn parses_worktrees_list() {
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
+33
View File
@@ -24,6 +24,10 @@ pub enum CliError {
#[error("{0}")]
Attach(#[from] crate::attach::AttachError),
#[error("{0}")]
Hooks(#[from] maestro_hooks::HooksError),
#[error("could not determine the home directory ($HOME unset?)")]
NoHome,
#[error("{0}")]
Io(#[from] std::io::Error),
}
@@ -40,6 +44,15 @@ pub async fn dispatch(
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)?;
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 {
WorktreesCommand::List(list_args) => {
let mut stdout = std::io::stdout().lock();
@@ -101,6 +115,25 @@ pub async fn dispatch(
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`.
const CTRL_Q: u8 = 0x11;
+1 -15
View File
@@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
SessionStatus, WorktreeSpec,
@@ -138,21 +139,6 @@ pub(crate) fn blocked_label(s: &Session) -> String {
.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)]
mod tests {
use std::collections::HashMap;
+1 -1
View File
@@ -6,10 +6,10 @@
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
use crate::sessions::abbreviate_dir;
/// `maestro worktrees list`
pub async fn run_list(
+138 -4
View File
@@ -22,7 +22,8 @@ use std::collections::BTreeMap;
use std::net::SocketAddr;
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`).
pub const DIR_NAME: &str = "maestro";
@@ -64,6 +65,24 @@ pub struct MaestroConfigFile {
pub tui: TuiSection,
#[serde(default)]
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)]
@@ -128,11 +147,14 @@ pub struct WebSection {
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
///
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
/// applied **over** the defaults, so a partial override is enough.
/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI
/// ships with a default keymap; entries in `keymap_normal` / `keymap_insert`
/// are applied **over** the defaults, so a partial override is enough.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
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`.
#[serde(default)]
pub leader: Option<String>,
@@ -149,6 +171,75 @@ pub struct TuiSection {
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).
///
/// 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());
}
#[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]
fn spawn_presets_default_to_builtins_plus_custom() {
// 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 {
toml::from_str(body).expect("parse")
}
+1
View File
@@ -18,6 +18,7 @@ dirs.workspace = true
futures-util.workspace = true
libc.workspace = true
maestro-config.workspace = true
maestro-hooks.workspace = true
maestro-names.workspace = true
maestro-protocol.workspace = true
serde.workspace = true
+14
View File
@@ -47,6 +47,20 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let _ = default_state_dir();
std::fs::create_dir_all(config.sidecar_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
// under their base repos, created on demand (#56).
+26
View File
@@ -88,6 +88,10 @@ pub struct DaemonConfig {
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
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 {
@@ -197,6 +201,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
spawn_presets,
project_roots,
project_scan_depth,
hooks_auto_install: file.hooks.auto_install(),
})
}
@@ -233,6 +238,27 @@ pub fn system_hostname() -> String {
mod tests {
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]
fn empty_file_defaults_everything() {
let dir = tempfile::tempdir().unwrap();
+76
View File
@@ -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);
}
}
+1
View File
@@ -14,6 +14,7 @@ pub mod cli;
pub mod config;
pub mod dirlist;
pub mod gitroot;
pub mod hooks;
pub mod local_api;
pub mod panes;
pub mod proc;
+27 -2
View File
@@ -20,7 +20,7 @@ use axum::{Json, Router};
use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
ReportActivityRequest, Session, PROTOCOL_VERSION,
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
@@ -47,7 +47,10 @@ pub fn router(state: LocalApiState) -> Router {
Router::new()
.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).patch(rename_session),
)
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
.route(routes::PROJECTS, get(projects))
@@ -213,6 +216,28 @@ async fn kill_session(
}
}
/// `PATCH /api/sessions/:id` (#125): rename a session. The name is trimmed
/// and capped server-side; a blank one clears `metadata.name` so the session
/// falls back to its command. Answers with the updated session, which also
/// goes out as `SessionUpdated` to every events subscriber.
async fn rename_session(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<RenameSessionRequest>,
) -> Response {
match s.manager.rename(id, &body.name).await {
Ok(session) => (StatusCode::OK, Json(session)).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(),
)
}
}
}
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
/// breakdown recomputed at read time — never stored, so a tree whose work has
/// since been committed or pushed says so. Entries whose directory somebody
+58
View File
@@ -62,6 +62,11 @@ pub struct SessionRecord {
/// 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)]
@@ -294,6 +299,17 @@ impl SessionManager {
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`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
@@ -429,6 +445,7 @@ impl SessionManager {
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
bridge,
reported_activity: None,
settled: false,
},
);
}
@@ -657,6 +674,7 @@ impl SessionManager {
worktree_branch: None,
bridge,
reported_activity: None,
settled: false,
},
);
let _ = self
@@ -698,6 +716,38 @@ impl SessionManager {
.map_err(SessionError::Tmux)
}
/// Rename a session (#125): set `metadata.name`, or clear it when the
/// requested name is blank so the session falls back to its command.
///
/// The new name is persisted to the sidecar before the event goes out — a
/// rename the operator can see but that a daemon restart would undo is
/// worse than one that failed outright.
pub async fn rename(&self, id: Uuid, name: &str) -> Result<Session, SessionError> {
// The sidecar write happens under the sessions lock so what is on disk
// and what is in memory can never disagree about the newest rename.
let session = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
let mut renamed = rec.session.clone();
match maestro_protocol::normalize_session_name(name) {
Some(name) => renamed
.metadata
.insert("name".into(), serde_json::Value::String(name)),
None => renamed.metadata.remove("name"),
};
self.inner
.sidecar
.write(&renamed)
.map_err(SessionError::Sidecar)?;
rec.session = renamed.clone();
renamed
};
let _ = self.inner.events.send(CatalogEvent::SessionUpdated {
session: session.clone(),
});
Ok(session)
}
/// 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
@@ -1069,6 +1119,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.events.send(CatalogEvent::SessionUpdated { session });
+19 -4
View File
@@ -189,8 +189,23 @@ fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
"never observed a foreground group whose leader had exited"
);
assert!(
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
"a surviving pipeline member still holds the tty"
);
// The leader can be gone while the surviving stage is still setting up:
// `true` exits as soon as both stages are forked, before the reader has
// 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");
}
+42
View File
@@ -323,6 +323,48 @@ async fn delete_unknown_session_returns_404() {
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
/// #125: the rename endpoint answers with the updated session, and the name
/// it stored is the trimmed one.
#[tokio::test]
async fn patch_session_renames_it() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let resp = reqwest::Client::new()
.patch(url(h.addr, &format!("/api/sessions/{}", session.id)))
.json(&serde_json::json!({ "name": " crimson-otter " }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let updated: Session = resp.json().await.unwrap();
assert_eq!(
updated.metadata.get("name").and_then(|v| v.as_str()),
Some("crimson-otter")
);
}
#[tokio::test]
async fn patch_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.patch(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
.json(&serde_json::json!({ "name": "nope" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_owned_session_kills_it() {
let h = spawn("naps-laptop").await;
+138
View File
@@ -0,0 +1,138 @@
//! Renaming a live session (#125): `SessionManager::rename` semantics —
//! what lands in `metadata.name`, what reaches the sidecar, and what goes out
//! on the event stream.
mod common;
use std::sync::Arc;
use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{CatalogEvent, CreateSessionRequest, MAX_SESSION_NAME_LEN};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
_state_dir: TempDir,
}
fn fixture() -> Fixture {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
sidecar.clone(),
WorktreeManager::new(None),
state_dir.path().join("fifos"),
);
Fixture {
manager,
sidecar,
_tmux: tmux,
_state_dir: state_dir,
}
}
async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
let metadata = match name {
Some(n) => std::collections::HashMap::from([(
"name".to_string(),
serde_json::Value::String(n.to_string()),
)]),
None => Default::default(),
};
manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
metadata,
worktree: None,
})
.await
.unwrap()
.id
}
fn name_of(session: &maestro_protocol::Session) -> Option<&str> {
session.metadata.get("name").and_then(|v| v.as_str())
}
#[tokio::test]
async fn rename_on_unknown_session_is_not_found() {
let fx = fixture();
let err = fx
.manager
.rename(Uuid::new_v4(), "whatever")
.await
.unwrap_err();
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
}
#[tokio::test]
async fn rename_sets_the_name_in_memory_and_on_disk() {
let fx = fixture();
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
let renamed = fx.manager.rename(id, " crimson-otter ").await.unwrap();
assert_eq!(name_of(&renamed), Some("crimson-otter"));
let snapshot = fx.manager.snapshot().await;
let live = snapshot.iter().find(|s| s.id == id).unwrap();
assert_eq!(name_of(live), Some("crimson-otter"));
// A rename a daemon restart would undo is worse than one that failed.
let persisted = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(name_of(&persisted), Some("crimson-otter"));
}
#[tokio::test]
async fn a_blank_rename_clears_the_name() {
let fx = fixture();
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
let renamed = fx.manager.rename(id, " ").await.unwrap();
assert_eq!(name_of(&renamed), None);
// Line 1 falls back to the command, per docs/SIDEBAR.md.
assert_eq!(renamed.command, "sleep 60");
assert_eq!(name_of(&fx.sidecar.read(id).unwrap().unwrap()), None);
}
#[tokio::test]
async fn rename_caps_a_long_name() {
let fx = fixture();
let id = spawn_named(&fx.manager, None).await;
let long = "x".repeat(MAX_SESSION_NAME_LEN + 20);
let renamed = fx.manager.rename(id, &long).await.unwrap();
assert_eq!(
name_of(&renamed).unwrap().chars().count(),
MAX_SESSION_NAME_LEN
);
}
#[tokio::test]
async fn rename_broadcasts_a_session_update() {
let fx = fixture();
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
let mut events = fx.manager.events();
fx.manager.rename(id, "crimson-otter").await.unwrap();
let event = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
.await
.expect("an update within the timeout")
.expect("the event channel stays open");
match event {
CatalogEvent::SessionUpdated { session } => {
assert_eq!(session.id, id);
assert_eq!(name_of(&session), Some("crimson-otter"));
}
other => panic!("expected SessionUpdated, got {other:?}"),
}
}
+53 -4
View File
@@ -397,6 +397,12 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
init_bare_repo(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(
Duration::from_millis(50),
Duration::from_millis(100),
@@ -418,14 +424,38 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
})
.await;
// A steady state must not re-emit every tick (#83 change-detection).
let mut events = fx.manager.events();
// Consume through the emission that carried the breakdown (#83
// 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() {}
tokio::time::sleep(Duration::from_millis(400)).await;
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
// The activity pass may still legitimately announce e.g. Active→Idle
// (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!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
"an unchanged worktree must not re-emit SessionUpdated"
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
@@ -848,6 +878,17 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
})
.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!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
@@ -905,6 +946,14 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
})
.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!(
wt_path.exists(),
"nothing may be destroyed when it cannot be recorded"
+15
View File
@@ -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
+261
View File
@@ -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;
+340
View File
@@ -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);
}
}
+13 -1
View File
@@ -16,7 +16,8 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
KillOutcome, Project, RecentDirs, ReportActivityRequest, RetainedWorktree, Session,
KillOutcome, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest,
RetainedWorktree, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -134,6 +135,17 @@ impl DaemonClient {
json(resp).await
}
/// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name
/// clears the custom one, so the session falls back to its command. The
/// daemon trims and caps the name and answers with the updated session.
pub async fn rename_session(&self, id: Uuid, name: &str) -> Result<Session, ClientError> {
let req = RenameSessionRequest { name: name.into() };
let resp = self
.send(Method::PATCH, &routes::session(id), &[], Some(&req))
.await?;
json(check(resp).await?).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`.
+321
View File
@@ -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");
}
}
+64
View File
@@ -9,6 +9,7 @@
#[cfg(feature = "client")]
pub mod client;
pub mod display;
pub mod routes;
#[cfg(feature = "client")]
@@ -494,6 +495,31 @@ pub struct NameBatch {
pub names: Vec<String>,
}
/// Longest session name the daemon stores (#125), in characters. A name is a
/// sidebar card's line 1; past this it only truncates on every client anyway.
pub const MAX_SESSION_NAME_LEN: usize = 64;
/// HTTP `PATCH /api/sessions/:id` body (#125): rename a live session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenameSessionRequest {
pub name: String,
}
/// Trim a requested session name and cap it at [`MAX_SESSION_NAME_LEN`]
/// characters. `None` when nothing is left — that clears `metadata.name` and
/// the card falls back to the command, the same rule the spawn form uses when
/// its name field is blank.
///
/// Shared so the daemon and every Rust client agree on what a name becomes;
/// `web/src/types.ts` carries the same function for the browser.
pub fn normalize_session_name(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.chars().take(MAX_SESSION_NAME_LEN).collect())
}
// ---------------------------------------------------------------------------
// Catalog events (`WS /api/events`)
// ---------------------------------------------------------------------------
@@ -963,4 +989,42 @@ mod tests {
assert_eq!(c, round_trip(&c));
}
}
/// #125: a rename request is trimmed and capped; a blank one clears the
/// name so the card falls back to the command.
#[test]
fn session_names_are_trimmed_capped_and_blankable() {
assert_eq!(
normalize_session_name(" amber-falcon "),
Some("amber-falcon".into())
);
assert_eq!(normalize_session_name(""), None);
assert_eq!(normalize_session_name(" \t "), None);
let long = "x".repeat(MAX_SESSION_NAME_LEN + 10);
assert_eq!(
normalize_session_name(&long).unwrap().chars().count(),
MAX_SESSION_NAME_LEN
);
}
/// The cap counts characters, not bytes — a multi-byte name must not be
/// sliced mid-codepoint.
#[test]
fn session_name_cap_counts_characters() {
let name = "é".repeat(MAX_SESSION_NAME_LEN + 5);
let capped = normalize_session_name(&name).unwrap();
assert_eq!(capped.chars().count(), MAX_SESSION_NAME_LEN);
}
#[test]
fn rename_request_round_trips() {
let req = RenameSessionRequest {
name: "amber-falcon".into(),
};
assert_eq!(req, round_trip(&req));
assert_eq!(
serde_json::to_value(&req).unwrap(),
serde_json::json!({"name": "amber-falcon"})
);
}
}
+70 -42
View File
@@ -31,9 +31,14 @@ pub enum Action {
FocusSidebar,
FocusPane(Direction),
// Session scrollback
ScrollPanePageUp,
ScrollPanePageDown,
// Sidebar highlight movement
SidebarNext,
SidebarPrev,
ToggleSidebarGroup,
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
@@ -43,16 +48,12 @@ pub enum Action {
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
/// Rename the highlighted session (#125): opens a prompt pre-filled with
/// its current name.
RenameHighlighted,
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Tabs
NewTab,
CloseTab,
NextTab,
PrevTab,
GotoTab(u8),
// Chrome
ToggleSidebar,
ShowHelp,
@@ -70,8 +71,40 @@ pub enum ActionParseError {
}
impl Action {
/// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`,
/// or `"GotoTab(3)"`.
/// Plain-language label for the in-app help menu. Configuration keeps the
/// 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::RenameHighlighted => "Rename 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> {
let s = s.trim();
match s {
@@ -79,18 +112,18 @@ impl Action {
"EnterInsert" => return Ok(Action::EnterInsert),
"LeaveInsert" => return Ok(Action::LeaveInsert),
"FocusSidebar" => return Ok(Action::FocusSidebar),
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
"SidebarNext" => return Ok(Action::SidebarNext),
"SidebarPrev" => return Ok(Action::SidebarPrev),
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"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),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
@@ -111,13 +144,6 @@ impl Action {
})?;
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())),
}
}
@@ -137,7 +163,6 @@ impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
Action::GotoTab(n) => write!(f, "GotoTab({n})"),
other => write!(f, "{other:?}"),
}
}
@@ -154,6 +179,13 @@ mod tests {
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]
fn parses_focus_pane_directions() {
assert_eq!(
@@ -166,12 +198,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]
fn rejects_unknown_action() {
let err = Action::parse("Frobnicate").unwrap_err();
@@ -186,22 +212,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]
fn rejects_unclosed_paren() {
let err = Action::parse("GotoTab(3").unwrap_err();
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
let err = Action::parse("FocusPane(Left").unwrap_err();
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
}
/// The fast-forward pair sits next to the draft-pane key (#67).
@@ -214,14 +228,28 @@ mod tests {
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
}
#[test]
fn rename_action_round_trips() {
assert_eq!(
Action::parse("RenameHighlighted").unwrap(),
Action::RenameHighlighted
);
assert_eq!(Action::RenameHighlighted.to_string(), "RenameHighlighted");
assert_eq!(
Action::RenameHighlighted.help_label(),
"Rename selected session"
);
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::RenameHighlighted,
Action::Quit,
Action::NewDraftPane,
Action::ScrollPanePageDown,
Action::FocusPane(Direction::Right),
Action::GotoTab(7),
] {
let s = a.to_string();
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
+590 -48
View File
@@ -30,15 +30,17 @@ use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::draft::DraftInputResult;
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::overlays::HelpOverlay;
use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
use crate::tabs::TabBar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
use crate::text_input::TextInput;
use crate::worktrees::WorktreesView;
use crate::TuiError;
@@ -52,6 +54,7 @@ enum OverlayDecision {
pub struct App {
cfg: TuiConfig,
home_dir: String,
catalog: CatalogStore,
emulators: SessionEmulators,
state: AppState,
@@ -73,6 +76,9 @@ pub struct App {
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
/// rather than in `AppState` so the overlay kind stays payload-free.
worktrees: WorktreesView,
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
/// same reason the worktree entries are.
rename_input: TextInput,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker,
@@ -121,6 +127,12 @@ pub enum Effect {
/// Reclaim one retained worktree (#85) — removes its directory; the
/// branch is never deleted.
ReclaimWorktree(Uuid),
/// Rename a session (#125). An empty name clears the custom one, so the
/// card falls back to the command.
Rename {
id: Uuid,
name: String,
},
}
impl App {
@@ -137,6 +149,7 @@ impl App {
.expect("build reqwest client");
Self {
cfg,
home_dir: std::env::var("HOME").unwrap_or_default(),
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
@@ -145,6 +158,7 @@ impl App {
daemon,
http,
worktrees: WorktreesView::default(),
rename_input: TextInput::default(),
draft_tx,
draft_rx,
region: RegionMap::default(),
@@ -158,6 +172,9 @@ impl App {
mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), TuiError> {
if let Ok(info) = self.daemon.daemon_info().await {
self.home_dir = info.home_dir;
}
let mut events = EventStream::new();
loop {
self.redraw(terminal)?;
@@ -197,6 +214,15 @@ impl App {
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);
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
@@ -224,6 +250,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
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
@@ -241,6 +295,7 @@ impl App {
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
Effect::LoadWorktrees => self.spawn_load_worktrees(),
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -263,6 +318,21 @@ impl App {
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::Rename(id)) => match key {
InputKey::Enter => {
let name = self.rename_input.value().to_string();
self.state.dismiss_overlay();
(OverlayDecision::Consumed, vec![Effect::Rename { id, name }])
}
InputKey::Esc => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
}
other => {
self.rename_input.handle_key(other);
(OverlayDecision::Consumed, Vec::new())
}
},
Some(OverlayKind::Worktrees) => match key {
InputKey::Char { c: 'j', .. } | InputKey::Down => {
self.worktrees.move_highlight(1);
@@ -322,32 +392,37 @@ impl App {
match act {
Action::Noop | Action::Quit => {}
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::FocusPane(_) => {
// v1 has one pane per tab; geometric focus motion lands
// either in the sidebar or back in the 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 => {
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.focus = Focus::Sidebar;
}
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.focus = Focus::Sidebar;
}
Action::NewTab => self.state.new_tab(),
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::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
Action::ToggleSidebar => self.state.toggle_sidebar(),
Action::ShowHelp => {
self.help = HelpOverlay {
@@ -356,13 +431,24 @@ impl App {
self.state.show_overlay(OverlayKind::Help);
}
Action::NewDraftPane => {
if let Some(sid) = self.state.active_session() {
self.release_session(sid);
}
self.state.open_draft_pane();
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
}
Action::FastForwardSpawn => effects.push(Effect::FastForward),
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
if let Some(SidebarItem::Group(key)) = self.state.sidebar_highlight.clone() {
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);
}
}
@@ -371,6 +457,23 @@ impl App {
self.state.show_overlay(OverlayKind::KillConfirm(sid));
}
}
Action::RenameHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
// Pre-fill with the custom name only: a session that has
// none starts empty rather than with its command already
// typed in, which Enter would then pin as the name.
self.rename_input = TextInput::with_value(
self.catalog
.snapshot()
.sessions
.iter()
.find(|s| s.id == sid)
.and_then(crate::sidebar::custom_name_for)
.unwrap_or_default(),
);
self.state.show_overlay(OverlayKind::Rename(sid));
}
}
Action::ShowWorktrees => {
self.state.show_overlay(OverlayKind::Worktrees);
effects.push(Effect::LoadWorktrees);
@@ -403,22 +506,127 @@ impl App {
/// Return the session under the sidebar highlight, falling back to the
/// first listed session so the user can attach without arrowing first.
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);
}
if self.state.sidebar_highlight.is_some() {
return None;
}
let snap = self.catalog.snapshot();
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
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_session()?;
self.emulators.get(sid)?;
let handle = self.emulators.get(sid)?;
let bytes = encode_key(key)?;
handle
.screen
.lock()
.expect("vt100 parser mutex")
.set_scrollback(0);
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
/// effect when the user accepted a valid form; an invalid one names the
/// missing field inside the form (#64) instead of posting.
@@ -704,11 +912,6 @@ impl App {
let resolved =
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
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;
return;
}
@@ -734,6 +937,9 @@ impl App {
// Fill the pane in place with the new session (web parity,
// #57) and attach to it right away.
let sid = session.id;
if let Some(previous) = self.state.active_session() {
self.release_session(previous);
}
self.state.complete_spawn(sid);
self.attach(sid);
}
@@ -766,6 +972,14 @@ impl App {
}
}
/// Rename a session (#125). The catalog updates from the daemon's
/// `SessionUpdated` broadcast, so nothing is patched locally here.
async fn submit_rename(&mut self, sid: Uuid, name: &str) {
if let Err(e) = self.daemon.rename_session(sid, name).await {
self.state.toast(format!("rename failed: {e}"));
}
}
/// Fetch the retained-worktree store without blocking the loop (#85) —
/// same off-loop pattern as the draft loaders.
fn spawn_load_worktrees(&self) {
@@ -811,13 +1025,9 @@ impl App {
.collect();
let sidebar = Sidebar {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight,
highlight: self.state.sidebar_highlight.clone(),
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 pane = active_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
@@ -830,15 +1040,27 @@ impl App {
Some(PaneRef::Draft(d)) => Some(d),
_ => 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 {
sidebar: &sidebar,
tab_bar: &tab_bar,
home_dir: &self.home_dir,
pane_title: &pane_title,
pane: pane.as_ref(),
state: &self.state,
draft,
help: &self.help,
worktrees: Some(&self.worktrees),
rename_input: &self.rename_input,
};
let mut new_region = RegionMap::default();
@@ -881,8 +1103,16 @@ impl App {
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
/// visual sync with what the user sees.
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
snap.sessions.iter().map(|s| s.id).collect()
fn visible_sidebar_order(
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
@@ -916,6 +1146,8 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
InputKey::Left => Some(b"\x1b[D".to_vec()),
InputKey::Home => Some(b"\x1b[H".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::Paste(text) => {
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
@@ -931,6 +1163,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
/// user always sees the bindings that are actually active (defaults + their
/// own `[tui.keymap.*]` overrides).
@@ -938,14 +1203,14 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
let mut out = vec![format!("{label} mode"), String::new()];
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
}
let leader = cfg.leader.canonical();
let mut lines = vec![
format!("Leader: {leader} (Insert → Normal)"),
format!("Leader: {leader} (return to sidebar)"),
String::new(),
];
lines.extend(section(Mode::Normal, "Normal", cfg));
@@ -1057,19 +1322,11 @@ mod tests {
let mut app = make_app();
assert_eq!(app.state.mode, Mode::Normal);
app.dispatch(Action::EnterInsert);
app.state.focus = Focus::Pane;
assert_eq!(app.state.mode, Mode::Insert);
app.dispatch(Action::LeaveInsert);
assert_eq!(app.state.mode, Mode::Normal);
}
#[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);
assert_eq!(app.state.focus, Focus::Sidebar);
}
#[test]
@@ -1079,6 +1336,18 @@ mod tests {
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]
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
let mut app = make_app();
@@ -1092,21 +1361,19 @@ mod tests {
assert!(app.state.overlay().is_none(), "no overlay involved");
}
/// A tab already showing a session gets a new tab for the draft.
#[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 mut app = app_with_highlighted_session(sid);
app.dispatch(Action::AttachHighlighted);
app.dispatch(Action::NewDraftPane);
assert_eq!(app.state.tabs.len(), 2);
assert_eq!(app.state.active_tab_idx, 1);
assert_eq!(app.state.tabs.len(), 1);
assert_eq!(app.state.active_tab_idx, 0);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.tabs[0].session(), Some(sid));
}
/// Seed the catalog with one session and highlight it.
@@ -1134,9 +1401,32 @@ mod tests {
}],
});
app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext);
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]
fn attach_highlighted_asks_for_an_attach() {
let sid = Uuid::from_u128(11);
@@ -1146,6 +1436,104 @@ mod tests {
vec![Effect::Attach(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]
@@ -1602,6 +1990,49 @@ mod tests {
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
/// paste into an attached session vanished once bracketed paste was
/// enabled. It also has to keep its DECSET 2004 markers — without them a
@@ -1657,6 +2088,117 @@ mod tests {
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
}
// -- rename (#125) ------------------------------------------------------
fn seed_session(app: &mut App, sid: Uuid, name: Option<&str>) {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let metadata = match name {
Some(n) => {
HashMap::from([("name".to_string(), serde_json::Value::String(n.to_string()))])
}
None => Default::default(),
};
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata,
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]
fn rename_highlighted_opens_a_prompt_prefilled_with_the_current_name() {
let mut app = make_app();
let sid = Uuid::from_u128(7);
seed_session(&mut app, sid, Some("amber-falcon"));
app.dispatch(Action::RenameHighlighted);
assert!(matches!(app.state.overlay(), Some(OverlayKind::Rename(s)) if *s == sid));
assert_eq!(app.rename_input.value(), "amber-falcon");
}
/// A session with no name of its own opens an empty field: pre-filling
/// with the command would let Enter pin the command as the name.
#[test]
fn rename_prompt_starts_empty_for_an_unnamed_session() {
let mut app = make_app();
seed_session(&mut app, Uuid::from_u128(7), None);
app.dispatch(Action::RenameHighlighted);
assert_eq!(app.rename_input.value(), "");
}
#[test]
fn typing_then_enter_submits_the_edited_name() {
let mut app = make_app();
let sid = Uuid::from_u128(7);
seed_session(&mut app, sid, Some("amber"));
app.dispatch(Action::RenameHighlighted);
for c in "-fox".chars() {
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
c,
ctrl: false,
alt: false,
shift: false,
});
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
}
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(
effects,
vec![Effect::Rename {
id: sid,
name: "amber-fox".into()
}]
);
assert!(app.state.overlay().is_none());
}
#[test]
fn escape_cancels_the_rename_without_an_effect() {
let mut app = make_app();
seed_session(&mut app, Uuid::from_u128(7), Some("amber"));
app.dispatch(Action::RenameHighlighted);
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert!(app.state.overlay().is_none());
}
/// Clearing the field and submitting is how the name is dropped, so the
/// card falls back to the command.
#[test]
fn an_emptied_field_submits_a_blank_name() {
let mut app = make_app();
let sid = Uuid::from_u128(7);
seed_session(&mut app, sid, Some("ab"));
app.dispatch(Action::RenameHighlighted);
app.handle_overlay_input(&InputKey::Backspace);
app.handle_overlay_input(&InputKey::Backspace);
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(
effects,
vec![Effect::Rename {
id: sid,
name: String::new()
}]
);
}
// -- retained worktrees (#85) -------------------------------------------
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
+55 -11
View File
@@ -8,13 +8,15 @@
use std::time::Duration;
use maestro_config::TuiSection;
use maestro_config::{TuiSection, TuiTheme};
use crate::input::InputKey;
use crate::keymap::{Keymap, KeymapError, Mode};
use crate::palette::Palette;
#[derive(Debug)]
pub struct TuiConfig {
pub palette: Palette,
pub leader: InputKey,
pub chord_timeout: Duration,
pub keymap: Keymap,
@@ -38,11 +40,12 @@ pub enum TuiConfigError {
const DEFAULT_NORMAL: &[(&str, &str)] = &[
// Mode
("i", "EnterInsert"),
// Sidebar nav
// Sidebar navigation
("j", "SidebarNext"),
("k", "SidebarPrev"),
("down", "SidebarNext"),
("up", "SidebarPrev"),
("c", "ToggleSidebarGroup"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "NewDraftPane"),
@@ -50,20 +53,19 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("r", "RenameHighlighted"),
("w", "ShowWorktrees"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
("g T", "PrevTab"),
// Focus
("ctrl-w h", "FocusSidebar"),
// Session scrollback
("pageup", "ScrollPanePageUp"),
("pagedown", "ScrollPanePageDown"),
// Chrome
("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"),
("ctrl-w q", "Quit"),
// Mouse
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
("mouse:click(tab)", "NextTab"),
];
/// Default Insert-mode bindings. Empty by default — the only Insert binding
@@ -76,6 +78,20 @@ impl TuiConfig {
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
/// applied first, then user overrides on top.
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 = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
@@ -131,6 +147,7 @@ impl TuiConfig {
}
Ok(TuiConfig {
palette,
leader,
chord_timeout,
keymap,
@@ -143,6 +160,37 @@ impl TuiConfig {
mod tests {
use super::*;
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(&section).unwrap().palette, expected);
}
}
#[test]
fn defaults_cover_core_actions() {
@@ -155,10 +203,6 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "enter"),
Some(Action::AttachHighlighted),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
+76 -66
View File
@@ -41,9 +41,9 @@ use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::input::InputKey;
@@ -699,12 +699,13 @@ impl DraftPane {
order.push(DraftField::DirPicker);
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
} else if self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
order.push(DraftField::Name);
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
order.push(DraftField::WorktreeToggle);
if self.worktree_body_visible() {
order.push(DraftField::BranchMode);
@@ -1078,20 +1079,17 @@ impl DraftPane {
/// cursor placement the same way a session pane hides its cursor when
/// unfocused.
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
let block = Block::default().title(" draft ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let inner = area;
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
// The worktree toggle and its body stay behind the "Advanced options"
// disclosure (#93): collapsed hides both, regardless of what they'd
// otherwise show. The worktree *choice* step is not part of that —
// it belongs to the directory picker (#95 rule 3), so it stays
// visible once a project is picked.
// The worktree choice step, the worktree toggle and its body all stay
// behind the "Advanced options" disclosure (#93): collapsed hides
// them, regardless of what they'd otherwise show.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice = self.selected_group_key.is_some() && !show_raw;
let show_wt_choice =
!self.advanced_collapsed && self.selected_group_key.is_some() && !show_raw;
let wt_choice_rows_data = if show_wt_choice {
self.worktree_choice_rows()
} else {
@@ -1120,11 +1118,12 @@ impl DraftPane {
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
constraints.push(Constraint::Length(1));
} else if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
if show_wt_toggle {
constraints.push(Constraint::Length(1)); // worktree toggle
}
@@ -1270,7 +1269,52 @@ impl DraftPane {
if self.focused_field == DraftField::CwdRaw {
cursor = Some((col, row.y));
}
} else if show_wt_choice {
}
// -- name + reroll ---------------------------------------------------------
let name_row = next_row();
draw_gutter(f, name_row, "name:");
let col = draw_text_field(f, &self.name, name_row, field_style(DraftField::Name));
if self.focused_field == DraftField::Name {
cursor = Some((col, name_row.y));
}
// Dim reroll hint, tucked after the typed name without covering it.
let hint_offset = (GUTTER as u16 + self.name.value().chars().count() as u16 + 1)
.min(name_row.width.saturating_sub(10));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"^R reroll",
Style::default().add_modifier(Modifier::DIM),
))),
Rect {
x: name_row.x + hint_offset,
y: name_row.y,
width: name_row.width.saturating_sub(hint_offset),
height: 1,
},
);
// -- advanced disclosure (#93) ------------------------------------------
let advanced_row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "adv:")),
Span::styled(
format!(
"[{}] Advanced options",
if self.advanced_collapsed { "+" } else { "-" }
),
field_style(DraftField::Advanced),
),
Span::styled(
format!(" — {}", self.advanced_summary()),
Style::default().add_modifier(Modifier::DIM),
),
])),
advanced_row,
);
if show_wt_choice {
// Step two (#96 rule 3): "+ new" first, then the repository's
// worktrees newest-first.
let choice_rows = wt_choice_rows_data;
@@ -1319,49 +1363,6 @@ impl DraftPane {
}
}
// -- name + reroll ---------------------------------------------------------
let name_row = next_row();
draw_gutter(f, name_row, "name:");
let col = draw_text_field(f, &self.name, name_row, field_style(DraftField::Name));
if self.focused_field == DraftField::Name {
cursor = Some((col, name_row.y));
}
// Dim reroll hint, tucked after the typed name without covering it.
let hint_offset = (GUTTER as u16 + self.name.value().chars().count() as u16 + 1)
.min(name_row.width.saturating_sub(10));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"^R reroll",
Style::default().add_modifier(Modifier::DIM),
))),
Rect {
x: name_row.x + hint_offset,
y: name_row.y,
width: name_row.width.saturating_sub(hint_offset),
height: 1,
},
);
// -- advanced disclosure (#93) ------------------------------------------
let advanced_row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "adv:")),
Span::styled(
format!(
"[{}] Advanced options",
if self.advanced_collapsed { "+" } else { "-" }
),
field_style(DraftField::Advanced),
),
Span::styled(
format!(" — {}", self.advanced_summary()),
Style::default().add_modifier(Modifier::DIM),
),
])),
advanced_row,
);
// -- worktree (#65) -----------------------------------------------------
if show_wt_toggle {
let wt_row = next_row();
@@ -1510,7 +1511,7 @@ impl DraftPane {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("error: {error}"),
Style::default().fg(Color::Red),
crate::palette::active().error_style(),
))),
row,
);
@@ -2117,7 +2118,7 @@ mod tests {
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (gutter is 5 chars, block border takes column 0).
/// field (gutter is 5 chars and the pane has no border).
#[test]
fn render_places_the_cursor_in_the_focused_text_field() {
let mut draft = DraftPane {
@@ -2129,10 +2130,7 @@ mod tests {
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(
pos,
ratatui::layout::Position::new(1 + GUTTER as u16 + 2, 2)
);
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1));
}
#[test]
@@ -2212,9 +2210,14 @@ mod tests {
assert_eq!(draft.worktree_choice, WorktreeChoice::New);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/main")));
assert!(draft.worktree_enabled, "\"+ new\" means create-worktree on");
assert!(
!draft.field_order().contains(&DraftField::WorktreeChoice),
"step two lives behind the collapsed \"Advanced options\" disclosure"
);
draft.toggle_advanced();
assert!(
draft.field_order().contains(&DraftField::WorktreeChoice),
"step two is now reachable"
"step two is reachable once advanced is open"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
@@ -2627,6 +2630,13 @@ mod tests {
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks the "main" box
let collapsed = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
!collapsed.contains("+ new"),
"step two hides with \"Advanced options\" closed:\n{collapsed}"
);
draft.toggle_advanced();
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
+10 -3
View File
@@ -36,6 +36,8 @@ pub enum InputKey {
Right,
Home,
End,
PageUp,
PageDown,
Delete,
/// Bracketed paste: crossterm delivers the whole payload as one event, so
/// text fields can insert it as a single operation.
@@ -63,7 +65,6 @@ pub enum MouseKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
SidebarRow,
Tab,
Pane,
Modal,
Unknown,
@@ -136,6 +137,8 @@ impl InputKey {
InputKey::Right => "right".into(),
InputKey::Home => "home".into(),
InputKey::End => "end".into(),
InputKey::PageUp => "pageup".into(),
InputKey::PageDown => "pagedown".into(),
InputKey::Delete => "delete".into(),
// Pastes are not bindable; the canonical form exists only to
// satisfy exhaustiveness and never round-trips through `parse`.
@@ -150,7 +153,6 @@ impl InputKey {
};
let r = match region {
Region::SidebarRow => "sidebar-row",
Region::Tab => "tab",
Region::Pane => "pane",
Region::Modal => "modal",
Region::Unknown => "unknown",
@@ -182,7 +184,6 @@ impl InputKey {
};
let region = match r {
"sidebar-row" => Region::SidebarRow,
"tab" => Region::Tab,
"pane" => Region::Pane,
"modal" => Region::Modal,
"unknown" => Region::Unknown,
@@ -207,6 +208,8 @@ impl InputKey {
"right" => return Ok(InputKey::Right),
"home" => return Ok(InputKey::Home),
"end" => return Ok(InputKey::End),
"pageup" => return Ok(InputKey::PageUp),
"pagedown" => return Ok(InputKey::PageDown),
"delete" => return Ok(InputKey::Delete),
_ => {}
}
@@ -281,6 +284,8 @@ where
KeyCode::Right => InputKey::Right,
KeyCode::Home => InputKey::Home,
KeyCode::End => InputKey::End,
KeyCode::PageUp => InputKey::PageUp,
KeyCode::PageDown => InputKey::PageDown,
KeyCode::Delete => InputKey::Delete,
_ => return None,
})
@@ -617,6 +622,8 @@ mod tests {
"right",
"home",
"end",
"pageup",
"pagedown",
"delete",
] {
let k = InputKey::parse(s).unwrap();
+4 -4
View File
@@ -107,8 +107,8 @@ mod tests {
#[test]
fn resolves_chord() {
let k = build(&[(Mode::Normal, "g t", "NextTab")]);
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
let k = build(&[(Mode::Normal, "g h", "FocusSidebar")]);
assert_eq!(k.resolve(Mode::Normal, "g h"), Some(Action::FocusSidebar));
}
#[test]
@@ -123,11 +123,11 @@ mod tests {
#[test]
fn known_bindings_sees_everything() {
let k = build(&[
(Mode::Normal, "g t", "NextTab"),
(Mode::Normal, "g h", "FocusSidebar"),
(Mode::Normal, "i", "EnterInsert"),
]);
let kb = k.known_bindings(Mode::Normal);
assert!(kb.contains("g t"));
assert!(kb.contains("g h"));
assert!(kb.contains("i"));
assert_eq!(kb.len(), 2);
}
+2 -1
View File
@@ -13,13 +13,13 @@ pub mod fast_forward;
pub mod input;
pub mod keymap;
pub mod overlays;
pub mod palette;
pub mod pane;
pub mod projects;
pub mod render;
pub mod session_task;
pub mod sidebar;
pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
pub mod worktrees;
@@ -89,6 +89,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
Err(_) => maestro_config::MaestroConfigFile::default(),
};
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 ws_url = daemon
+69 -5
View File
@@ -1,4 +1,5 @@
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay,
//! transient toast.
//!
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]).
@@ -7,10 +8,13 @@
//! `Clear` widget wipes the underlying buffer before drawing.
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::text_input::TextInput;
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
@@ -39,6 +43,50 @@ impl KillConfirm {
}
}
/// Rename prompt for the highlighted session (#125).
///
/// Borrows its text buffer rather than owning it: the buffer lives on `App`
/// alongside the worktrees view's entries, so [`crate::state::OverlayKind`]
/// stays a plain comparable enum.
#[derive(Debug)]
pub struct RenamePrompt<'a> {
/// What the card shows today — the session's name, or the command it
/// falls back to when it has none.
pub current: &'a str,
pub input: &'a TextInput,
}
impl RenamePrompt<'_> {
/// Draws the prompt and returns where the terminal cursor belongs.
pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> {
let area = centered(parent, 64, 7);
f.render_widget(Clear, area);
let block = Block::default().title(" rename ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
if inner.height < 4 || inner.width == 0 {
return None;
}
let row = |offset: u16| Rect {
x: inner.x,
y: inner.y + offset,
width: inner.width,
height: 1,
};
f.render_widget(
Paragraph::new(Line::from(format!("Rename {}", self.current))),
row(0),
);
let field = row(1);
let col = self.input.render(field, f.buffer_mut(), Style::default());
f.render_widget(
Paragraph::new(Line::from("[enter] save [esc] cancel")),
row(3),
);
Some((col, field.y))
}
}
#[derive(Debug, Clone)]
pub struct HelpOverlay {
pub lines: Vec<String>,
@@ -151,16 +199,32 @@ mod tests {
assert!(rendered.contains("refused"), "{rendered}");
}
#[test]
fn rename_prompt_shows_the_current_name_and_the_edit_buffer() {
let input = TextInput::with_value("crimson-otter");
let prompt = RenamePrompt {
current: "amber-falcon",
input: &input,
};
let rendered = render_to_string(70, 12, |f| {
prompt.render(f, f.area());
});
assert!(rendered.contains("Rename amber-falcon"), "{rendered}");
assert!(rendered.contains("crimson-otter"), "{rendered}");
assert!(rendered.contains("[enter] save"), "{rendered}");
assert!(rendered.contains("[esc] cancel"), "{rendered}");
}
#[test]
fn help_overlay_lists_lines() {
let help = HelpOverlay {
lines: vec![
"i EnterInsert".into(),
"s NewDraftPane".into(),
"i Send keys to session".into(),
"s Create a session".into(),
],
};
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("NewDraftPane"));
assert!(rendered.contains("Send keys to session"));
assert!(rendered.contains("Create a session"));
}
}
+851
View File
@@ -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);
}
}
+4 -1
View File
@@ -74,7 +74,7 @@ impl Pane {
// 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.
if self.focused && !screen.hide_cursor() {
if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() {
let (cr, cc) = screen.cursor_position();
if cr < rows && cc < cols {
f.set_cursor_position((area.x + cc, area.y + cr));
@@ -106,6 +106,9 @@ fn translate_cell_style(cell: &vt100::Cell) -> Style {
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> {
match c {
vt100::Color::Default => None,
+203 -71
View File
@@ -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
//! hit-test mouse clicks.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::style::Modifier;
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::input::Region;
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
use crate::palette;
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
use crate::worktrees::{ReclaimConfirm, WorktreesView};
/// 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)]
pub struct RegionMap {
pub sidebar: Option<Rect>,
pub tab_bar: Option<Rect>,
pub sidebar_items: Vec<SidebarItem>,
pub pane: Option<Rect>,
pub modal: Option<Rect>,
}
@@ -34,11 +34,6 @@ impl RegionMap {
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 inside(r, col, row) {
return Region::SidebarRow;
@@ -51,6 +46,24 @@ impl RegionMap {
}
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 {
@@ -62,14 +75,17 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
#[derive(Debug)]
pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub home_dir: &'a str,
pub pane_title: &'a str,
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 state: &'a AppState,
pub help: &'a HelpOverlay,
/// Retained-worktree entries for the worktrees view (#85).
pub worktrees: Option<&'a WorktreesView>,
/// Edit buffer behind the rename prompt (#125).
pub rename_input: &'a crate::text_input::TextInput,
}
/// Draw one frame. Returns the `RegionMap` the input router will consult for
@@ -101,43 +117,36 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
let right = cols[1];
if inputs.state.sidebar_visible {
inputs
.sidebar
.render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
inputs.sidebar.render_with_focus_and_collapsed_home(
f,
sidebar_area,
inputs.state.focus == Focus::Sidebar,
&inputs.state.collapsed_sidebar_groups,
inputs.home_dir,
);
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()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(right);
let tab_area = rows[0];
let separator_area = rows[1];
let pane_area = rows[2];
inputs.tab_bar.render(f, tab_area);
map.tab_bar = Some(tab_area);
let title_area = rows[0];
let pane_area = rows[1];
let pane_focused = inputs.state.focus == Focus::Pane;
f.render_widget(
Block::default()
.borders(Borders::TOP)
.border_style(focus_style(pane_focused)),
separator_area,
);
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
if let Some(p) = inputs.pane {
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
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);
// Status bar — always visible, never eclipsed by overlays so the user can
@@ -163,6 +172,23 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
kc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Rename(id)) => {
let current = inputs
.sidebar
.sessions
.iter()
.find(|s| &s.id == id)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| id.to_string());
let prompt = RenamePrompt {
current: &current,
input: inputs.rename_input,
};
if let Some((x, y)) = prompt.render(f, area) {
f.set_cursor_position((x, y));
}
map.modal = Some(area);
}
Some(OverlayKind::Help) => {
inputs.help.render(f, area);
map.modal = Some(area);
@@ -193,14 +219,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
map
}
/// Border style for a panel based on whether it currently has focus.
/// Focused = cyan; unfocused = the terminal default.
fn focus_style(focused: bool) -> Style {
if focused {
Style::default().fg(Color::Cyan)
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
let palette = palette::active();
let style = if focused {
palette.mode_style(false)
} 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
@@ -217,12 +244,9 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
};
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
Mode::Insert => "^a: back to normal",
};
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),
Mode::Insert => "^a: return to sidebar",
};
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
let line = ratatui::text::Line::from(vec![
Span::styled(format!(" {mode_label} "), mode_style),
Span::raw(format!(" {focus_label} • {hint}")),
@@ -244,22 +268,16 @@ mod tests {
width: 10,
height: 20,
}),
tab_bar: Some(Rect {
sidebar_items: Vec::new(),
pane: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 3,
}),
pane: Some(Rect {
x: 10,
y: 3,
width: 30,
height: 17,
height: 20,
}),
modal: None,
};
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(50, 50), Region::Unknown);
}
@@ -273,17 +291,12 @@ mod tests {
width: 10,
height: 20,
}),
tab_bar: Some(Rect {
sidebar_items: Vec::new(),
pane: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 3,
}),
pane: Some(Rect {
x: 10,
y: 3,
width: 30,
height: 17,
height: 20,
}),
modal: Some(Rect {
x: 5,
@@ -297,6 +310,40 @@ mod tests {
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
/// form where the terminal would otherwise be.
#[test]
@@ -316,19 +363,17 @@ mod tests {
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
home_dir: "",
pane_title: "new session",
pane: None,
draft: Some(draft),
state: &state,
help: &help,
worktrees: None,
rename_input: &crate::text_input::TextInput::default(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
@@ -347,7 +392,7 @@ mod tests {
out.push('\n');
}
for s in [
"draft",
"new session",
"cmd:",
"dir:",
"name:",
@@ -357,4 +402,91 @@ mod tests {
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,
rename_input: &crate::text_input::TextInput::default(),
};
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,
rename_input: &crate::text_input::TextInput::default(),
};
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:?}");
}
}
+5 -1
View File
@@ -23,6 +23,10 @@ use maestro_protocol::ClientControl;
use tokio::sync::mpsc;
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.
#[derive(Debug, Clone)]
pub enum SessionInput {
@@ -103,7 +107,7 @@ impl SessionTask {
/// up with `SessionInput::Resize` on every frame whose allocation changes.
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
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 parser_bg = parser.clone();
File diff suppressed because it is too large Load Diff
+77 -182
View File
@@ -1,13 +1,15 @@
//! `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
//! session or a draft form), an active tab index, focus split between sidebar
//! and pane, the current input mode, and an optional overlay (kill confirm,
//! v1 keeps it small on purpose: one pane containing a session or draft form,
//! focus split between sidebar 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
//! pane is a draft pane (issue #63).
//!
//! 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 crate::draft::DraftPane;
@@ -19,8 +21,18 @@ pub enum Focus {
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.
// 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.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
@@ -48,6 +60,9 @@ impl Tab {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
KillConfirm(Uuid),
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
/// like the worktrees view's entries, so this stays comparable.
Rename(Uuid),
Help,
Toast(String),
/// The retained-worktrees view (#85); its entries live on `App` because
@@ -70,7 +85,10 @@ pub struct AppState {
pub focus: Focus,
pub mode: Mode,
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`],
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
/// from four call sites is what left a toast on screen with no way to
@@ -96,6 +114,7 @@ impl AppState {
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
collapsed_sidebar_groups: HashSet::new(),
overlay: None,
}
}
@@ -126,57 +145,6 @@ impl AppState {
&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
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
@@ -186,14 +154,10 @@ impl AppState {
self.mode = Mode::Insert;
}
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
/// starts immediately, same flow as [`Self::attach_to_focused`].
/// Replace the pane with a draft form. Focus lands in the pane, Insert
/// mode on, so typing starts immediately, same flow as
/// [`Self::attach_to_focused`].
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.focus = Focus::Pane;
self.mode = Mode::Insert;
@@ -213,19 +177,32 @@ impl AppState {
}
/// Move the sidebar highlight by `delta` (positive = down) within the
/// given ordered list of selectable session ids. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
/// rendered item sequence. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
if order.is_empty() {
return;
}
let len = order.len() as i32;
let cur = self
.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)
.unwrap_or(-1);
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.
@@ -252,88 +229,36 @@ mod tests {
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]
fn move_sidebar_highlight_walks_order_and_wraps() {
let mut state = AppState::new();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
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.
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);
assert_eq!(state.sidebar_highlight, Some(b));
assert_eq!(
state.sidebar_highlight,
Some(SidebarItem::Group("repo".into()))
);
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(c));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
// Wraps forward.
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(a));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
// Wraps backward.
state.move_sidebar_highlight(&order, -1);
assert_eq!(state.sidebar_highlight, Some(c));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
}
#[test]
@@ -343,6 +268,22 @@ mod tests {
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]
fn attach_binds_focuses_pane_and_enters_insert_mode() {
let mut state = AppState::new();
@@ -367,38 +308,6 @@ mod tests {
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]
fn dismiss_overlay_reports_whether_one_was_showing() {
let mut state = AppState::new();
@@ -420,18 +329,14 @@ mod tests {
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]
fn open_draft_over_a_session_opens_a_new_tab() {
fn open_draft_over_a_session_reuses_the_pane() {
let mut state = AppState::new();
state.attach_to_focused(Uuid::from_u128(9));
state.open_draft_pane();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab_idx, 0);
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.
@@ -445,14 +350,4 @@ mod tests {
assert_eq!(state.focus, Focus::Pane);
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);
}
}
-61
View File
@@ -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"));
}
}
+49 -8
View File
@@ -19,8 +19,7 @@ use tracing_subscriber::EnvFilter;
#[command(
name = "maestro",
version,
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator",
arg_required_else_help = true
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator"
)]
struct Cli {
/// 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)]
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: Command,
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
@@ -55,6 +57,9 @@ enum Command {
/// Kill a session by ID.
#[cfg(feature = "cli")]
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.
#[cfg(feature = "daemon")]
Daemon {
@@ -86,7 +91,22 @@ fn main() -> ExitCode {
}
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")]
Command::List(args) => {
maestro_cli::dispatch(
@@ -123,6 +143,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
)
.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")]
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
#[cfg(feature = "tui")]
@@ -154,15 +183,19 @@ fn init_tracing(cli: &Cli) {
}
#[cfg(feature = "cli")]
fn is_cli_subcommand(command: &Command) -> bool {
fn is_cli_subcommand(command: &Option<Command>) -> bool {
matches!(
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"))]
fn is_cli_subcommand(_command: &Command) -> bool {
fn is_cli_subcommand(_command: &Option<Command>) -> bool {
false
}
@@ -200,12 +233,20 @@ mod tests {
}
/// 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)
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
.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")]
#[test]
fn cli_subcommands_are_classified_as_cli() {
+27 -3
View File
@@ -54,11 +54,35 @@ fn version_flag_succeeds() {
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]
fn no_args_prints_help_and_fails() {
// `arg_required_else_help = true` => exit non-zero but show usage.
fn bare_invocation_dispatches_to_the_tui() {
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(&[]);
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}");
}
+30 -4
View File
@@ -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
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
(`--no-default-features --features cli,daemon`).
@@ -131,24 +134,47 @@ cover every v1 action — only override what you want to change:
```toml
[tui]
theme = "dark" # see the theme list below
leader = "ctrl-a" # one byte that escapes Insert mode
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]
"i" = "EnterInsert"
"s" = "OpenSpawnModal"
"s" = "NewDraftPane"
"x" = "KillHighlighted"
"r" = "RenameHighlighted"
"?" = "ShowHelp"
"ctrl-t" = "NewTab"
"g t" = "NextTab"
"ctrl-w h" = "FocusSidebar"
"enter" = "AttachHighlighted"
"pageup" = "ScrollPanePageUp"
"pagedown" = "ScrollPanePageDown"
[tui.keymap.insert]
# (leader is added automatically as LeaveInsert; only override here when you
# 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
`crates/maestro-tui/src/action.rs`.
+73 -44
View File
@@ -5,52 +5,56 @@ 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 has **line 1** always and **line 2** only when line 2 has something to
say.
A card is always two lines: **line 1** and **line 2**.
## Line 1 — name + actions
## Line 1 — name
- Left: the session name — `metadata.name` if present, else the command line
(same fallback both clients already use).
- Right: two actions anchored to the top-right corner: **spawn similar**
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
the highlighted row; in the web they are buttons. The glyphs are shared so
muscle memory transfers.
- Actions reserve their fixed width; the name truncates with `…` before it can
ever reach them. **Overlap between the name and the actions is a defect** —
that overlap is what started this milestone.
- 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.
The name is editable in place (#125). In the web client, right-clicking a card
opens a context menu whose **rename** entry swaps the name for a text field —
Enter commits, Escape cancels. The TUI opens the same edit as a prompt, bound
to `r` in the sidebar by default. Both send `PATCH /api/sessions/:id`; both
start from the session's own name, never from the command it falls back to,
and an empty field clears the name so the fallback resumes.
## Line 2 — meta line (adaptive)
Rendered only when at least one of these holds:
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.
1. The session has been held in its current activity state longer than
**30 seconds** (`activity_since` from #102). A freshly-active session
stays one line.
2. Its worktree holds blocked work: `blocked_work` is `Some` and not clear
(`InTheWay::is_clear()`), rendered in `formatInTheWay`'s phrasing on web
and as `⚠ <summary>` on the TUI.
3. It has exited: `status == Dead`.
4. An agent-reported state (#105) of `Error` is in force (see Precedence).
Otherwise line 2 is absent — never an empty placeholder row.
When present, line 2 reads left to right:
Line 2 reads left to right:
```
<dot> <duration> [<detail>] [<blocked work> | exited] [<command>]
<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. 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
and duration are unconditional parts of line 2; the bracketed fragments appear
only when their trigger holds. A payload that predates #102 (no usable
`activity_since`) contributes no duration — the dot still renders.
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
@@ -60,29 +64,40 @@ 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 optional and muted: the session's command line, shown only when
the rendered name came from `metadata.name` and differs from the command (the
web has always surfaced this; the TUI may omit it).
`<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 | ratatui colour |
| ---------------- | --------- | ----------------- |
| `Active` | `--live` | `Color::Green` |
| `Idle` | `--muted` | `Color::DarkGray` |
| `AwaitingInput` | `--warn` | `Color::Yellow` |
| `Error` | `--danger`| `Color::Red` |
| 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 uses the plain ratatui
named colours above; do not introduce RGB values or a new palette module for
this.
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 muted colour (`--faint` / default style), since the
⚠ glyph already signals severity.
command fragments use the surface's `muted` role, since the ⚠ glyph already
signals severity.
## Duration format
@@ -107,6 +122,20 @@ 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
-41
View File
@@ -4,9 +4,7 @@ import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
showsMetaLine,
} from "./activity.js";
import type { Session } from "./types.js";
describe("activityHeldMs", () => {
it("measures time spent in the current state", () => {
@@ -60,42 +58,3 @@ describe("activityColorSlug", () => {
expect(activityColorSlug("Teleported")).toBe("idle");
});
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
activity: "Active",
...overrides,
};
}
describe("showsMetaLine", () => {
const fresh = new Date(Date.now() - 10_000).toISOString();
it("stays hidden for a freshly-active session", () => {
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
});
it("appears once the state has been held past 30 seconds", () => {
const held = new Date(Date.now() - 31_000).toISOString();
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
});
it("appears for blocked work, exited sessions and reported errors", () => {
expect(
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
});
it("needs no activity timestamp when another trigger holds", () => {
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
});
});
+3 -15
View File
@@ -2,11 +2,10 @@
// the shared duration format. Both clients compute it identically; the TUI
// mirrors this in crates/maestro-tui.
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
import { sessionBlockers } from "./types.js";
import type { ActivityState, IsoTimestamp } from "./types.js";
/** A state held shorter than this keeps the card to one line (contract rule 1). */
const META_LINE_MIN_HOLD_MS = 30_000;
/** 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.
@@ -43,14 +42,3 @@ export function activityColorSlug(state: ActivityState | string): string {
return "idle";
}
}
/** Line 2 renders only when at least one contract trigger holds. */
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
const held = activityHeldMs(s.activity_since, now);
return (
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
sessionBlockers(s) !== null ||
s.status === "Dead" ||
s.activity === "Error"
);
}
+14
View File
@@ -44,6 +44,20 @@ export async function createSession(req: CreateSessionRequest): Promise<Session>
);
}
/** Rename a session (#125). An empty name clears the custom one, so the card
* falls back to the command. The daemon trims and caps what it stores and
* broadcasts the update, so callers need not patch the store themselves. */
export async function renameSession(id: Uuid, name: string): Promise<Session> {
return jsonOrError(
await fetch(routes.session(id), {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name }),
}),
);
}
export async function killSession(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(routes.session(id), {
+2 -1
View File
@@ -123,7 +123,8 @@ export function createAttachConnection(
// 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).
// • 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 screen = el.querySelector(".xterm-screen") as HTMLElement | null;
+16 -2
View File
@@ -150,14 +150,18 @@ describe("session cards (#107)", () => {
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
});
it("stays a single line while freshly active", () => {
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;
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
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", () => {
@@ -173,6 +177,16 @@ describe("session cards (#107)", () => {
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({
+107 -19
View File
@@ -1,10 +1,24 @@
import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession } from "./api.js";
import { getDaemonInfo, killSession, renameSession } from "./api.js";
import { openContextMenu } from "./contextMenu.js";
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
import {
abbreviateFolderName,
customSessionName,
formatInTheWay,
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
sessionName,
} from "./types.js";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
@@ -18,6 +32,11 @@ export function requestOpenSpawn(): void {
const collapsed = new Set<string>();
/** Session whose card is currently being renamed inline (#125). The tree
* re-renders on every catalog delta, and an activity update arriving mid-edit
* would otherwise replace the field the user is typing into. */
let renamingId: string | null = null;
// 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> = {
@@ -28,16 +47,13 @@ const STATE_WORDS: Record<string, string> = {
};
function metaLine(s: Session, now: number): string {
const name = sessionName(s);
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 ? activityDurationLabel(held) : null;
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
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>`,
@@ -49,9 +65,7 @@ function metaLine(s: Session, now: number): string {
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
dead ? `<span class="card-flag">exited</span>` : "",
showCmd
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</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>`;
}
@@ -106,10 +120,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
// the actions reserve their width, so the name truncates before it can ever
// reach them. Line 2 renders only when a contract trigger holds.
const showMeta = showsMetaLine(s, now);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
@@ -118,7 +129,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${showMeta ? metaLine(s, now) : ""}
${metaLine(s, now)}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
@@ -127,10 +138,29 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", async (ev) => {
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
// #83: the confirm restates the warning at the moment of decision.
void confirmAndKill(s, name);
});
row.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, s) },
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
},
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
]);
});
return row;
}
/** #83: the confirm restates the warning at the moment of decision. */
async function confirmAndKill(s: Session, name: string): Promise<void> {
const blockers = sessionBlockers(s);
const question = blockers
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
@@ -141,8 +171,61 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
} catch (e) {
alert(`Failed to kill: ${(e as Error).message}`);
}
}
/**
* Swap the card's name for a text field (#125). Enter commits, Escape and
* blur cancel. An emptied field clears the name, so the card falls back to
* the command — the same rule the spawn form's blank name field follows.
*/
function beginRename(row: HTMLElement, s: Session): void {
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
if (!nameEl) return;
const input = document.createElement("input");
input.type = "text";
input.className = "leaf-name leaf-rename";
input.value = customSessionName(s) ?? "";
input.maxLength = MAX_SESSION_NAME_LEN;
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
// The card is an <a>, and the tree starts a drag on pointerdown: without
// these, clicking into the field navigates away or begins dragging the row.
for (const type of ["click", "pointerdown", "dblclick"]) {
input.addEventListener(type, (ev) => {
ev.preventDefault();
ev.stopPropagation();
input.focus();
});
return row;
}
renamingId = s.id;
let settled = false;
const finish = () => {
if (settled) return false;
settled = true;
renamingId = null;
document.dispatchEvent(new CustomEvent("catalog-rerender"));
return true;
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
// Normalised here so what is sent is what the daemon will store.
const name = normalizeSessionName(input.value);
if (!finish()) return;
void renameSession(s.id, name).catch((e: Error) => {
alert(`Failed to rename: ${e.message}`);
});
} else if (ev.key === "Escape") {
ev.preventDefault();
finish();
}
});
input.addEventListener("blur", () => finish());
nameEl.replaceWith(input);
input.focus();
input.select();
}
function renderStatusbar(sessions: Session[]): HTMLElement {
@@ -405,6 +488,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
};
const refresh = () => {
// Rebuilding the tree now would blow away the field being typed into.
if (renamingId !== null) return;
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, homeDir, filter);
@@ -461,6 +546,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
},
dispose() {
clearInterval(clock);
// An unfinished rename would otherwise leave every later sidebar
// refusing to refresh.
renamingId = null;
document.removeEventListener("catalog-rerender", rerenderHandler);
unsubscribe();
},
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { closeContextMenu, openContextMenu } from "./contextMenu.js";
afterEach(() => {
closeContextMenu();
document.body.replaceChildren();
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
}
describe("context menu (#125)", () => {
it("renders one item per entry, in order", () => {
openContextMenu(10, 20, [
{ label: "Rename…", onSelect: () => {} },
{ label: "Kill session", onSelect: () => {}, danger: true },
]);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe(
"Kill session",
);
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]);
(document.querySelector(".context-menu-item") as HTMLButtonElement).click();
expect(chosen).toHaveBeenCalledOnce();
expect(document.querySelector(".context-menu")).toBeNull();
});
it("opening a second menu replaces the first", () => {
openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]);
openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]);
expect(document.querySelectorAll(".context-menu")).toHaveLength(1);
expect(labels()).toEqual(["second"]);
});
it("closes on Escape and on a press outside it", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(document.querySelector(".context-menu")).toBeNull();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).toBeNull();
});
it("stays open for a press inside itself", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
const menu = document.querySelector(".context-menu") as HTMLElement;
menu.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).not.toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
/** Custom right-click menu (#125).
*
* One menu exists at a time: opening a second closes the first. The menu is
* appended to `<body>` rather than to the row it belongs to, so an overflowing
* sidebar cannot clip it.
*/
export interface ContextMenuItem {
label: string;
onSelect: () => void;
/** Renders the entry in the destructive style. */
danger?: boolean;
}
let open: { el: HTMLElement; dispose: () => void } | null = null;
/** Close the menu that is currently up, if any. */
export function closeContextMenu(): void {
open?.dispose();
}
/**
* Open a menu at viewport coordinates `x`/`y`. The menu closes on selection,
* on Escape, on a pointer press anywhere outside it, and on scroll or resize —
* a menu anchored to coordinates goes stale the moment the page moves under it.
*/
export function openContextMenu(x: number, y: number, items: ContextMenuItem[]): HTMLElement {
closeContextMenu();
const el = document.createElement("div");
el.className = "context-menu";
el.setAttribute("role", "menu");
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = item.danger ? "context-menu-item is-danger" : "context-menu-item";
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
closeContextMenu();
item.onSelect();
});
el.appendChild(btn);
}
document.body.appendChild(el);
// Nudge back inside the viewport when the click was near an edge. happy-dom
// reports 0 for every measurement, which lands the menu at the click point —
// the same place it would sit with room to spare.
const rect = el.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
el.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
el.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
const onPointerDown = (ev: Event) => {
if (!el.contains(ev.target as Node)) closeContextMenu();
};
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape") closeContextMenu();
};
const dispose = () => {
if (open?.el !== el) return;
open = null;
el.remove();
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("scroll", closeContextMenu, true);
window.removeEventListener("resize", closeContextMenu);
};
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("scroll", closeContextMenu, true);
window.addEventListener("resize", closeContextMenu);
open = { el, dispose };
(el.firstElementChild as HTMLElement | null)?.focus();
return el;
}
+178
View File
@@ -0,0 +1,178 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { renderSidebar } from "./catalog.js";
import { closeContextMenu } from "./contextMenu.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { normalizeSessionName } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
});
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
closeContextMenu();
mounted?.dispose();
mounted = null;
vi.unstubAllGlobals();
});
function session(overrides: Partial<Session> = {}): Session {
return {
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: { name: "amber-falcon" },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
/** Mount a sidebar with one session; the handle is disposed after each test. */
function mount(s: Session): HTMLElement {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const handle = renderSidebar(storeWith([s]));
mounted = handle;
return handle.el;
}
function cardIn(el: HTMLElement, s: Session): HTMLElement {
return el.querySelector<HTMLElement>(`[data-session-id="${s.id}"]`) as HTMLElement;
}
/** Mount a sidebar with one session and right-click its card. */
function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } {
const el = mount(s);
const row = cardIn(el, s);
row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true }));
return { el, row };
}
function menuItem(label: string): HTMLButtonElement {
const found = [...document.querySelectorAll<HTMLButtonElement>(".context-menu-item")].find(
(b) => b.textContent === label,
);
if (!found) throw new Error(`no "${label}" entry in the context menu`);
return found;
}
describe("session name normalisation (#125)", () => {
it("trims, and treats a blank name as a clear", () => {
expect(normalizeSessionName(" amber-falcon ")).toBe("amber-falcon");
expect(normalizeSessionName(" ")).toBe("");
});
it("caps at the daemon's limit, counting code points", () => {
expect(normalizeSessionName("x".repeat(80))).toHaveLength(64);
expect([...normalizeSessionName("é".repeat(80))]).toHaveLength(64);
});
});
describe("sidebar card context menu (#125)", () => {
it("suppresses the browser menu and offers rename, spawn similar and kill", () => {
const s = session();
const row = cardIn(mount(s), s);
const ev = new Event("contextmenu", { bubbles: true, cancelable: true });
row.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([
"Rename…",
"Spawn similar",
"Kill session",
]);
});
it("spawn similar dispatches the same event the ⧉ button does", () => {
const s = session();
openCardMenu(s);
const seen = vi.fn();
document.addEventListener("catalog-spawn-similar", seen);
menuItem("Spawn similar").click();
document.removeEventListener("catalog-spawn-similar", seen);
expect(seen).toHaveBeenCalledOnce();
});
});
describe("inline rename (#125)", () => {
it("pre-fills the field with the session's own name", () => {
const { row } = openCardMenu(session());
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
expect(input?.value).toBe("amber-falcon");
expect(row.querySelector("span.leaf-name")).toBeNull();
});
it("starts empty for a session that has no name of its own", () => {
const { row } = openCardMenu(session({ metadata: {} }));
menuItem("Rename…").click();
expect(row.querySelector<HTMLInputElement>("input.leaf-rename")?.value).toBe("");
});
it("holds the tree still while the field is open", () => {
const s = session();
const { el, row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
// A catalog delta mid-edit must not rebuild the row out from under it.
document.dispatchEvent(new CustomEvent("catalog-rerender"));
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input);
});
it("Enter PATCHes the session with the typed name", () => {
const s = session();
const { row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
input.value = "crimson-otter";
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify(s), { status: 200 })),
);
vi.stubGlobal("fetch", fetchMock);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${s.id}`);
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({ name: "crimson-otter" });
});
it("Escape cancels without a request and restores the name", () => {
const s = session();
const { el, row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
input.value = "discarded";
const fetchMock = vi.fn(() => Promise.reject(new Error("offline")));
vi.stubGlobal("fetch", fetchMock);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(fetchMock).not.toHaveBeenCalled();
// The row is rebuilt from the store, so the old name comes back.
expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon");
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull();
});
});
+4 -4
View File
@@ -140,10 +140,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field wt-step" hidden>
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
@@ -158,6 +154,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<span class="wt-summary"></span>
</button>
<div class="wt-collapsible" hidden>
<div class="field wt-step" hidden>
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
</div>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
+79 -7
View File
@@ -736,7 +736,6 @@ textarea {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 30px;
margin: 1px var(--sp-2);
padding: 4px var(--sp-3);
border-radius: var(--r-1);
@@ -787,12 +786,13 @@ textarea {
font-weight: 400;
}
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
always, line 2 (meta) only when it has something to say. */
/* #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 {
@@ -814,6 +814,68 @@ textarea {
white-space: nowrap;
}
/* Inline rename field (#125): sits exactly where the name it replaces did, so
the card does not jump when the edit starts. */
input.leaf-rename {
font: inherit;
font-weight: 500;
font-size: var(--fs-13);
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--accent-ring);
border-radius: var(--r-2);
padding: 0 var(--sp-1);
min-width: 0;
}
input.leaf-rename:focus {
outline: none;
box-shadow: 0 0 0 3px var(--accent-tint);
}
/* Right-click menu on a session card (#125). Positioned from the click point,
so it is fixed to the viewport rather than to the scrolling sidebar. */
.context-menu {
position: fixed;
z-index: var(--z-modal);
display: flex;
flex-direction: column;
min-width: 168px;
padding: var(--sp-1);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.context-menu-item {
appearance: none;
background: none;
border: 0;
border-radius: var(--r-2);
color: var(--ink);
font: inherit;
font-size: var(--fs-13);
text-align: left;
padding: var(--sp-1) var(--sp-2);
cursor: pointer;
}
.context-menu-item:hover,
.context-menu-item:focus-visible {
background: var(--surface-3);
outline: none;
}
.context-menu-item.is-danger {
color: var(--danger);
}
.context-menu-item.is-danger:hover,
.context-menu-item.is-danger:focus-visible {
background: var(--danger-tint);
}
/* 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 {
@@ -855,7 +917,8 @@ textarea {
ellipsis so it never pushes the trailing fragments off the line. */
.activity-detail {
color: var(--faint);
flex: 0 1 auto;
/* Consume only the space left after the trailing status + command. */
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -866,12 +929,19 @@ textarea {
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 {
@@ -1134,9 +1204,11 @@ textarea {
.attach-body .xterm .xterm-viewport {
overflow-y: auto;
scrollbar-width: none;
/* We drive scrollback from touchmove (see attach.ts); claim vertical pans so
the browser doesn't fight the handler or trigger gestures mid-scroll. */
touch-action: pan-y;
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
the gesture to the browser, which may cancel the event stream before the
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 {
+20 -1
View File
@@ -244,10 +244,29 @@ export interface ApiError {
}
export function sessionName(s: Session): string {
return customSessionName(s) ?? s.command;
}
/** The session's own name, without the command fallback (#125): `undefined`
* for a session that was never given one. */
export function customSessionName(s: Session): string | undefined {
const meta = s.metadata as Record<string, unknown>;
const name = meta?.name;
if (typeof name === "string" && name.length > 0) return name;
return s.command;
return undefined;
}
/** Longest session name the daemon stores (#125), in characters. Mirrors
* `maestro_protocol::MAX_SESSION_NAME_LEN`. */
export const MAX_SESSION_NAME_LEN = 64;
/** Trim a requested session name and cap it at {@link MAX_SESSION_NAME_LEN}
* characters. Empty means "clear the name", so the card falls back to the
* command. Mirrors `maestro_protocol::normalize_session_name` — the daemon
* applies the same rule, this only keeps the UI honest about what it will
* get back. Uses the spread so the cap counts code points, not UTF-16 units. */
export function normalizeSessionName(name: string): string {
return [...name.trim()].slice(0, MAX_SESSION_NAME_LEN).join("");
}
/** The directory a session lives in: its live `current_cwd` if the daemon has
+10
View File
@@ -108,6 +108,16 @@ describe("spawnForm worktree step (#95)", () => {
expect(step(form).hidden).toBe(false);
});
it("keeps the worktree step inside the collapsed Advanced options section", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
const collapsible = form.querySelector<HTMLElement>(".wt-collapsible")!;
expect(collapsible.contains(step(form))).toBe(true);
expect(collapsible.hidden).toBe(true);
});
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();