Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbf57fc49 | ||
|
|
7935663ed7 | ||
|
|
bff0eddc12 | ||
|
|
3ce1a6f331 | ||
|
|
8cb6aeb61e | ||
|
|
247060de68 | ||
|
|
29545d15fe | ||
|
|
4d7e368d31 | ||
|
|
eaf60d9dac | ||
|
|
f89be4a716 | ||
|
|
6be0dba74e | ||
|
|
28778b2224 | ||
|
|
766eabbcb6 | ||
|
|
c67fa2bd4e | ||
|
|
88b6bba9f2 | ||
|
|
1c349e5858 | ||
|
|
9fc21b4306 | ||
|
|
19162f9952 | ||
|
|
2fcfc06c3c | ||
|
|
f82ab45766 | ||
|
|
d304171d7c | ||
|
|
95b459bd65 | ||
|
|
c9cfdb5668 |
@@ -7,6 +7,10 @@ on:
|
||||
- 'linear/**'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -11,6 +11,7 @@ persistence, WebSocket for transport.
|
||||
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
||||
├── GET/POST /api/sessions list / spawn
|
||||
├── DELETE /api/sessions/:id kill
|
||||
├── POST /api/sessions/:id/input type text into a session
|
||||
├── GET /api/info hostname, version, home dir, spawn presets
|
||||
├── GET /api/dirs directory listing (spawn autocomplete)
|
||||
├── GET /api/recent-dirs spawn history
|
||||
@@ -18,7 +19,7 @@ maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
||||
├── WS /api/events Snapshot + session deltas
|
||||
└── WS /api/attach/:id raw PTY bytes
|
||||
|
||||
maestro list/spawn/attach/kill CLI client
|
||||
maestro list/spawn/attach/kill/send CLI client
|
||||
maestro tui ratatui client
|
||||
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
|
||||
```
|
||||
@@ -70,6 +71,11 @@ before changing anything that creates or removes one.
|
||||
sidebars — line layout, activity colours, duration format, state precedence.
|
||||
Read it before changing sidebar rendering.
|
||||
|
||||
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
|
||||
or is upgraded under them — the build stamp on `/api/info`, the status
|
||||
indicator's states, the blocking overlay, grace and poll timings. Read it
|
||||
before changing reconnect behaviour on either client.
|
||||
|
||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||
|
||||
@@ -100,10 +100,11 @@ Three surfaces, all DOM built in TS, styled by class:
|
||||
no-sessions, no-search-match.
|
||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||
a framed dark xterm with a green cursor.
|
||||
- **Draft pane** — the spawn form mounted inline in an empty split pane,
|
||||
focusable like any session pane; mono inputs with green focus ring; grouped
|
||||
worktree fieldset. Spawn opens one (reusing an empty focused pane, else
|
||||
splitting), and "Spawn similar" seeds one from a session's dir + command.
|
||||
- **Spawn modal** — the spawn form in a centered dialog (a bottom sheet below
|
||||
600px) over a blurred backdrop; mono inputs with green focus ring; grouped
|
||||
worktree fieldset. Escape, Cancel, ✕ and a backdrop click all dismiss it.
|
||||
Submitting gives the new session its own tab; "Spawn similar" seeds the form
|
||||
from a session's dir + command and puts the result beside it.
|
||||
|
||||
Interactive elements ship default / hover / focus-visible / active / disabled
|
||||
states; the accent green is used only for action, selection, focus, and live
|
||||
@@ -112,5 +113,5 @@ status — never decoration.
|
||||
## Dev review aids (DEV-only, stripped from production)
|
||||
|
||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane),
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
|
||||
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
||||
|
||||
@@ -41,6 +41,9 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// Type text into a running session, as if it had been typed on an
|
||||
/// attached terminal.
|
||||
Send(SendArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
Hooks(HooksArgs),
|
||||
}
|
||||
@@ -103,6 +106,20 @@ pub struct AttachArgs {
|
||||
pub detach_key: String,
|
||||
}
|
||||
|
||||
/// `maestro send <session> <text...>`
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct SendArgs {
|
||||
/// Session to type into: its ID, a unique ID prefix, or its name.
|
||||
pub session: String,
|
||||
/// Text to send. Omit it to read the text from stdin.
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
pub text: Vec<String>,
|
||||
/// Leave the text sitting in the session's prompt instead of pressing
|
||||
/// Enter after it.
|
||||
#[arg(long)]
|
||||
pub no_enter: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct KillArgs {
|
||||
/// Session ID to kill.
|
||||
@@ -245,6 +262,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_send_joining_the_message_words() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["maestro", "send", "planner", "run", "the", "tests"]).unwrap();
|
||||
let Command::Send(a) = cli.command else {
|
||||
panic!("expected send")
|
||||
};
|
||||
assert_eq!(a.session, "planner");
|
||||
assert_eq!(a.text, vec!["run", "the", "tests"]);
|
||||
assert!(!a.no_enter);
|
||||
}
|
||||
|
||||
/// The message is trailing_var_arg, so a word starting with `-` inside it
|
||||
/// is text, not a flag clap should reject.
|
||||
#[test]
|
||||
fn send_message_may_start_with_a_dash() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["maestro", "send", "--no-enter", "planner", "--force"]).unwrap();
|
||||
let Command::Send(a) = cli.command else {
|
||||
panic!("expected send")
|
||||
};
|
||||
assert!(a.no_enter);
|
||||
assert_eq!(a.text, vec!["--force"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_text_may_be_omitted_for_stdin() {
|
||||
let cli = Cli::try_parse_from(["maestro", "send", "planner"]).unwrap();
|
||||
let Command::Send(a) = cli.command else {
|
||||
panic!("expected send")
|
||||
};
|
||||
assert!(a.text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_env_without_equals() {
|
||||
let err = Cli::try_parse_from([
|
||||
|
||||
@@ -10,7 +10,7 @@ use maestro_protocol::DaemonClient;
|
||||
|
||||
use crate::attach::{parse_detach_key, run_attach_url};
|
||||
use crate::cli::Command;
|
||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||
use crate::sessions::{run_kill, run_list, run_send, run_spawn};
|
||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -24,6 +24,10 @@ pub enum CliError {
|
||||
Attach(#[from] crate::attach::AttachError),
|
||||
#[error("{0}")]
|
||||
Hooks(#[from] maestro_hooks::HooksError),
|
||||
#[error("{0}")]
|
||||
Resolve(#[from] crate::sessions::ResolveError),
|
||||
#[error("nothing to send: pass the text as arguments or pipe it on stdin")]
|
||||
NothingToSend,
|
||||
#[error("could not determine the home directory ($HOME unset?)")]
|
||||
NoHome,
|
||||
#[error("{0}")]
|
||||
@@ -82,6 +86,10 @@ pub async fn dispatch(
|
||||
run_kill(&daemon, &args).await?;
|
||||
println!("killed {}", args.session_id);
|
||||
}
|
||||
Command::Send(args) => {
|
||||
let id = run_send(&daemon, &args, &mut std::io::stdin()).await?;
|
||||
println!("sent to {id}");
|
||||
}
|
||||
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -5,12 +5,13 @@ use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
||||
SessionStatus, WorktreeSpec,
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||
Session, SessionStatus, WorktreeSpec,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
|
||||
use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs};
|
||||
use crate::dispatch::CliError;
|
||||
|
||||
/// `maestro list`
|
||||
pub async fn run_list(
|
||||
@@ -66,6 +67,92 @@ pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), Clie
|
||||
client.kill_session(args.session_id, args.force).await
|
||||
}
|
||||
|
||||
/// Nothing on the daemon answers to what the operator named.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
#[error("no session matches {0:?} — run `maestro list` to see what is running")]
|
||||
NoMatch(String),
|
||||
#[error("{0:?} matches {1} sessions; use a session id instead")]
|
||||
Ambiguous(String, usize),
|
||||
}
|
||||
|
||||
/// Turn what the operator typed into a session id: a full ID, a unique ID
|
||||
/// prefix, or a session's name (#125), matched case-insensitively. Names come
|
||||
/// first — an operator who named a session after a hex string meant the name.
|
||||
pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid, ResolveError> {
|
||||
let needle = needle.trim();
|
||||
let by_name: Vec<&Session> = sessions
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
maestro_protocol::display::custom_name(s)
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case(needle))
|
||||
})
|
||||
.collect();
|
||||
match by_name.len() {
|
||||
1 => return Ok(by_name[0].id),
|
||||
0 => {}
|
||||
n => return Err(ResolveError::Ambiguous(needle.to_string(), n)),
|
||||
}
|
||||
|
||||
let by_id: Vec<&Session> = sessions
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.id.to_string()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&needle.to_ascii_lowercase())
|
||||
})
|
||||
.collect();
|
||||
match by_id.len() {
|
||||
1 => Ok(by_id[0].id),
|
||||
0 => Err(ResolveError::NoMatch(needle.to_string())),
|
||||
n => Err(ResolveError::Ambiguous(needle.to_string(), n)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `maestro send`. Answers with the session that was typed into, so the
|
||||
/// caller can name it once the daemon has accepted the text.
|
||||
///
|
||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||
/// the shape a heredoc or a pipe wants.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_send(
|
||||
client: &DaemonClient,
|
||||
args: &SendArgs,
|
||||
stdin: &mut impl std::io::Read,
|
||||
) -> Result<uuid::Uuid, CliError> {
|
||||
let text = if args.text.is_empty() {
|
||||
let mut buf = String::new();
|
||||
stdin.read_to_string(&mut buf)?;
|
||||
// One trailing newline is how a pipe ends, not part of the message —
|
||||
// keeping it would submit the text twice.
|
||||
buf.strip_suffix('\n')
|
||||
.map(|t| t.strip_suffix('\r').unwrap_or(t))
|
||||
.unwrap_or(&buf)
|
||||
.to_string()
|
||||
} else {
|
||||
args.text.join(" ")
|
||||
};
|
||||
if text.is_empty() {
|
||||
return Err(CliError::NothingToSend);
|
||||
}
|
||||
|
||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
let sessions = client.list_sessions().await?;
|
||||
resolve_session(&sessions, &args.session)?
|
||||
}
|
||||
};
|
||||
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[allow(clippy::write_literal)]
|
||||
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
|
||||
if sessions.is_empty() {
|
||||
@@ -218,6 +305,63 @@ mod tests {
|
||||
assert!(out.contains("bash [claude]"), "{out}");
|
||||
}
|
||||
|
||||
fn named(id: u128, name: &str) -> Session {
|
||||
let mut s = session("/tmp", "claude", id);
|
||||
s.metadata
|
||||
.insert("name".into(), serde_json::Value::String(name.into()));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_by_name_id_prefix_and_reports_what_it_cannot() {
|
||||
use super::{resolve_session, ResolveError};
|
||||
|
||||
// Ids that differ in their first characters, so a short prefix is
|
||||
// enough to pick one — the ergonomics the prefix match exists for.
|
||||
let (a, b) = (1u128 << 124, 2u128 << 124);
|
||||
let sessions = vec![named(a, "planner"), named(b, "builder")];
|
||||
let one = Uuid::from_u128(a);
|
||||
|
||||
assert_eq!(resolve_session(&sessions, "planner").unwrap(), one);
|
||||
// Names match case-insensitively and ignore surrounding space.
|
||||
assert_eq!(resolve_session(&sessions, " Planner ").unwrap(), one);
|
||||
// An id prefix works when it picks exactly one session.
|
||||
let prefix = &one.to_string()[..8];
|
||||
assert_eq!(resolve_session(&sessions, prefix).unwrap(), one);
|
||||
assert!(matches!(
|
||||
resolve_session(&sessions, "nope"),
|
||||
Err(ResolveError::NoMatch(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// Two sessions sharing a name is legal — the daemon never enforced
|
||||
/// uniqueness — so `send` must refuse rather than pick one.
|
||||
#[test]
|
||||
fn an_ambiguous_name_is_refused() {
|
||||
use super::{resolve_session, ResolveError};
|
||||
|
||||
let sessions = vec![named(1 << 124, "worker"), named(2 << 124, "worker")];
|
||||
assert!(matches!(
|
||||
resolve_session(&sessions, "worker"),
|
||||
Err(ResolveError::Ambiguous(_, 2))
|
||||
));
|
||||
}
|
||||
|
||||
/// A session named after its own id prefix means the name: an operator
|
||||
/// who typed a name they chose should not be routed by hex coincidence.
|
||||
#[test]
|
||||
fn names_win_over_id_prefixes() {
|
||||
use super::resolve_session;
|
||||
|
||||
let two = Uuid::from_u128(2 << 124);
|
||||
let prefix = two.to_string()[..8].to_string();
|
||||
let sessions = vec![named(1 << 124, &prefix), session("/tmp", "bash", 2 << 124)];
|
||||
assert_eq!(
|
||||
resolve_session(&sessions, &prefix).unwrap(),
|
||||
Uuid::from_u128(1 << 124)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sessions() {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
@@ -29,6 +29,7 @@ async fn info_carries_version_and_presets() {
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let info = mock.client().daemon_info().await.expect("info");
|
||||
@@ -108,3 +109,110 @@ async fn spawn_round_trips_through_the_daemon() {
|
||||
.expect("kill");
|
||||
assert_eq!(mock.session_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_types_text_and_submits_by_default() {
|
||||
let mock = MockDaemon::start().await;
|
||||
let id = Uuid::from_u128(7);
|
||||
mock.add_session(session(id, "/home/mock/work", "claude"));
|
||||
|
||||
let args = maestro_cli::cli::SendArgs {
|
||||
session: id.to_string(),
|
||||
text: vec!["run".into(), "the".into(), "tests".into()],
|
||||
no_enter: false,
|
||||
};
|
||||
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||
.await
|
||||
.expect("send");
|
||||
|
||||
assert_eq!(sent, id);
|
||||
let inputs = mock.inputs();
|
||||
assert_eq!(inputs.len(), 1);
|
||||
assert_eq!(inputs[0].0, id);
|
||||
assert_eq!(inputs[0].1.text, "run the tests");
|
||||
assert!(inputs[0].1.submit, "Enter is the default");
|
||||
}
|
||||
|
||||
/// `--no-enter` leaves the text in the prompt; the flag has to survive the
|
||||
/// whole way to the daemon or the message is sent anyway.
|
||||
#[tokio::test]
|
||||
async fn send_without_enter_leaves_the_text_unsubmitted() {
|
||||
let mock = MockDaemon::start().await;
|
||||
let id = Uuid::from_u128(8);
|
||||
mock.add_session(session(id, "/tmp", "bash"));
|
||||
|
||||
let args = maestro_cli::cli::SendArgs {
|
||||
session: id.to_string(),
|
||||
text: vec!["ls".into()],
|
||||
no_enter: true,
|
||||
};
|
||||
maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||
.await
|
||||
.expect("send");
|
||||
|
||||
assert!(!mock.inputs()[0].1.submit);
|
||||
}
|
||||
|
||||
/// No text arguments → the message comes from stdin, minus the trailing
|
||||
/// newline a pipe or heredoc adds.
|
||||
#[tokio::test]
|
||||
async fn send_reads_stdin_and_drops_its_trailing_newline() {
|
||||
let mock = MockDaemon::start().await;
|
||||
let id = Uuid::from_u128(9);
|
||||
mock.add_session(session(id, "/tmp", "bash"));
|
||||
|
||||
let args = maestro_cli::cli::SendArgs {
|
||||
session: id.to_string(),
|
||||
text: Vec::new(),
|
||||
no_enter: false,
|
||||
};
|
||||
let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec());
|
||||
maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin)
|
||||
.await
|
||||
.expect("send");
|
||||
|
||||
assert_eq!(mock.inputs()[0].1.text, "multi\nline");
|
||||
}
|
||||
|
||||
/// A session named through `PATCH /api/sessions/:id` is addressable by that
|
||||
/// name, so scripts don't have to carry a UUID around.
|
||||
#[tokio::test]
|
||||
async fn send_resolves_a_session_by_name() {
|
||||
let mock = MockDaemon::start().await;
|
||||
let id = Uuid::from_u128(10);
|
||||
let mut named = session(id, "/tmp", "claude");
|
||||
named
|
||||
.metadata
|
||||
.insert("name".into(), serde_json::Value::String("planner".into()));
|
||||
mock.add_session(named);
|
||||
mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash"));
|
||||
|
||||
let args = maestro_cli::cli::SendArgs {
|
||||
session: "Planner".into(),
|
||||
text: vec!["go".into()],
|
||||
no_enter: false,
|
||||
};
|
||||
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||
.await
|
||||
.expect("send");
|
||||
|
||||
assert_eq!(sent, id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||
let mock = MockDaemon::start().await;
|
||||
mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash"));
|
||||
|
||||
let args = maestro_cli::cli::SendArgs {
|
||||
session: "nope".into(),
|
||||
text: vec!["hi".into()],
|
||||
no_enter: false,
|
||||
};
|
||||
let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||
.await
|
||||
.expect_err("should not resolve");
|
||||
|
||||
assert!(err.to_string().contains("no session matches"), "{err}");
|
||||
assert!(mock.inputs().is_empty());
|
||||
}
|
||||
|
||||
@@ -174,10 +174,20 @@ pub struct TuiSection {
|
||||
/// 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.
|
||||
/// The graphite dark palette and the default.
|
||||
Dark,
|
||||
/// A high-contrast light palette.
|
||||
Light,
|
||||
/// Ice Cyan — cyan accent on the graphite base.
|
||||
IceCyan,
|
||||
/// Periwinkle — indigo base, blue-violet accent.
|
||||
Periwinkle,
|
||||
/// Amber Phosphor — CRT amber on warm graphite.
|
||||
AmberPhosphor,
|
||||
/// Ember — warm orange-red accent.
|
||||
Ember,
|
||||
/// Orchid — muted violet accent.
|
||||
Orchid,
|
||||
/// Named ANSI colours, allowing the terminal to provide the colour set.
|
||||
Ansi,
|
||||
/// Solarized Dark.
|
||||
@@ -204,6 +214,11 @@ pub enum TuiTheme {
|
||||
pub const TUI_THEMES: &[(&str, TuiTheme)] = &[
|
||||
("dark", TuiTheme::Dark),
|
||||
("light", TuiTheme::Light),
|
||||
("ice-cyan", TuiTheme::IceCyan),
|
||||
("periwinkle", TuiTheme::Periwinkle),
|
||||
("amber-phosphor", TuiTheme::AmberPhosphor),
|
||||
("ember", TuiTheme::Ember),
|
||||
("orchid", TuiTheme::Orchid),
|
||||
("ansi", TuiTheme::Ansi),
|
||||
("solarized-dark", TuiTheme::SolarizedDark),
|
||||
("solarized-light", TuiTheme::SolarizedLight),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Stamps the daemon binary with the commit it was built from, so
|
||||
//! `GET /api/info` can tell clients which release they are talking to.
|
||||
//!
|
||||
//! Two sources, in order:
|
||||
//!
|
||||
//! 1. `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY` in
|
||||
//! the environment. The Nix build has no `.git` in its source tree (crane
|
||||
//! filters it out), so `flake.nix` passes the flake's own revision this way.
|
||||
//! 2. The git checkout the crate is being compiled in.
|
||||
//!
|
||||
//! Neither available leaves the values empty; clients treat that as "unknown
|
||||
//! release" rather than an error.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_COMMIT");
|
||||
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DATE");
|
||||
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DIRTY");
|
||||
// Rebuild when HEAD moves, so a plain `cargo build` after a commit or a
|
||||
// branch switch restamps instead of serving the previous hash.
|
||||
for f in ["HEAD", "ORIG_HEAD"] {
|
||||
if let Some(p) = git_dir().map(|d| d.join(f)) {
|
||||
if p.exists() {
|
||||
println!("cargo:rerun-if-changed={}", p.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let commit = env_or_git("MAESTRO_BUILD_COMMIT", &["rev-parse", "--short=12", "HEAD"]);
|
||||
let date = env_or_git("MAESTRO_BUILD_DATE", &["log", "-1", "--format=%cI"]);
|
||||
let dirty = match std::env::var("MAESTRO_BUILD_DIRTY") {
|
||||
Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"),
|
||||
Err(_) => !git(&["status", "--porcelain"])
|
||||
.unwrap_or_default()
|
||||
.is_empty(),
|
||||
};
|
||||
|
||||
println!("cargo:rustc-env=MAESTRO_BUILD_COMMIT={commit}");
|
||||
println!("cargo:rustc-env=MAESTRO_BUILD_DATE={date}");
|
||||
println!("cargo:rustc-env=MAESTRO_BUILD_DIRTY={dirty}");
|
||||
}
|
||||
|
||||
/// The environment variable if set and non-empty, else what git reports, else
|
||||
/// an empty string.
|
||||
fn env_or_git(var: &str, args: &[&str]) -> String {
|
||||
match std::env::var(var) {
|
||||
Ok(v) if !v.trim().is_empty() => v.trim().to_string(),
|
||||
_ => git(args).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `git` inside the crate's source tree. `None` when git is missing, the
|
||||
/// tree is not a checkout, or the command fails — every one of which is a
|
||||
/// legitimate way to build Maestro.
|
||||
fn git(args: &[&str]) -> Option<String> {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Absolute path of the `.git` directory (a worktree's `.git` is a file
|
||||
/// pointing elsewhere, and `--git-common-dir` resolves both).
|
||||
fn git_dir() -> Option<PathBuf> {
|
||||
let dir = git(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
|
||||
if dir.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(dir))
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub mod panes;
|
||||
pub mod proc;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod release;
|
||||
pub mod session;
|
||||
pub mod sidecar;
|
||||
pub mod spawn_history;
|
||||
|
||||
@@ -20,7 +20,7 @@ use axum::{Json, Router};
|
||||
use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
|
||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
@@ -44,6 +44,7 @@ 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_INPUT, post(send_input))
|
||||
.route(
|
||||
routes::patterns::SESSION,
|
||||
delete(kill_session).patch(rename_session),
|
||||
@@ -100,6 +101,9 @@ fn session_error_status(code: &str) -> StatusCode {
|
||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||
"kill_guarded" => StatusCode::CONFLICT,
|
||||
// The session outlived its pane: nothing to type into, and nothing
|
||||
// the daemon can do about it.
|
||||
"not_running" => StatusCode::CONFLICT,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
@@ -137,7 +141,7 @@ struct NamesQuery {
|
||||
}
|
||||
|
||||
/// Fresh session handles for the spawn form's instant reroll (#53). The web
|
||||
/// client fetches one batch per draft pane and rerolls locally from it.
|
||||
/// client fetches one batch per spawn form and rerolls locally from it.
|
||||
async fn names(Query(q): Query<NamesQuery>) -> Json<NameBatch> {
|
||||
let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH);
|
||||
Json(NameBatch {
|
||||
@@ -151,6 +155,8 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
||||
version: PROTOCOL_VERSION.into(),
|
||||
home_dir: (*s.home_dir).clone(),
|
||||
presets: (*s.presets).clone(),
|
||||
build: crate::release::build_info(),
|
||||
instance: crate::release::instance_id().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -247,6 +253,28 @@ async fn report_activity(
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/input`: type text into a session's pane, then
|
||||
/// press Enter unless the request turns `submit` off. Answers `204 No
|
||||
/// Content`. A session that has exited is refused (409) rather than accepted
|
||||
/// into nowhere.
|
||||
async fn send_input(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<SendInputRequest>,
|
||||
) -> Response {
|
||||
match s.manager.send_input(id, &body.text, body.submit).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(
|
||||
session_error_status(&info.code),
|
||||
info.code.as_str(),
|
||||
info.message.as_str(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
(status, Json(ApiError::new(code, message))).into_response()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub const DEFAULT_HOME_IGNORE: &[&str] =
|
||||
/// triggers a rescan.
|
||||
///
|
||||
/// A scan walks the roots and runs one `git worktree list` per hit, holding
|
||||
/// the scanner lock throughout, and clients re-request whenever a draft pane
|
||||
/// the scanner lock throughout, and clients re-request whenever a spawn form
|
||||
/// re-mounts — which `multiview.render()` does on unrelated events. Keep this
|
||||
/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is
|
||||
/// what refreshes, and a request only rescans when that timer has failed to.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! What release this daemon is, and which run of it.
|
||||
//!
|
||||
//! Two facts ride `GET /api/info` so clients can render a status indicator and
|
||||
//! survive an upgrade:
|
||||
//!
|
||||
//! * [`build_info`] — the commit the binary was compiled from, stamped by
|
||||
//! `build.rs`. Shown in the indicator's tooltip; a change across a reconnect
|
||||
//! means the operator upgraded, so a browser reloads its assets.
|
||||
//! * [`instance_id`] — a fresh id per daemon process. A change with the same
|
||||
//! commit means a plain restart, which clients ride out silently.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use maestro_protocol::BuildInfo;
|
||||
|
||||
/// The commit this binary was built from. Every field is empty when the build
|
||||
/// happened outside a git checkout and no `MAESTRO_BUILD_*` override was given.
|
||||
pub fn build_info() -> BuildInfo {
|
||||
BuildInfo {
|
||||
commit: env!("MAESTRO_BUILD_COMMIT").to_string(),
|
||||
commit_date: env!("MAESTRO_BUILD_DATE").to_string(),
|
||||
dirty: env!("MAESTRO_BUILD_DIRTY") == "true",
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of this daemon process, stable for its lifetime and different on
|
||||
/// every start. Clients diff it across a reconnect to detect a restart.
|
||||
pub fn instance_id() -> &'static str {
|
||||
static ID: OnceLock<String> = OnceLock::new();
|
||||
ID.get_or_init(|| uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
@@ -40,6 +40,15 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||
/// attempt, which is all #83 needs.
|
||||
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Gap between the text of a `send` and the Enter that submits it.
|
||||
///
|
||||
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
|
||||
/// burst of input into one paste; a newline inside that burst becomes a line
|
||||
/// break in the message instead of sending it. Landing the Enter as its own
|
||||
/// write, after the reader has drained the text, is what makes the message
|
||||
/// actually go.
|
||||
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
|
||||
|
||||
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionRecord {
|
||||
@@ -78,6 +87,11 @@ pub enum SessionError {
|
||||
NotFound(Uuid),
|
||||
#[error("invalid environment variable name: {0:?}")]
|
||||
InvalidEnv(String),
|
||||
/// The session exists but its pane is gone, so there is nothing to type
|
||||
/// into. Writing to a dead session is a client bug worth reporting, unlike
|
||||
/// a stray keystroke on an attach socket, which is dropped.
|
||||
#[error("session is no longer running: {0}")]
|
||||
NotRunning(Uuid),
|
||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||
/// so it stays the operator's handle on their own unfinished work.
|
||||
@@ -94,6 +108,7 @@ impl SessionError {
|
||||
SessionError::Io(_) => "io_failed",
|
||||
SessionError::NotFound(_) => "not_found",
|
||||
SessionError::InvalidEnv(_) => "invalid_env",
|
||||
SessionError::NotRunning(_) => "not_running",
|
||||
SessionError::KillGuarded(_) => "kill_guarded",
|
||||
};
|
||||
let unsaved_work = match self {
|
||||
@@ -671,6 +686,33 @@ impl SessionManager {
|
||||
.map_err(SessionError::Tmux)
|
||||
}
|
||||
|
||||
/// Type `text` into a session's pane, with an Enter after it when
|
||||
/// `submit` is set.
|
||||
///
|
||||
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
|
||||
/// asked for text to land somewhere, and a silent success would let a
|
||||
/// script believe an agent had been told something it never heard.
|
||||
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
||||
Some(SessionStatus::Running) => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let bytes = text.as_bytes().to_vec();
|
||||
tmux_blocking(move || tmux.send_keys(id, &bytes))
|
||||
.await
|
||||
.map_err(SessionError::Tmux)?;
|
||||
if submit {
|
||||
tokio::time::sleep(SUBMIT_DELAY).await;
|
||||
let tmux = self.inner.tmux.clone();
|
||||
tmux_blocking(move || tmux.send_keys(id, b"\r"))
|
||||
.await
|
||||
.map_err(SessionError::Tmux)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resize a session's window. No-op for a dead session, as with
|
||||
/// [`Self::send_keys`].
|
||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||
|
||||
@@ -91,6 +91,35 @@ async fn info_returns_daemon_identity() {
|
||||
assert!(resp["version"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn info_reports_build_and_instance() {
|
||||
// The status indicator needs a release to name and a process to compare
|
||||
// against after a reconnect. `commit`/`commit_date` are empty when the
|
||||
// binary was built outside a checkout, so only their presence is asserted;
|
||||
// `instance` is a fresh uuid per daemon process.
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp["build"]["commit"].is_string());
|
||||
assert!(resp["build"]["commit_date"].is_string());
|
||||
assert!(resp["build"]["dirty"].is_boolean());
|
||||
let instance = resp["instance"].as_str().unwrap();
|
||||
assert!(Uuid::parse_str(instance).is_ok(), "instance is a uuid");
|
||||
|
||||
// Same process, same id — a client only treats a *changed* id as a restart.
|
||||
let again: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(again["instance"].as_str().unwrap(), instance);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -232,17 +261,14 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
|
||||
assert!(projects.is_empty());
|
||||
}
|
||||
|
||||
/// #53: every returned handle splits into a known adjective and noun.
|
||||
/// #53: every returned handle is one word from the shared list.
|
||||
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
|
||||
for name in &batch.names {
|
||||
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
|
||||
panic!("handle {name:?} is not adjective-noun");
|
||||
});
|
||||
assert!(!name.contains('-'), "handle {name:?} is not one word");
|
||||
assert!(
|
||||
maestro_names::ADJECTIVES.contains(&adj),
|
||||
"{adj} not in ADJECTIVES"
|
||||
maestro_names::WORDS.contains(&name.as_str()),
|
||||
"{name} not in WORDS"
|
||||
);
|
||||
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1250,6 +1250,143 @@ async fn cli_list_reads_from_daemon() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `maestro send` end to end: the shipped binary, a real daemon, a real tmux
|
||||
/// pane. The shell only runs the command if the Enter arrived as its own
|
||||
/// write after the text, so the file appearing is the whole path working.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cli_send_types_a_command_into_a_live_shell() {
|
||||
let (daemon, client) = bring_up().await;
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let sess = client
|
||||
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
|
||||
.await
|
||||
.expect("spawn bash");
|
||||
|
||||
let home = TempDir::new().unwrap();
|
||||
let cfg_path = home.path().join("config.toml");
|
||||
let out = run_cli(
|
||||
&["send", &sess.id.to_string(), "touch", "marker.txt"],
|
||||
&[
|
||||
("HOME", home.path().to_str().unwrap()),
|
||||
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
|
||||
("MAESTRO_DAEMON_URL", &daemon.http_base()),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"send failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
let marker = cwd.path().join("marker.txt");
|
||||
wait_for_async(
|
||||
"the shell runs the sent command",
|
||||
Duration::from_secs(10),
|
||||
|| {
|
||||
let marker = marker.clone();
|
||||
async move { marker.exists() }
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
client.kill_session(sess.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// `--no-enter` fills the prompt without submitting: the command must sit
|
||||
/// there unrun. Same setup as the test above, so the only difference is the
|
||||
/// flag.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn send_without_enter_does_not_run_the_command() {
|
||||
let (daemon, client) = bring_up().await;
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let sess = client
|
||||
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
|
||||
.await
|
||||
.expect("spawn bash");
|
||||
|
||||
let home = TempDir::new().unwrap();
|
||||
let out = run_cli(
|
||||
&[
|
||||
"send",
|
||||
"--no-enter",
|
||||
&sess.id.to_string(),
|
||||
"touch",
|
||||
"unsubmitted.txt",
|
||||
],
|
||||
&[
|
||||
("HOME", home.path().to_str().unwrap()),
|
||||
(
|
||||
"MAESTRO_CONFIG",
|
||||
home.path().join("c.toml").to_str().unwrap(),
|
||||
),
|
||||
("MAESTRO_DAEMON_URL", &daemon.http_base()),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"send failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
assert!(
|
||||
!cwd.path().join("unsubmitted.txt").exists(),
|
||||
"the command ran despite --no-enter"
|
||||
);
|
||||
|
||||
client.kill_session(sess.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// A session whose command exited still exists in the catalog, but its pane
|
||||
/// is gone. Typing into it is refused instead of being swallowed — a script
|
||||
/// that sends an instruction to a dead agent has to hear about it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn send_to_a_dead_session_is_refused() {
|
||||
use maestro_protocol::{SendInputRequest, SessionStatus};
|
||||
let (_daemon, client) = bring_up().await;
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let sess = client
|
||||
.spawn_session(&create_req(cwd.path(), "true"))
|
||||
.await
|
||||
.expect("spawn true");
|
||||
|
||||
wait_for_async(
|
||||
"the session to be seen dead",
|
||||
Duration::from_secs(15),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let id = sess.id;
|
||||
async move {
|
||||
client
|
||||
.list_sessions()
|
||||
.await
|
||||
.map(|ss| {
|
||||
ss.iter()
|
||||
.any(|s| s.id == id && s.status == SessionStatus::Dead)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let err = client
|
||||
.daemon()
|
||||
.send_input(
|
||||
sess.id,
|
||||
&SendInputRequest {
|
||||
text: "hello".into(),
|
||||
submit: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a dead session cannot be typed into");
|
||||
assert!(
|
||||
err.to_string().contains("no longer running"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cli_list_fails_when_daemon_is_down() {
|
||||
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
|
||||
|
||||
+233
-29
@@ -1,10 +1,195 @@
|
||||
//! The one home of the session-handle wordlists (#53).
|
||||
//! The one home of the session-handle wordlist (#53).
|
||||
//!
|
||||
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
|
||||
//! from here, served by `GET /api/names`.
|
||||
|
||||
/// Musical adjectives — tempo, dynamics, and articulation markings.
|
||||
pub const ADJECTIVES: &[&str] = &[
|
||||
/// Every word a handle can be: instruments, forms, and the vocabulary of
|
||||
/// tempo, dynamics, notation and theory. One word per handle — the list is
|
||||
/// wide enough that a single word still reads as distinct.
|
||||
pub const WORDS: &[&str] = &[
|
||||
// Instruments
|
||||
"cello",
|
||||
"violin",
|
||||
"viola",
|
||||
"oboe",
|
||||
"flute",
|
||||
"piccolo",
|
||||
"clarinet",
|
||||
"bassoon",
|
||||
"trumpet",
|
||||
"trombone",
|
||||
"tuba",
|
||||
"horn",
|
||||
"cornet",
|
||||
"bugle",
|
||||
"flugelhorn",
|
||||
"euphonium",
|
||||
"saxophone",
|
||||
"recorder",
|
||||
"timpani",
|
||||
"harp",
|
||||
"piano",
|
||||
"organ",
|
||||
"lyre",
|
||||
"lute",
|
||||
"mandolin",
|
||||
"banjo",
|
||||
"ukulele",
|
||||
"guitar",
|
||||
"sitar",
|
||||
"koto",
|
||||
"shamisen",
|
||||
"balalaika",
|
||||
"zither",
|
||||
"dulcimer",
|
||||
"kantele",
|
||||
"erhu",
|
||||
"guzheng",
|
||||
"pipa",
|
||||
"duduk",
|
||||
"ocarina",
|
||||
"kalimba",
|
||||
"harmonica",
|
||||
"accordion",
|
||||
"bandoneon",
|
||||
"bagpipe",
|
||||
"harpsichord",
|
||||
"clavichord",
|
||||
"celesta",
|
||||
"theremin",
|
||||
"marimba",
|
||||
"xylophone",
|
||||
"vibraphone",
|
||||
"glockenspiel",
|
||||
"snare",
|
||||
"cymbal",
|
||||
"tambourine",
|
||||
"castanet",
|
||||
"djembe",
|
||||
"tabla",
|
||||
"bodhran",
|
||||
"gong",
|
||||
"chimes",
|
||||
"contrabass",
|
||||
"shawm",
|
||||
"crumhorn",
|
||||
"sackbut",
|
||||
"didgeridoo",
|
||||
// Forms and pieces
|
||||
"sonata",
|
||||
"fugue",
|
||||
"canon",
|
||||
"prelude",
|
||||
"overture",
|
||||
"rondo",
|
||||
"nocturne",
|
||||
"scherzo",
|
||||
"etude",
|
||||
"ballade",
|
||||
"barcarolle",
|
||||
"bagatelle",
|
||||
"caprice",
|
||||
"chaconne",
|
||||
"passacaglia",
|
||||
"toccata",
|
||||
"intermezzo",
|
||||
"interlude",
|
||||
"minuet",
|
||||
"gavotte",
|
||||
"sarabande",
|
||||
"gigue",
|
||||
"allemande",
|
||||
"courante",
|
||||
"mazurka",
|
||||
"polonaise",
|
||||
"waltz",
|
||||
"bolero",
|
||||
"habanera",
|
||||
"tarantella",
|
||||
"fandango",
|
||||
"serenade",
|
||||
"aubade",
|
||||
"requiem",
|
||||
"cantata",
|
||||
"oratorio",
|
||||
"madrigal",
|
||||
"motet",
|
||||
"chorale",
|
||||
"hymn",
|
||||
"anthem",
|
||||
"lullaby",
|
||||
"elegy",
|
||||
"rhapsody",
|
||||
"symphony",
|
||||
"concerto",
|
||||
"fantasia",
|
||||
"divertimento",
|
||||
"quartet",
|
||||
"quintet",
|
||||
"sextet",
|
||||
"septet",
|
||||
"octet",
|
||||
"trio",
|
||||
// Structure, notation and theory
|
||||
"octave",
|
||||
"chord",
|
||||
"cadence",
|
||||
"cadenza",
|
||||
"coda",
|
||||
"motif",
|
||||
"refrain",
|
||||
"verse",
|
||||
"chorus",
|
||||
"bridge",
|
||||
"finale",
|
||||
"arpeggio",
|
||||
"glissando",
|
||||
"ostinato",
|
||||
"counterpoint",
|
||||
"descant",
|
||||
"harmony",
|
||||
"melody",
|
||||
"rhythm",
|
||||
"tempo",
|
||||
"timbre",
|
||||
"treble",
|
||||
"clef",
|
||||
"staff",
|
||||
"quaver",
|
||||
"crotchet",
|
||||
"semibreve",
|
||||
"fermata",
|
||||
"trill",
|
||||
"mordent",
|
||||
"appoggiatura",
|
||||
"portamento",
|
||||
"vibrato",
|
||||
"triplet",
|
||||
"tuplet",
|
||||
"downbeat",
|
||||
"upbeat",
|
||||
"anacrusis",
|
||||
"syncopation",
|
||||
"modulation",
|
||||
"transposition",
|
||||
"unison",
|
||||
"semitone",
|
||||
"tritone",
|
||||
"diapason",
|
||||
"tonic",
|
||||
"dominant",
|
||||
"mediant",
|
||||
"ionian",
|
||||
"dorian",
|
||||
"phrygian",
|
||||
"lydian",
|
||||
"mixolydian",
|
||||
"aeolian",
|
||||
"locrian",
|
||||
"pentatonic",
|
||||
"chromatic",
|
||||
"diatonic",
|
||||
// Tempo, dynamics and articulation
|
||||
"allegro",
|
||||
"andante",
|
||||
"adagio",
|
||||
@@ -15,6 +200,7 @@ pub const ADJECTIVES: &[&str] = &[
|
||||
"lento",
|
||||
"moderato",
|
||||
"crescendo",
|
||||
"diminuendo",
|
||||
"staccato",
|
||||
"legato",
|
||||
"forte",
|
||||
@@ -35,60 +221,78 @@ pub const ADJECTIVES: &[&str] = &[
|
||||
"sotto",
|
||||
"vivo",
|
||||
"feroce",
|
||||
];
|
||||
|
||||
/// Musical nouns — instruments and forms from the concert hall.
|
||||
pub const NOUNS: &[&str] = &[
|
||||
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
|
||||
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
|
||||
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
|
||||
"motif", "overture",
|
||||
"giocoso",
|
||||
"affettuoso",
|
||||
"risoluto",
|
||||
"leggiero",
|
||||
"pesante",
|
||||
"morendo",
|
||||
"accelerando",
|
||||
"ritardando",
|
||||
];
|
||||
|
||||
/// Upper bound for a `GET /api/names?count=N` batch.
|
||||
pub const MAX_BATCH: usize = 100;
|
||||
|
||||
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
|
||||
/// A fresh one-word handle, e.g. `nocturne`.
|
||||
pub fn generate() -> String {
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
format!(
|
||||
"{}-{}",
|
||||
ADJECTIVES.choose(&mut rng).unwrap(),
|
||||
NOUNS.choose(&mut rng).unwrap()
|
||||
)
|
||||
(*WORDS.choose(&mut rng).unwrap()).to_string()
|
||||
}
|
||||
|
||||
/// `count` fresh handles (the caller clamps; this only trusts its input).
|
||||
/// `count` fresh handles, distinct while the wordlist lasts (the caller
|
||||
/// clamps; this only trusts its input).
|
||||
pub fn generate_batch(count: usize) -> Vec<String> {
|
||||
(0..count).map(|_| generate()).collect()
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut pool: Vec<&str> = WORDS.to_vec();
|
||||
pool.shuffle(&mut rng);
|
||||
(0..count)
|
||||
.map(|i| match pool.get(i) {
|
||||
// Past the wordlist a batch has to repeat itself.
|
||||
Some(word) => (*word).to_string(),
|
||||
None => generate(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn generated_name_is_adjective_dash_noun() {
|
||||
fn generated_name_is_a_single_known_word() {
|
||||
let name = generate();
|
||||
let (adj, noun) = name.split_once('-').expect("name contains a dash");
|
||||
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
|
||||
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
assert!(!name.contains('-'), "{name} is not one word");
|
||||
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_returns_the_requested_count() {
|
||||
fn the_wordlist_has_no_duplicates_and_covers_a_full_batch() {
|
||||
let unique: HashSet<&&str> = WORDS.iter().collect();
|
||||
assert_eq!(unique.len(), WORDS.len(), "WORDS has duplicates");
|
||||
assert!(
|
||||
WORDS.len() >= MAX_BATCH,
|
||||
"a full batch would have to repeat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_returns_the_requested_count_without_repeats() {
|
||||
assert_eq!(generate_batch(1).len(), 1);
|
||||
assert_eq!(generate_batch(50).len(), 50);
|
||||
for name in generate_batch(10) {
|
||||
let (adj, noun) = name.split_once('-').unwrap();
|
||||
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
|
||||
let batch = generate_batch(MAX_BATCH);
|
||||
assert_eq!(batch.len(), MAX_BATCH);
|
||||
for name in &batch {
|
||||
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
|
||||
}
|
||||
let unique: HashSet<&String> = batch.iter().collect();
|
||||
assert_eq!(unique.len(), batch.len(), "batch repeated a handle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerolls_can_differ() {
|
||||
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
|
||||
let first = generate();
|
||||
let differs = (0..20).any(|_| generate() != first);
|
||||
assert!(differs);
|
||||
|
||||
@@ -16,7 +16,7 @@ use uuid::Uuid;
|
||||
use crate::routes::{self, UrlError};
|
||||
use crate::{
|
||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session,
|
||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||
};
|
||||
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
@@ -155,6 +155,17 @@ impl DaemonClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/input`: type text into a session's pane, with
|
||||
/// an Enter after it unless `submit` is off. Answers `204 No Content`; a
|
||||
/// session that has exited is refused rather than silently accepted.
|
||||
pub async fn send_input(&self, id: Uuid, req: &SendInputRequest) -> Result<(), ClientError> {
|
||||
let resp = self
|
||||
.send(Method::POST, &routes::session_input(id), &[], Some(req))
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||
self.get(routes::INFO, &[]).await
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The name a session was given (#125), if any. `None` for one that was never
|
||||
/// renamed — clients fall back to the command.
|
||||
pub fn custom_name(s: &Session) -> Option<&str> {
|
||||
match s.metadata.get("name") {
|
||||
Some(serde_json::Value::String(name)) => Some(name.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -140,6 +149,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
||||
cur
|
||||
}
|
||||
|
||||
/// Command wrappers that run another program: the program is what follows.
|
||||
const WRAPPERS: [&str; 8] = [
|
||||
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
|
||||
];
|
||||
|
||||
/// The program a command line runs, basenamed and stripped of its arguments —
|
||||
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
||||
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
||||
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
|
||||
pub fn command_program(command: &str) -> String {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
let mut i = 0;
|
||||
while i < words.len() {
|
||||
let word = words[i];
|
||||
if is_env_assignment(word) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let wrapper = basename(word).to_lowercase();
|
||||
if WRAPPERS.contains(&wrapper.as_str()) {
|
||||
i += 1;
|
||||
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
match words.get(i) {
|
||||
Some(word) => basename(word).to_string(),
|
||||
// Nothing but wrappers and assignments: say what was there rather than
|
||||
// rendering an empty fragment.
|
||||
None => command.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||
fn is_env_assignment(word: &str) -> bool {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
let rest: String = chars.collect();
|
||||
match rest.split_once('=') {
|
||||
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn basename(word: &str) -> &str {
|
||||
match word.rsplit_once('/') {
|
||||
Some((_, tail)) => tail,
|
||||
None => word,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -318,4 +384,28 @@ mod tests {
|
||||
};
|
||||
assert_eq!(nested.name, "a/b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_program_drops_arguments() {
|
||||
assert_eq!(
|
||||
command_program("claude --dangerously-skip-permissions"),
|
||||
"claude"
|
||||
);
|
||||
assert_eq!(command_program(" zsh -l "), "zsh");
|
||||
assert_eq!(command_program("hx src/main.rs"), "hx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_program_looks_through_paths_env_and_wrappers() {
|
||||
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
|
||||
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
|
||||
assert_eq!(command_program("npx opencode"), "opencode");
|
||||
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_program_keeps_a_line_that_is_only_wrappers() {
|
||||
assert_eq!(command_program("sudo"), "sudo");
|
||||
assert_eq!(command_program(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +485,40 @@ pub struct SpawnPreset {
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// Build provenance of the running daemon: the commit it was compiled
|
||||
/// from and when that commit was authored.
|
||||
///
|
||||
/// Clients show it in the status indicator's tooltip, and compare `commit`
|
||||
/// across a reconnect to tell an upgrade apart from a plain restart — an
|
||||
/// upgrade means the served assets changed, so a browser reloads rather than
|
||||
/// resuming against new code with an old bundle.
|
||||
///
|
||||
/// Stamped at compile time by `maestro-daemon/build.rs`, which reads the git
|
||||
/// checkout it is built in, or the `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE`
|
||||
/// environment variables when there is no checkout (the Nix build). Every field
|
||||
/// is empty when neither is available; clients must render that gracefully.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BuildInfo {
|
||||
/// Abbreviated commit hash the binary was built from. Empty if unknown.
|
||||
#[serde(default)]
|
||||
pub commit: String,
|
||||
/// Commit date of `commit`, RFC 3339 (`2026-09-02T14:03:11+01:00`). Empty
|
||||
/// if unknown.
|
||||
#[serde(default)]
|
||||
pub commit_date: String,
|
||||
/// Whether the working tree carried uncommitted changes at build time.
|
||||
#[serde(default)]
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl BuildInfo {
|
||||
/// Whether anything is known about this build. A client with `false` here
|
||||
/// shows the indicator without a release line rather than an empty one.
|
||||
pub fn is_known(&self) -> bool {
|
||||
!self.commit.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload of `GET /api/info` — daemon identity, protocol version, and the
|
||||
/// spawn presets clients render as one-click tiles.
|
||||
///
|
||||
@@ -503,15 +537,42 @@ pub struct DaemonInfo {
|
||||
pub home_dir: String,
|
||||
#[serde(default)]
|
||||
pub presets: Vec<SpawnPreset>,
|
||||
/// Which commit this daemon was built from.
|
||||
#[serde(default)]
|
||||
pub build: BuildInfo,
|
||||
/// Identity of this daemon *process*, regenerated on every start. Two
|
||||
/// replies carrying different ids mean the daemon restarted in between —
|
||||
/// which is how a client that lost its socket tells a restart from a
|
||||
/// network blip, even when the build did not change.
|
||||
#[serde(default)]
|
||||
pub instance: String,
|
||||
}
|
||||
|
||||
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||
/// `adjective-noun` session handles generated by the `maestro-names` crate.
|
||||
/// one-word session handles generated by the `maestro-names` crate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NameBatch {
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
/// HTTP `POST /api/sessions/:id/input` body: text typed into a session's
|
||||
/// pane, as if the operator had typed it on an attached terminal.
|
||||
///
|
||||
/// `submit` presses Enter after the text — what makes `maestro send` a way to
|
||||
/// give an agent an instruction rather than only to fill its prompt. It goes
|
||||
/// out as its own write a beat later, because a TUI agent that reads the text
|
||||
/// as one paste would otherwise swallow the newline into the message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SendInputRequest {
|
||||
pub text: String,
|
||||
#[serde(default = "submit_default")]
|
||||
pub submit: bool,
|
||||
}
|
||||
|
||||
fn submit_default() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -49,6 +49,11 @@ pub fn session_activity(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/activity")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/input`: type text into a session's pane.
|
||||
pub fn session_input(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/input")
|
||||
}
|
||||
|
||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||
pub fn attach(id: Uuid) -> String {
|
||||
format!("{API_PREFIX}/attach/{id}")
|
||||
@@ -59,6 +64,7 @@ pub fn attach(id: Uuid) -> String {
|
||||
pub mod patterns {
|
||||
pub const SESSION: &str = "/api/sessions/:id";
|
||||
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
||||
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
|
||||
pub const ATTACH: &str = "/api/attach/:id";
|
||||
}
|
||||
|
||||
@@ -170,6 +176,10 @@ mod tests {
|
||||
session_activity(id()),
|
||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
|
||||
);
|
||||
assert_eq!(
|
||||
session_input(id()),
|
||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
|
||||
);
|
||||
assert_eq!(
|
||||
attach(id()),
|
||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
@@ -192,6 +202,10 @@ mod tests {
|
||||
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
|
||||
session(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::SESSION_INPUT.trim_end_matches(":id/input"),
|
||||
session(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::ATTACH.trim_end_matches(":id"),
|
||||
attach(id()).trim_end_matches(&id().to_string())
|
||||
|
||||
@@ -16,13 +16,13 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{any, delete, get};
|
||||
use axum::routing::{any, delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::Utc;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION,
|
||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
@@ -55,6 +55,8 @@ struct Inner {
|
||||
info: Option<DaemonInfo>,
|
||||
/// When set, every route answers with this instead of its normal response.
|
||||
forced_error: Option<(u16, ApiError)>,
|
||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -86,6 +88,7 @@ impl MockDaemon {
|
||||
let app = Router::new()
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
@@ -167,6 +170,11 @@ impl MockDaemon {
|
||||
self.state.inner.lock().expect("poisoned").forced_error = None;
|
||||
}
|
||||
|
||||
/// Every `send` this daemon received, oldest first.
|
||||
pub fn inputs(&self) -> Vec<(Uuid, SendInputRequest)> {
|
||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||
}
|
||||
@@ -233,6 +241,28 @@ async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Respon
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
/// Records the text instead of typing it — the subject under test is the
|
||||
/// client's request, not tmux.
|
||||
async fn send_input(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<SendInputRequest>,
|
||||
) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
}
|
||||
let mut guard = s.inner.lock().expect("poisoned");
|
||||
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
guard.inputs.push((id, req));
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
@@ -248,6 +278,7 @@ async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||
version: PROTOCOL_VERSION.into(),
|
||||
home_dir: "/home/mock".into(),
|
||||
presets: Vec::new(),
|
||||
..Default::default()
|
||||
});
|
||||
Json(info).into_response()
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ pub enum Action {
|
||||
// Catalog actions on the highlighted sidebar row
|
||||
AttachHighlighted,
|
||||
ToggleAttachHighlighted,
|
||||
NewDraftPane,
|
||||
OpenSpawnModal,
|
||||
/// Fast-forward spawn (#67): spawn immediately with every default
|
||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||
/// resolved; unresolvable defaults open a pre-filled form instead.
|
||||
FastForwardSpawn,
|
||||
KillHighlighted,
|
||||
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
||||
@@ -90,7 +90,7 @@ impl Action {
|
||||
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::OpenSpawnModal => "Create a session".into(),
|
||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||
Action::KillHighlighted => "Kill selected session".into(),
|
||||
Action::RenameHighlighted => "Rename selected session".into(),
|
||||
@@ -116,7 +116,7 @@ impl Action {
|
||||
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||
@@ -179,7 +179,9 @@ mod tests {
|
||||
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"));
|
||||
assert!(!Action::OpenSpawnModal
|
||||
.help_label()
|
||||
.contains("OpenSpawnModal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -214,7 +216,7 @@ mod tests {
|
||||
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
|
||||
}
|
||||
|
||||
/// The fast-forward pair sits next to the draft-pane key (#67).
|
||||
/// The fast-forward pair sits next to the spawn key (#67).
|
||||
#[test]
|
||||
fn fast_forward_spawn_round_trips() {
|
||||
assert_eq!(
|
||||
@@ -243,7 +245,7 @@ mod tests {
|
||||
Action::EnterInsert,
|
||||
Action::RenameHighlighted,
|
||||
Action::Quit,
|
||||
Action::NewDraftPane,
|
||||
Action::OpenSpawnModal,
|
||||
Action::ScrollPanePageDown,
|
||||
Action::FocusPane(Direction::Right),
|
||||
] {
|
||||
@@ -252,13 +254,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The spawn modal is gone (issue #63); its old action string must not
|
||||
/// resolve to anything.
|
||||
/// Draft panes are gone; their old action string must not resolve.
|
||||
#[test]
|
||||
fn the_removed_spawn_modal_action_no_longer_parses() {
|
||||
fn the_removed_draft_pane_action_no_longer_parses() {
|
||||
assert_eq!(
|
||||
Action::parse("OpenSpawnModal").unwrap_err(),
|
||||
ActionParseError::Unknown("OpenSpawnModal".into())
|
||||
Action::parse("NewDraftPane").unwrap_err(),
|
||||
ActionParseError::Unknown("NewDraftPane".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+261
-208
@@ -6,9 +6,9 @@
|
||||
//! changes, and a 30Hz heartbeat.
|
||||
//!
|
||||
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
|
||||
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
|
||||
//! (`AttachHighlighted` spawning a `SessionTask`, a spawn-form submit firing
|
||||
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
||||
//! `dispatch` / the Insert-mode pane routing.
|
||||
//! `dispatch` / the overlay routing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Stdout;
|
||||
@@ -26,7 +26,8 @@ use uuid::Uuid;
|
||||
use crate::action::Action;
|
||||
use crate::catalog::CatalogStore;
|
||||
use crate::config::TuiConfig;
|
||||
use crate::draft::DraftInputResult;
|
||||
use crate::daemon_link::DaemonLink;
|
||||
use crate::draft::{DraftInputResult, DraftPane};
|
||||
use crate::emulators::SessionEmulators;
|
||||
use crate::input::{
|
||||
canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region,
|
||||
@@ -37,7 +38,7 @@ 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, SidebarItem};
|
||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||
use crate::text_input::TextInput;
|
||||
use crate::TuiError;
|
||||
|
||||
@@ -58,6 +59,13 @@ pub struct App {
|
||||
help: HelpOverlay,
|
||||
chord: ChordState,
|
||||
daemon: DaemonClient,
|
||||
/// Liveness + release of the daemon (`docs/RESTART.md`): drives the status
|
||||
/// bar's indicator, and locks the keyboard behind the restart overlay.
|
||||
link: DaemonLink,
|
||||
/// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in
|
||||
/// `AppState` so the overlay enum stays comparable — the same reason the
|
||||
/// rename buffer lives here.
|
||||
draft: Option<DraftPane>,
|
||||
/// Results of draft data loads, delivered back to the event loop.
|
||||
///
|
||||
/// These are network calls — `/api/projects` on a cold cache walks all of
|
||||
@@ -99,22 +107,21 @@ pub enum Effect {
|
||||
id: Uuid,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
/// Fetch presets / projects / names for a freshly opened draft (#64).
|
||||
LoadDraft(usize),
|
||||
/// Fetch presets / projects / names for a freshly opened form (#64).
|
||||
LoadDraft,
|
||||
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
||||
/// spawn with no form; unresolvable defaults open a pre-filled draft.
|
||||
/// spawn with no form; unresolvable defaults open a pre-filled form.
|
||||
FastForward,
|
||||
/// A reroll drained the draft's local name batch (#64).
|
||||
RefillNames(usize),
|
||||
/// The draft's target changed: probe `/api/gitroot` so the worktree
|
||||
/// A reroll drained the form's local name batch (#64).
|
||||
RefillNames,
|
||||
/// The form's target changed: probe `/api/gitroot` so the worktree
|
||||
/// section knows its base repo (#65). `seq` guards stale answers.
|
||||
ProbeGitRoot {
|
||||
tab: usize,
|
||||
seq: u32,
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Fetch the draft target's branches from `/api/branches` (#65).
|
||||
LoadBranches(usize),
|
||||
/// Fetch the form target's branches from `/api/branches` (#65).
|
||||
LoadBranches,
|
||||
/// Rename a session (#125). An empty name clears the custom one, so the
|
||||
/// card falls back to the command.
|
||||
Rename {
|
||||
@@ -124,7 +131,12 @@ pub enum Effect {
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self {
|
||||
pub fn new(
|
||||
cfg: TuiConfig,
|
||||
catalog: CatalogStore,
|
||||
daemon: DaemonClient,
|
||||
link: DaemonLink,
|
||||
) -> Self {
|
||||
let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let chord_timeout = cfg.chord_timeout;
|
||||
let help = HelpOverlay {
|
||||
@@ -141,9 +153,11 @@ impl App {
|
||||
catalog,
|
||||
emulators: SessionEmulators::default(),
|
||||
state: AppState::new(),
|
||||
draft: None,
|
||||
help,
|
||||
chord: ChordState::new(chord_timeout),
|
||||
daemon,
|
||||
link,
|
||||
http,
|
||||
rename_input: TextInput::default(),
|
||||
draft_tx,
|
||||
@@ -178,6 +192,10 @@ impl App {
|
||||
_ = self.catalog.changed() => {
|
||||
// Render on next loop iteration.
|
||||
}
|
||||
_ = self.link.changed() => {
|
||||
// The status indicator and the restart overlay both read
|
||||
// the link; redraw on the next loop iteration.
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
||||
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
||||
self.perform(effects).await;
|
||||
@@ -194,6 +212,21 @@ impl App {
|
||||
};
|
||||
let key = self.clicks.promote(key, std::time::Instant::now());
|
||||
|
||||
// The daemon is gone: everything a key could do goes through it, so
|
||||
// only the quit binding still resolves until it answers again.
|
||||
if self.link.is_blocking() {
|
||||
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
||||
if let ChordResult::Resolved(canon) = self.chord.feed(&key, &known) {
|
||||
if matches!(
|
||||
self.cfg.keymap.resolve(self.state.mode, &canon),
|
||||
Some(Action::Quit)
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Overlay intercept first.
|
||||
let (decision, effects) = self.handle_overlay_input(&key);
|
||||
self.perform(effects).await;
|
||||
@@ -222,14 +255,7 @@ impl App {
|
||||
self.perform(effects).await;
|
||||
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
|
||||
{
|
||||
// Insert mode over a focused pane: a draft eats the key
|
||||
// into its form, a session gets the raw bytes.
|
||||
let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
|
||||
{
|
||||
self.handle_draft_input(&key)
|
||||
} else {
|
||||
self.forward_to_session(&key).into_iter().collect()
|
||||
};
|
||||
let effects = self.forward_to_session(&key).into_iter().collect();
|
||||
self.perform(effects).await;
|
||||
}
|
||||
Ok(false)
|
||||
@@ -273,13 +299,11 @@ impl App {
|
||||
Effect::Attach(id) => self.attach(id),
|
||||
Effect::Spawn(req) => self.submit_spawn(req).await,
|
||||
Effect::Kill(id) => self.submit_kill(id).await,
|
||||
Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx),
|
||||
Effect::LoadDraft => self.spawn_load_draft(),
|
||||
Effect::FastForward => self.spawn_fast_forward(),
|
||||
Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx),
|
||||
Effect::ProbeGitRoot { tab, seq, path } => {
|
||||
self.spawn_probe_git_root(tab, seq, path)
|
||||
}
|
||||
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
||||
Effect::RefillNames => self.spawn_refill_names(),
|
||||
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
|
||||
Effect::LoadBranches => self.spawn_load_branches(),
|
||||
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
||||
Effect::Forward { id, bytes } => {
|
||||
if let Some(handle) = self.emulators.get(id) {
|
||||
@@ -292,6 +316,15 @@ impl App {
|
||||
|
||||
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
|
||||
match self.state.overlay().cloned() {
|
||||
// The form owns every key while it is up, Escape aside — the
|
||||
// keymap must not see `s`, `j` or `x` as commands mid-typing.
|
||||
Some(OverlayKind::Spawn) => match key {
|
||||
InputKey::Esc => {
|
||||
self.dismiss_spawn_modal();
|
||||
(OverlayDecision::Consumed, Vec::new())
|
||||
}
|
||||
other => (OverlayDecision::Consumed, self.handle_draft_input(other)),
|
||||
},
|
||||
Some(OverlayKind::KillConfirm(id)) => match key {
|
||||
InputKey::Char { c: 'y', .. } => {
|
||||
self.state.dismiss_overlay();
|
||||
@@ -363,12 +396,9 @@ 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::OpenSpawnModal => {
|
||||
self.open_spawn_modal();
|
||||
effects.push(Effect::LoadDraft);
|
||||
}
|
||||
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||
@@ -571,12 +601,42 @@ impl App {
|
||||
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.
|
||||
/// Put the spawn form up with a fresh draft behind it, preselecting the
|
||||
/// project of the session the operator was last on — so `n` then Enter
|
||||
/// spawns another session there, the way "spawn similar" would. A
|
||||
/// worktree session seeds its *base repository*, not its checkout: the
|
||||
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
|
||||
/// rule as web/src/spawnSimilar.ts.
|
||||
fn open_spawn_modal(&mut self) {
|
||||
let seed_cwd = self.state.active_session().and_then(|sid| {
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == sid)
|
||||
.map(|s| match &s.worktree {
|
||||
Some(wt) => wt.base_repo.clone(),
|
||||
None => s.cwd.clone(),
|
||||
})
|
||||
});
|
||||
let mut draft = DraftPane::default();
|
||||
draft.seed_cwd = seed_cwd;
|
||||
self.draft = Some(draft);
|
||||
self.state.open_spawn_modal();
|
||||
}
|
||||
|
||||
/// Close the form and drop its half-filled state — reopening starts clean,
|
||||
/// as the web modal does.
|
||||
fn dismiss_spawn_modal(&mut self) {
|
||||
self.state.dismiss_overlay();
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
/// Feed one keystroke into the spawn 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.
|
||||
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
|
||||
let tab_idx = self.state.active_tab_idx;
|
||||
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
|
||||
let Some(draft) = self.draft.as_mut() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut effects = Vec::new();
|
||||
@@ -590,48 +650,37 @@ impl App {
|
||||
}
|
||||
}
|
||||
if draft.take_name_refill() {
|
||||
effects.push(Effect::RefillNames(tab_idx));
|
||||
effects.push(Effect::RefillNames);
|
||||
}
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
|
||||
effects.push(Effect::ProbeGitRoot {
|
||||
tab: tab_idx,
|
||||
seq,
|
||||
path,
|
||||
});
|
||||
effects.push(Effect::ProbeGitRoot { seq, path });
|
||||
}
|
||||
if draft.take_branch_load().is_some() {
|
||||
effects.push(Effect::LoadBranches(tab_idx));
|
||||
effects.push(Effect::LoadBranches);
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
/// Fire any draft gitroot probes whose debounce window has elapsed (#74).
|
||||
/// The 30 Hz heartbeat calls this so a burst of typing issues its one
|
||||
/// probe even after the last keystroke.
|
||||
/// Fire any gitroot probe whose debounce window has elapsed (#74). The
|
||||
/// 30 Hz heartbeat calls this so a burst of typing issues its one probe
|
||||
/// even after the last keystroke.
|
||||
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
||||
let mut effects = Vec::new();
|
||||
for tab_idx in 0..self.state.tabs.len() {
|
||||
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe(now) {
|
||||
effects.push(Effect::ProbeGitRoot {
|
||||
tab: tab_idx,
|
||||
seq,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
effects
|
||||
let Some(draft) = self.draft.as_mut() else {
|
||||
return Vec::new();
|
||||
};
|
||||
draft
|
||||
.take_gitroot_probe(now)
|
||||
.map(|(seq, path)| Effect::ProbeGitRoot { seq, path })
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
||||
/// Populate a freshly opened form: presets from `/api/info` (falling back
|
||||
/// to the built-ins already in place), projects from `/api/projects`, and
|
||||
/// a first name batch. Applies only if that tab still shows the draft —
|
||||
/// a slow response must never clobber what replaced it.
|
||||
/// Kick off the draft's initial data load without blocking the loop.
|
||||
fn spawn_load_draft(&self, tab: usize) {
|
||||
let Some(draft) = self.draft_id(tab) else {
|
||||
/// a first name batch. Applies only if the same form is still up — a slow
|
||||
/// response must never clobber the one that replaced it.
|
||||
fn spawn_load_draft(&self) {
|
||||
let Some(draft) = self.draft_id() else {
|
||||
return;
|
||||
};
|
||||
let (daemon, http, base, tx) = self.loader_handles();
|
||||
@@ -647,8 +696,8 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_refill_names(&self, tab: usize) {
|
||||
let Some(draft) = self.draft_id(tab) else {
|
||||
fn spawn_refill_names(&self) {
|
||||
let Some(draft) = self.draft_id() else {
|
||||
return;
|
||||
};
|
||||
let (_, http, base, tx) = self.loader_handles();
|
||||
@@ -658,8 +707,8 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_probe_git_root(&self, tab: usize, seq: u32, path: PathBuf) {
|
||||
let Some(draft) = self.draft_id(tab) else {
|
||||
fn spawn_probe_git_root(&self, seq: u32, path: PathBuf) {
|
||||
let Some(draft) = self.draft_id() else {
|
||||
return;
|
||||
};
|
||||
let (daemon, _, _, tx) = self.loader_handles();
|
||||
@@ -673,20 +722,11 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_load_branches(&self, tab: usize) {
|
||||
let Some(draft) = self.draft_id(tab) else {
|
||||
fn spawn_load_branches(&self) {
|
||||
let Some(draft) = self.draft_id() else {
|
||||
return;
|
||||
};
|
||||
let Some(cwd) = self
|
||||
.state
|
||||
.tabs
|
||||
.get(tab)
|
||||
.and_then(|t| t.pane.as_ref())
|
||||
.and_then(|p| match p {
|
||||
PaneRef::Draft(d) => d.cwd(),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
let Some(cwd) = self.draft.as_ref().and_then(DraftPane::cwd) else {
|
||||
return;
|
||||
};
|
||||
let (_, http, base, tx) = self.loader_handles();
|
||||
@@ -700,12 +740,9 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
/// The draft pane living in `tab`, by identity.
|
||||
fn draft_id(&self, tab: usize) -> Option<u64> {
|
||||
match self.state.tabs.get(tab).and_then(|t| t.pane.as_ref()) {
|
||||
Some(PaneRef::Draft(d)) => Some(d.id),
|
||||
_ => None,
|
||||
}
|
||||
/// Identity of the form currently up, if any.
|
||||
fn draft_id(&self) -> Option<u64> {
|
||||
self.draft.as_ref().map(|d| d.id)
|
||||
}
|
||||
|
||||
fn loader_handles(
|
||||
@@ -743,24 +780,9 @@ impl App {
|
||||
// Handled above; it targets no particular draft.
|
||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||
};
|
||||
// Find it by identity: closing a tab shifts every index to its right,
|
||||
// so an in-flight load addressed by index would be dropped or land on
|
||||
// whatever pane inherited the slot (#67 review).
|
||||
let Some(tab_idx) = self
|
||||
.state
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|t| matches!(&t.pane, Some(PaneRef::Draft(d)) if d.id == want))
|
||||
else {
|
||||
// The draft went away while the request was in flight.
|
||||
return;
|
||||
};
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
// Match by identity: dismissing the form and opening another while a
|
||||
// load is in flight must not let the old answer land in the new form.
|
||||
let Some(draft) = self.draft.as_mut().filter(|d| d.id == want) else {
|
||||
return;
|
||||
};
|
||||
let mut follow_up_branches = false;
|
||||
@@ -799,7 +821,7 @@ impl App {
|
||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||
}
|
||||
if follow_up_branches {
|
||||
self.spawn_load_branches(tab_idx);
|
||||
self.spawn_load_branches();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,7 +829,7 @@ impl App {
|
||||
/// form. The most recent project, its last command (or the first
|
||||
/// non-custom preset), a freshly generated name, worktree on with a new
|
||||
/// branch named after that name. Anything unresolvable — no projects
|
||||
/// known yet, no command, no name — downgrades to a draft pane seeded
|
||||
/// known yet, no command, no name — downgrades to the spawn form seeded
|
||||
/// with whatever did resolve.
|
||||
/// Load what the fast path needs without blocking the loop — the same
|
||||
/// reason the draft loaders were moved off it (#67 review): a cold
|
||||
@@ -850,12 +872,15 @@ impl App {
|
||||
self.submit_spawn(req).await;
|
||||
return;
|
||||
}
|
||||
// Unresolved defaults: a draft pre-filled with what did resolve.
|
||||
self.state.open_draft_pane();
|
||||
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
|
||||
// Unresolved defaults: the form, pre-filled with what did resolve.
|
||||
self.open_spawn_modal();
|
||||
let Some(draft) = self.draft.as_mut() else {
|
||||
return;
|
||||
};
|
||||
draft.home_dir = home_dir;
|
||||
// The fast path resolved its own project; its pick wins over the
|
||||
// focused session's, so the seed never lands on top of it.
|
||||
draft.seed_cwd = None;
|
||||
if !presets.is_empty() {
|
||||
draft.set_presets(presets);
|
||||
}
|
||||
@@ -869,12 +894,13 @@ impl App {
|
||||
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||
match self.daemon.create_session(&req).await {
|
||||
Ok(session) => {
|
||||
// Fill the pane in place with the new session (web parity,
|
||||
// #57) and attach to it right away.
|
||||
// The form closes and the new session takes the pane, attached
|
||||
// right away — the same landing the web modal gives it.
|
||||
let sid = session.id;
|
||||
if let Some(previous) = self.state.active_session() {
|
||||
self.release_session(previous);
|
||||
}
|
||||
self.draft = None;
|
||||
self.state.complete_spawn(sid);
|
||||
self.attach(sid);
|
||||
}
|
||||
@@ -885,7 +911,7 @@ impl App {
|
||||
.daemon_message()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| e.to_string());
|
||||
if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() {
|
||||
if let Some(draft) = self.draft.as_mut() {
|
||||
draft.error = Some(format!("spawn failed: {message}"));
|
||||
} else {
|
||||
self.state.toast(format!("spawn failed: {e}"));
|
||||
@@ -933,30 +959,27 @@ impl App {
|
||||
focused: matches!(self.state.focus, Focus::Pane),
|
||||
})
|
||||
});
|
||||
let draft = match self.state.active_tab().pane.as_ref() {
|
||||
Some(PaneRef::Draft(d)) => Some(d),
|
||||
_ => None,
|
||||
};
|
||||
let pane_title = match self.state.active_tab().pane.as_ref() {
|
||||
Some(PaneRef::Session(sid)) => snap
|
||||
let pane_title = match active_sid {
|
||||
Some(sid) => snap
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == *sid)
|
||||
.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 link_status = self.link.status();
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: &self.home_dir,
|
||||
pane_title: &pane_title,
|
||||
pane: pane.as_ref(),
|
||||
state: &self.state,
|
||||
draft,
|
||||
draft: self.draft.as_ref(),
|
||||
help: &self.help,
|
||||
rename_input: &self.rename_input,
|
||||
link: &link_status,
|
||||
};
|
||||
|
||||
let mut new_region = RegionMap::default();
|
||||
@@ -1205,7 +1228,12 @@ mod tests {
|
||||
|
||||
fn make_app_at(base: &str) -> App {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
App::new(cfg, CatalogStore::default(), DaemonClient::new(base))
|
||||
App::new(
|
||||
cfg,
|
||||
CatalogStore::default(),
|
||||
DaemonClient::new(base),
|
||||
DaemonLink::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1235,36 +1263,52 @@ mod tests {
|
||||
assert!(help.contains("Create a session"));
|
||||
assert!(help.contains("Expand or collapse directory"));
|
||||
assert!(!help.contains("EnterInsert"));
|
||||
assert!(!help.contains("NewDraftPane"));
|
||||
assert!(!help.contains("OpenSpawnModal"));
|
||||
assert!(!help.contains("ToggleSidebarGroup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
||||
fn open_spawn_modal_puts_the_form_up_and_asks_for_its_data() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
assert!(matches!(
|
||||
app.state.active_tab().pane,
|
||||
Some(PaneRef::Draft(_))
|
||||
));
|
||||
assert_eq!(app.state.focus, Focus::Pane);
|
||||
assert_eq!(app.state.mode, Mode::Insert);
|
||||
assert!(app.state.overlay().is_none(), "no overlay involved");
|
||||
let effects = app.dispatch(Action::OpenSpawnModal);
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
||||
assert!(app.draft.is_some());
|
||||
assert_eq!(effects, vec![Effect::LoadDraft]);
|
||||
}
|
||||
|
||||
/// The modal covers the attached session rather than evicting it: Escape
|
||||
/// gives back exactly what was there, still attached.
|
||||
#[test]
|
||||
fn new_draft_pane_over_a_session_reuses_the_pane() {
|
||||
fn spawning_over_a_session_leaves_it_attached_behind_the_modal() {
|
||||
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(), 1);
|
||||
assert_eq!(app.state.active_tab_idx, 0);
|
||||
assert!(matches!(
|
||||
app.state.active_tab().pane,
|
||||
Some(PaneRef::Draft(_))
|
||||
));
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
assert_eq!(app.state.active_session(), Some(sid));
|
||||
|
||||
app.handle_overlay_input(&InputKey::Esc);
|
||||
assert!(app.state.overlay().is_none());
|
||||
assert!(app.draft.is_none());
|
||||
assert_eq!(app.state.active_session(), Some(sid));
|
||||
}
|
||||
|
||||
/// While the form is up the keymap must not see its keystrokes: `x` is a
|
||||
/// kill binding in Normal mode, and typing it into a name would fire it.
|
||||
#[test]
|
||||
fn the_modal_swallows_keys_the_keymap_would_otherwise_claim() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
|
||||
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
||||
c: 'x',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
});
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert!(effects.is_empty());
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
||||
}
|
||||
|
||||
/// Seed the catalog with one session and highlight it.
|
||||
@@ -1507,11 +1551,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_draft_pane_schedules_a_data_load() {
|
||||
fn opening_the_spawn_modal_schedules_a_data_load() {
|
||||
let mut app = make_app();
|
||||
assert_eq!(
|
||||
app.dispatch(Action::NewDraftPane),
|
||||
vec![Effect::LoadDraft(0)]
|
||||
app.dispatch(Action::OpenSpawnModal),
|
||||
vec![Effect::LoadDraft]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1602,7 +1646,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// No projects known yet: nothing can resolve, so the keystroke opens a
|
||||
/// draft pane (focused, Insert mode) instead of guessing (#67).
|
||||
/// spawn form instead of guessing (#67).
|
||||
#[tokio::test]
|
||||
async fn unresolvable_defaults_open_a_prefilled_draft_instead() {
|
||||
use axum::routing::get;
|
||||
@@ -1641,41 +1685,71 @@ mod tests {
|
||||
.expect("channel open");
|
||||
app.apply_draft_update(update).await;
|
||||
|
||||
assert!(
|
||||
matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))),
|
||||
"the fallback is a draft pane"
|
||||
assert_eq!(
|
||||
app.state.overlay(),
|
||||
Some(&OverlayKind::Spawn),
|
||||
"the fallback is the spawn modal"
|
||||
);
|
||||
assert_eq!(app.state.focus, Focus::Pane);
|
||||
assert_eq!(app.state.mode, Mode::Insert);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
let draft = app.draft.as_ref().expect("a form behind the modal");
|
||||
// Whatever did resolve (nothing project-side) is seeded; the
|
||||
// generated name still lands so the form is ready to go.
|
||||
assert_eq!(draft.name.value(), "amber-falcon");
|
||||
assert_eq!(draft.command(), "claude");
|
||||
}
|
||||
|
||||
/// Opening the form preselects the project of the session in the pane, so
|
||||
/// `n` then Enter spawns another session there.
|
||||
#[test]
|
||||
fn opening_the_spawn_modal_seeds_the_focused_sessions_project() {
|
||||
use maestro_protocol::{CatalogEvent, SessionWorktree};
|
||||
|
||||
let sid = Uuid::from_u128(21);
|
||||
let mut app = make_app();
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![test_session(sid, "/repos/maestro")],
|
||||
});
|
||||
app.state.attach_to_focused(sid);
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
|
||||
Some(std::path::Path::new("/repos/maestro"))
|
||||
);
|
||||
|
||||
// A worktree session seeds its base repository: the daemon never
|
||||
// adopts an existing worktree (docs/WORKTREES.md).
|
||||
let mut wt_session = test_session(sid, "/repos/maestro/worktrees/flugelhorn");
|
||||
wt_session.worktree = Some(SessionWorktree {
|
||||
base_repo: "/repos/maestro".into(),
|
||||
path: "/repos/maestro/worktrees/flugelhorn".into(),
|
||||
});
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![wt_session],
|
||||
});
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
|
||||
Some(std::path::Path::new("/repos/maestro"))
|
||||
);
|
||||
}
|
||||
|
||||
/// A valid form flows through: pick a project (set directly — the picker
|
||||
/// itself is covered in `draft.rs`), select the custom tile, type a
|
||||
/// command, then submit from the Spawn row.
|
||||
#[test]
|
||||
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
|
||||
fn typing_into_the_form_then_enter_carries_the_typed_fields() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
let draft = app.draft.as_mut().unwrap();
|
||||
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
||||
app.handle_draft_input(&InputKey::Tab); // dir picker → preset
|
||||
app.handle_draft_input(&InputKey::Tab); // → custom command
|
||||
for c in "claude".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // → dir picker
|
||||
app.handle_draft_input(&InputKey::Tab); // → name
|
||||
for c in "crimson-otter".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
@@ -1703,10 +1777,8 @@ mod tests {
|
||||
#[test]
|
||||
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
let draft = app.draft.as_mut().unwrap();
|
||||
draft.cwd_raw_visible = true;
|
||||
draft.focused_field = DraftField::CwdRaw;
|
||||
|
||||
@@ -1720,7 +1792,7 @@ mod tests {
|
||||
let due = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
|
||||
match app.poll_due_probes_at(due).as_slice() {
|
||||
[Effect::ProbeGitRoot { tab: 0, path, .. }] => {
|
||||
[Effect::ProbeGitRoot { path, .. }] => {
|
||||
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
|
||||
}
|
||||
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
|
||||
@@ -1736,10 +1808,8 @@ mod tests {
|
||||
#[test]
|
||||
fn switching_to_existing_branch_mode_schedules_a_branch_load() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
let draft = app.draft.as_mut().unwrap();
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
draft.base_repo = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
@@ -1747,7 +1817,7 @@ mod tests {
|
||||
|
||||
let effects = app.handle_draft_input(&InputKey::Right);
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::LoadBranches(0)]),
|
||||
matches!(effects.as_slice(), [Effect::LoadBranches]),
|
||||
"{effects:?}"
|
||||
);
|
||||
}
|
||||
@@ -1757,31 +1827,24 @@ mod tests {
|
||||
#[test]
|
||||
fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
// Walk to Submit without picking anything.
|
||||
for _ in 0..4 {
|
||||
app.handle_draft_input(&InputKey::Tab);
|
||||
}
|
||||
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
assert_eq!(
|
||||
draft.error.as_deref(),
|
||||
app.draft.as_ref().unwrap().error.as_deref(),
|
||||
Some("Pick a working directory."),
|
||||
"the refusal shows in the form"
|
||||
);
|
||||
|
||||
// With a directory but an empty custom command, it names the command.
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
let draft = app.draft.as_mut().unwrap();
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
|
||||
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
draft.error.as_deref(),
|
||||
Some("Pick a command (or fill the custom command).")
|
||||
@@ -1801,8 +1864,9 @@ mod tests {
|
||||
Duration::from_millis(200),
|
||||
Duration::from_millis(500),
|
||||
),
|
||||
DaemonLink::default(),
|
||||
);
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
let req = CreateSessionRequest {
|
||||
cwd: std::path::PathBuf::from("/srv/work"),
|
||||
command: "bash".into(),
|
||||
@@ -1812,17 +1876,16 @@ mod tests {
|
||||
};
|
||||
app.submit_spawn(req).await;
|
||||
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
let draft = app.draft.as_ref().expect("the form stays up on failure");
|
||||
let error = draft
|
||||
.error
|
||||
.as_deref()
|
||||
.expect("spawn failure must reach the form");
|
||||
assert!(error.starts_with("spawn failed"), "{error}");
|
||||
assert!(
|
||||
matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))),
|
||||
"no kill-confirm style overlay took over"
|
||||
assert_eq!(
|
||||
app.state.overlay(),
|
||||
Some(&OverlayKind::Spawn),
|
||||
"the form stays up so the error is readable next to the fields"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1866,8 +1929,8 @@ mod tests {
|
||||
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
||||
|
||||
let mut app = make_app_at(&base);
|
||||
let effects = app.dispatch(Action::NewDraftPane);
|
||||
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)]));
|
||||
let effects = app.dispatch(Action::OpenSpawnModal);
|
||||
assert!(matches!(effects.as_slice(), [Effect::LoadDraft]));
|
||||
// `perform` only spawns the load — it must not block the event loop on
|
||||
// the network (#67 review). The result arrives on the draft channel,
|
||||
// which the run loop drains; do that by hand here.
|
||||
@@ -1878,9 +1941,7 @@ mod tests {
|
||||
.expect("channel open");
|
||||
app.apply_draft_update(update).await;
|
||||
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert_eq!(draft.groups.len(), 1);
|
||||
assert_eq!(draft.groups[0].main.name, "maestro");
|
||||
@@ -1888,9 +1949,9 @@ mod tests {
|
||||
assert_eq!(draft.name.value(), "crimson-otter");
|
||||
}
|
||||
|
||||
/// Keys fed while no draft exists must be ignored, not panic.
|
||||
/// Keys fed while the form is not up must be ignored, not panic.
|
||||
#[test]
|
||||
fn draft_input_is_a_no_op_without_a_draft() {
|
||||
fn draft_input_is_a_no_op_without_a_form() {
|
||||
let mut app = make_app();
|
||||
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty());
|
||||
}
|
||||
@@ -1923,14 +1984,6 @@ mod tests {
|
||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||
}
|
||||
|
||||
/// A draft pane is not a session: forwarding must not try to reach it.
|
||||
#[test]
|
||||
fn forwarding_is_a_no_op_with_a_draft_pane() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
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};
|
||||
@@ -1948,7 +2001,7 @@ mod tests {
|
||||
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.state.active_tab_mut().pane = Some(sid);
|
||||
app.emulators.insert(sid, handle);
|
||||
|
||||
app.scroll_active_pane(3);
|
||||
|
||||
@@ -127,11 +127,24 @@ impl CatalogStore {
|
||||
/// apply them. Reconnects with exponential backoff on disconnect. Never
|
||||
/// returns — spawn it on a Tokio task.
|
||||
pub async fn run_events_loop(self, ws_url: String) {
|
||||
self.run_events_loop_reporting(ws_url, crate::daemon_link::DaemonLink::default())
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`Self::run_events_loop`], reporting every connect and disconnect to
|
||||
/// `link` — the socket dying is the first sign the daemon went down or was
|
||||
/// upgraded under the client (`docs/RESTART.md`).
|
||||
pub async fn run_events_loop_reporting(
|
||||
self,
|
||||
ws_url: String,
|
||||
link: crate::daemon_link::DaemonLink,
|
||||
) {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
loop {
|
||||
match tokio_tungstenite::connect_async(&ws_url).await {
|
||||
Ok((ws, _resp)) => {
|
||||
backoff = Duration::from_secs(1);
|
||||
link.note_connected();
|
||||
let (_sink, mut stream) = ws.split();
|
||||
while let Some(frame) = stream.next().await {
|
||||
match frame {
|
||||
@@ -152,6 +165,7 @@ impl CatalogStore {
|
||||
tracing::debug!(error = ?e, "catalog: connect failed");
|
||||
}
|
||||
}
|
||||
link.note_disconnected(std::time::Instant::now());
|
||||
tracing::debug!(?backoff, "catalog: sleeping before reconnect");
|
||||
tokio::time::sleep(jitter(backoff)).await;
|
||||
backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30));
|
||||
|
||||
@@ -48,8 +48,8 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("c", "ToggleSidebarGroup"),
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "NewDraftPane"),
|
||||
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
|
||||
("s", "OpenSpawnModal"),
|
||||
// Fast-forward spawn (#67): the spawn key's neighbour does the same
|
||||
// thing with every default resolved and no form.
|
||||
("S", "FastForwardSpawn"),
|
||||
("x", "KillHighlighted"),
|
||||
@@ -80,6 +80,11 @@ impl TuiConfig {
|
||||
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
|
||||
TuiTheme::Dark => Palette::DARK,
|
||||
TuiTheme::Light => Palette::LIGHT,
|
||||
TuiTheme::IceCyan => Palette::ICE_CYAN,
|
||||
TuiTheme::Periwinkle => Palette::PERIWINKLE,
|
||||
TuiTheme::AmberPhosphor => Palette::AMBER_PHOSPHOR,
|
||||
TuiTheme::Ember => Palette::EMBER,
|
||||
TuiTheme::Orchid => Palette::ORCHID,
|
||||
TuiTheme::Ansi => Palette::ANSI,
|
||||
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
|
||||
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
|
||||
@@ -172,6 +177,11 @@ mod tests {
|
||||
for (theme, expected) in [
|
||||
(TuiTheme::Dark, Palette::DARK),
|
||||
(TuiTheme::Light, Palette::LIGHT),
|
||||
(TuiTheme::IceCyan, Palette::ICE_CYAN),
|
||||
(TuiTheme::Periwinkle, Palette::PERIWINKLE),
|
||||
(TuiTheme::AmberPhosphor, Palette::AMBER_PHOSPHOR),
|
||||
(TuiTheme::Ember, Palette::EMBER),
|
||||
(TuiTheme::Orchid, Palette::ORCHID),
|
||||
(TuiTheme::Ansi, Palette::ANSI),
|
||||
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
|
||||
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
|
||||
@@ -204,7 +214,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||
Some(Action::NewDraftPane),
|
||||
Some(Action::OpenSpawnModal),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Liveness and identity of the daemon behind this TUI.
|
||||
//!
|
||||
//! The status bar renders [`LinkStatus`], and [`LinkState::Down`] puts the
|
||||
//! restart overlay up and locks the keyboard. `docs/RESTART.md` is the
|
||||
//! contract; this is the TUI half of what `web/src/daemonLink.ts` does.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use maestro_protocol::{BuildInfo, DaemonClient, DaemonInfo};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// How long a lost connection stays quiet before the overlay blocks the UI.
|
||||
pub const GRACE: Duration = Duration::from_millis(1200);
|
||||
/// Cadence of `GET /api/info` while the daemon is unreachable.
|
||||
pub const POLL: Duration = Duration::from_millis(700);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LinkState {
|
||||
/// No probe has landed yet.
|
||||
Connecting,
|
||||
/// The daemon answered.
|
||||
Online,
|
||||
/// The events socket dropped, still inside the grace window.
|
||||
Reconnecting,
|
||||
/// Unreachable past the grace window: the overlay is up.
|
||||
Down,
|
||||
}
|
||||
|
||||
/// What happened between two `/api/info` replies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RestartKind {
|
||||
Same,
|
||||
Restarted,
|
||||
Upgraded,
|
||||
}
|
||||
|
||||
/// Compare two daemon replies. The commit decides first — a different one is
|
||||
/// new code. `instance` catches a restart of the same build, and is the only
|
||||
/// signal for a daemon built outside a git checkout (empty commit), which must
|
||||
/// never be read as an upgrade.
|
||||
pub fn compare_identity(before: &DaemonInfo, after: &DaemonInfo) -> RestartKind {
|
||||
let a = before.build.commit.as_str();
|
||||
let b = after.build.commit.as_str();
|
||||
if !a.is_empty() && !b.is_empty() && a != b {
|
||||
return RestartKind::Upgraded;
|
||||
}
|
||||
if !before.instance.is_empty()
|
||||
&& !after.instance.is_empty()
|
||||
&& before.instance != after.instance
|
||||
{
|
||||
return RestartKind::Restarted;
|
||||
}
|
||||
RestartKind::Same
|
||||
}
|
||||
|
||||
/// One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
||||
/// build carried uncommitted changes.
|
||||
pub fn format_release(build: &BuildInfo) -> String {
|
||||
if build.commit.is_empty() {
|
||||
return "unknown release".to_string();
|
||||
}
|
||||
let mut out = build.commit.clone();
|
||||
if build.commit_date.len() >= 10 {
|
||||
out.push_str(" · ");
|
||||
out.push_str(&build.commit_date[..10]);
|
||||
}
|
||||
if build.dirty {
|
||||
out.push_str(" (dirty)");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Snapshot of the link, taken once per frame by the renderer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinkStatus {
|
||||
pub state: LinkState,
|
||||
/// The release the daemon last reported. `None` until the first probe.
|
||||
pub build: Option<BuildInfo>,
|
||||
/// Failed probes in the current outage — the overlay counts them so a long
|
||||
/// restart doesn't read as a hang.
|
||||
pub attempts: u32,
|
||||
/// Set once the daemon comes back on a different commit. The TUI is its own
|
||||
/// binary, so it keeps running against the new daemon and shows this until
|
||||
/// the user restarts it onto the new client.
|
||||
pub upgraded_to: Option<BuildInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DaemonLink {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
state: RwLock<State>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct State {
|
||||
state: LinkState,
|
||||
identity: Option<DaemonInfo>,
|
||||
attempts: u32,
|
||||
/// When the events socket dropped, for the grace window.
|
||||
down_since: Option<Instant>,
|
||||
upgraded_to: Option<BuildInfo>,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: LinkState::Connecting,
|
||||
identity: None,
|
||||
attempts: 0,
|
||||
down_since: None,
|
||||
upgraded_to: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonLink {
|
||||
pub fn status(&self) -> LinkStatus {
|
||||
let s = self.read();
|
||||
LinkStatus {
|
||||
state: s.state,
|
||||
build: s.identity.as_ref().map(|i| i.build.clone()),
|
||||
attempts: s.attempts,
|
||||
upgraded_to: s.upgraded_to.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the UI is locked behind the restart overlay.
|
||||
pub fn is_blocking(&self) -> bool {
|
||||
self.read().state == LinkState::Down
|
||||
}
|
||||
|
||||
/// Wait until anything about the link changes, so the render loop redraws.
|
||||
pub async fn changed(&self) {
|
||||
self.inner.notify.notified().await;
|
||||
}
|
||||
|
||||
/// The events socket dropped. Starts the grace window.
|
||||
pub fn note_disconnected(&self, now: Instant) {
|
||||
{
|
||||
let mut s = self.write();
|
||||
if s.state == LinkState::Down {
|
||||
return;
|
||||
}
|
||||
s.state = LinkState::Reconnecting;
|
||||
s.attempts = 0;
|
||||
s.down_since = Some(now);
|
||||
}
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// The events socket came back. Does not clear the state on its own: the
|
||||
/// socket carries no identity, and only a probe can say which daemon
|
||||
/// answered.
|
||||
pub fn note_connected(&self) {
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// Fold in a successful `/api/info`. A reply on a new commit is recorded as
|
||||
/// an upgrade; either way the link is back online.
|
||||
pub fn apply_info(&self, info: DaemonInfo) {
|
||||
{
|
||||
let mut s = self.write();
|
||||
if let Some(before) = &s.identity {
|
||||
if compare_identity(before, &info) == RestartKind::Upgraded {
|
||||
s.upgraded_to = Some(info.build.clone());
|
||||
}
|
||||
}
|
||||
s.identity = Some(info);
|
||||
s.state = LinkState::Online;
|
||||
s.attempts = 0;
|
||||
s.down_since = None;
|
||||
}
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// Fold in a failed probe. The overlay goes up once the grace window since
|
||||
/// the disconnect has passed.
|
||||
pub fn probe_failed(&self, now: Instant) {
|
||||
{
|
||||
let mut s = self.write();
|
||||
s.attempts = s.attempts.saturating_add(1);
|
||||
let since = *s.down_since.get_or_insert(now);
|
||||
if now.duration_since(since) >= GRACE {
|
||||
s.state = LinkState::Down;
|
||||
} else if s.state == LinkState::Online {
|
||||
s.state = LinkState::Reconnecting;
|
||||
}
|
||||
}
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// Long-running task: keep [`LinkStatus`] true. Probes on a flat cadence
|
||||
/// while the daemon is unreachable and idles while it is up. Never returns.
|
||||
pub async fn run_probe_loop(self, daemon: DaemonClient) {
|
||||
loop {
|
||||
if self.read().state == LinkState::Online {
|
||||
// A tick bounds the wait so a disconnect racing the notify
|
||||
// costs a second, not the whole outage.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(1), self.changed()).await;
|
||||
continue;
|
||||
}
|
||||
match daemon.daemon_info().await {
|
||||
Ok(info) => self.apply_info(info),
|
||||
Err(_) => {
|
||||
self.probe_failed(Instant::now());
|
||||
tokio::time::sleep(POLL).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wake(&self) {
|
||||
self.inner.notify.notify_waiters();
|
||||
}
|
||||
|
||||
fn read(&self) -> std::sync::RwLockReadGuard<'_, State> {
|
||||
self.inner
|
||||
.state
|
||||
.read()
|
||||
.expect("daemon link rwlock poisoned")
|
||||
}
|
||||
|
||||
fn write(&self) -> std::sync::RwLockWriteGuard<'_, State> {
|
||||
self.inner
|
||||
.state
|
||||
.write()
|
||||
.expect("daemon link rwlock poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn info(commit: &str, instance: &str) -> DaemonInfo {
|
||||
DaemonInfo {
|
||||
hostname: "konishi".into(),
|
||||
version: "0.1.0".into(),
|
||||
home_dir: "/home/naps".into(),
|
||||
presets: Vec::new(),
|
||||
build: BuildInfo {
|
||||
commit: commit.into(),
|
||||
commit_date: "2026-09-02T14:03:11Z".into(),
|
||||
dirty: false,
|
||||
},
|
||||
instance: instance.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_tells_upgrade_from_restart() {
|
||||
assert_eq!(
|
||||
compare_identity(&info("aaa", "i1"), &info("bbb", "i2")),
|
||||
RestartKind::Upgraded
|
||||
);
|
||||
assert_eq!(
|
||||
compare_identity(&info("aaa", "i1"), &info("aaa", "i2")),
|
||||
RestartKind::Restarted
|
||||
);
|
||||
assert_eq!(
|
||||
compare_identity(&info("aaa", "i1"), &info("aaa", "i1")),
|
||||
RestartKind::Same
|
||||
);
|
||||
// No build stamp: the instance still reports the restart, and nothing
|
||||
// ever claims a phantom upgrade.
|
||||
assert_eq!(
|
||||
compare_identity(&info("", "i1"), &info("", "i2")),
|
||||
RestartKind::Restarted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_line_pairs_hash_with_date() {
|
||||
assert_eq!(
|
||||
format_release(&info("f89be4a716c7", "i1").build),
|
||||
"f89be4a716c7 · 2026-09-02"
|
||||
);
|
||||
assert_eq!(
|
||||
format_release(&BuildInfo {
|
||||
commit: "abc".into(),
|
||||
commit_date: String::new(),
|
||||
dirty: true,
|
||||
}),
|
||||
"abc (dirty)"
|
||||
);
|
||||
assert_eq!(format_release(&BuildInfo::default()), "unknown release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grace_window_holds_the_overlay_back() {
|
||||
let link = DaemonLink::default();
|
||||
link.apply_info(info("aaa", "i1"));
|
||||
assert_eq!(link.status().state, LinkState::Online);
|
||||
|
||||
let t0 = Instant::now();
|
||||
link.note_disconnected(t0);
|
||||
assert_eq!(link.status().state, LinkState::Reconnecting);
|
||||
assert!(!link.is_blocking());
|
||||
|
||||
link.probe_failed(t0 + Duration::from_millis(400));
|
||||
assert_eq!(link.status().state, LinkState::Reconnecting);
|
||||
|
||||
link.probe_failed(t0 + GRACE);
|
||||
assert_eq!(link.status().state, LinkState::Down);
|
||||
assert!(link.is_blocking());
|
||||
assert_eq!(link.status().attempts, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_restart_unlocks_without_an_upgrade_note() {
|
||||
let link = DaemonLink::default();
|
||||
link.apply_info(info("aaa", "i1"));
|
||||
let t0 = Instant::now();
|
||||
link.note_disconnected(t0);
|
||||
link.probe_failed(t0 + GRACE);
|
||||
assert!(link.is_blocking());
|
||||
|
||||
link.apply_info(info("aaa", "i2"));
|
||||
assert_eq!(link.status().state, LinkState::Online);
|
||||
assert!(link.status().upgraded_to.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upgrade_unlocks_and_names_the_new_release() {
|
||||
let link = DaemonLink::default();
|
||||
link.apply_info(info("aaa", "i1"));
|
||||
let t0 = Instant::now();
|
||||
link.note_disconnected(t0);
|
||||
link.probe_failed(t0 + GRACE);
|
||||
|
||||
link.apply_info(info("bbb", "i2"));
|
||||
let status = link.status();
|
||||
assert_eq!(status.state, LinkState::Online);
|
||||
assert_eq!(status.upgraded_to.unwrap().commit, "bbb");
|
||||
assert!(!link.is_blocking());
|
||||
}
|
||||
}
|
||||
+164
-72
@@ -1,9 +1,9 @@
|
||||
//! Draft pane — the spawn form living in an ordinary pane (issues #63/#64).
|
||||
//! The spawn form (issues #63/#64), drawn inside the spawn modal.
|
||||
//!
|
||||
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
|
||||
//! the caller a built request and the pane is replaced with the new session,
|
||||
//! matching the web client's inline draft panes (#57). Field editing goes
|
||||
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
|
||||
//! Submitting hands the caller a built request; the modal closes and the new
|
||||
//! session takes the pane, matching the web client's spawn modal. Field
|
||||
//! editing goes through [`crate::text_input::TextInput`]; Tab / BackTab walk
|
||||
//! the fields.
|
||||
//!
|
||||
//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`):
|
||||
//! command presets from `/api/info` (with an implicit `custom` free-text
|
||||
@@ -18,8 +18,9 @@
|
||||
//!
|
||||
//! The picker itself is two steps (#96), verbatim from the rules in
|
||||
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
|
||||
//! 1. Step one is one box per repository (name, then full path). A worktree
|
||||
//! is a box only when its main checkout is missing from the scan.
|
||||
//! 1. Step one is one box per repository (name, then its parent directory).
|
||||
//! A worktree is a box only when its main checkout is missing from the
|
||||
//! scan.
|
||||
//! 2. The filter keeps a repository when the query matches it or any of its
|
||||
//! worktrees, so a worktree name still finds its box.
|
||||
//! 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
@@ -32,6 +33,8 @@
|
||||
//! these are separate acts.
|
||||
//! 6. Neither step probes `/api/gitroot`; the scan already reports
|
||||
//! repository membership. Only the free-text hatch pays for a probe.
|
||||
//! 7. Neither step touches the command: the directory and what runs in it
|
||||
//! are independent choices.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -39,6 +42,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
@@ -128,6 +132,15 @@ pub struct DraftWorktreeRow {
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// A box's second column: the directory holding the project, `~`-abbreviated.
|
||||
/// Only the stem — the last segment is the name column beside it.
|
||||
fn parent_dir_label(path: &Path, home: &str) -> String {
|
||||
match path.parent() {
|
||||
Some(parent) => abbreviate_dir(parent, home),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The repository key a [`DraftGroup`] was formed under — same key
|
||||
/// `projects::group_projects` groups by, kept separately because the main
|
||||
/// box's own path only equals it when the main checkout itself was found.
|
||||
@@ -208,6 +221,11 @@ pub struct DraftPane {
|
||||
pub cwd_raw_visible: bool,
|
||||
pub cwd_raw: crate::text_input::TextInput,
|
||||
pub picked_cwd: Option<PathBuf>,
|
||||
/// Directory to preselect once the project scan lands — the project of
|
||||
/// the session the operator was last looking at, so `n` then Enter
|
||||
/// respawns there without touching the picker. Consumed by the first
|
||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||
pub seed_cwd: Option<PathBuf>,
|
||||
/// The repository picked in step one ([`group_key`] of its main box),
|
||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||
/// cached, so a project-list refresh can't leave it stale.
|
||||
@@ -280,6 +298,7 @@ impl Default for DraftPane {
|
||||
cwd_raw_visible: false,
|
||||
cwd_raw: crate::text_input::TextInput::default(),
|
||||
picked_cwd: None,
|
||||
seed_cwd: None,
|
||||
selected_group_key: None,
|
||||
worktree_choice: WorktreeChoice::New,
|
||||
name: crate::text_input::TextInput::default(),
|
||||
@@ -302,7 +321,9 @@ impl Default for DraftPane {
|
||||
branches_for: None,
|
||||
needs_branches: false,
|
||||
error: None,
|
||||
focused_field: DraftField::Preset,
|
||||
// The picker owns the keyboard on open: typing filters projects
|
||||
// straight away, and Tab from here reaches the command tiles.
|
||||
focused_field: DraftField::DirPicker,
|
||||
home_dir: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -345,6 +366,26 @@ impl DraftPane {
|
||||
})
|
||||
.collect();
|
||||
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
|
||||
self.apply_seed_cwd();
|
||||
}
|
||||
|
||||
/// Preselect the seeded directory now that the scan has landed: highlight
|
||||
/// its repository box and pick it, so Enter from the untouched picker
|
||||
/// spawns there. A seed no box matches is dropped — the operator picks by
|
||||
/// hand, exactly as before.
|
||||
fn apply_seed_cwd(&mut self) {
|
||||
let Some(seed) = self.seed_cwd.take() else {
|
||||
return;
|
||||
};
|
||||
let hit = self.visible_groups().iter().enumerate().find_map(|(i, g)| {
|
||||
let matches = g.main.path == seed || g.worktrees.iter().any(|w| w.project.path == seed);
|
||||
matches.then(|| (i, (*g).clone()))
|
||||
});
|
||||
let Some((index, group)) = hit else {
|
||||
return;
|
||||
};
|
||||
self.highlighted = index;
|
||||
self.select_group(&group);
|
||||
}
|
||||
|
||||
/// Fill the local name batch from `GET /api/names`, seeding the field
|
||||
@@ -692,14 +733,14 @@ impl DraftPane {
|
||||
}
|
||||
|
||||
fn field_order(&self) -> Vec<DraftField> {
|
||||
let mut order = vec![DraftField::Preset];
|
||||
if self.is_custom() {
|
||||
order.push(DraftField::CustomCommand);
|
||||
}
|
||||
order.push(DraftField::DirPicker);
|
||||
let mut order = vec![DraftField::DirPicker];
|
||||
if self.cwd_raw_visible {
|
||||
order.push(DraftField::CwdRaw);
|
||||
}
|
||||
order.push(DraftField::Preset);
|
||||
if self.is_custom() {
|
||||
order.push(DraftField::CustomCommand);
|
||||
}
|
||||
order.push(DraftField::Name);
|
||||
order.push(DraftField::Advanced);
|
||||
if !self.advanced_collapsed {
|
||||
@@ -893,13 +934,6 @@ impl DraftPane {
|
||||
self.worktree_choice = choice.clone();
|
||||
self.picked_cwd = Some(target.path.clone());
|
||||
self.cwd_raw_visible = false;
|
||||
if let Some(cmd) = target.last_command.as_deref() {
|
||||
if !cmd.is_empty() {
|
||||
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
|
||||
self.selected_preset = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
match choice {
|
||||
WorktreeChoice::New => {
|
||||
self.worktree_enabled = true;
|
||||
@@ -1110,15 +1144,17 @@ impl DraftPane {
|
||||
+ u16::from(self.error.is_some());
|
||||
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
|
||||
|
||||
let mut constraints = vec![Constraint::Length(1)];
|
||||
if show_custom {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1));
|
||||
// The picker leads the form: it is where focus lands on open, and Tab
|
||||
// walks down from it into the command tiles.
|
||||
let mut constraints = vec![Constraint::Length(1)]; // dir filter
|
||||
constraints.push(Constraint::Length(list_capacity as u16));
|
||||
if show_raw {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // command tiles
|
||||
if show_custom {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // name
|
||||
constraints.push(Constraint::Length(1)); // advanced header
|
||||
if show_wt_choice {
|
||||
@@ -1157,36 +1193,6 @@ impl DraftPane {
|
||||
// Tracks where the terminal cursor belongs for the focused text field.
|
||||
let mut cursor: Option<(u16, u16)> = None;
|
||||
|
||||
// -- command presets ---------------------------------------------------
|
||||
let tiles_row = next_row();
|
||||
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
|
||||
for (i, preset) in self.presets.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tiles.push(Span::raw(" "));
|
||||
}
|
||||
let mut style = Style::default();
|
||||
if i == self.selected_preset {
|
||||
style = style.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
tiles.push(Span::styled(format!("[{}]", preset.label), style));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
|
||||
|
||||
// -- custom command (only while a custom tile is selected) -------------
|
||||
if show_custom {
|
||||
let row = next_row();
|
||||
draw_gutter(f, row, "");
|
||||
let col = draw_text_field(
|
||||
f,
|
||||
&self.custom_command,
|
||||
row,
|
||||
field_style(DraftField::CustomCommand),
|
||||
);
|
||||
if self.focused_field == DraftField::CustomCommand {
|
||||
cursor = Some((col, row.y));
|
||||
}
|
||||
}
|
||||
|
||||
// -- directory picker ----------------------------------------------------
|
||||
let filter_row = next_row();
|
||||
draw_gutter(f, filter_row, "dir:");
|
||||
@@ -1200,9 +1206,9 @@ impl DraftPane {
|
||||
cursor = Some((col, filter_row.y));
|
||||
}
|
||||
|
||||
// Step one (#96 rule 1): one box per repository, name then the FULL
|
||||
// path — not the abbreviated form other lists use — so two projects
|
||||
// sharing a basename stay distinguishable.
|
||||
// Step one (#96 rule 1): one box per repository, name then the parent
|
||||
// directory it sits in. The last segment is the name column already;
|
||||
// the stem is what keeps two projects sharing a basename apart.
|
||||
let visible = self.visible_groups();
|
||||
let sel = self.selection();
|
||||
let count = visible.len() + 1;
|
||||
@@ -1241,7 +1247,10 @@ impl DraftPane {
|
||||
Span::styled(if active { ">" } else { " " }, base),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{:<name_width$}", group.main.name), base),
|
||||
Span::styled(format!(" {}", group.main.path.display()), base),
|
||||
Span::styled(
|
||||
format!(" {}", parent_dir_label(&group.main.path, &self.home_dir)),
|
||||
base,
|
||||
),
|
||||
])
|
||||
} else {
|
||||
let mut style = Style::default();
|
||||
@@ -1271,6 +1280,36 @@ impl DraftPane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- command presets ---------------------------------------------------
|
||||
let tiles_row = next_row();
|
||||
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
|
||||
for (i, preset) in self.presets.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tiles.push(Span::raw(" "));
|
||||
}
|
||||
let mut style = Style::default();
|
||||
if i == self.selected_preset {
|
||||
style = style.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
tiles.push(Span::styled(format!("[{}]", preset.label), style));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
|
||||
|
||||
// -- custom command (only while a custom tile is selected) -------------
|
||||
if show_custom {
|
||||
let row = next_row();
|
||||
draw_gutter(f, row, "");
|
||||
let col = draw_text_field(
|
||||
f,
|
||||
&self.custom_command,
|
||||
row,
|
||||
field_style(DraftField::CustomCommand),
|
||||
);
|
||||
if self.focused_field == DraftField::CustomCommand {
|
||||
cursor = Some((col, row.y));
|
||||
}
|
||||
}
|
||||
|
||||
// -- name + reroll ---------------------------------------------------------
|
||||
let name_row = next_row();
|
||||
draw_gutter(f, name_row, "name:");
|
||||
@@ -1672,7 +1711,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||
let mut draft = DraftPane::default();
|
||||
let mut draft = DraftPane {
|
||||
focused_field: DraftField::Preset,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.on_input(&InputKey::Right);
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
draft.on_input(&InputKey::Left);
|
||||
@@ -1695,17 +1737,22 @@ mod tests {
|
||||
draft.selected_preset = draft.presets.len() - 1;
|
||||
assert!(draft.is_custom());
|
||||
assert_eq!(
|
||||
draft.field_order()[..2],
|
||||
[DraftField::Preset, DraftField::CustomCommand],
|
||||
"custom command sits between presets and the dir picker"
|
||||
draft.field_order()[..3],
|
||||
[
|
||||
DraftField::DirPicker,
|
||||
DraftField::Preset,
|
||||
DraftField::CustomCommand
|
||||
],
|
||||
"the picker leads; custom command sits right after the tiles"
|
||||
);
|
||||
draft.next_field(); // DirPicker → Preset
|
||||
draft.next_field();
|
||||
assert_eq!(draft.focused_field, DraftField::CustomCommand);
|
||||
typed(&mut draft, "bash -l");
|
||||
assert_eq!(draft.command(), "bash -l");
|
||||
// Tab walks past the hidden fields in order.
|
||||
draft.next_field();
|
||||
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
||||
assert_eq!(draft.focused_field, DraftField::Name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1741,8 +1788,9 @@ mod tests {
|
||||
let stale_pos = rendered.find("stale-repo").unwrap();
|
||||
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
|
||||
assert!(
|
||||
rendered.contains("/home/u/new/repo"),
|
||||
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
|
||||
rendered.contains("~/new") && !rendered.contains("~/new/repo"),
|
||||
"the parent directory only — the name column is the last segment \
|
||||
(#96 rule 1):\n{rendered}"
|
||||
);
|
||||
|
||||
// Staleness dims the row's cells.
|
||||
@@ -1767,8 +1815,11 @@ mod tests {
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.next_field(); // Preset → DirPicker
|
||||
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
||||
assert_eq!(
|
||||
draft.focused_field,
|
||||
DraftField::DirPicker,
|
||||
"the form opens with the project filter focused"
|
||||
);
|
||||
typed(&mut draft, "maes");
|
||||
assert_eq!(
|
||||
draft.visible_groups().len(),
|
||||
@@ -1787,7 +1838,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
draft.command(),
|
||||
"claude",
|
||||
"pick pre-selects the last command"
|
||||
"the preset is the operator's own choice — picking a project leaves it alone"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1880,6 +1931,47 @@ mod tests {
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seeded_directory_is_preselected_so_one_enter_spawns_there() {
|
||||
let mut draft = DraftPane {
|
||||
seed_cwd: Some(PathBuf::from("/repos/other")),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
vec![
|
||||
project("maestro", "/repos/maestro", 1, Some("claude")),
|
||||
project("other", "/repos/other", 2, Some("zsh")),
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/other")),
|
||||
"the seeded project is picked before a key is pressed"
|
||||
);
|
||||
assert_eq!(draft.selection(), 1, "and its row is the highlighted one");
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Submit,
|
||||
"one Enter spawns in the seeded project"
|
||||
);
|
||||
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/other")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_no_project_matches_leaves_the_picker_untouched() {
|
||||
let mut draft = DraftPane {
|
||||
seed_cwd: Some(PathBuf::from("/gone")),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
vec![project("maestro", "/repos/maestro", 1, None)],
|
||||
Utc::now(),
|
||||
);
|
||||
assert_eq!(draft.picked_cwd, None);
|
||||
assert_eq!(draft.selection(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
|
||||
let mut draft = DraftPane::default();
|
||||
@@ -2130,7 +2222,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(GUTTER as u16 + 2, 1));
|
||||
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2640,8 +2732,8 @@ mod tests {
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
|
||||
assert!(
|
||||
rendered.contains("/home/u/repos/main"),
|
||||
"step one shows the full repository path:\n{rendered}"
|
||||
rendered.contains("~/repos"),
|
||||
"step one shows the directory holding the repository:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("+ new"),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Fast-forward spawn (#67): one keystroke, every default resolved, no form.
|
||||
//!
|
||||
//! The defaults mirror what a draft pane would settle on after the operator
|
||||
//! The defaults mirror what the spawn form would settle on after the operator
|
||||
//! picked the most recent project and kept their hands off everything else:
|
||||
//! its last command (or the first non-custom preset), a freshly generated
|
||||
//! name, and a worktree with a branch named after that name. Any default
|
||||
//! that cannot be resolved — no projects yet, no command, no generated
|
||||
//! name — downgrades the keystroke to a draft pane pre-filled with whatever
|
||||
//! name — downgrades the keystroke to the spawn form pre-filled with whatever
|
||||
//! did resolve ([`FastForwardDefaults::apply_to_draft`]).
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -85,7 +85,7 @@ impl FastForwardDefaults {
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed a fresh draft pane with whatever did resolve — the fallback when
|
||||
/// Seed a fresh spawn form with whatever did resolve — the fallback when
|
||||
/// [`Self::build_request`] is `None`. The operator fills in the rest.
|
||||
pub fn apply_to_draft(&self, draft: &mut DraftPane) {
|
||||
if let Some(project) = &self.project {
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod app;
|
||||
pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod daemon_link;
|
||||
pub mod draft;
|
||||
pub mod emulators;
|
||||
pub mod fast_forward;
|
||||
@@ -33,6 +34,7 @@ use ratatui::Terminal;
|
||||
use crate::app::App;
|
||||
use crate::catalog::CatalogStore;
|
||||
use crate::config::TuiConfig;
|
||||
use crate::daemon_link::DaemonLink;
|
||||
|
||||
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
|
||||
/// `TuiArgs` plus any global flags.
|
||||
@@ -94,8 +96,15 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
||||
let ws_url = daemon
|
||||
.events_url()
|
||||
.map_err(|e| TuiError::Config(e.to_string()))?;
|
||||
// The link watches the same daemon over `/api/info`; the events socket
|
||||
// reports its own connects and disconnects into it (`docs/RESTART.md`).
|
||||
let link = DaemonLink::default();
|
||||
let bg_catalog = catalog.clone();
|
||||
tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await });
|
||||
let ws_link = link.clone();
|
||||
tokio::spawn(async move { bg_catalog.run_events_loop_reporting(ws_url, ws_link).await });
|
||||
let probe_link = link.clone();
|
||||
let probe_daemon = daemon.clone();
|
||||
tokio::spawn(async move { probe_link.run_probe_loop(probe_daemon).await });
|
||||
|
||||
// Wait briefly for the bootstrap Snapshot to land. If it doesn't, we
|
||||
// still enter the UI — the sidebar will populate when events flow.
|
||||
@@ -107,7 +116,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
||||
let backend = CrosstermBackend::new(std::io::stdout());
|
||||
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
|
||||
|
||||
let app = App::new(tui_cfg, catalog, daemon);
|
||||
let app = App::new(tui_cfg, catalog, daemon, link);
|
||||
app.run(&mut terminal).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
//! 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`]).
|
||||
//! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help
|
||||
//! overlay, restart overlay, transient toast.
|
||||
//!
|
||||
//! All overlays are centered popups drawn over the rest of the UI; the
|
||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||
@@ -15,6 +12,63 @@ use ratatui::Frame;
|
||||
|
||||
use crate::text_input::TextInput;
|
||||
|
||||
/// Chrome for the spawn form. The form itself ([`crate::draft::DraftPane`])
|
||||
/// draws into the rect this returns, so the two can be sized independently.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SpawnModal;
|
||||
|
||||
impl SpawnModal {
|
||||
/// Draw the frame and return the area the form should render into.
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) -> (Rect, Rect) {
|
||||
// Tall enough for the whole form — presets, picker, name and the
|
||||
// advanced section — without pushing past small terminals.
|
||||
let area = centered(parent, 72, parent.height.saturating_sub(4).min(26));
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.title(" new session ")
|
||||
.borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
(area, inner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown while the daemon is unreachable past the grace window, over
|
||||
/// everything else, with the keyboard locked (`docs/RESTART.md`). Nothing to
|
||||
/// dismiss: it clears itself when a probe succeeds.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RestartOverlay {
|
||||
/// Failed probes so far, so a long restart doesn't read as a hang.
|
||||
pub attempts: u32,
|
||||
/// The release the daemon was running before it went away.
|
||||
pub release: String,
|
||||
}
|
||||
|
||||
impl RestartOverlay {
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = centered(parent, 54, 7);
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.title(" daemon restarting ")
|
||||
.borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let attempts = if self.attempts <= 1 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({} attempts)", self.attempts)
|
||||
};
|
||||
let lines = vec![
|
||||
Line::from("Waiting for the daemon to come back…"),
|
||||
Line::from(format!("Polling /api/info{attempts}")),
|
||||
Line::from(""),
|
||||
Line::from(format!("was: {}", self.release)),
|
||||
Line::from("Sessions are held by tmux and survive the restart."),
|
||||
];
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KillConfirm {
|
||||
pub label: String,
|
||||
@@ -168,6 +222,20 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_modal_frames_an_inner_area_inside_its_border() {
|
||||
let mut inner = None;
|
||||
let rendered = render_to_string(90, 30, |f| {
|
||||
let (area, i) = SpawnModal.render(f, f.area());
|
||||
inner = Some((area, i));
|
||||
f.render_widget(Paragraph::new("form goes here"), i);
|
||||
});
|
||||
let (area, i) = inner.unwrap();
|
||||
assert!(i.x > area.x && i.y > area.y, "{area:?} vs {i:?}");
|
||||
assert!(rendered.contains("new session"), "{rendered}");
|
||||
assert!(rendered.contains("form goes here"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_confirm_renders_prompt() {
|
||||
let modal = KillConfirm {
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
//! 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.
|
||||
//! [`Palette::DARK`] — graphite, no accent hue — is the default.
|
||||
//! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses
|
||||
//! terminal-provided ANSI colours. Five house presets recolour the graphite
|
||||
//! base with an accent (Ice Cyan, Periwinkle, Amber Phosphor, Ember, Orchid);
|
||||
//! 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.
|
||||
@@ -135,73 +137,75 @@ pub struct Palette {
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// The dark terminal-native palette and the default preset.
|
||||
/// The graphite dark palette and the default preset: pure near-neutral
|
||||
/// chrome with an ink-coloured accent, so activity colours are the only
|
||||
/// hue on screen.
|
||||
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),
|
||||
bg: Color::Rgb(0x1d, 0x1f, 0x22),
|
||||
fg: Color::Rgb(0xe6, 0xe8, 0xeb),
|
||||
muted: Color::Rgb(0xb1, 0xb4, 0xb9),
|
||||
dim: Color::Rgb(0x86, 0x89, 0x8e),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||
idle: Color::Rgb(0xb1, 0xb4, 0xb9),
|
||||
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),
|
||||
bg: Color::Rgb(0x3f, 0x43, 0x48),
|
||||
fg: Color::Rgb(0xf0, 0xf2, 0xf5),
|
||||
muted: Color::Rgb(0xce, 0xd1, 0xd5),
|
||||
dim: Color::Rgb(0xa5, 0xa8, 0xab),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xc4, 0xcf, 0xc7),
|
||||
idle: Color::Rgb(0xd5, 0xd8, 0xdb),
|
||||
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),
|
||||
chrome_bg: Color::Rgb(0x1d, 0x1f, 0x22),
|
||||
sidebar_focus_bg: Color::Rgb(0x28, 0x2a, 0x2d),
|
||||
border_focused: Color::Rgb(0xe2, 0xe5, 0xe8),
|
||||
border_unfocused: Color::Rgb(0x72, 0x75, 0x79),
|
||||
focus_accent: Color::Rgb(0xe2, 0xe5, 0xe8),
|
||||
group_row: Color::Rgb(0xba, 0xbe, 0xc3),
|
||||
mode_normal_bg: Color::Rgb(0xe2, 0xe5, 0xe8),
|
||||
mode_normal_fg: Color::Rgb(0x17, 0x1b, 0x20),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
mode_insert_fg: Color::Rgb(0x1d, 0x1f, 0x22),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// A high-contrast light peer to [`Self::DARK`].
|
||||
/// A high-contrast light peer to [`Self::DARK`]: ink-on-paper graphite.
|
||||
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),
|
||||
fg: Color::Rgb(0x20, 0x24, 0x2a),
|
||||
muted: Color::Rgb(0x56, 0x5b, 0x63),
|
||||
dim: Color::Rgb(0x7b, 0x81, 0x89),
|
||||
active: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
idle: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||
idle: Color::Rgb(0x56, 0x5b, 0x63),
|
||||
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),
|
||||
bg: Color::Rgb(0xc5, 0xc7, 0xca),
|
||||
fg: Color::Rgb(0x15, 0x19, 0x1e),
|
||||
muted: Color::Rgb(0x43, 0x48, 0x4f),
|
||||
dim: Color::Rgb(0x5e, 0x64, 0x6b),
|
||||
active: Color::Rgb(0x15, 0x5b, 0x2e),
|
||||
idle: Color::Rgb(0x3f, 0x55, 0x45),
|
||||
idle: Color::Rgb(0x38, 0x3e, 0x45),
|
||||
awaiting: Color::Rgb(0x69, 0x46, 0x00),
|
||||
error: Color::Rgb(0xa5, 0x1d, 0x14),
|
||||
error: Color::Rgb(0x99, 0x1a, 0x12),
|
||||
},
|
||||
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),
|
||||
sidebar_focus_bg: Color::Rgb(0xef, 0xf0, 0xf2),
|
||||
border_focused: Color::Rgb(0x36, 0x3b, 0x41),
|
||||
border_unfocused: Color::Rgb(0x76, 0x7b, 0x82),
|
||||
focus_accent: Color::Rgb(0x36, 0x3b, 0x41),
|
||||
group_row: Color::Rgb(0x4e, 0x53, 0x5a),
|
||||
mode_normal_bg: Color::Rgb(0x23, 0x27, 0x2c),
|
||||
mode_normal_fg: Color::Rgb(0xf9, 0xfa, 0xfb),
|
||||
mode_insert_bg: Color::Rgb(0x8f, 0x63, 0x00),
|
||||
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
error_text: Color::Rgb(0xb4, 0x23, 0x18),
|
||||
};
|
||||
|
||||
@@ -556,14 +560,195 @@ impl Palette {
|
||||
error_text: Color::Rgb(0xff, 0x59, 0x59),
|
||||
};
|
||||
|
||||
/// Ice Cyan — signal-cyan accent on the graphite slate base.
|
||||
pub const ICE_CYAN: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x1c, 0x20, 0x25),
|
||||
fg: Color::Rgb(0xe4, 0xe8, 0xed),
|
||||
muted: Color::Rgb(0xaf, 0xb5, 0xbc),
|
||||
dim: Color::Rgb(0x84, 0x8a, 0x91),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xaf, 0xb5, 0xbc),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x24, 0x4d, 0x59),
|
||||
fg: Color::Rgb(0xee, 0xf2, 0xf7),
|
||||
muted: Color::Rgb(0xcd, 0xd1, 0xd7),
|
||||
dim: Color::Rgb(0xa4, 0xa8, 0xae),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xd3, 0xd8, 0xde),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x1c, 0x20, 0x25),
|
||||
sidebar_focus_bg: Color::Rgb(0x26, 0x2a, 0x30),
|
||||
border_focused: Color::Rgb(0x61, 0xe3, 0xf1),
|
||||
border_unfocused: Color::Rgb(0x70, 0x75, 0x7b),
|
||||
focus_accent: Color::Rgb(0x61, 0xe3, 0xf1),
|
||||
group_row: Color::Rgb(0xb8, 0xbe, 0xc6),
|
||||
mode_normal_bg: Color::Rgb(0x61, 0xe3, 0xf1),
|
||||
mode_normal_fg: Color::Rgb(0x0f, 0x22, 0x2b),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x1c, 0x20, 0x25),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// Periwinkle — indigo-tinted base with a soft blue-violet accent.
|
||||
pub const PERIWINKLE: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x1d, 0x1e, 0x29),
|
||||
fg: Color::Rgb(0xe5, 0xe7, 0xf1),
|
||||
muted: Color::Rgb(0xb1, 0xb3, 0xc2),
|
||||
dim: Color::Rgb(0x86, 0x88, 0x96),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xb1, 0xb3, 0xc2),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x3b, 0x43, 0x6c),
|
||||
fg: Color::Rgb(0xef, 0xf1, 0xfb),
|
||||
muted: Color::Rgb(0xce, 0xd0, 0xdc),
|
||||
dim: Color::Rgb(0xa5, 0xa7, 0xb2),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xd4, 0xd7, 0xe3),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x1d, 0x1e, 0x29),
|
||||
sidebar_focus_bg: Color::Rgb(0x27, 0x29, 0x35),
|
||||
border_focused: Color::Rgb(0x97, 0xa7, 0xfd),
|
||||
border_unfocused: Color::Rgb(0x71, 0x74, 0x80),
|
||||
focus_accent: Color::Rgb(0x97, 0xa7, 0xfd),
|
||||
group_row: Color::Rgb(0xba, 0xbd, 0xcc),
|
||||
mode_normal_bg: Color::Rgb(0x97, 0xa7, 0xfd),
|
||||
mode_normal_fg: Color::Rgb(0x12, 0x14, 0x28),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x1d, 0x1e, 0x29),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// Amber Phosphor — CRT amber on warm graphite. The awaiting colour
|
||||
/// shifts orange so it stays distinct from the amber accent.
|
||||
pub const AMBER_PHOSPHOR: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x22, 0x1f, 0x1a),
|
||||
fg: Color::Rgb(0xec, 0xe7, 0xe1),
|
||||
muted: Color::Rgb(0xba, 0xb3, 0xaa),
|
||||
dim: Color::Rgb(0x8f, 0x88, 0x7f),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xba, 0xb3, 0xaa),
|
||||
awaiting: Color::Rgb(0xff, 0x9b, 0x50),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x55, 0x43, 0x26),
|
||||
fg: Color::Rgb(0xf6, 0xf1, 0xeb),
|
||||
muted: Color::Rgb(0xd6, 0xd0, 0xc9),
|
||||
dim: Color::Rgb(0xad, 0xa7, 0xa0),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xdd, 0xd6, 0xcf),
|
||||
awaiting: Color::Rgb(0xff, 0xb9, 0x72),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x22, 0x1f, 0x1a),
|
||||
sidebar_focus_bg: Color::Rgb(0x2d, 0x29, 0x25),
|
||||
border_focused: Color::Rgb(0xf9, 0xbb, 0x51),
|
||||
border_unfocused: Color::Rgb(0x7a, 0x73, 0x6c),
|
||||
focus_accent: Color::Rgb(0xf9, 0xbb, 0x51),
|
||||
group_row: Color::Rgb(0xc4, 0xbc, 0xb3),
|
||||
mode_normal_bg: Color::Rgb(0xf9, 0xbb, 0x51),
|
||||
mode_normal_fg: Color::Rgb(0x2f, 0x1e, 0x01),
|
||||
mode_insert_bg: Color::Rgb(0xff, 0x9b, 0x50),
|
||||
mode_insert_fg: Color::Rgb(0x22, 0x1f, 0x1a),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// Ember — warm orange-red accent on a cleaner warm neutral.
|
||||
pub const EMBER: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x23, 0x1e, 0x1b),
|
||||
fg: Color::Rgb(0xed, 0xe6, 0xe3),
|
||||
muted: Color::Rgb(0xbc, 0xb2, 0xad),
|
||||
dim: Color::Rgb(0x90, 0x87, 0x82),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xbc, 0xb2, 0xad),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x5d, 0x3e, 0x30),
|
||||
fg: Color::Rgb(0xf7, 0xf0, 0xed),
|
||||
muted: Color::Rgb(0xd7, 0xcf, 0xcb),
|
||||
dim: Color::Rgb(0xae, 0xa6, 0xa2),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xde, 0xd5, 0xd1),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x23, 0x1e, 0x1b),
|
||||
sidebar_focus_bg: Color::Rgb(0x2e, 0x29, 0x26),
|
||||
border_focused: Color::Rgb(0xf7, 0x89, 0x55),
|
||||
border_unfocused: Color::Rgb(0x7b, 0x72, 0x6e),
|
||||
focus_accent: Color::Rgb(0xf7, 0x89, 0x55),
|
||||
group_row: Color::Rgb(0xc6, 0xbb, 0xb6),
|
||||
mode_normal_bg: Color::Rgb(0xf7, 0x89, 0x55),
|
||||
mode_normal_fg: Color::Rgb(0x2a, 0x14, 0x0a),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x23, 0x1e, 0x1b),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// Orchid — muted violet accent on a faintly violet base.
|
||||
pub const ORCHID: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x21, 0x1e, 0x25),
|
||||
fg: Color::Rgb(0xe9, 0xe6, 0xee),
|
||||
muted: Color::Rgb(0xb6, 0xb2, 0xbd),
|
||||
dim: Color::Rgb(0x8b, 0x87, 0x91),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xb6, 0xb2, 0xbd),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x51, 0x3c, 0x63),
|
||||
fg: Color::Rgb(0xf3, 0xf0, 0xf8),
|
||||
muted: Color::Rgb(0xd3, 0xcf, 0xd8),
|
||||
dim: Color::Rgb(0xa9, 0xa6, 0xaf),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xd9, 0xd5, 0xdf),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x21, 0x1e, 0x25),
|
||||
sidebar_focus_bg: Color::Rgb(0x2c, 0x28, 0x30),
|
||||
border_focused: Color::Rgb(0xd1, 0x9f, 0xf5),
|
||||
border_unfocused: Color::Rgb(0x76, 0x72, 0x7c),
|
||||
focus_accent: Color::Rgb(0xd1, 0x9f, 0xf5),
|
||||
group_row: Color::Rgb(0xc0, 0xbb, 0xc7),
|
||||
mode_normal_bg: Color::Rgb(0xd1, 0x9f, 0xf5),
|
||||
mode_normal_fg: Color::Rgb(0x22, 0x13, 0x2c),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x21, 0x1e, 0x25),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// 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] {
|
||||
pub fn presets() -> [(&'static str, Palette); 17] {
|
||||
[
|
||||
("dark", Self::DARK),
|
||||
("light", Self::LIGHT),
|
||||
("ice-cyan", Self::ICE_CYAN),
|
||||
("periwinkle", Self::PERIWINKLE),
|
||||
("amber-phosphor", Self::AMBER_PHOSPHOR),
|
||||
("ember", Self::EMBER),
|
||||
("orchid", Self::ORCHID),
|
||||
("ansi", Self::ANSI),
|
||||
("solarized-dark", Self::SOLARIZED_DARK),
|
||||
("solarized-light", Self::SOLARIZED_LIGHT),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Pure helpers behind the draft pane's project picker (#64): staleness,
|
||||
//! Pure helpers behind the spawn form's project picker (#64): staleness,
|
||||
//! recency ordering, worktree grouping, and free-text filtering. Mirrors
|
||||
//! `web/src/projectList.ts` so both clients order and dim identically — the
|
||||
//! daemon reports the timestamps; what "stale" means is a client decision
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
//! hit-test mouse clicks.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
use maestro_protocol::ActivityState;
|
||||
|
||||
use crate::daemon_link::{format_release, LinkState, LinkStatus};
|
||||
use crate::input::Region;
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, RestartOverlay, SpawnModal, Toast};
|
||||
use crate::palette;
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
@@ -77,12 +80,15 @@ pub struct RenderInputs<'a> {
|
||||
pub home_dir: &'a str,
|
||||
pub pane_title: &'a str,
|
||||
pub pane: Option<&'a Pane>,
|
||||
/// Inline spawn form when the pane is a draft (issue #63).
|
||||
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
pub help: &'a HelpOverlay,
|
||||
/// Edit buffer behind the rename prompt (#125).
|
||||
pub rename_input: &'a crate::text_input::TextInput,
|
||||
/// Liveness + release of the daemon, for the status bar's indicator and the
|
||||
/// restart overlay (`docs/RESTART.md`).
|
||||
pub link: &'a crate::daemon_link::LinkStatus,
|
||||
}
|
||||
|
||||
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
||||
@@ -139,18 +145,24 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
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
|
||||
// always see the current mode + key hints.
|
||||
render_status_bar(f, status_area, inputs.state);
|
||||
render_status_bar(f, status_area, inputs.state, inputs.link);
|
||||
|
||||
match inputs.state.overlay() {
|
||||
Some(OverlayKind::Spawn) => {
|
||||
if let Some(d) = inputs.draft {
|
||||
let (area, inner) = SpawnModal.render(f, area);
|
||||
// The modal owns the keyboard while it is up, so the form
|
||||
// always draws focused.
|
||||
d.render(f, inner, true);
|
||||
map.modal = Some(area);
|
||||
}
|
||||
}
|
||||
Some(OverlayKind::KillConfirm(id)) => {
|
||||
// #83: surface what the watcher last saw in the worktree, in
|
||||
// InTheWay's own words, before the operator commits to the kill.
|
||||
@@ -197,6 +209,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Last, so it covers whatever else is up: while the daemon is gone none of
|
||||
// it can act (`docs/RESTART.md`).
|
||||
if inputs.link.state == LinkState::Down {
|
||||
RestartOverlay {
|
||||
attempts: inputs.link.attempts,
|
||||
release: inputs
|
||||
.link
|
||||
.build
|
||||
.as_ref()
|
||||
.map(format_release)
|
||||
.unwrap_or_else(|| "unknown release".to_string()),
|
||||
}
|
||||
.render(f, area);
|
||||
map.modal = Some(area);
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
@@ -214,7 +242,16 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||
/// mode + focused panel + the most useful key hints so the user is never
|
||||
/// guessing what state they're in.
|
||||
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) {
|
||||
// The daemon indicator is pinned right and sized to its own text, so a
|
||||
// narrow terminal truncates the key hints rather than the release.
|
||||
let label = link_label(link);
|
||||
let indicator_w = (label.chars().count() as u16).saturating_add(3);
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(indicator_w)])
|
||||
.split(area);
|
||||
|
||||
let mode_label = match state.mode {
|
||||
Mode::Normal => "[NORMAL]",
|
||||
Mode::Insert => "[INSERT]",
|
||||
@@ -224,7 +261,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
Focus::Pane => "pane",
|
||||
};
|
||||
let hint = match state.mode {
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
||||
Mode::Insert => "^a: return to sidebar",
|
||||
};
|
||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||
@@ -232,7 +269,42 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
Span::styled(format!(" {mode_label} "), mode_style),
|
||||
Span::raw(format!(" {focus_label} • {hint}")),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(line), area);
|
||||
f.render_widget(Paragraph::new(line), cols[0]);
|
||||
|
||||
let indicator = ratatui::text::Line::from(vec![
|
||||
Span::styled(" ●", Style::default().fg(link_colour(link.state))),
|
||||
Span::raw(format!(" {label} ")),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(indicator), cols[1]);
|
||||
}
|
||||
|
||||
/// The status dot's colour: the same three the sidebar cards use for session
|
||||
/// activity, so "green is fine, red is broken" reads the same everywhere.
|
||||
fn link_colour(state: LinkState) -> ratatui::style::Color {
|
||||
let card = palette::active().card;
|
||||
match state {
|
||||
LinkState::Online => card.activity(ActivityState::Active),
|
||||
LinkState::Connecting | LinkState::Reconnecting => {
|
||||
card.activity(ActivityState::AwaitingInput)
|
||||
}
|
||||
LinkState::Down => card.activity(ActivityState::Error),
|
||||
}
|
||||
}
|
||||
|
||||
/// What sits next to the dot. The browser shows the release on hover; a
|
||||
/// terminal has no hover, so it is always on the line — replaced by the state
|
||||
/// while the daemon is not answering, which is the more urgent fact.
|
||||
fn link_label(link: &LinkStatus) -> String {
|
||||
match link.state {
|
||||
LinkState::Connecting => "connecting…".to_string(),
|
||||
LinkState::Reconnecting => "reconnecting…".to_string(),
|
||||
LinkState::Down => "daemon unreachable".to_string(),
|
||||
LinkState::Online => match (&link.upgraded_to, &link.build) {
|
||||
(Some(build), _) => format!("updated: {} (restart tui)", format_release(build)),
|
||||
(None, Some(build)) => format_release(build),
|
||||
(None, None) => "unknown release".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -240,6 +312,16 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::input::Region;
|
||||
|
||||
/// A link that answered — the normal case every other render test assumes.
|
||||
fn online_link() -> LinkStatus {
|
||||
LinkStatus {
|
||||
state: LinkState::Online,
|
||||
build: None,
|
||||
attempts: 0,
|
||||
upgraded_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_map_resolves_known_areas() {
|
||||
let map = RegionMap {
|
||||
@@ -325,20 +407,106 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||
/// form where the terminal would otherwise be.
|
||||
#[test]
|
||||
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
|
||||
fn frame_text(link: &LinkStatus) -> String {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let state = AppState::new();
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
link,
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(120, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw(f, inputs);
|
||||
})
|
||||
.unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
buf.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_bar_names_the_release_the_daemon_is_running() {
|
||||
let text = frame_text(&LinkStatus {
|
||||
state: LinkState::Online,
|
||||
build: Some(maestro_protocol::BuildInfo {
|
||||
commit: "f89be4a716c7".into(),
|
||||
commit_date: "2026-09-02T14:03:11Z".into(),
|
||||
dirty: false,
|
||||
}),
|
||||
attempts: 0,
|
||||
upgraded_to: None,
|
||||
});
|
||||
assert!(text.contains("f89be4a716c7"), "{text}");
|
||||
assert!(text.contains("2026-09-02"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_daemon_covers_the_frame_with_the_restart_overlay() {
|
||||
let up = frame_text(&LinkStatus {
|
||||
state: LinkState::Online,
|
||||
build: None,
|
||||
attempts: 0,
|
||||
upgraded_to: None,
|
||||
});
|
||||
assert!(!up.contains("daemon restarting"));
|
||||
|
||||
let down = frame_text(&LinkStatus {
|
||||
state: LinkState::Down,
|
||||
build: None,
|
||||
attempts: 4,
|
||||
upgraded_to: None,
|
||||
});
|
||||
assert!(down.contains("daemon restarting"), "{down}");
|
||||
assert!(down.contains("(4 attempts)"), "{down}");
|
||||
assert!(down.contains("daemon unreachable"), "{down}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upgraded_daemon_tells_the_user_to_restart_the_tui() {
|
||||
let text = frame_text(&LinkStatus {
|
||||
state: LinkState::Online,
|
||||
build: Some(maestro_protocol::BuildInfo::default()),
|
||||
attempts: 0,
|
||||
upgraded_to: Some(maestro_protocol::BuildInfo {
|
||||
commit: "bbb111222333".into(),
|
||||
commit_date: "2026-09-03T09:00:00Z".into(),
|
||||
dirty: false,
|
||||
}),
|
||||
});
|
||||
assert!(text.contains("updated: bbb111222333"), "{text}");
|
||||
assert!(text.contains("restart tui"), "{text}");
|
||||
}
|
||||
|
||||
/// The spawn form is a modal: the frame shows it over whatever the pane
|
||||
/// was displaying, framed by its own border.
|
||||
#[test]
|
||||
fn the_spawn_overlay_renders_the_form_in_a_modal() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::state::PaneRef;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
let draft = match &state.tabs[0].pane {
|
||||
Some(PaneRef::Draft(d)) => d,
|
||||
other => panic!("expected a draft pane, got {other:?}"),
|
||||
};
|
||||
state.open_spawn_modal();
|
||||
let draft = crate::draft::DraftPane::default();
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
@@ -348,21 +516,21 @@ mod tests {
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
pane_title: "new session",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
draft: Some(draft),
|
||||
draft: Some(&draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
link: &online_link(),
|
||||
};
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw(f, inputs);
|
||||
})
|
||||
.unwrap();
|
||||
let mut map = None;
|
||||
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
|
||||
// Mouse clicks inside the form must not fall through to the pane.
|
||||
assert!(map.unwrap().modal.is_some());
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
@@ -407,6 +575,7 @@ mod tests {
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
link: &online_link(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
@@ -446,6 +615,7 @@ mod tests {
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
link: &online_link(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
|
||||
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
|
||||
use maestro_protocol::display::{
|
||||
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
|
||||
};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
@@ -297,7 +299,8 @@ impl Sidebar {
|
||||
if dead {
|
||||
tail.push(Span::styled(" exited", muted));
|
||||
}
|
||||
let command = Span::styled(format!(" {}", s.command), muted);
|
||||
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
||||
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
||||
|
||||
// Reserve the triggered status fragments before detail and command.
|
||||
// If the row is exceptionally narrow, keep one column for the
|
||||
@@ -874,12 +877,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||
#[test]
|
||||
fn meta_line_drops_the_commands_arguments() {
|
||||
let mut s = session_named(1, "handle");
|
||||
s.command = "claude --dangerously-skip-permissions".into();
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 60, 6));
|
||||
let meta = content(&all[3]);
|
||||
assert!(
|
||||
meta.contains("claude"),
|
||||
"line 2 must name the program: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
!meta.contains("dangerously"),
|
||||
"line 2 must not carry arguments: {meta:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sidebar is narrow: the command truncates with `…` and never wraps
|
||||
/// onto a third row (docs/SIDEBAR.md).
|
||||
#[test]
|
||||
fn meta_line_command_truncates_instead_of_wrapping() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.command = "a command that cannot fit".into();
|
||||
s.command = "a-program-name-that-cannot-fit".into();
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 120,
|
||||
@@ -1099,7 +1124,7 @@ mod tests {
|
||||
#[test]
|
||||
fn blocked_warning_survives_before_a_truncated_command() {
|
||||
let mut s = session_named(1, "name");
|
||||
s.command = "a command that cannot fit".into();
|
||||
s.command = "a-program-name-that-cannot-fit".into();
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 1,
|
||||
..Default::default()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! `AppState` — the top-level mutable state of the running TUI.
|
||||
//!
|
||||
//! 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 keeps it small on purpose: one pane containing a session, focus split
|
||||
//! between sidebar and pane, the current input mode, and an optional overlay
|
||||
//! (spawn form, kill confirm, rename, help, transient toast).
|
||||
//!
|
||||
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||
|
||||
@@ -12,7 +11,6 @@ use std::path::PathBuf;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::draft::DraftPane;
|
||||
use crate::keymap::Mode;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -31,34 +29,24 @@ pub enum SidebarItem {
|
||||
Session(Uuid),
|
||||
}
|
||||
|
||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||
// 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)]
|
||||
pub enum PaneRef {
|
||||
Session(Uuid),
|
||||
Draft(DraftPane),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tab {
|
||||
pub name: String,
|
||||
pub pane: Option<PaneRef>,
|
||||
pub pane: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
/// The session this tab shows, if it shows one. Draft panes have none.
|
||||
/// The session this tab shows, if it shows one.
|
||||
pub fn session(&self) -> Option<Uuid> {
|
||||
match &self.pane {
|
||||
Some(PaneRef::Session(sid)) => Some(*sid),
|
||||
_ => None,
|
||||
}
|
||||
self.pane
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OverlayKind {
|
||||
/// The spawn form. Its [`crate::draft::DraftPane`] lives on `App` for the
|
||||
/// same reason the rename buffer does — so this stays comparable.
|
||||
Spawn,
|
||||
KillConfirm(Uuid),
|
||||
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
|
||||
/// so this stays comparable.
|
||||
@@ -138,26 +126,23 @@ impl AppState {
|
||||
/// drop straight into Insert mode so keystrokes go to the session
|
||||
/// without an extra `i` press. The leader returns to Normal.
|
||||
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
||||
self.active_tab_mut().pane = Some(PaneRef::Session(session_id));
|
||||
self.active_tab_mut().pane = Some(session_id);
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
/// Put the spawn form up. Whatever the pane was showing stays behind it,
|
||||
/// so dismissing gives that back untouched — the modal owns the keyboard
|
||||
/// while it is up, so focus and mode are left alone.
|
||||
pub fn open_spawn_modal(&mut self) {
|
||||
self.show_overlay(OverlayKind::Spawn);
|
||||
}
|
||||
|
||||
/// A draft submit succeeded: replace whatever the active tab showed with
|
||||
/// the new session in place, matching the web client's behaviour (#57).
|
||||
/// A spawn submit succeeded: the modal closes and the new session takes
|
||||
/// the pane, attached and ready to type into.
|
||||
pub fn complete_spawn(&mut self, sid: Uuid) {
|
||||
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
self.dismiss_overlay();
|
||||
self.attach_to_focused(sid);
|
||||
}
|
||||
|
||||
/// The session shown by the active tab's pane, if any.
|
||||
@@ -385,35 +370,28 @@ mod tests {
|
||||
assert!(state.overlay().is_none());
|
||||
}
|
||||
|
||||
/// Issue #63: `s` over an empty pane turns that pane into a draft —
|
||||
/// focused, Insert mode on, typing starts immediately.
|
||||
/// The modal covers the pane rather than replacing it: an attached
|
||||
/// session is still there once the form is dismissed.
|
||||
#[test]
|
||||
fn open_draft_reuses_the_empty_active_pane() {
|
||||
fn open_spawn_modal_leaves_the_pane_alone() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
assert_eq!(state.mode, Mode::Insert);
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
let sid = Uuid::from_u128(9);
|
||||
state.attach_to_focused(sid);
|
||||
state.open_spawn_modal();
|
||||
assert_eq!(state.overlay(), Some(&OverlayKind::Spawn));
|
||||
assert_eq!(state.active_tab().session(), Some(sid));
|
||||
|
||||
state.dismiss_overlay();
|
||||
assert_eq!(state.active_tab().session(), Some(sid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_draft_over_a_session_reuses_the_pane() {
|
||||
fn complete_spawn_dismisses_the_modal_and_attaches() {
|
||||
let mut state = AppState::new();
|
||||
state.attach_to_focused(Uuid::from_u128(9));
|
||||
state.open_draft_pane();
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
assert_eq!(state.active_tab_idx, 0);
|
||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||
}
|
||||
|
||||
/// Web parity (#57): submitting fills the pane in place.
|
||||
#[test]
|
||||
fn complete_spawn_replaces_the_draft_with_the_session() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
state.open_spawn_modal();
|
||||
let sid = Uuid::from_u128(21);
|
||||
state.complete_spawn(sid);
|
||||
assert!(state.overlay().is_none());
|
||||
assert_eq!(state.active_tab().session(), Some(sid));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
assert_eq!(state.mode, Mode::Insert);
|
||||
|
||||
@@ -57,6 +57,10 @@ enum Command {
|
||||
/// Kill a session by ID.
|
||||
#[cfg(feature = "cli")]
|
||||
Kill(maestro_cli::cli::KillArgs),
|
||||
/// Type text into a running session, as if it had been typed on an
|
||||
/// attached terminal.
|
||||
#[cfg(feature = "cli")]
|
||||
Send(maestro_cli::cli::SendArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
#[cfg(feature = "cli")]
|
||||
Hooks(maestro_cli::cli::HooksArgs),
|
||||
@@ -144,6 +148,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await?
|
||||
}
|
||||
#[cfg(feature = "cli")]
|
||||
Command::Send(args) => {
|
||||
maestro_cli::dispatch(
|
||||
maestro_cli::cli::Command::Send(args),
|
||||
cli.config,
|
||||
cli.daemon_url,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
#[cfg(feature = "cli")]
|
||||
Command::Hooks(args) => {
|
||||
maestro_cli::dispatch(
|
||||
maestro_cli::cli::Command::Hooks(args),
|
||||
@@ -190,6 +203,7 @@ fn is_cli_subcommand(command: &Option<Command>) -> bool {
|
||||
| Some(Command::Spawn(_))
|
||||
| Some(Command::Attach(_))
|
||||
| Some(Command::Kill(_))
|
||||
| Some(Command::Send(_))
|
||||
| Some(Command::Hooks(_))
|
||||
)
|
||||
}
|
||||
|
||||
+14
-7
@@ -140,7 +140,7 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||
|
||||
[tui.keymap.normal]
|
||||
"i" = "EnterInsert"
|
||||
"s" = "NewDraftPane"
|
||||
"s" = "OpenSpawnModal"
|
||||
"x" = "KillHighlighted"
|
||||
"r" = "RenameHighlighted"
|
||||
"?" = "ShowHelp"
|
||||
@@ -154,15 +154,22 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||
# 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:
|
||||
`theme` selects the complete TUI palette. `dark` is the default: Maestro's
|
||||
graphite palette, near-neutral chrome where the activity colours are the only
|
||||
hue on screen; `light` is its light peer; `ansi` uses the terminal's named
|
||||
ANSI colours for terminals without truecolour. Five house presets recolour
|
||||
the graphite base with an accent, and the remaining presets adapt well-known
|
||||
editor themes — in both groups individual colours are lifted or darkened
|
||||
where they would fall short of the WCAG AA contrast floors the palette tests
|
||||
assert:
|
||||
|
||||
| name | based on |
|
||||
|-------------------|------------------|
|
||||
| `ice-cyan` | graphite base, signal-cyan accent |
|
||||
| `periwinkle` | indigo base, blue-violet accent |
|
||||
| `amber-phosphor` | warm graphite, CRT-amber accent |
|
||||
| `ember` | warm graphite, orange-red accent |
|
||||
| `orchid` | violet base, muted violet accent |
|
||||
| `solarized-dark` | Solarized Dark |
|
||||
| `solarized-light` | Solarized Light |
|
||||
| `catppuccin` | Catppuccin Mocha |
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Restart and upgrade contract
|
||||
|
||||
Maestro is upgraded in place: the operator reinstalls the binary and systemd
|
||||
restarts the daemon under every browser tab and TUI that is attached to it.
|
||||
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
|
||||
lose their sockets. This document is the contract for what a client does about
|
||||
it — what the daemon publishes, how a client tells a restart from a blip, and
|
||||
what the user sees while it happens.
|
||||
|
||||
## What the daemon publishes
|
||||
|
||||
`GET /api/info` carries two fields beyond identity and presets:
|
||||
|
||||
| field | meaning |
|
||||
| ---------- | -------------------------------------------------------------- |
|
||||
| `build` | `{ commit, commit_date, dirty }` — the commit the binary was compiled from |
|
||||
| `instance` | uuid regenerated on every daemon start |
|
||||
|
||||
`build` is stamped at compile time by `crates/maestro-daemon/build.rs`, which
|
||||
runs `git` in the checkout it is compiled in. The Nix build has no `.git` in the
|
||||
crane-filtered source tree, so `flake.nix` passes the flake's own revision as
|
||||
`MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY`. A build
|
||||
with neither source reports empty strings — clients render that as
|
||||
"unknown release" and fall back to `instance` alone.
|
||||
|
||||
`instance` is the one that never goes missing. It says *this process*, so two
|
||||
replies that differ on it are two runs of the daemon, even when the commit did
|
||||
not change (a plain `systemctl restart`, or a crash).
|
||||
|
||||
## How a client reads them
|
||||
|
||||
Three verdicts, from comparing the reply before an outage with the one after
|
||||
(`compareIdentity` in `web/src/daemonLink.ts`, mirrored by
|
||||
`crates/maestro-tui/src/daemon_link.rs`):
|
||||
|
||||
- **upgraded** — both commits are known and differ. New server code.
|
||||
- **restarted** — commits match (or are unknown) and `instance` differs.
|
||||
- **same** — neither changed; the socket blipped, the daemon never moved.
|
||||
|
||||
Only *upgraded* is special. A browser tab is holding a bundle that the upgraded
|
||||
daemon no longer serves, so it reloads the page rather than driving new server
|
||||
code from an old bundle. The TUI is its own binary — a reload would not replace
|
||||
it — so it stays up, reconnects, and says which release it is now talking to;
|
||||
the user restarts `maestro tui` when they want the new client.
|
||||
|
||||
## What the user sees
|
||||
|
||||
The **status indicator** lives in the sidebar footer, left of the spawn button,
|
||||
in both clients. Its colour is the link state, and it names the running release
|
||||
— commit hash and commit date — on hover (web) or in the status bar (TUI).
|
||||
|
||||
| state | colour | meaning |
|
||||
| -------------- | ------ | ---------------------------------------------------- |
|
||||
| `connecting` | amber | first probe hasn't landed |
|
||||
| `online` | green | daemon answered; same process, or a resolved new one |
|
||||
| `reconnecting` | amber | socket dropped, still inside the grace window |
|
||||
| `down` | red | unreachable past the grace window — the UI blocks |
|
||||
| `updating` | red | answered on a new commit; the page is reloading |
|
||||
|
||||
`updating` is web-only, because reloading is web-only. The TUI goes straight
|
||||
back to `online` after an upgrade and puts `updated: <hash> (restart tui)` on
|
||||
its status bar instead, until the user restarts it.
|
||||
|
||||
The **restart overlay** is up for exactly `down` and `updating`. It is not
|
||||
dismissable and it swallows the app's keyboard shortcuts: everything behind it
|
||||
would act on a daemon that is not there. In the TUI the quit binding still
|
||||
resolves, so a blocked client is never a trapped one. It clears itself the
|
||||
moment a probe succeeds — there is no "retry" button, because the client is
|
||||
already retrying.
|
||||
|
||||
## Timing
|
||||
|
||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||
appearing. A wifi hiccup or a laptop waking up should not throw a modal.
|
||||
- **Poll cadence** — `GET /api/info` every 700 ms while unreachable, flat, not
|
||||
backed off: a restart takes a second or two and the point is to unlock as soon
|
||||
as it is over. The events socket keeps its own exponential backoff for the
|
||||
session stream; the two are independent, and the probe is what decides state.
|
||||
|
||||
The events socket dropping is the trigger, not a timer: the daemon cannot warn
|
||||
anyone it is about to die, so its socket closing is the earliest signal there
|
||||
is. A reconnect of that socket does **not** by itself clear the state — only a
|
||||
probe does, because the socket carries no identity and reconnecting to an
|
||||
upgraded daemon must reload rather than resume.
|
||||
+19
-14
@@ -88,21 +88,26 @@ of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web
|
||||
card's tooltip and the wording of the kill refusal, so no two clients describe
|
||||
the same tree differently.
|
||||
|
||||
`<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.
|
||||
`<command>` is unconditional and muted: it names the **program** the session
|
||||
runs — never its arguments. `claude --dangerously-skip-permissions` renders as
|
||||
`claude`. Flags say how a session was invoked, which is not what a card is for,
|
||||
and a long one crowds out every other fragment on the line. It shows on every
|
||||
card in both clients — no omission in the TUI, no name-vs-command comparison
|
||||
gating it. A session named by its program then shows the same string on both
|
||||
lines; that is accepted, the price of a row that never changes height.
|
||||
|
||||
In the **web client** the command's leading program is replaced by that tool's
|
||||
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini
|
||||
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the
|
||||
command line follow as text. Recognition looks through a path, leading
|
||||
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
|
||||
unrecognised program keeps its full text, so the card never shows a mark
|
||||
standing for the wrong tool. The full command line stays in the card's tooltip
|
||||
either way. The **TUI** keeps the command as text: a terminal cannot be assumed
|
||||
to have a font carrying tool glyphs.
|
||||
The program is found by looking through a path, leading `FOO=bar` assignments
|
||||
and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
|
||||
`/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
|
||||
is shown as written rather than as an empty fragment.
|
||||
|
||||
In the **web client** the program is replaced by that tool's icon when it is
|
||||
one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
|
||||
Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
|
||||
program keeps its name as text, so the card never shows a mark standing for the
|
||||
wrong tool. The full command line, arguments and all, stays in the card's
|
||||
tooltip either way. The **TUI** keeps the program as text: a terminal cannot be
|
||||
assumed to have a font carrying tool glyphs.
|
||||
|
||||
## Colour per activity state
|
||||
|
||||
|
||||
@@ -79,6 +79,11 @@ would have blocked. A tree whose guard is clear never needed the override, so a
|
||||
forced kill removes it as usual — force changes what happens to blocked work,
|
||||
not ordinary cleanup.
|
||||
|
||||
In the web client the kill button sends the unforced `DELETE` straight away —
|
||||
there is no pre-confirmation, because the daemon is the one that decides. Only
|
||||
the refusal raises a prompt, and that prompt names what is in the way and offers
|
||||
"Force kill", which repeats the request with `force=true`.
|
||||
|
||||
Ending a session and destroying its work are two separate acts. Maestro only
|
||||
performs the first one. Removing a tree it left behind is the operator's own
|
||||
`git worktree remove`, chosen while looking at exactly what would be lost.
|
||||
|
||||
@@ -33,6 +33,25 @@
|
||||
|
||||
version = (lib.importTOML ./Cargo.toml).workspace.package.version;
|
||||
|
||||
# Build provenance for `GET /api/info`. The Rust source tree crane
|
||||
# hands to the compiler has no `.git`, so `maestro-daemon/build.rs`
|
||||
# cannot ask git itself — the flake's own revision is passed instead.
|
||||
# A dirty tree has no `rev`, and `dirtyRev` carries a `-dirty` suffix.
|
||||
rev = inputs.self.rev or inputs.self.dirtyRev or "";
|
||||
# `lastModifiedDate` is `YYYYMMDDhhmmss` in UTC; clients want RFC 3339.
|
||||
ld = inputs.self.lastModifiedDate or "";
|
||||
iso =
|
||||
if ld == "" then
|
||||
""
|
||||
else
|
||||
"${builtins.substring 0 4 ld}-${builtins.substring 4 2 ld}-${builtins.substring 6 2 ld}"
|
||||
+ "T${builtins.substring 8 2 ld}:${builtins.substring 10 2 ld}:${builtins.substring 12 2 ld}Z";
|
||||
buildStamp = {
|
||||
MAESTRO_BUILD_COMMIT = builtins.substring 0 12 rev;
|
||||
MAESTRO_BUILD_DATE = iso;
|
||||
MAESTRO_BUILD_DIRTY = if inputs.self ? rev then "0" else "1";
|
||||
};
|
||||
|
||||
# Cargo source filtering keeps only *.rs, *.toml and Cargo.lock. The
|
||||
# SPA is built separately (below) and handed to the Rust build as a
|
||||
# path, so nothing under web/ needs to be in this source tree.
|
||||
@@ -85,6 +104,7 @@
|
||||
|
||||
maestro = craneLib.buildPackage (
|
||||
commonArgs
|
||||
// buildStamp
|
||||
// {
|
||||
inherit cargoArtifacts;
|
||||
pname = "maestro";
|
||||
|
||||
+3
-2
@@ -22,8 +22,9 @@ src/
|
||||
events.ts # `/api/events` WebSocket subscriber
|
||||
catalog.ts # session tree view (groups by working directory)
|
||||
attach.ts # xterm.js + attach WS view
|
||||
multiview.ts # tabs + split panes; draft panes host the spawn form
|
||||
multiview.ts # tabs + split panes
|
||||
spawnForm.ts # shared spawn form (preset tiles, project picker, worktree)
|
||||
spawnModal.ts # dialog chrome around the spawn form
|
||||
theme.ts # dark/light theme toggle + persistence
|
||||
types.ts # mirror of `maestro-protocol` JSON shapes
|
||||
styles.css # design-system tokens + component styles
|
||||
@@ -41,7 +42,7 @@ follows the OS by default and can be pinned via the header toggle (persisted in
|
||||
|
||||
`mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev
|
||||
server with `?mock` to seed a representative catalog without a running daemon,
|
||||
`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme.
|
||||
`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme.
|
||||
|
||||
Routing is hash-based:
|
||||
- `#/` → catalog
|
||||
|
||||
+32
-7
@@ -1,6 +1,7 @@
|
||||
import * as routes from "./routes.js";
|
||||
import type {
|
||||
ApiError,
|
||||
InTheWay,
|
||||
Branches,
|
||||
CreateSessionRequest,
|
||||
DaemonInfo,
|
||||
@@ -13,19 +14,38 @@ import type {
|
||||
Uuid,
|
||||
} from "./types.js";
|
||||
|
||||
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
|
||||
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
|
||||
* render `unsavedWork` rather than re-parsing the message. */
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly detail: string;
|
||||
readonly unsavedWork: InTheWay | null;
|
||||
|
||||
constructor(res: Response, info?: ApiError["error"], text?: string) {
|
||||
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
|
||||
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = res.status;
|
||||
this.code = info?.code ?? "";
|
||||
this.detail = info?.message ?? text ?? "";
|
||||
this.unsavedWork = info?.unsaved_work ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||
if (res.ok) {
|
||||
if (res.status === 204) return undefined as unknown as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await res.json()) as ApiError;
|
||||
detail = `${body.error.code}: ${body.error.message}`;
|
||||
} catch {
|
||||
detail = await res.text();
|
||||
throw new ApiRequestError(res, body.error);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError) throw e;
|
||||
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
|
||||
}
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<Session[]> {
|
||||
@@ -57,9 +77,14 @@ export async function renameSession(id: Uuid, name: string): Promise<Session> {
|
||||
);
|
||||
}
|
||||
|
||||
export async function killSession(id: Uuid): Promise<void> {
|
||||
/** Kill a session. With `force` (#81) the daemon overrides its refusal over a
|
||||
* worktree holding unsaved work and leaves that worktree on disk. */
|
||||
export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||
const url = force
|
||||
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
|
||||
: routes.session(id);
|
||||
await jsonOrError(
|
||||
await fetch(routes.session(id), {
|
||||
await fetch(url, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
|
||||
+51
-16
@@ -5,7 +5,8 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { attachWsUrl } from "./routes.js";
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { applyModsToChar, cursorSeq } from "./keys.js";
|
||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import type { ClientControl, Uuid } from "./types.js";
|
||||
|
||||
@@ -55,12 +56,13 @@ export interface AttachInfo {
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Header echoes the sidebar: the command's leading program becomes its tool
|
||||
* icon when we have a mark for it, remaining words stay as text. */
|
||||
/** Header echoes the sidebar: the program becomes its tool icon when we have a
|
||||
* mark for it and stays as its bare name when we do not. Arguments are
|
||||
* dropped, as on the card. */
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, rest } = splitCommand(command);
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? rest : command;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ export function createAttachConnection(
|
||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||||
el.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a>
|
||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||
@@ -100,6 +102,12 @@ export function createAttachConnection(
|
||||
stateEl.dataset.state = kind;
|
||||
};
|
||||
|
||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
|
||||
// the session list is an off-canvas drawer and this is what opens it.
|
||||
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
||||
requestToggleDrawer(),
|
||||
);
|
||||
|
||||
disconnectBtn.addEventListener("click", () => {
|
||||
opts.onRequestClose?.(sessionId);
|
||||
});
|
||||
@@ -135,8 +143,10 @@ export function createAttachConnection(
|
||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||||
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||||
// gesture stream; only the terminal surface opts out of native page panning.
|
||||
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
|
||||
// later sibling of the viewport, so it paints over it and takes every touch.
|
||||
{
|
||||
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
||||
const root = term.element as HTMLElement | undefined;
|
||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||||
const cellH = () => {
|
||||
const core = (term as unknown as {
|
||||
@@ -144,11 +154,11 @@ export function createAttachConnection(
|
||||
})._core;
|
||||
return core?._renderService?.dimensions?.css?.cell?.height || 16;
|
||||
};
|
||||
if (vp) {
|
||||
if (root) {
|
||||
let lastY = 0;
|
||||
let lastX = 0;
|
||||
let remainder = 0;
|
||||
vp.addEventListener(
|
||||
root.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
@@ -158,7 +168,7 @@ export function createAttachConnection(
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
vp.addEventListener(
|
||||
root.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
@@ -423,11 +433,17 @@ export function createAttachConnection(
|
||||
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
|
||||
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
|
||||
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
|
||||
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
|
||||
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
|
||||
* - `char` is a literal character (the symbols the soft keyboard buries behind
|
||||
* a page switch), folded through a pending modifier like typed input.
|
||||
* - `seq` is a plain key (Esc, Tab) sent verbatim.
|
||||
*/
|
||||
type KeyDef =
|
||||
| { label: string; mod: Modifier }
|
||||
| { label: string; cursor: CursorFinal }
|
||||
| { label: string; tilde: number }
|
||||
| { label: string; char: string }
|
||||
| { label: string; seq: string };
|
||||
|
||||
type Modifier = "ctrl" | "alt";
|
||||
@@ -435,14 +451,17 @@ type Modifier = "ctrl" | "alt";
|
||||
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
||||
|
||||
/**
|
||||
* The keys the mobile soft keyboard hides, ordered to flow row-major into a
|
||||
* 5-column / 2-row grid (NAP-342 review):
|
||||
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
|
||||
* 8-column / 2-row grid (#7):
|
||||
*
|
||||
* Esc Tab Home ↑ End
|
||||
* Ctrl Alt ← ↓ →
|
||||
* Esc Tab Home ↑ End PgUp - /
|
||||
* Ctrl Alt ← ↓ → PgDn | ~
|
||||
*
|
||||
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, and the cursor keys as
|
||||
* a real-keyboard inverted-T on the right, with Home/End filling its top corners.
|
||||
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a
|
||||
* real-keyboard inverted-T in the middle with Home/End filling its top corners,
|
||||
* then the paging pair and the shell symbols that cost a soft-keyboard page
|
||||
* switch to reach. Narrower screens scroll the bar sideways rather than
|
||||
* shrinking the caps below a thumb's width.
|
||||
*/
|
||||
const KEY_DEFS: KeyDef[] = [
|
||||
{ label: "Esc", seq: "\x1b" },
|
||||
@@ -450,11 +469,17 @@ const KEY_DEFS: KeyDef[] = [
|
||||
{ label: "Home", cursor: "H" },
|
||||
{ label: "↑", cursor: "A" },
|
||||
{ label: "End", cursor: "F" },
|
||||
{ label: "PgUp", tilde: 5 },
|
||||
{ label: "-", char: "-" },
|
||||
{ label: "/", char: "/" },
|
||||
{ label: "Ctrl", mod: "ctrl" },
|
||||
{ label: "Alt", mod: "alt" },
|
||||
{ label: "←", cursor: "D" },
|
||||
{ label: "↓", cursor: "B" },
|
||||
{ label: "→", cursor: "C" },
|
||||
{ label: "PgDn", tilde: 6 },
|
||||
{ label: "|", char: "|" },
|
||||
{ label: "~", char: "~" },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -511,6 +536,16 @@ function buildKeyBar(send: (data: string) => void): KeyBar {
|
||||
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else if ("tilde" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(tildeSeq(def.tilde, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else if ("char" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(applyModsToChar(def.char, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else {
|
||||
btn.addEventListener("click", () => {
|
||||
send(def.seq);
|
||||
|
||||
+79
-28
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
@@ -16,6 +16,10 @@ beforeAll(() => {
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector(".kill-dialog")?.remove();
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
@@ -46,6 +50,11 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
||||
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||
}
|
||||
|
||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("blocked-work indicator (#83)", () => {
|
||||
it("marks the row of a blocked session in diff notation", () => {
|
||||
const blocked = session({
|
||||
@@ -89,37 +98,77 @@ describe("blocked-work indicator (#83)", () => {
|
||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
});
|
||||
|
||||
it("restates the warning at the kill confirm", async () => {
|
||||
const confirmations: string[] = [];
|
||||
window.confirm = (msg?: string) => {
|
||||
confirmations.push(msg ?? "");
|
||||
return false;
|
||||
};
|
||||
it("kills straight away, without a first confirmation", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "kill_guarded",
|
||||
message: "worktree has unsaved work",
|
||||
unsaved_work: { untracked_files: 1 },
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
||||
command: "bash",
|
||||
blocked_work: { untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill");
|
||||
kill?.click();
|
||||
await Promise.resolve();
|
||||
expect(confirmations).toHaveLength(1);
|
||||
expect(confirmations[0]).toContain("1 untracked file");
|
||||
expect(confirmations[0]).toContain("refused unless forced");
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("confirms plainly when nothing blocks", async () => {
|
||||
const confirmations: string[] = [];
|
||||
window.confirm = (msg?: string) => {
|
||||
confirmations.push(msg ?? "");
|
||||
return false;
|
||||
};
|
||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await Promise.resolve();
|
||||
expect(confirmations).toEqual(['Kill session "claude"?']);
|
||||
it("closes the override prompt without killing on cancel", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { code: "kill_guarded", message: "worktree has unsaved work" },
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000006",
|
||||
blocked_work: { unpushed_commits: 2 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,15 +258,16 @@ describe("session cards (#107)", () => {
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the command's arguments as text beside the icon", () => {
|
||||
it("drops the command's arguments, keeping them in the tooltip", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
||||
command: "claude --resume",
|
||||
command: "claude --dangerously-skip-permissions",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume");
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
|
||||
});
|
||||
|
||||
it("keeps an unknown command as plain text", () => {
|
||||
@@ -228,7 +278,8 @@ describe("session cards (#107)", () => {
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs");
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
|
||||
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
|
||||
+52
-77
@@ -1,5 +1,6 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||
import { openForceKillDialog } from "./killDialog.js";
|
||||
import { openContextMenu } from "./contextMenu.js";
|
||||
import { isTypingTarget, toggleHelp } from "./help.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
@@ -23,9 +24,11 @@ import {
|
||||
} from "./activity.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
import { createPaletteSelect, createThemeToggle } from "./theme.js";
|
||||
import type { DaemonLink } from "./daemonLink.js";
|
||||
import { createStatusIndicator } from "./statusIndicator.js";
|
||||
|
||||
/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the
|
||||
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
|
||||
* multi-view controller lives; dispatched from every Spawn affordance. */
|
||||
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
|
||||
export function requestOpenSpawn(): void {
|
||||
@@ -37,7 +40,7 @@ export function requestOpenSpawn(): void {
|
||||
* connection pool. */
|
||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||
|
||||
/** Ask the shell to open a draft pane seeded from an existing session
|
||||
/** Ask the shell to open the spawn modal seeded from an existing session
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
@@ -96,13 +99,14 @@ function blockedFragment(blockers: InTheWay): string {
|
||||
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when
|
||||
* we have a mark for it, and only the remaining words stay as text. The full
|
||||
* command line stays in the tooltip either way. */
|
||||
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
|
||||
* the card. The program becomes its tool icon when we have a mark for it, and
|
||||
* stays as its bare name when we do not. The full command line, arguments and
|
||||
* all, stays in the tooltip either way. */
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, rest } = splitCommand(command);
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? rest : command;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
||||
}
|
||||
|
||||
@@ -179,7 +183,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void confirmAndKill(s, name);
|
||||
void requestKill(s, name);
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
@@ -191,22 +195,31 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
||||
},
|
||||
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
|
||||
{ label: "Kill session", danger: true, onSelect: () => void requestKill(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.`
|
||||
: `Kill session "${name}"?`;
|
||||
if (!confirm(question)) return;
|
||||
/**
|
||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
||||
* a forced kill. Anything else surfaces as an alert.
|
||||
*/
|
||||
async function requestKill(s: Session, name: string): Promise<void> {
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
openForceKillDialog({
|
||||
name,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
reason: e.detail,
|
||||
onForce: () => killSession(s.id, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
alert(`Failed to kill: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -266,18 +279,6 @@ function beginRename(row: HTMLElement, s: Session): void {
|
||||
input.select();
|
||||
}
|
||||
|
||||
function renderStatusbar(sessions: Session[]): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "statusbar";
|
||||
|
||||
const live = sessions.filter((s) => s.status === "Running").length;
|
||||
const tally = document.createElement("span");
|
||||
tally.className = "session-tally";
|
||||
tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`;
|
||||
wrap.appendChild(tally);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "empty";
|
||||
@@ -302,51 +303,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* The global app header (full width, above the sidebar/content split): brand +
|
||||
* session tally. App-level actions (help, theme, spawn) live pinned to the
|
||||
* sidebar's foot instead — see {@link renderSidebar}.
|
||||
*/
|
||||
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
|
||||
const header = document.createElement("header");
|
||||
header.className = "app-header";
|
||||
header.innerHTML = `
|
||||
<button type="button" class="app-menu" title="Sessions" aria-label="Toggle session list">☰</button>
|
||||
<a class="brand" href="#/" aria-label="maestro — clear selection">
|
||||
<span class="brand-dot" aria-hidden="true"></span>
|
||||
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
|
||||
</a>
|
||||
<div class="app-status"></div>
|
||||
<div class="app-actions"></div>
|
||||
`;
|
||||
// Mobile-only ☰ (CSS hides it on desktop) — opens the off-canvas session drawer.
|
||||
(header.querySelector(".app-menu") as HTMLElement).addEventListener("click", () =>
|
||||
onToggleSidebar?.(),
|
||||
);
|
||||
|
||||
// Session tally lives centered in the header (not the sidebar).
|
||||
const statusHost = header.querySelector(".app-status") as HTMLElement;
|
||||
const renderStatus = () => {
|
||||
const state = store.snapshot();
|
||||
statusHost.replaceChildren(renderStatusbar([...state.sessions.values()]));
|
||||
};
|
||||
store.subscribe(renderStatus);
|
||||
renderStatus();
|
||||
|
||||
const actions = header.querySelector(".app-actions") as HTMLElement;
|
||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||
if (import.meta.env.DEV) {
|
||||
const kit = document.createElement("a");
|
||||
kit.className = "btn btn-ghost";
|
||||
kit.href = "?kit";
|
||||
kit.textContent = "kit";
|
||||
kit.title = "Open the design kit (DEV)";
|
||||
actions.appendChild(kit);
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
||||
* pane (quiet), and which one is focused (loud). */
|
||||
export interface SidebarHighlights {
|
||||
@@ -382,6 +338,9 @@ export interface SidebarOptions {
|
||||
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
||||
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
||||
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
||||
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
||||
* design kit, which render the sidebar without a daemon. */
|
||||
link?: DaemonLink;
|
||||
}
|
||||
|
||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||
@@ -426,12 +385,27 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
helpBtn.addEventListener("click", () => toggleHelp());
|
||||
footer.appendChild(helpBtn);
|
||||
footer.appendChild(createThemeToggle());
|
||||
footer.appendChild(createPaletteSelect());
|
||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||
if (import.meta.env.DEV) {
|
||||
const kit = document.createElement("a");
|
||||
kit.className = "btn btn-ghost";
|
||||
kit.href = "?kit";
|
||||
kit.textContent = "kit";
|
||||
kit.title = "Open the design kit (DEV)";
|
||||
footer.appendChild(kit);
|
||||
}
|
||||
// Daemon status sits beside the spawn button: colour for the link state,
|
||||
// hover for the release the daemon is running.
|
||||
const status = opts.link ? createStatusIndicator(opts.link) : null;
|
||||
if (status) footer.appendChild(status.el);
|
||||
const spawnBtn = document.createElement("button");
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
// The keyboard pair sits right here in the tooltip (#67).
|
||||
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawnBtn.title = "Spawn form: n — fast-forward spawn: N";
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
footer.appendChild(spawnBtn);
|
||||
el.appendChild(footer);
|
||||
@@ -614,7 +588,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed a draft pane with the session's dir/command.
|
||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||
|
||||
const unsubscribe = store.subscribe(refresh);
|
||||
|
||||
@@ -653,6 +627,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
},
|
||||
dispose() {
|
||||
clearInterval(clock);
|
||||
status?.dispose();
|
||||
// An unfinished rename would otherwise leave every later sidebar
|
||||
// refusing to refresh.
|
||||
renamingId = null;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js";
|
||||
import type { Scheduler } from "./events.js";
|
||||
import type { DaemonInfo } from "./types.js";
|
||||
|
||||
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
|
||||
return {
|
||||
hostname: "konishi",
|
||||
version: "0.1.0",
|
||||
presets: [],
|
||||
build: { commit, commit_date: date, dirty: false },
|
||||
instance,
|
||||
};
|
||||
}
|
||||
|
||||
/** Scheduler whose timers only fire when the test says so. */
|
||||
function manual(): Scheduler & { run(): void; pending(): number } {
|
||||
let next = 1;
|
||||
const timers = new Map<number, () => void>();
|
||||
return {
|
||||
setTimer(fn) {
|
||||
const h = next++;
|
||||
timers.set(h, fn);
|
||||
return h;
|
||||
},
|
||||
clearTimer(h) {
|
||||
timers.delete(h);
|
||||
},
|
||||
run() {
|
||||
const due = [...timers.entries()];
|
||||
timers.clear();
|
||||
for (const [, fn] of due) fn();
|
||||
},
|
||||
pending: () => timers.size,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compareIdentity", () => {
|
||||
it("reads a changed commit as an upgrade", () => {
|
||||
expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded");
|
||||
});
|
||||
|
||||
it("reads a changed instance on the same commit as a plain restart", () => {
|
||||
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted");
|
||||
});
|
||||
|
||||
it("reads an unchanged process as no event at all", () => {
|
||||
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same");
|
||||
});
|
||||
|
||||
it("falls back to the instance when the build is unknown", () => {
|
||||
// A daemon built outside a checkout reports empty commits; the instance is
|
||||
// still enough to see the restart, and never claims a phantom upgrade.
|
||||
expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted");
|
||||
expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelease", () => {
|
||||
it("pairs the short hash with a readable date", () => {
|
||||
expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe(
|
||||
"f89be4a716c7 · 2026-09-02",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a dirty build", () => {
|
||||
expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)");
|
||||
});
|
||||
|
||||
it("says so when the release is unknown", () => {
|
||||
expect(formatRelease(undefined)).toBe("unknown release");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DaemonLink", () => {
|
||||
it("starts online once the first probe lands", async () => {
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => info("aaa", "i1"),
|
||||
scheduler: manual(),
|
||||
reload: () => {},
|
||||
});
|
||||
await link.start();
|
||||
expect(link.status().state).toBe("online");
|
||||
expect(link.status().build?.commit).toBe("aaa");
|
||||
});
|
||||
|
||||
it("holds the modal back during the grace period, then blocks", async () => {
|
||||
const sched = manual();
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
graceMs: 1000,
|
||||
});
|
||||
link.noteDisconnected();
|
||||
// Still inside the grace window: the dot goes amber, nothing blocks.
|
||||
expect(link.status().state).toBe("reconnecting");
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
expect(link.status().state).toBe("down");
|
||||
});
|
||||
|
||||
it("unlocks without reloading when the same build comes back", async () => {
|
||||
const sched = manual();
|
||||
const reload = vi.fn();
|
||||
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => {
|
||||
if (!reply) throw new Error("down");
|
||||
return reply;
|
||||
},
|
||||
scheduler: sched,
|
||||
reload,
|
||||
});
|
||||
await link.start();
|
||||
|
||||
reply = null;
|
||||
link.noteDisconnected();
|
||||
sched.run(); // grace expires → blocked, poll scheduled
|
||||
await Promise.resolve();
|
||||
expect(link.status().state).toBe("down");
|
||||
|
||||
// Same commit, new process: a restart, not an upgrade — the page stays.
|
||||
reply = info("aaa", "i2");
|
||||
await link.probe();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(link.status().state).toBe("online");
|
||||
});
|
||||
|
||||
it("reloads the page when the daemon comes back on a new commit", async () => {
|
||||
const sched = manual();
|
||||
const reload = vi.fn();
|
||||
let reply = info("aaa", "i1");
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => reply,
|
||||
scheduler: sched,
|
||||
reload,
|
||||
});
|
||||
await link.start();
|
||||
|
||||
link.noteDisconnected();
|
||||
reply = info("bbb", "i2");
|
||||
await link.probe();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
// Left blocked deliberately: the reload is what ends this page's life, and
|
||||
// flashing an unlocked UI in between would let the user act on stale code.
|
||||
expect(link.status().state).toBe("updating");
|
||||
});
|
||||
|
||||
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
|
||||
const sched = manual();
|
||||
let attempts = 0;
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => {
|
||||
attempts += 1;
|
||||
throw new Error("down");
|
||||
},
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
link.noteDisconnected();
|
||||
sched.run(); // grace → first poll
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const first = attempts;
|
||||
expect(first).toBeGreaterThan(0);
|
||||
sched.run(); // next poll tick
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(attempts).toBeGreaterThan(first);
|
||||
link.stop();
|
||||
});
|
||||
|
||||
it("notifies subscribers on every state change", async () => {
|
||||
const sched = manual();
|
||||
const seen: string[] = [];
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => info("aaa", "i1"),
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
link.subscribe((s) => seen.push(s.state));
|
||||
await link.start();
|
||||
link.noteDisconnected();
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
expect(seen).toContain("reconnecting");
|
||||
expect(seen[0]).toBe("connecting");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
// The link to the daemon behind this page: is it up, and which release is it?
|
||||
// The sidebar's status indicator and the restart overlay both read this one
|
||||
// controller. See docs/RESTART.md for the restart contract it implements.
|
||||
|
||||
import { getDaemonInfo } from "./api.js";
|
||||
import type { Scheduler } from "./events.js";
|
||||
import type { BuildInfo, DaemonInfo } from "./types.js";
|
||||
|
||||
/**
|
||||
* - `connecting` — first probe hasn't landed yet.
|
||||
* - `online` — the daemon answered and is the same process, or a resolved new one.
|
||||
* - `reconnecting` — the socket dropped, but within the grace window; a blip
|
||||
* shouldn't throw a modal in the user's face.
|
||||
* - `down` — unreachable past the grace window: the overlay blocks the UI and
|
||||
* the poll runs until it answers.
|
||||
* - `updating` — it answered on a different commit; the page is reloading and
|
||||
* stays blocked until it does.
|
||||
*/
|
||||
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
|
||||
|
||||
export interface LinkStatus {
|
||||
state: LinkState;
|
||||
/** The release the daemon last reported. Undefined until the first probe,
|
||||
* and on a daemon too old to carry one. */
|
||||
build?: BuildInfo;
|
||||
/** Hostname the daemon reports, for the indicator's tooltip. */
|
||||
hostname?: string;
|
||||
/** How many probes have failed in the current outage — the overlay counts
|
||||
* attempts so a long restart doesn't look frozen. */
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
/** What happened between two `/api/info` replies. */
|
||||
export type RestartKind = "same" | "restarted" | "upgraded";
|
||||
|
||||
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
|
||||
* to ride out a wifi hiccup, short enough that a real restart is visible. */
|
||||
export const GRACE_MS = 1200;
|
||||
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
|
||||
export const POLL_MS = 700;
|
||||
|
||||
const browserScheduler: Scheduler = {
|
||||
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
||||
clearTimer: (h) => window.clearTimeout(h),
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two daemon replies.
|
||||
*
|
||||
* The commit decides first: a different one means new code, which for a browser
|
||||
* means new assets. The per-process `instance` catches the rest — a restart of
|
||||
* the same build, which needs no reload. A daemon built outside a git checkout
|
||||
* reports an empty commit, so an empty-vs-empty pair never counts as an
|
||||
* upgrade; the instance still reports the restart.
|
||||
*/
|
||||
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
||||
const a = before.build?.commit ?? "";
|
||||
const b = after.build?.commit ?? "";
|
||||
if (a && b && a !== b) return "upgraded";
|
||||
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
|
||||
return "same";
|
||||
}
|
||||
|
||||
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
|
||||
* the build carried uncommitted changes. Empty builds say so rather than
|
||||
* rendering a blank tooltip. */
|
||||
export function formatRelease(build: BuildInfo | undefined): string {
|
||||
if (!build?.commit) return "unknown release";
|
||||
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
|
||||
const parts = [build.commit];
|
||||
if (day) parts.push(`· ${day}`);
|
||||
if (build.dirty) parts.push("(dirty)");
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export interface DaemonLinkOptions {
|
||||
/** Injectable for tests; defaults to `GET /api/info`. */
|
||||
fetchInfo?: () => Promise<DaemonInfo>;
|
||||
scheduler?: Scheduler;
|
||||
/** What an upgrade does. Injectable so tests don't navigate. */
|
||||
reload?: () => void;
|
||||
graceMs?: number;
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
export type LinkListener = (status: LinkStatus) => void;
|
||||
|
||||
export class DaemonLink {
|
||||
private state: LinkState = "connecting";
|
||||
private identity: DaemonInfo | null = null;
|
||||
private attempts = 0;
|
||||
private graceTimer: number | null = null;
|
||||
private pollTimer: number | null = null;
|
||||
private readonly listeners = new Set<LinkListener>();
|
||||
private readonly fetchInfo: () => Promise<DaemonInfo>;
|
||||
private readonly scheduler: Scheduler;
|
||||
private readonly reload: () => void;
|
||||
private readonly graceMs: number;
|
||||
private readonly pollMs: number;
|
||||
|
||||
constructor(opts: DaemonLinkOptions = {}) {
|
||||
this.fetchInfo = opts.fetchInfo ?? getDaemonInfo;
|
||||
this.scheduler = opts.scheduler ?? browserScheduler;
|
||||
this.reload = opts.reload ?? (() => window.location.reload());
|
||||
this.graceMs = opts.graceMs ?? GRACE_MS;
|
||||
this.pollMs = opts.pollMs ?? POLL_MS;
|
||||
}
|
||||
|
||||
/** Record the release we started against. Everything later is a diff on it. */
|
||||
async start(): Promise<void> {
|
||||
await this.probe();
|
||||
}
|
||||
|
||||
status(): LinkStatus {
|
||||
return {
|
||||
state: this.state,
|
||||
build: this.identity?.build,
|
||||
hostname: this.identity?.hostname,
|
||||
attempts: this.attempts,
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(listener: LinkListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.status());
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The events socket dropped. Starts the grace timer and the poll. */
|
||||
noteDisconnected(): void {
|
||||
if (this.state === "down" || this.state === "updating") return;
|
||||
this.attempts = 0;
|
||||
this.setState("reconnecting");
|
||||
if (this.graceTimer === null) {
|
||||
this.graceTimer = this.scheduler.setTimer(() => {
|
||||
this.graceTimer = null;
|
||||
if (this.state === "reconnecting") this.setState("down");
|
||||
void this.poll();
|
||||
}, this.graceMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** The events socket came back. It carries no identity of its own, so the
|
||||
* state only clears once a probe confirms *which* daemon answered — a
|
||||
* reconnect to an upgraded daemon must reload, not resume. */
|
||||
noteConnected(): void {
|
||||
if (this.state === "online" || this.state === "updating") return;
|
||||
void this.probe();
|
||||
}
|
||||
|
||||
/**
|
||||
* One `/api/info` round-trip. Resolves `true` when the daemon answered.
|
||||
*
|
||||
* A first answer becomes the baseline. A later one that differs on commit
|
||||
* reloads the page; anything else (plain restart, or the same process) puts
|
||||
* the UI back online.
|
||||
*/
|
||||
async probe(): Promise<boolean> {
|
||||
let info: DaemonInfo;
|
||||
try {
|
||||
info = await this.fetchInfo();
|
||||
} catch {
|
||||
this.attempts += 1;
|
||||
this.emit();
|
||||
return false;
|
||||
}
|
||||
const before = this.identity;
|
||||
this.identity = info;
|
||||
if (before && compareIdentity(before, info) === "upgraded") {
|
||||
// Stay blocked through the reload: an unlocked frame here would let the
|
||||
// user drive new server code from the bundle it just replaced.
|
||||
this.setState("updating");
|
||||
this.reload();
|
||||
return true;
|
||||
}
|
||||
this.attempts = 0;
|
||||
this.clearTimers();
|
||||
this.setState("online");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stop polling. The page's own teardown; not used in normal operation. */
|
||||
stop(): void {
|
||||
this.clearTimers();
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (this.state === "online" || this.state === "updating") return;
|
||||
const ok = await this.probe();
|
||||
if (ok || this.pollTimer !== null) return;
|
||||
this.pollTimer = this.scheduler.setTimer(() => {
|
||||
this.pollTimer = null;
|
||||
void this.poll();
|
||||
}, this.pollMs);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.graceTimer !== null) {
|
||||
this.scheduler.clearTimer(this.graceTimer);
|
||||
this.graceTimer = null;
|
||||
}
|
||||
if (this.pollTimer !== null) {
|
||||
this.scheduler.clearTimer(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: LinkState): void {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const s = this.status();
|
||||
for (const fn of this.listeners) fn(s);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
// ↑/↓ move the highlight
|
||||
// Enter accept the highlighted entry (and suppress form submit)
|
||||
// Esc close the dropdown, refocus the input, and swallow the key so it
|
||||
// can't also discard a surrounding draft pane
|
||||
// can't also discard the surrounding spawn modal
|
||||
//
|
||||
// Once dismissed (Esc or blur) the popup stays closed until the operator types
|
||||
// again — refocusing alone doesn't reopen it.
|
||||
@@ -181,8 +181,8 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
if (e.key === "Escape") {
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
// Swallow the key: an Escape that bubbles would also discard the
|
||||
// draft pane hosting this form.
|
||||
// Swallow the key: an Escape that bubbles would also close the
|
||||
// spawn modal hosting this form.
|
||||
e.stopPropagation();
|
||||
dismiss();
|
||||
input.focus();
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
|
||||
|
||||
/** Toggling the drawer from outside the shell (the pane header's ☰ on mobile),
|
||||
* without threading the controller through every view. */
|
||||
export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
|
||||
|
||||
export function requestToggleDrawer(): void {
|
||||
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
|
||||
}
|
||||
|
||||
export interface Drawer {
|
||||
open(): void;
|
||||
close(): void;
|
||||
@@ -31,6 +39,7 @@ export function installDrawer(
|
||||
sidebarEl.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close();
|
||||
});
|
||||
document.addEventListener(TOGGLE_DRAWER_EVENT, () => drawer.toggle());
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close());
|
||||
document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close());
|
||||
|
||||
|
||||
@@ -160,3 +160,21 @@ describe("CatalogStore reconnect", () => {
|
||||
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection listeners", () => {
|
||||
it("report the socket opening and dropping", () => {
|
||||
const sock = new FakeSocket();
|
||||
const store = new CatalogStore({
|
||||
socket: () => sock as unknown as WebSocket,
|
||||
scheduler: fakeScheduler(),
|
||||
});
|
||||
const seen: boolean[] = [];
|
||||
store.subscribeConnection((up) => seen.push(up));
|
||||
store.start();
|
||||
// Nothing on subscribe — the daemon link's own probe sets the first state.
|
||||
expect(seen).toEqual([]);
|
||||
sock.open();
|
||||
sock.drop();
|
||||
expect(seen).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,9 +33,15 @@ export interface CatalogStoreOptions {
|
||||
refetch?: () => Promise<Session[]>;
|
||||
}
|
||||
|
||||
/** Called with `true` when the events socket opens, `false` when it drops.
|
||||
* Drives the daemon link (daemonLink.ts) — the socket dying is the first sign
|
||||
* the daemon went down or was upgraded under the page. */
|
||||
export type ConnectionListener = (up: boolean) => void;
|
||||
|
||||
export class CatalogStore {
|
||||
private state: CatalogState = { sessions: new Map() };
|
||||
private listeners = new Set<CatalogListener>();
|
||||
private connectionListeners = new Set<ConnectionListener>();
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: number | null = null;
|
||||
private backoffMs = INITIAL_BACKOFF_MS;
|
||||
@@ -70,6 +76,13 @@ export class CatalogStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Watch the socket's up/down transitions. Not called on subscribe: the
|
||||
* caller's own probe decides the starting state. */
|
||||
subscribeConnection(listener: ConnectionListener): () => void {
|
||||
this.connectionListeners.add(listener);
|
||||
return () => this.connectionListeners.delete(listener);
|
||||
}
|
||||
|
||||
subscribe(listener: CatalogListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.snapshot());
|
||||
@@ -99,10 +112,12 @@ export class CatalogStore {
|
||||
|
||||
ws.onopen = () => {
|
||||
this.backoffMs = INITIAL_BACKOFF_MS;
|
||||
this.emitConnection(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.emitConnection(false);
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
@@ -161,6 +176,10 @@ export class CatalogStore {
|
||||
}
|
||||
}
|
||||
|
||||
private emitConnection(up: boolean): void {
|
||||
for (const fn of this.connectionListeners) fn(up);
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const snap = this.snapshot();
|
||||
for (const fn of this.listeners) fn(snap);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Fast-forward spawn (#67): one keystroke, every default resolved, no form.
|
||||
// Pure resolution only — the caller (main.ts) owns the fetches and the POST,
|
||||
// and falls back to a seeded draft pane when anything is missing.
|
||||
// and falls back to a seeded spawn form when anything is missing.
|
||||
|
||||
import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js";
|
||||
import { sortProjects } from "./projectList.js";
|
||||
|
||||
/** What one fast-forward keystroke resolved. Anything absent means "open a
|
||||
* draft pre-filled with the rest instead of spawning". */
|
||||
* form pre-filled with the rest instead of spawning". */
|
||||
export interface FastForwardResolution {
|
||||
project?: Project;
|
||||
command?: string;
|
||||
@@ -47,7 +47,7 @@ export function buildFastForwardRequest(
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed for the fallback draft pane: whatever did resolve goes into the
|
||||
/** Seed for the fallback spawn form: whatever did resolve goes into the
|
||||
* form; the rest stays with its own defaults. */
|
||||
export function fastForwardSeed(res: FastForwardResolution) {
|
||||
return {
|
||||
@@ -55,8 +55,8 @@ export function fastForwardSeed(res: FastForwardResolution) {
|
||||
command: res.command ?? "",
|
||||
name: res.name ?? "",
|
||||
// Left undefined rather than false when nothing resolved: the form
|
||||
// defaults the toggle on, and `false` would stick, so the fallback draft
|
||||
// would disagree with both a normally-opened draft and the TUI's fast path
|
||||
// defaults the toggle on, and `false` would stick, so the fallback form
|
||||
// would disagree with both a normally-opened form and the TUI's fast path
|
||||
// (#67 review).
|
||||
worktreeEnabled: res.project ? true : undefined,
|
||||
worktreeBranch:
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export const SHORTCUTS: ShortcutGroup[] = [
|
||||
{
|
||||
title: "Sessions",
|
||||
items: [
|
||||
{ keys: ["n"], description: "Open a spawn draft pane" },
|
||||
{ keys: ["n"], description: "Open the spawn form" },
|
||||
{ keys: ["N"], description: "Spawn a session with every default resolved" },
|
||||
],
|
||||
},
|
||||
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyModsToChar, cursorSeq, toCtrl } from "./keys.js";
|
||||
import { applyModsToChar, cursorSeq, tildeSeq, toCtrl } from "./keys.js";
|
||||
|
||||
describe("toCtrl", () => {
|
||||
it("maps letters to their control code, case-insensitively", () => {
|
||||
@@ -43,3 +43,16 @@ describe("cursorSeq", () => {
|
||||
expect(cursorSeq("A", true, true)).toBe("\x1b[1;7A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tildeSeq", () => {
|
||||
it("uses the bare CSI form when unmodified", () => {
|
||||
expect(tildeSeq(5, false, false)).toBe("\x1b[5~");
|
||||
expect(tildeSeq(6, false, false)).toBe("\x1b[6~");
|
||||
});
|
||||
|
||||
it("keeps the parameter in place when modified, unlike the cursor keys", () => {
|
||||
expect(tildeSeq(5, true, false)).toBe("\x1b[5;5~");
|
||||
expect(tildeSeq(6, false, true)).toBe("\x1b[6;3~");
|
||||
expect(tildeSeq(6, true, true)).toBe("\x1b[6;7~");
|
||||
});
|
||||
});
|
||||
|
||||
+14
-2
@@ -23,6 +23,18 @@ export function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string
|
||||
* pending — `\x1b[1;<n><final>`, n = 1 + alt(2) + ctrl(4) (xterm convention). */
|
||||
export function cursorSeq(final: string, ctrl: boolean, alt: boolean): string {
|
||||
if (!ctrl && !alt) return `\x1b[${final}`;
|
||||
const n = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
|
||||
return `\x1b[1;${n}${final}`;
|
||||
return `\x1b[1;${modParam(ctrl, alt)}${final}`;
|
||||
}
|
||||
|
||||
/** Modifier parameter of the xterm CSI forms: n = 1 + alt(2) + ctrl(4). */
|
||||
function modParam(ctrl: boolean, alt: boolean): number {
|
||||
return 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
|
||||
}
|
||||
|
||||
/** Sequence for the `~`-terminated navigation keys (PgUp `5`, PgDn `6`), which
|
||||
* carry modifiers as `\x1b[<code>;<n>~` rather than the cursor keys' `1;<n>`
|
||||
* form. */
|
||||
export function tildeSeq(code: number, ctrl: boolean, alt: boolean): string {
|
||||
if (!ctrl && !alt) return `\x1b[${code}~`;
|
||||
return `\x1b[${code};${modParam(ctrl, alt)}~`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Forced-kill prompt (#81).
|
||||
*
|
||||
* The daemon refuses to kill a session whose worktree still holds uncommitted
|
||||
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
||||
* refusal gets its own dialog: it names what is in the way and offers the
|
||||
* override the daemon's `force=true` already implements. A kill the daemon
|
||||
* accepts never reaches here — there is nothing to warn about.
|
||||
*/
|
||||
import { formatInTheWay, type InTheWay } from "./types.js";
|
||||
|
||||
export interface ForceKillPrompt {
|
||||
/** Session name, as the sidebar shows it. */
|
||||
name: string;
|
||||
/** What the daemon reported in the way, when it reported anything. */
|
||||
blockers: InTheWay | null;
|
||||
/** Message from the daemon's refusal, shown when there is no breakdown. */
|
||||
reason: string;
|
||||
/** Run the forced kill. A rejection is rendered in the dialog, which stays
|
||||
* open so the override can be retried. */
|
||||
onForce: () => Promise<void>;
|
||||
}
|
||||
|
||||
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
|
||||
|
||||
export function closeForceKillDialog(): void {
|
||||
if (!open) return;
|
||||
window.removeEventListener("keydown", open.onKeydown, true);
|
||||
open.el.remove();
|
||||
open = null;
|
||||
}
|
||||
|
||||
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
||||
* can never stack two dialogs. */
|
||||
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
closeForceKillDialog();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "kill-dialog";
|
||||
el.setAttribute("role", "dialog");
|
||||
el.setAttribute("aria-modal", "true");
|
||||
el.setAttribute("aria-label", `Kill ${p.name}`);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "kill-dialog-panel";
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.className = "kill-dialog-title";
|
||||
title.textContent = `Kill "${p.name}"?`;
|
||||
|
||||
const body = document.createElement("p");
|
||||
body.className = "kill-dialog-body";
|
||||
body.textContent = p.blockers
|
||||
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
|
||||
|
||||
const error = document.createElement("p");
|
||||
error.className = "kill-dialog-error";
|
||||
error.hidden = true;
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "kill-dialog-actions";
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", closeForceKillDialog);
|
||||
const force = document.createElement("button");
|
||||
force.type = "button";
|
||||
force.className = "btn btn-danger kill-dialog-force";
|
||||
force.textContent = "Force kill";
|
||||
force.addEventListener("click", () => {
|
||||
force.disabled = true;
|
||||
void p
|
||||
.onForce()
|
||||
.then(closeForceKillDialog)
|
||||
.catch((e: unknown) => {
|
||||
force.disabled = false;
|
||||
error.hidden = false;
|
||||
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
||||
});
|
||||
});
|
||||
actions.append(cancel, force);
|
||||
|
||||
panel.append(title, body, error, actions);
|
||||
|
||||
// A press on the scrim cancels; one inside the panel is left alone.
|
||||
el.addEventListener("click", (ev) => {
|
||||
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
|
||||
});
|
||||
el.appendChild(panel);
|
||||
document.body.appendChild(el);
|
||||
|
||||
const onKeydown = (ev: KeyboardEvent): void => {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
closeForceKillDialog();
|
||||
};
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
open = { el, onKeydown };
|
||||
cancel.focus();
|
||||
return el;
|
||||
}
|
||||
+3
-4
@@ -4,7 +4,7 @@
|
||||
// critiqued as a whole — buttons (including terminal-native variants for
|
||||
// review), inputs, chips, badges, session rows, and empty states.
|
||||
import "./kit.css";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
import { createPaletteSelect, createThemeToggle } from "./theme.js";
|
||||
|
||||
function h(tag: string, className?: string, html?: string): HTMLElement {
|
||||
const el = document.createElement(tag);
|
||||
@@ -102,7 +102,7 @@ export function renderKit(): HTMLElement {
|
||||
back.className = "btn btn-ghost";
|
||||
back.href = "?mock";
|
||||
back.textContent = "← back to app";
|
||||
headerActions.append(back, createThemeToggle());
|
||||
headerActions.append(back, createThemeToggle(), createPaletteSelect());
|
||||
header.appendChild(headerActions);
|
||||
page.appendChild(header);
|
||||
|
||||
@@ -113,7 +113,7 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Color tokens",
|
||||
"Slate neutrals (hue ~256) + signal-green = alive. Swap by theme toggle.",
|
||||
"Graphite neutrals; activity colours are the only hue. Swap by theme toggle + palette picker.",
|
||||
);
|
||||
body.classList.add("kit-swatches");
|
||||
const tokens = [
|
||||
@@ -205,7 +205,6 @@ export function renderKit(): HTMLElement {
|
||||
);
|
||||
activeLine.setAttribute("data-activity", "active");
|
||||
body.append(
|
||||
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
||||
specimen("folder-count", h("span", "folder-count", "4")),
|
||||
specimen("card-line2 · active", activeLine),
|
||||
specimen("attach-state · open", h("span", "attach-state", "live")),
|
||||
|
||||
+2
-17
@@ -11,23 +11,8 @@ import type { Uuid } from "./types.js";
|
||||
|
||||
export type Dir = "row" | "col";
|
||||
|
||||
/** Half-filled spawn-form state for a draft pane (issue #57): an empty leaf
|
||||
* renders the spawn form instead of a placeholder, and each pane's values
|
||||
* hang off its own leaf so two drafts stay independent. Never persisted —
|
||||
* a reload gives back an empty form (persistence strips it). */
|
||||
export interface Draft {
|
||||
command: string;
|
||||
cwd: string;
|
||||
name: string;
|
||||
worktreeEnabled?: boolean;
|
||||
worktreeBranch?: string;
|
||||
worktreeCleanup?: boolean;
|
||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
|
||||
export type Node =
|
||||
| { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null }
|
||||
| { kind: "leaf"; id: string; sessionId: Uuid | null }
|
||||
| { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number };
|
||||
|
||||
export type Leaf = Extract<Node, { kind: "leaf" }>;
|
||||
@@ -104,7 +89,7 @@ export function splitLeaf(
|
||||
dir: Dir,
|
||||
after: boolean,
|
||||
): string {
|
||||
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null };
|
||||
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId };
|
||||
const rec = (n: Node): Node => {
|
||||
if (n.kind === "leaf") {
|
||||
if (n.id !== targetId) return n;
|
||||
|
||||
+62
-27
@@ -2,12 +2,14 @@ import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/500.css";
|
||||
import "@fontsource/jetbrains-mono/600.css";
|
||||
import "@fontsource/jetbrains-mono/700.css";
|
||||
import { renderSidebar, renderTopbar } from "./catalog.js";
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import { ConnectionPool } from "./connections.js";
|
||||
import { installDrawer, installDrawerSwipe } from "./drawer.js";
|
||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { installRestartOverlay } from "./restartOverlay.js";
|
||||
import { shouldRetryClose } from "./reconnect.js";
|
||||
import {
|
||||
createSession,
|
||||
@@ -21,7 +23,8 @@ import {
|
||||
resolveFastForward,
|
||||
} from "./fastForward.js";
|
||||
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
||||
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import { openSpawnModal } from "./spawnModal.js";
|
||||
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
@@ -68,6 +71,15 @@ if (import.meta.env.DEV && location.search.includes("kit")) {
|
||||
async function bootApp(): Promise<void> {
|
||||
|
||||
const store = new CatalogStore();
|
||||
|
||||
// The daemon's identity and liveness: names the release in the sidebar's status
|
||||
// dot, and blocks the UI behind the restart overlay while the daemon is away
|
||||
// (docs/RESTART.md). The events socket is the trigger — it dies first.
|
||||
const link = new DaemonLink();
|
||||
installRestartOverlay(link);
|
||||
store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected()));
|
||||
void link.start();
|
||||
|
||||
store.start();
|
||||
|
||||
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
|
||||
@@ -88,21 +100,21 @@ shellEl.dataset.route = "catalog";
|
||||
let mv: MultiView;
|
||||
const sidebar = renderSidebar(store, {
|
||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||
link,
|
||||
});
|
||||
const content = document.createElement("section");
|
||||
content.className = "content";
|
||||
|
||||
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
|
||||
// the header. `installDrawer` owns the close rules (session tap, Spawn, Spawn
|
||||
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
|
||||
// similar, backdrop); on desktop the class it toggles is inert.
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "mv-backdrop";
|
||||
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
|
||||
installDrawerSwipe(drawer);
|
||||
const toggleDrawer = () => drawer.toggle();
|
||||
|
||||
shellEl.append(sidebar.el, content, backdrop);
|
||||
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl);
|
||||
root.replaceChildren(shellEl);
|
||||
|
||||
// The pool owns attach connections independently of the route: navigating away
|
||||
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
|
||||
@@ -182,13 +194,22 @@ function renderWelcome(): HTMLElement {
|
||||
</p>
|
||||
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
|
||||
<div class="empty-actions"></div>`;
|
||||
const actions = el.querySelector(".empty-actions") as HTMLElement;
|
||||
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
|
||||
// the drawer would be swipe-only. CSS hides this on desktop.
|
||||
const sessions = document.createElement("button");
|
||||
sessions.type = "button";
|
||||
sessions.className = "btn btn-ghost welcome-sessions";
|
||||
sessions.textContent = "☰ Sessions";
|
||||
sessions.addEventListener("click", () => requestToggleDrawer());
|
||||
actions.appendChild(sessions);
|
||||
const spawn = document.createElement("button");
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
actions.appendChild(spawn);
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -211,8 +232,8 @@ mv = createMultiView({
|
||||
infoFor,
|
||||
onDisconnect: closePane,
|
||||
renderWelcome,
|
||||
// A draft-pane submit (#57) fills its leaf in place; the shell route may flip
|
||||
// from catalog (no sessions) to attach.
|
||||
// A spawn fills a pane with its new session; the shell route may flip from
|
||||
// catalog (no sessions) to attach.
|
||||
onOpenSessionsChange: updateShellRoute,
|
||||
onLayoutChange: scheduleSave,
|
||||
});
|
||||
@@ -226,32 +247,44 @@ function showMultiView(): void {
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
// Spawn affordances (#62): the header button, the sidebar's empty state, and
|
||||
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
|
||||
// Spawn affordances: the sidebar's footer button, its empty state, and
|
||||
// `?mock&spawn` all open the spawn modal; "Spawn similar" seeds one from the
|
||||
// cloned session (see spawnSimilar.ts).
|
||||
function requestOpenSpawn(): void {
|
||||
showMultiView();
|
||||
// A new tab, not a split of the pane you were reading (#100). "Spawn
|
||||
// similar" and the fast-forward key below deliberately keep the old
|
||||
// behaviour: both act on the session in front of you, so a split beside it
|
||||
// is the point.
|
||||
mv.openDraft(undefined, { newTab: true });
|
||||
// The session lands in a new tab, not a split of the pane you were reading
|
||||
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
|
||||
// the old behaviour: both act on the session in front of you, so a split
|
||||
// beside it is the point.
|
||||
// The session you were last on preselects its project: `n` then Enter spawns
|
||||
// another session there without touching the picker.
|
||||
const focused = mv?.focusedSessionId() ?? null;
|
||||
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
|
||||
openSpawnModal({
|
||||
seed: current ? spawnSimilarProjectSeed(current) : undefined,
|
||||
onSuccess: (session) => mountSpawned(session, { newTab: true }),
|
||||
});
|
||||
}
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
showMultiView();
|
||||
mv.openDraft(spawnSimilarSeed(s));
|
||||
openSpawnModal({ seed: spawnSimilarSeed(s), onSuccess: (session) => mountSpawned(session) });
|
||||
});
|
||||
|
||||
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
|
||||
/** Show a freshly spawned session: bring the multi-view up (the shell may still
|
||||
* be on the catalog route) and give the session a pane. */
|
||||
function mountSpawned(session: Session, placement?: { newTab?: boolean }): void {
|
||||
showMultiView();
|
||||
mv.openSessionBeside(session.id, placement);
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
// Fast-forward spawn (#67): the spawn modal's keystroke neighbour spawns
|
||||
// immediately with every default resolved — most recent project, its last
|
||||
// command (or the first non-custom preset), a fresh name, worktree on with
|
||||
// a new branch named after that name. Anything unresolvable (no projects
|
||||
// known yet, no command, no generated name) opens a draft pre-filled with
|
||||
// known yet, no command, no generated name) opens the form pre-filled with
|
||||
// what did resolve instead.
|
||||
async function requestFastForward(): Promise<void> {
|
||||
showMultiView();
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
@@ -273,18 +306,20 @@ async function requestFastForward(): Promise<void> {
|
||||
// Mount it, the way the TUI's fast path attaches. Without this the
|
||||
// keystroke creates a session and leaves the UI untouched, so it reads
|
||||
// as a dead key (#67 review).
|
||||
mv.openSessionBeside(session.id);
|
||||
updateShellRoute();
|
||||
mountSpawned(session);
|
||||
return;
|
||||
} catch {
|
||||
// A failed POST degrades to the same pre-filled draft as an
|
||||
// A failed POST degrades to the same pre-filled form as an
|
||||
// unresolvable default — never a silent dead keystroke.
|
||||
}
|
||||
}
|
||||
mv.openDraft(fastForwardSeed(resolved));
|
||||
openSpawnModal({
|
||||
seed: fastForwardSeed(resolved),
|
||||
onSuccess: (session) => mountSpawned(session),
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn.
|
||||
// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn.
|
||||
// Ignored while typing anywhere (form fields, the terminal's hidden
|
||||
// textarea, search) so an attached session's keys are never hijacked.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@ export function seedMock(store: CatalogStore): void {
|
||||
sessions,
|
||||
});
|
||||
|
||||
// `?mock&spawn` auto-opens a draft pane for visual review, with the spawn
|
||||
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
||||
// backend endpoints stubbed so presets/projects/gitroot all render.
|
||||
if (location.search.includes("spawn")) {
|
||||
stubSpawnApi();
|
||||
|
||||
+60
-162
@@ -17,7 +17,7 @@ function stubFetch(): void {
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello"] }
|
||||
? { names: ["cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
@@ -55,12 +55,12 @@ function makeMv(layout: Layout): MultiView {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drain the microtask queue so the draft form's async seed application lands. */
|
||||
/** Drain the microtask queue so any pending async work lands. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("multiview openDraft (#62)", () => {
|
||||
describe("multiview empty panes", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
@@ -70,144 +70,23 @@ describe("multiview openDraft (#62)", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("turns an empty tab into a draft pane", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(layout.tabs[0].root);
|
||||
expect(ls).toHaveLength(1);
|
||||
expect(ls[0].sessionId).toBeNull();
|
||||
expect(ls[0].draft).not.toBeNull();
|
||||
expect(mv.el.querySelector(".mv-draft")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("splits the focused session pane into a fresh draft", async () => {
|
||||
it("renders a placeholder, not a spawn form", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
tab.focusedLeafId = first;
|
||||
tab.focusedLeafId = splitLeaf(tab, first, null, "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(2);
|
||||
expect(ls[0].sessionId).toBe("s1");
|
||||
const draftLeaf = ls.find((l) => l.sessionId === null)!;
|
||||
expect(draftLeaf.draft).not.toBeNull();
|
||||
expect(tab.focusedLeafId).toBe(draftLeaf.id);
|
||||
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reuses the focused pane when it is already empty", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
const empty = splitLeaf(tab, first, null, "row", true);
|
||||
tab.focusedLeafId = empty;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(2);
|
||||
const target = ls.find((l) => l.id === empty)!;
|
||||
expect(target.draft).not.toBeNull();
|
||||
expect(tab.focusedLeafId).toBe(empty);
|
||||
});
|
||||
|
||||
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
||||
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
|
||||
tab.focusedLeafId = draftLeaf;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
|
||||
nameInput.value = "wip";
|
||||
nameInput.dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
|
||||
|
||||
const formBefore = mv.el.querySelector(".mv-draft");
|
||||
mv.closeSession("s1");
|
||||
await flush();
|
||||
|
||||
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
|
||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||
});
|
||||
|
||||
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
const empty = splitLeaf(tab, first, null, "row", true);
|
||||
tab.focusedLeafId = empty;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
// Let the mounted form's loadProjects() settle and auto-sync its picked
|
||||
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
expect(leaves(tab.root)).toHaveLength(2);
|
||||
expect(tab.focusedLeafId).toBe(empty);
|
||||
});
|
||||
|
||||
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
const empty = splitLeaf(tab, first, null, "row", true);
|
||||
tab.focusedLeafId = empty;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||
await flush();
|
||||
|
||||
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
|
||||
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
|
||||
expect(cwdRaw.value).toBe("/home/user/proj");
|
||||
});
|
||||
|
||||
it("seeds a clone's cwd and command into the draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
|
||||
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||
await flush();
|
||||
|
||||
const leaf = leaves(layout.tabs[0].root)[0];
|
||||
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
expect(mv.el.querySelector(".mv-pane-empty")).toBeTruthy();
|
||||
expect(mv.el.querySelector(".spawn-form")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview pane-drag swap (#73)", () => {
|
||||
describe("multiview pane-drag swap", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
@@ -223,8 +102,8 @@ describe("multiview pane-drag swap (#73)", () => {
|
||||
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
|
||||
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
|
||||
// The real drag-start gate requires the pointerdown target to sit inside
|
||||
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
|
||||
// no such element, so stand one in for the gate check itself.
|
||||
// an `.attach-header` (attach.ts's session title bar), which the fake pool
|
||||
// never mounts; stand one in for the gate check itself.
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "attach-header";
|
||||
sourcePane.appendChild(marker);
|
||||
@@ -254,40 +133,26 @@ describe("multiview pane-drag swap (#73)", () => {
|
||||
await flush();
|
||||
}
|
||||
|
||||
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
|
||||
it("swaps the two panes' sessions when one is dropped on the other's centre", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const leaf1 = splitLeaf(tab, "", null, "row", true);
|
||||
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
|
||||
const leaf1 = splitLeaf(tab, "", "s1", "row", true);
|
||||
const leaf2 = splitLeaf(tab, leaf1, "s2", "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
|
||||
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
|
||||
|
||||
nameInputOf(leaf1).value = "west";
|
||||
nameInputOf(leaf1).dispatchEvent(new Event("input"));
|
||||
nameInputOf(leaf2).value = "east";
|
||||
nameInputOf(leaf2).dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
|
||||
|
||||
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
|
||||
|
||||
// The leaf data swaps (already correct pre-fix)...
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
|
||||
// ...and the mounted forms must follow, not stay behind showing the old
|
||||
// pane's values (#73).
|
||||
expect(nameInputOf(leaf1).value).toBe("east");
|
||||
expect(nameInputOf(leaf2).value).toBe("west");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.sessionId).toBe("s2");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.sessionId).toBe("s1");
|
||||
expect(tab.focusedLeafId).toBe(leaf2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openDraft newTab (#100)", () => {
|
||||
describe("openSessionBeside newTab (#100)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
@@ -306,7 +171,7 @@ describe("openDraft newTab (#100)", () => {
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft(undefined, { newTab: true });
|
||||
mv.openSessionBeside("s2", { newTab: true });
|
||||
await flush();
|
||||
|
||||
expect(layout.tabs).toHaveLength(2);
|
||||
@@ -314,11 +179,10 @@ describe("openDraft newTab (#100)", () => {
|
||||
const original = leaves(layout.tabs[0].root);
|
||||
expect(original).toHaveLength(1);
|
||||
expect(original[0].sessionId).toBe("s1");
|
||||
// The draft is alone in the new, now-active tab.
|
||||
// The new session is alone in the new, now-active tab.
|
||||
const fresh = leaves(layout.tabs[1].root);
|
||||
expect(fresh).toHaveLength(1);
|
||||
expect(fresh[0].sessionId).toBeNull();
|
||||
expect(fresh[0].draft).not.toBeNull();
|
||||
expect(fresh[0].sessionId).toBe("s2");
|
||||
expect(layout.activeTabId).toBe(layout.tabs[1].id);
|
||||
});
|
||||
|
||||
@@ -330,7 +194,7 @@ describe("openDraft newTab (#100)", () => {
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
mv.openSessionBeside("s2");
|
||||
await flush();
|
||||
|
||||
expect(layout.tabs).toHaveLength(1);
|
||||
@@ -399,7 +263,7 @@ describe("multiview closeSession tab cleanup (#137)", () => {
|
||||
expect(leaves(tab.root)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a tab holding a draft pane", async () => {
|
||||
it("keeps a tab holding an empty pane", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
||||
@@ -478,7 +342,7 @@ describe("multiview mobile single pane", () => {
|
||||
expect(layout.tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a spawn draft in the current pane rather than a new tab", async () => {
|
||||
it("keeps a spawned session in the current pane rather than a new tab", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||
@@ -486,14 +350,13 @@ describe("multiview mobile single pane", () => {
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft(undefined, { newTab: true });
|
||||
mv.openSessionBeside("s2", { newTab: true });
|
||||
await flush();
|
||||
|
||||
expect(layout.tabs).toHaveLength(1);
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(1);
|
||||
expect(ls[0].sessionId).toBeNull();
|
||||
expect(ls[0].draft).not.toBeNull();
|
||||
expect(ls[0].sessionId).toBe("s2");
|
||||
});
|
||||
|
||||
it("renders one pane and no pane pills for a multi-leaf tab", () => {
|
||||
@@ -523,3 +386,38 @@ describe("multiview mobile single pane", () => {
|
||||
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview solo tab bar", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("hides the tab bar while only one tab exists", () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the tab bar again once a second tab appears", () => {
|
||||
const layout = emptyLayout();
|
||||
const first = layout.tabs[0];
|
||||
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
||||
const second = addTab(layout);
|
||||
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+27
-130
@@ -1,9 +1,8 @@
|
||||
// The multi-view renderer/controller (issues #30 splits, #31 tabs, #33
|
||||
// highlights). Owns the tab bar + canvas inside the content pane, builds the
|
||||
// active tab's split tree into DOM, and mounts a live AttachConnection into each
|
||||
// session leaf via the pool. Empty leaves are draft panes (#57): they render
|
||||
// the spawn form inline, and submitting replaces the draft with that session's
|
||||
// terminal in place. Drag-to-dock is issue #32; mobile projection is #35.
|
||||
// session leaf via the pool. Empty leaves (freshly split, not yet filled) show a
|
||||
// placeholder. Drag-to-dock is issue #32; mobile projection is #35.
|
||||
|
||||
import type { SidebarHandle } from "./catalog.js";
|
||||
import type { ConnectionPool } from "./connections.js";
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
closeTab,
|
||||
findLeaf,
|
||||
type Dir,
|
||||
type Draft,
|
||||
type Layout,
|
||||
leaves,
|
||||
locateSession,
|
||||
@@ -23,7 +21,6 @@ import {
|
||||
splitLeaf,
|
||||
} from "./layout.js";
|
||||
import type { AttachInfo } from "./attach.js";
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
export interface MultiViewOptions {
|
||||
pool: ConnectionPool;
|
||||
@@ -35,8 +32,8 @@ export interface MultiViewOptions {
|
||||
onDisconnect: (sessionId: string) => void;
|
||||
/** Welcome content for an empty tab. */
|
||||
renderWelcome: () => HTMLElement;
|
||||
/** Called when a draft-pane submit turns an empty pane into a session pane,
|
||||
* so the shell can re-evaluate its route (sidebar vs attach layout). */
|
||||
/** Called when a spawn fills a pane with a new session, so the shell can
|
||||
* re-evaluate its route (sidebar vs attach layout). */
|
||||
onOpenSessionsChange?: () => void;
|
||||
/** Called after any layout change (structure, focus, or divider resize) so the
|
||||
* shell can persist it (issue #34). */
|
||||
@@ -48,15 +45,11 @@ export interface MultiView {
|
||||
/** Bring a session into the layout: focus it if already open, else fill the
|
||||
* focused pane (or open the first pane in an empty tab). */
|
||||
openSession: (sessionId: string) => void;
|
||||
openSessionBeside: (sessionId: string) => void;
|
||||
openSessionBeside: (sessionId: string, opts?: { newTab?: boolean }) => void;
|
||||
/** Drop the pane showing `sessionId` (the session was disconnected/killed). */
|
||||
closeSession: (sessionId: string) => void;
|
||||
/** Every session currently shown in some pane, across all tabs. */
|
||||
openSessions: () => Set<string>;
|
||||
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||
* ("Spawn similar" clones a session's cwd + command). */
|
||||
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
|
||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||
* drag shows drop zones to place it. */
|
||||
@@ -70,12 +63,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||
const persist = () => opts.onLayoutChange?.();
|
||||
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
|
||||
// whose leaves are gone are destroyed on each render. Forms for draft leaves
|
||||
// that survive the render are reused in place (#68): rebuilding one mid-edit
|
||||
// would drop focus, caret and selection, and re-fetch info/projects/names.
|
||||
const draftForms = new Map<string, SpawnFormHandle>();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "mv";
|
||||
const tabBar = document.createElement("div");
|
||||
@@ -141,38 +128,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const host = document.createElement("div");
|
||||
host.className = "mv-pane-host";
|
||||
if (!leaf.sessionId) {
|
||||
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
||||
// leaf (never persisted) so each draft keeps its own half-filled state;
|
||||
// submitting fills this leaf with the new session in place.
|
||||
const form = draftForms.get(leaf.id);
|
||||
if (form) {
|
||||
host.appendChild(form.el);
|
||||
} else {
|
||||
const created = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
// The pane can be closed while the POST is in flight. Mounting the
|
||||
// session onto a leaf no longer in the tree would strand it in no
|
||||
// pane at all, so only claim the leaf while it is still there —
|
||||
// searching every tab, since the operator may have switched tabs
|
||||
// while the request was in flight.
|
||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||
if (host) {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
host.focusedLeafId = leaf.id;
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.set(leaf.id, created);
|
||||
host.appendChild(created.el);
|
||||
}
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "mv-pane-empty";
|
||||
empty.textContent = "Empty pane — pick a session from the sidebar, or press n to spawn.";
|
||||
host.appendChild(empty);
|
||||
}
|
||||
|
||||
const tools = document.createElement("div");
|
||||
@@ -365,7 +324,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// Fill the focused pane (VS Code style: open replaces the active group).
|
||||
const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
|
||||
target.sessionId = sessionId;
|
||||
target.draft = null;
|
||||
tab.focusedLeafId = target.id;
|
||||
render();
|
||||
}
|
||||
@@ -375,9 +333,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
/// `openSession` deliberately replaces the focused pane (VS Code style) for
|
||||
/// a sidebar click, but the fast path must never do that: pressing the key
|
||||
/// while attached would drop the running session out of the layout
|
||||
/// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else
|
||||
/// split beside the focused one (#67 review).
|
||||
function openSessionBeside(sessionId: string): void {
|
||||
/// entirely. Reuses an empty pane, else splits beside the focused one
|
||||
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
|
||||
/// operator was reading (#100) — what the Spawn button asks for.
|
||||
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
layout.activeTabId = loc.tab.id;
|
||||
@@ -385,6 +344,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
// Mobile keeps a single tab and a single pane, so a new tab is not an
|
||||
// option there: the session takes over the pane below.
|
||||
if (placement?.newTab && !isMobile) {
|
||||
const fresh = addTab(layout);
|
||||
fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
return;
|
||||
}
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||
@@ -397,7 +365,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// evicted session keeps running and stays a tap away in the sidebar.
|
||||
if (!target.sessionId || isMobile) {
|
||||
target.sessionId = sessionId;
|
||||
target.draft = null;
|
||||
} else {
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
||||
}
|
||||
@@ -405,76 +372,21 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
opts.onOpenSessionsChange?.();
|
||||
}
|
||||
|
||||
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||
// Mobile keeps a single tab and a single pane: the spawn form takes over the
|
||||
// current pane instead of opening a tab or a split.
|
||||
if (opts?.newTab && !isMobile) {
|
||||
// Spawn opens its own tab rather than halving whatever you were reading
|
||||
// (#100). The empty-pane reuse below still applies within a tab.
|
||||
const fresh = addTab(layout);
|
||||
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
|
||||
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||
findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId || isMobile) {
|
||||
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
|
||||
// picked defaults into `draft` once ready, so that's never reliably null.
|
||||
target.sessionId = null;
|
||||
target.draft = draft();
|
||||
const stale = draftForms.get(target.id);
|
||||
if (stale) {
|
||||
stale.destroy();
|
||||
draftForms.delete(target.id);
|
||||
}
|
||||
} else {
|
||||
// Split a fresh draft beside the focused pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||
findLeaf(tab.root, tab.focusedLeafId)!.draft = draft();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- top-level render ----
|
||||
|
||||
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
|
||||
* a session). Surviving draft leaves keep their form instance (#68). */
|
||||
function pruneForms(tab: { root: Node | null }): void {
|
||||
const alive = new Set(
|
||||
tab.root
|
||||
? leaves(tab.root)
|
||||
.filter((l) => !l.sessionId)
|
||||
.map((l) => l.id)
|
||||
: [],
|
||||
);
|
||||
for (const [id, f] of draftForms) {
|
||||
if (!alive.has(id)) {
|
||||
f.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
renderTabs();
|
||||
canvas.replaceChildren();
|
||||
const tab = activeTab(layout);
|
||||
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
|
||||
// collapses it; the header ☰ keeps the sidebar drawer reachable.
|
||||
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
|
||||
el.classList.toggle("mv-mobile-solo", isMobile);
|
||||
// A lone tab has nothing to switch between, so the bar is only a strip of
|
||||
// chrome; CSS hides it until a second tab exists. Spawning still opens its
|
||||
// own tab (#100), which brings the bar back with the "+" button on it.
|
||||
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
|
||||
|
||||
if (!tab.root) {
|
||||
pruneForms(tab);
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
@@ -490,14 +402,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
if (isMobile) {
|
||||
renderMobile(tab);
|
||||
pruneForms(tab);
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
|
||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||
canvas.appendChild(buildTree(tab.root, hosts));
|
||||
pruneForms(tab);
|
||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||
syncFocus();
|
||||
persist();
|
||||
@@ -693,17 +603,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (source.kind === "pane" && self) {
|
||||
const dragged = findLeaf(tab.root, self)!;
|
||||
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
||||
[dragged.draft, target.draft] = [target.draft, dragged.draft];
|
||||
// The leaf data moved but each pane's mounted form is still keyed to
|
||||
// its old leaf (#73) — tear both down like openDraft's reuse case
|
||||
// (#72) so render() remounts fresh forms seeded from the swapped data.
|
||||
for (const id of [dragged.id, target.id]) {
|
||||
const stale = draftForms.get(id);
|
||||
if (stale) {
|
||||
stale.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
tab.focusedLeafId = target.id;
|
||||
} else if (sessionId) {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
@@ -711,7 +610,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const t2 = findLeaf(tab.root, targetLeafId);
|
||||
if (t2) {
|
||||
t2.sessionId = sessionId;
|
||||
t2.draft = null;
|
||||
tab.focusedLeafId = t2.id;
|
||||
}
|
||||
}
|
||||
@@ -737,7 +635,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
el,
|
||||
openSession,
|
||||
openSessionBeside,
|
||||
openDraft,
|
||||
closeSession,
|
||||
openSessions: () => openSessions(layout),
|
||||
beginSessionDrag,
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
||||
import { longestCommonPrefix, parentDir, splitPath } from "./paths.js";
|
||||
|
||||
describe("splitPath", () => {
|
||||
it("splits on the last slash, keeping it with the directory", () => {
|
||||
@@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => {
|
||||
expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parentDir", () => {
|
||||
it("drops the last segment", () => {
|
||||
expect(parentDir("/home/user/tea/maestro")).toBe("/home/user/tea");
|
||||
expect(parentDir("/home/user/tea/maestro/")).toBe("/home/user/tea");
|
||||
});
|
||||
|
||||
it("keeps the root for a top-level path", () => {
|
||||
expect(parentDir("/maestro")).toBe("/");
|
||||
expect(parentDir("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("is empty for a bare name", () => {
|
||||
expect(parentDir("maestro")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,3 +19,12 @@ export function longestCommonPrefix(items: string[]): string {
|
||||
}
|
||||
return lcp;
|
||||
}
|
||||
|
||||
/** Everything above the last segment of an absolute path: `/a/b/c` → `/a/b`.
|
||||
* A top-level path keeps `/`, and a trailing slash is ignored. */
|
||||
export function parentDir(path: string): string {
|
||||
const trimmed = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
if (slash <= 0) return slash === 0 ? "/" : "";
|
||||
return trimmed.slice(0, slash);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("hydrate", () => {
|
||||
expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]);
|
||||
});
|
||||
|
||||
it("never restores draft state, even if a stale blob carries it", () => {
|
||||
it("ignores leaf fields a stale blob carries that the layout no longer has", () => {
|
||||
const layout = hydrate({
|
||||
tabs: [
|
||||
{ id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } },
|
||||
@@ -74,7 +74,7 @@ describe("hydrate", () => {
|
||||
});
|
||||
|
||||
describe("serializeLayout", () => {
|
||||
it("strips draft state so a reload gives back an empty form", () => {
|
||||
it("round-trips the tree, its sessions and its split ratios", () => {
|
||||
const layout = hydrate({
|
||||
tabs: [
|
||||
{
|
||||
@@ -82,6 +82,7 @@ describe("serializeLayout", () => {
|
||||
root: {
|
||||
kind: "split",
|
||||
dir: "row",
|
||||
ratio: 0.3,
|
||||
a: { kind: "leaf", id: "l1", sessionId: "s1" },
|
||||
b: { kind: "leaf", id: "l2", sessionId: null },
|
||||
},
|
||||
@@ -89,40 +90,10 @@ describe("serializeLayout", () => {
|
||||
],
|
||||
activeTabId: "t",
|
||||
}) as Layout;
|
||||
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
|
||||
draftLeaf.draft = { command: "claude", cwd: "/home/user/project", name: "amber-fox" };
|
||||
|
||||
const restored = hydrate(JSON.parse(serializeLayout(layout)).layout);
|
||||
expect(restored).not.toBeNull();
|
||||
for (const leaf of leaves(restored!.tabs[0].root)) {
|
||||
expect(leaf.draft ?? null).toBeNull();
|
||||
}
|
||||
expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
|
||||
});
|
||||
|
||||
it("keeps the draft out of the stored blob itself, not just out of hydrate", () => {
|
||||
// A draft pane only exists once a tab has split, so the strip has to walk
|
||||
// into split branches; asserting on the restored layout alone passes even
|
||||
// when the draft was written to localStorage, because hydrate drops it.
|
||||
const layout = hydrate({
|
||||
tabs: [
|
||||
{
|
||||
id: "t",
|
||||
root: {
|
||||
kind: "split",
|
||||
dir: "row",
|
||||
a: { kind: "leaf", id: "l1", sessionId: "s1" },
|
||||
b: { kind: "leaf", id: "l2", sessionId: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
activeTabId: "t",
|
||||
}) as Layout;
|
||||
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
|
||||
draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" };
|
||||
|
||||
const raw = serializeLayout(layout);
|
||||
expect(raw).not.toContain("secret-project");
|
||||
expect(raw).not.toContain("amber-fox");
|
||||
expect((restored!.tabs[0].root as { ratio: number }).ratio).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-20
@@ -20,27 +20,8 @@ export function saveLayout(layout: Layout): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialise the layout for storage with draft state stripped (issue #57): a
|
||||
* reload gives back an empty form, never a stale half-filled one. */
|
||||
export function serializeLayout(layout: Layout): string {
|
||||
const clean: Layout = {
|
||||
...layout,
|
||||
tabs: layout.tabs.map((t) => ({
|
||||
...t,
|
||||
root: stripDrafts(t.root),
|
||||
})),
|
||||
};
|
||||
return JSON.stringify({ v: VERSION, layout: clean });
|
||||
}
|
||||
|
||||
function stripDrafts(node: Node | null): Node | null {
|
||||
if (!node) return node;
|
||||
// Recurse: a draft pane only exists once a tab has split, so a shallow strip
|
||||
// would persist every half-filled form it was meant to drop.
|
||||
if (node.kind === "split") {
|
||||
return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! };
|
||||
}
|
||||
return { ...node, draft: null };
|
||||
return JSON.stringify({ v: VERSION, layout });
|
||||
}
|
||||
|
||||
export function loadLayout(): Layout | null {
|
||||
|
||||
@@ -27,7 +27,7 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
||||
: url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
||||
? { names: ["cello", "fugue", "lyre"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
@@ -79,14 +79,22 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
||||
it("shows the name as the primary line and its parent directory below", async () => {
|
||||
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||
// projects sharing a basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||
// Only the stem: repeating the last segment would just echo the name. Not
|
||||
// `~/t`, since the stem is what distinguishes two projects sharing a
|
||||
// basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
|
||||
});
|
||||
|
||||
it("keeps the last command out of the box", async () => {
|
||||
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.textContent).not.toContain("claude");
|
||||
});
|
||||
|
||||
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||
@@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
||||
it("picking a project selects its cwd and leaves the command alone", async () => {
|
||||
const p = project({
|
||||
name: "maestro",
|
||||
path: "/home/user/maestro",
|
||||
last_command: "claude",
|
||||
last_command: "aider",
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
stubFetch(
|
||||
@@ -157,6 +165,8 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
// The directory and the command are independent choices: the preset stays
|
||||
// where it was, whatever the project was last run with.
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked box is the source of truth.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Blocking overlay for a daemon that went away — see docs/RESTART.md.
|
||||
//
|
||||
// Mounted while the link is `down` or `updating`, torn down the moment it is
|
||||
// back. It owns no polling of its own: DaemonLink polls, and this only renders
|
||||
// what the link reports.
|
||||
|
||||
import { formatRelease, type DaemonLink, type LinkStatus } from "./daemonLink.js";
|
||||
|
||||
export interface RestartOverlayHandle {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Mount the overlay controller. It stays subscribed for the page's lifetime. */
|
||||
export function installRestartOverlay(
|
||||
link: DaemonLink,
|
||||
host: HTMLElement = document.body,
|
||||
): RestartOverlayHandle {
|
||||
let el: HTMLElement | null = null;
|
||||
let title: HTMLElement | null = null;
|
||||
let detail: HTMLElement | null = null;
|
||||
let release: HTMLElement | null = null;
|
||||
|
||||
const unsubscribe = link.subscribe((status) => render(status));
|
||||
|
||||
function render(status: LinkStatus): void {
|
||||
const blocking = status.state === "down" || status.state === "updating";
|
||||
if (!blocking) {
|
||||
unmount();
|
||||
return;
|
||||
}
|
||||
if (!el) mount();
|
||||
const updating = status.state === "updating";
|
||||
title!.textContent = updating ? "Maestro was updated" : "Maestro is restarting";
|
||||
detail!.textContent = updating
|
||||
? "Reloading this page onto the new release…"
|
||||
: attemptText(status.attempts);
|
||||
release!.textContent = formatRelease(status.build);
|
||||
}
|
||||
|
||||
/** The poll runs while this is up; saying how many tries have gone by is what
|
||||
* separates "still restarting" from "wedged" for someone staring at it. */
|
||||
function attemptText(attempts: number): string {
|
||||
if (attempts <= 1) return "Waiting for the daemon to come back…";
|
||||
return `Waiting for the daemon to come back… (${attempts} attempts)`;
|
||||
}
|
||||
|
||||
function mount(): void {
|
||||
el = document.createElement("div");
|
||||
el.className = "modal-backdrop restart-backdrop";
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "modal restart-modal";
|
||||
dialog.setAttribute("role", "alertdialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
dialog.setAttribute("aria-labelledby", "restart-title");
|
||||
|
||||
title = document.createElement("h2");
|
||||
title.id = "restart-title";
|
||||
detail = document.createElement("p");
|
||||
detail.className = "restart-detail";
|
||||
release = document.createElement("p");
|
||||
release.className = "restart-release";
|
||||
const spinner = document.createElement("div");
|
||||
spinner.className = "restart-spinner";
|
||||
spinner.setAttribute("aria-hidden", "true");
|
||||
|
||||
dialog.append(spinner, title, detail, release);
|
||||
el.appendChild(dialog);
|
||||
host.appendChild(el);
|
||||
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
|
||||
// does not stop them: `n` would still open a spawn form against a daemon
|
||||
// that is not there. Swallow keys in the capture phase until the link is
|
||||
// back, without `preventDefault` so the browser's own chords still work.
|
||||
window.addEventListener("keydown", swallow, true);
|
||||
}
|
||||
|
||||
function swallow(e: KeyboardEvent): void {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
function unmount(): void {
|
||||
if (!el) return;
|
||||
window.removeEventListener("keydown", swallow, true);
|
||||
el.remove();
|
||||
el = null;
|
||||
}
|
||||
|
||||
return {
|
||||
dispose(): void {
|
||||
unsubscribe();
|
||||
unmount();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export const EVENTS = "/api/events";
|
||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||
export const PATH_QUERY = "path";
|
||||
|
||||
/** Name of the query parameter `DELETE /api/sessions/:id` takes (#81):
|
||||
* `force=true` overrides the kill refusal over a guarded worktree. */
|
||||
export const FORCE_QUERY = "force";
|
||||
|
||||
/** Name of the query parameter `NAMES` takes. */
|
||||
export const COUNT_QUERY = "count";
|
||||
|
||||
|
||||
+38
-36
@@ -8,17 +8,19 @@ import {
|
||||
gitRoot,
|
||||
} from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { parentDir } from "./paths.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
* from the repository's existing branches (#61). */
|
||||
export type WorktreeBranchMode = "new" | "existing";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to open a draft with the
|
||||
* same cwd, command, and name as an existing session, and by draft panes to
|
||||
* restore their own half-filled state after a re-render. */
|
||||
/** Seed values for the spawn form — used by "Spawn similar" to open the modal
|
||||
* with the same cwd, command, and name as an existing session, and by the
|
||||
* fast-forward fallback to pre-fill whatever it did resolve. */
|
||||
export interface SpawnFormSeed {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
@@ -33,8 +35,7 @@ export interface SpawnFormSeed {
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
|
||||
/** The form's current values, reported on every change so a caller (the draft
|
||||
* pane) can hang them off its layout leaf. */
|
||||
/** The form's current values, reported on every change. */
|
||||
export interface SpawnFormValues {
|
||||
command: string;
|
||||
cwd: string;
|
||||
@@ -107,8 +108,6 @@ export interface SpawnFormHandle {
|
||||
|
||||
export interface SpawnFormOptions {
|
||||
seed?: SpawnFormSeed;
|
||||
/** Extra class(es) for the <form> root (e.g. `mv-draft` in a draft pane). */
|
||||
class?: string;
|
||||
/** Fired whenever any value changes (input, tile pick, checkbox). */
|
||||
onChange?: (values: SpawnFormValues) => void;
|
||||
/** Called with the created session after a successful submit. */
|
||||
@@ -116,19 +115,13 @@ export interface SpawnFormOptions {
|
||||
}
|
||||
|
||||
/** The spawn form itself — preset tiles, project picker, name, worktree.
|
||||
* Mounted inline by draft panes (issue #57). */
|
||||
* Mounted inside the spawn modal (spawnModal.ts). */
|
||||
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const prefill = opts.seed ?? {};
|
||||
|
||||
const form = document.createElement("form");
|
||||
form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`;
|
||||
form.className = "spawn-form";
|
||||
form.innerHTML = `
|
||||
<div class="field">
|
||||
<span class="field-label">Command</span>
|
||||
<div class="tile-row cmd-tiles"></div>
|
||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Project</span>
|
||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||
@@ -140,6 +133,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Command</span>
|
||||
<div class="tile-row cmd-tiles"></div>
|
||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<div class="name-row">
|
||||
@@ -191,8 +190,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
<button type="submit" class="btn btn-primary">Spawn</button>
|
||||
</div>
|
||||
`;
|
||||
// Field queries are scoped to this form instance — several forms can be
|
||||
// mounted at once (one per draft pane), so ids would collide.
|
||||
// Field queries are scoped to this form instance rather than by id, so a
|
||||
// second form mounted before the first is torn down cannot collide with it.
|
||||
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||
@@ -239,9 +238,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// `emit` stays silent until the initial load settles and goes silent again
|
||||
// once the form is destroyed. Construction runs with `cwd === ""` and the
|
||||
// seed is only restored two fetches later, so emitting in between would
|
||||
// blank the leaf's draft; and `multiview.render()` rebuilds forms while the
|
||||
// old one's fetches are still in flight, so a destroyed form emitting would
|
||||
// clobber the live one's leaf (#57 review).
|
||||
// report blank values as if the operator had cleared the form.
|
||||
let ready = false;
|
||||
let destroyed = false;
|
||||
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
||||
@@ -339,7 +336,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
// ---- command tiles ---------------------------------------------------
|
||||
// `focus` is false for the two initial applications (construction, and again
|
||||
// when `/api/info` resolves): a draft pane must not grab the caret out from
|
||||
// when `/api/info` resolves): the form must not grab the caret out from
|
||||
// under the operator seconds after it mounts.
|
||||
function selectPreset(id: string, focus = true): void {
|
||||
selectedPresetId = id;
|
||||
@@ -440,8 +437,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
// ---- working-directory picker (#95) ----------------------------------
|
||||
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
||||
// 1. Step one is one box per repository (name, then full path). A worktree
|
||||
// is a box only when its main checkout is missing from the scan.
|
||||
// 1. Step one is one box per repository (name, then its parent directory).
|
||||
// A worktree is a box only when its main checkout is missing from the
|
||||
// scan.
|
||||
// 2. The filter keeps a repository when the query matches it or any of its
|
||||
// worktrees, so a worktree name still finds its box.
|
||||
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
@@ -454,6 +452,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// these are separate acts — #91's rule, now an explicit choice.
|
||||
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
||||
// membership. Only the free-text hatch pays for a probe.
|
||||
// 7. Neither step touches the command: the directory and what runs in it
|
||||
// are independent choices.
|
||||
|
||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||
type WorktreeChoice = "new" | string;
|
||||
@@ -544,9 +544,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
emit();
|
||||
// The chosen directory pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
}
|
||||
|
||||
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
||||
@@ -634,14 +631,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||
box.dataset.cwd = project.path;
|
||||
const trees = worktreesOf(group).length;
|
||||
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
||||
// tells two projects sharing a basename apart (#95).
|
||||
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
// The parent directory only — the last segment is the name line above it.
|
||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||
// sharing a basename apart (#95).
|
||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
||||
box.innerHTML = `
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="project-path">${escapeHtml(project.path)}</span>
|
||||
<span class="project-path">${escapeHtml(parent)}</span>
|
||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||
box.addEventListener("click", () => selectProject(group));
|
||||
return box;
|
||||
@@ -790,8 +787,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
/** Fetch the target repo's branches once per cwd. Like every async load in
|
||||
* this form: token-guarded, and nothing touches the DOM (or emits) after
|
||||
* destroy() — a late response into a rebuilt form would corrupt that
|
||||
* form's draft (#57 review). */
|
||||
* destroy() — a late response into a closed modal would throw. */
|
||||
async function loadBranches(): Promise<void> {
|
||||
if (!cwd || !inferredBaseRepo) return;
|
||||
const token = ++branchToken;
|
||||
@@ -900,8 +896,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
e.preventDefault();
|
||||
if (target === filterInput) {
|
||||
const top = visibleGroups(filterInput.value)[0];
|
||||
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
|
||||
// Resolve the filter to a project only when the current pick isn't one
|
||||
// of the matches: a filter typed past it means the operator moved on,
|
||||
// while a pick that still matches (including the seeded one the form
|
||||
// opens with) keeps its worktree choice.
|
||||
const visible = visibleGroups(filterInput.value);
|
||||
const stillMatches =
|
||||
selectedGroup != null && visible.some((g) => g.main.path === selectedGroup?.main.path);
|
||||
if (!stillMatches && visible[0]) selectProject(visible[0]);
|
||||
}
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
@@ -68,9 +68,12 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||
async function mount(
|
||||
projects: Project[],
|
||||
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
|
||||
): Promise<HTMLFormElement> {
|
||||
stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
handle = createSpawnForm({ seed, onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
@@ -232,6 +235,24 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("submits in the seeded project rather than the freshest one", async () => {
|
||||
// What `n` then Enter must do: the modal opens parked on the project of
|
||||
// the session you were last on (OTHER here), even though MAESTRO's
|
||||
// worktree is the freshest row in the list.
|
||||
const form = await mount([MAESTRO, WT_A, OTHER], {
|
||||
cwd: OTHER.path,
|
||||
cwdRawVisible: false,
|
||||
});
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(form.querySelector<HTMLInputElement>(".project-filter")!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
|
||||
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
|
||||
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
|
||||
const BATCH_A = ["cello", "fugue", "lyre"];
|
||||
const BATCH_B = ["oboe", "tuba", "harp"];
|
||||
|
||||
/** Stub the three fetches the form's initial load makes. Records every
|
||||
* /api/names URL so tests can assert how many batches were fetched. */
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { closeSpawnModal, openSpawnModal } from "./spawnModal.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function stubFetch(): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/info")
|
||||
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Drain the microtask queue so the form's initial loads land. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
const modal = () => document.getElementById("modal-host");
|
||||
|
||||
describe("spawn modal", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSpawnModal();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("mounts the shared spawn form inside a dialog", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
const host = modal()!;
|
||||
expect(host).toBeTruthy();
|
||||
expect(host.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(host.querySelector("form.spawn-form")).toBeTruthy();
|
||||
expect(host.querySelector("#spawn-title")?.textContent).toBe("Spawn session");
|
||||
// The modal adds the Cancel the shared form doesn't ship.
|
||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
const form = modal()!.querySelector("form.spawn-form")!;
|
||||
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
||||
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
||||
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
|
||||
});
|
||||
|
||||
it("titles a seeded open as a clone", async () => {
|
||||
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
expect(modal()!.querySelector("#spawn-title")?.textContent).toBe("Spawn similar");
|
||||
});
|
||||
|
||||
it("closes on Escape, on Cancel and on a backdrop click", async () => {
|
||||
for (const dismiss of [
|
||||
() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
|
||||
() => (modal()!.querySelector(".form-actions .cancel") as HTMLButtonElement).click(),
|
||||
() => modal()!.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
||||
() => (modal()!.querySelector(".close") as HTMLButtonElement).click(),
|
||||
]) {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
dismiss();
|
||||
expect(modal()).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves no stray Escape handler behind after closing", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
closeSpawnModal();
|
||||
|
||||
// A second Escape with no modal up must not throw on the torn-down form.
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(modal()).toBeNull();
|
||||
});
|
||||
|
||||
it("opening twice tears the first one down rather than stacking dialogs", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
expect(document.querySelectorAll("#modal-host")).toHaveLength(1);
|
||||
expect(document.querySelectorAll("form.spawn-form")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closes before handing the created session to its caller", async () => {
|
||||
const seen: Array<{ session: Session; stillOpen: boolean }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/sessions") && init?.method === "POST") {
|
||||
return new Response(JSON.stringify({ id: "s1", cwd: "/x", command: "zsh" }), { status: 200 });
|
||||
}
|
||||
const body = url.includes("/api/info")
|
||||
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
openSpawnModal({
|
||||
// A plain, worktree-free spawn: the point here is where the created
|
||||
// session is handed over, not what the form validates.
|
||||
seed: { command: "zsh", cwd: "/x", cwdRawVisible: true, worktreeEnabled: false },
|
||||
onSuccess: (session) => seen.push({ session, stillOpen: modal() != null }),
|
||||
});
|
||||
await flush();
|
||||
(modal()!.querySelector("form.spawn-form") as HTMLFormElement).dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].session.id).toBe("s1");
|
||||
expect(seen[0].stillOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// The spawn modal: dialog chrome around the shared spawn form (spawnForm.ts).
|
||||
//
|
||||
// One modal at a time — opening while one is up replaces it. Escape, the ✕ and
|
||||
// a click on the backdrop all close it; the form's own Escape handlers (the
|
||||
// directory autocomplete) swallow the key first, so dismissing a dropdown never
|
||||
// also discards the form.
|
||||
|
||||
import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
/** Seed values for the spawn form — used by "Spawn similar" to open the dialog
|
||||
* with the same cwd, command and name as an existing session. */
|
||||
export type SpawnPrefill = SpawnFormSeed;
|
||||
|
||||
export interface SpawnModalOptions {
|
||||
seed?: SpawnPrefill;
|
||||
/** Called with the created session after a successful submit; the modal has
|
||||
* already closed by then, so the caller decides where it lands. */
|
||||
onSuccess: (session: Session) => void;
|
||||
}
|
||||
|
||||
/** Closer for the modal currently up, so opening a second one tears the first
|
||||
* one down properly — dropping its node alone would strand the form's global
|
||||
* listeners (dir autocomplete) and the Escape handler. */
|
||||
let closeCurrent: (() => void) | null = null;
|
||||
|
||||
export function openSpawnModal(opts: SpawnModalOptions): void {
|
||||
closeSpawnModal();
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.id = "modal-host";
|
||||
host.className = "modal-backdrop";
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "modal spawn-modal";
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
dialog.setAttribute("aria-labelledby", "spawn-title");
|
||||
const header = document.createElement("header");
|
||||
header.className = "modal-header";
|
||||
const title = document.createElement("h2");
|
||||
title.id = "spawn-title";
|
||||
// "Spawn similar" arrives with a seed; the title says which act this is.
|
||||
title.textContent = opts.seed ? "Spawn similar" : "Spawn session";
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.className = "btn btn-icon close";
|
||||
closeBtn.setAttribute("aria-label", "Close");
|
||||
closeBtn.textContent = "✕";
|
||||
header.append(title, closeBtn);
|
||||
dialog.appendChild(header);
|
||||
host.appendChild(dialog);
|
||||
document.body.appendChild(host);
|
||||
|
||||
const form = createSpawnForm({
|
||||
seed: opts.seed,
|
||||
onSuccess: (session) => {
|
||||
close();
|
||||
opts.onSuccess(session);
|
||||
},
|
||||
});
|
||||
dialog.appendChild(form.el);
|
||||
// The project filter owns the keyboard on open: typing narrows the list and
|
||||
// Tab from here reaches the command tiles, matching the TUI's draft pane.
|
||||
form.el.querySelector<HTMLInputElement>(".project-filter")?.focus();
|
||||
|
||||
function close(): void {
|
||||
if (closeCurrent === close) closeCurrent = null;
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
form.destroy();
|
||||
host.remove();
|
||||
}
|
||||
closeCurrent = close;
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
// The modal keeps its Cancel affordance; the shared form ships only Submit.
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "btn btn-ghost cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", close);
|
||||
form.el.querySelector(".form-actions")?.prepend(cancel);
|
||||
|
||||
closeBtn.addEventListener("click", close);
|
||||
host.addEventListener("click", (e) => {
|
||||
if (e.target === host) close();
|
||||
});
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
}
|
||||
|
||||
/** Close whatever spawn modal is up. No-op when none is. */
|
||||
export function closeSpawnModal(): void {
|
||||
closeCurrent?.();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
@@ -43,3 +43,23 @@ describe("spawnSimilarSeed (#28)", () => {
|
||||
expect(seed.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnSimilarProjectSeed", () => {
|
||||
it("seeds the project alone, leaving command and worktree at their defaults", () => {
|
||||
const seed = spawnSimilarProjectSeed(session());
|
||||
expect(seed.cwd).toBe("/repos/app");
|
||||
expect(seed.cwdRawVisible).toBe(false);
|
||||
expect(seed.command).toBeUndefined();
|
||||
expect(seed.worktreeEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("seeds a worktree session's base repository", () => {
|
||||
const seed = spawnSimilarProjectSeed(
|
||||
session({
|
||||
cwd: "/repos/app-worktrees/feature",
|
||||
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
|
||||
}),
|
||||
);
|
||||
expect(seed.cwd).toBe("/repos/app");
|
||||
});
|
||||
});
|
||||
|
||||
+19
-5
@@ -1,10 +1,10 @@
|
||||
// "Spawn similar" (#28): the seed a cloned session hands to a draft pane.
|
||||
// Pure — the caller owns opening the pane.
|
||||
// "Spawn similar" (#28): the seed a cloned session hands to the spawn modal.
|
||||
// Pure — the caller owns opening the dialog.
|
||||
|
||||
import type { Draft } from "./layout.js";
|
||||
import type { SpawnFormSeed } from "./spawnForm.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
/** Clone a session into draft values: same project, same command, same
|
||||
/** Clone a session into form values: same project, same command, same
|
||||
* worktree decision, fresh name.
|
||||
*
|
||||
* A worktree session seeds its *base repository*, not the checkout the daemon
|
||||
@@ -19,7 +19,7 @@ import type { Session } from "./types.js";
|
||||
*
|
||||
* The name is left out on purpose: a clone is a new session and takes its own
|
||||
* generated handle. */
|
||||
export function spawnSimilarSeed(s: Session): Partial<Draft> {
|
||||
export function spawnSimilarSeed(s: Session): SpawnFormSeed {
|
||||
const wt = s.worktree ?? null;
|
||||
return {
|
||||
command: s.command,
|
||||
@@ -28,3 +28,17 @@ export function spawnSimilarSeed(s: Session): Partial<Draft> {
|
||||
cwdRawVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The project half of a clone: the session's repository, nothing else.
|
||||
*
|
||||
* The plain spawn modal opens on this so the picker is already parked on the
|
||||
* project you were last looking at — one Enter spawns there — while the
|
||||
* command, worktree decision and name stay at the form's own defaults. Same
|
||||
* base-repository rule as `spawnSimilarSeed`. */
|
||||
export function spawnSimilarProjectSeed(s: Session): SpawnFormSeed {
|
||||
const wt = s.worktree ?? null;
|
||||
return {
|
||||
cwd: wt ? wt.base_repo : s.cwd,
|
||||
cwdRawVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { installRestartOverlay } from "./restartOverlay.js";
|
||||
import { createStatusIndicator } from "./statusIndicator.js";
|
||||
import type { DaemonInfo } from "./types.js";
|
||||
import type { Scheduler } from "./events.js";
|
||||
|
||||
function info(commit: string, instance: string): DaemonInfo {
|
||||
return {
|
||||
hostname: "konishi",
|
||||
version: "0.1.0",
|
||||
presets: [],
|
||||
build: { commit, commit_date: "2026-09-02T14:03:11Z", dirty: false },
|
||||
instance,
|
||||
};
|
||||
}
|
||||
|
||||
function manual(): Scheduler & { run(): void } {
|
||||
let next = 1;
|
||||
const timers = new Map<number, () => void>();
|
||||
return {
|
||||
setTimer(fn) {
|
||||
const h = next++;
|
||||
timers.set(h, fn);
|
||||
return h;
|
||||
},
|
||||
clearTimer(h) {
|
||||
timers.delete(h);
|
||||
},
|
||||
run() {
|
||||
const due = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const fn of due) fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("status indicator", () => {
|
||||
it("shows the release on the tooltip once the daemon answers", async () => {
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => info("f89be4a716c7", "i1"),
|
||||
scheduler: manual(),
|
||||
reload: () => {},
|
||||
});
|
||||
const ind = createStatusIndicator(link);
|
||||
await link.start();
|
||||
|
||||
expect(ind.el.dataset.state).toBe("online");
|
||||
const tip = ind.el.querySelector(".status-tip")!.textContent ?? "";
|
||||
expect(tip).toContain("f89be4a716c7");
|
||||
expect(tip).toContain("2026-09-02");
|
||||
expect(tip).toContain("konishi");
|
||||
expect(ind.el.getAttribute("aria-label")).toContain("Connected");
|
||||
ind.dispose();
|
||||
});
|
||||
|
||||
it("tracks the link through a drop", async () => {
|
||||
const sched = manual();
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
const ind = createStatusIndicator(link);
|
||||
expect(ind.el.dataset.state).toBe("connecting");
|
||||
|
||||
link.noteDisconnected();
|
||||
expect(ind.el.dataset.state).toBe("reconnecting");
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
expect(ind.el.dataset.state).toBe("down");
|
||||
ind.dispose();
|
||||
link.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restart overlay", () => {
|
||||
it("blocks once the daemon is unreachable and unlocks when it answers", async () => {
|
||||
const sched = manual();
|
||||
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => {
|
||||
if (!reply) throw new Error("down");
|
||||
return reply;
|
||||
},
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
const overlay = installRestartOverlay(link);
|
||||
await link.start();
|
||||
expect(document.querySelector(".restart-backdrop")).toBeNull();
|
||||
|
||||
reply = null;
|
||||
link.noteDisconnected();
|
||||
expect(document.querySelector(".restart-backdrop")).toBeNull(); // grace
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
const dialog = document.querySelector(".restart-modal");
|
||||
expect(dialog?.textContent).toContain("Maestro is restarting");
|
||||
|
||||
// Restarted on the same build: no reload, and the overlay goes away.
|
||||
reply = info("aaa", "i2");
|
||||
await link.probe();
|
||||
expect(document.querySelector(".restart-backdrop")).toBeNull();
|
||||
overlay.dispose();
|
||||
});
|
||||
|
||||
it("swallows the app's shortcuts while it is up", async () => {
|
||||
const sched = manual();
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
const overlay = installRestartOverlay(link);
|
||||
let sawKey = false;
|
||||
window.addEventListener("keydown", () => {
|
||||
sawKey = true;
|
||||
});
|
||||
|
||||
link.noteDisconnected();
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
|
||||
expect(sawKey).toBe(false);
|
||||
|
||||
overlay.dispose();
|
||||
link.stop();
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
|
||||
expect(sawKey).toBe(true);
|
||||
});
|
||||
|
||||
it("says the page is reloading after an upgrade", async () => {
|
||||
const sched = manual();
|
||||
let reply = info("aaa", "i1");
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => reply,
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
const overlay = installRestartOverlay(link);
|
||||
await link.start();
|
||||
link.noteDisconnected();
|
||||
reply = info("bbb", "i2");
|
||||
await link.probe();
|
||||
const dialog = document.querySelector(".restart-modal");
|
||||
expect(dialog?.textContent).toContain("Maestro was updated");
|
||||
expect(dialog?.textContent).toContain("bbb");
|
||||
overlay.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// The sidebar footer's daemon-status dot, sat next to the spawn button.
|
||||
//
|
||||
// Colour is the link state; hovering (or focusing — it is a button, so the
|
||||
// keyboard reaches it) reveals the release the daemon is running: commit hash
|
||||
// and commit date.
|
||||
|
||||
import { formatRelease, type DaemonLink, type LinkState, type LinkStatus } from "./daemonLink.js";
|
||||
|
||||
/** One human-readable phrase per link state, used for both the tooltip's first
|
||||
* line and the button's accessible name. */
|
||||
const LABELS: Record<LinkState, string> = {
|
||||
connecting: "Connecting…",
|
||||
online: "Connected",
|
||||
reconnecting: "Reconnecting…",
|
||||
down: "Daemon unreachable",
|
||||
updating: "Updated — reloading",
|
||||
};
|
||||
|
||||
export interface StatusIndicatorHandle {
|
||||
el: HTMLElement;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createStatusIndicator(link: DaemonLink): StatusIndicatorHandle {
|
||||
const el = document.createElement("button");
|
||||
el.type = "button";
|
||||
el.className = "btn btn-ghost btn-icon status-chip";
|
||||
el.setAttribute("aria-describedby", "status-tip");
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "status-dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
el.appendChild(dot);
|
||||
|
||||
const tip = document.createElement("span");
|
||||
tip.className = "status-tip";
|
||||
tip.id = "status-tip";
|
||||
tip.setAttribute("role", "tooltip");
|
||||
el.appendChild(tip);
|
||||
|
||||
const unsubscribe = link.subscribe((status) => render(status));
|
||||
|
||||
function render(status: LinkStatus): void {
|
||||
const label = LABELS[status.state];
|
||||
const release = formatRelease(status.build);
|
||||
el.dataset.state = status.state;
|
||||
el.setAttribute("aria-label", `${label} — ${release}`);
|
||||
tip.textContent = "";
|
||||
tip.append(line("status-tip-state", status.hostname ? `${label} · ${status.hostname}` : label));
|
||||
tip.append(line("status-tip-release", release));
|
||||
}
|
||||
|
||||
function line(cls: string, text: string): HTMLElement {
|
||||
const span = document.createElement("span");
|
||||
span.className = cls;
|
||||
span.textContent = text;
|
||||
return span;
|
||||
}
|
||||
|
||||
return {
|
||||
el,
|
||||
dispose(): void {
|
||||
unsubscribe();
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
+700
-186
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createPaletteSelect,
|
||||
initTheme,
|
||||
PALETTES,
|
||||
resolvedPalette,
|
||||
setPalette,
|
||||
} from "./theme.js";
|
||||
|
||||
// happy-dom's vitest environment ships no localStorage global; back it with a
|
||||
// plain in-memory map so persistence is observable.
|
||||
let store = new Map<string, string>();
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("palette selection", () => {
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
delete document.documentElement.dataset.palette;
|
||||
delete document.documentElement.dataset.theme;
|
||||
});
|
||||
|
||||
it("defaults to graphite with no data-palette attribute", () => {
|
||||
initTheme();
|
||||
expect(resolvedPalette()).toBe("graphite");
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setPalette stamps data-palette and persists", () => {
|
||||
setPalette("ember");
|
||||
expect(document.documentElement.dataset.palette).toBe("ember");
|
||||
expect(resolvedPalette()).toBe("ember");
|
||||
|
||||
// A fresh boot re-applies the stored palette.
|
||||
delete document.documentElement.dataset.palette;
|
||||
initTheme();
|
||||
expect(document.documentElement.dataset.palette).toBe("ember");
|
||||
});
|
||||
|
||||
it("selecting graphite removes the attribute again", () => {
|
||||
setPalette("orchid");
|
||||
setPalette("graphite");
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
initTheme();
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores an unknown stored palette", () => {
|
||||
localStorage.setItem("maestro-palette", "glorb");
|
||||
initTheme();
|
||||
expect(resolvedPalette()).toBe("graphite");
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("the picker lists every palette and applies the chosen one", () => {
|
||||
const select = createPaletteSelect();
|
||||
expect([...select.options].map((o) => o.value)).toEqual([...PALETTES]);
|
||||
expect(select.value).toBe("graphite");
|
||||
|
||||
select.value = "periwinkle";
|
||||
select.dispatchEvent(new Event("change"));
|
||||
expect(document.documentElement.dataset.palette).toBe("periwinkle");
|
||||
expect(localStorage.getItem("maestro-palette")).toBe("periwinkle");
|
||||
});
|
||||
});
|
||||
+64
-1
@@ -1,10 +1,26 @@
|
||||
// Theme: dark (default) + light, persisted. When the user hasn't pinned a
|
||||
// theme we follow the OS via CSS `prefers-color-scheme`; pinning sets an
|
||||
// explicit `data-theme` on <html> that overrides the media query.
|
||||
//
|
||||
// Palette: orthogonal to light/dark. Each palette ships both a dark and a
|
||||
// light token set in styles.css; `data-palette` on <html> selects it, and
|
||||
// graphite — the default — is the bare document with no attribute.
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const KEY = "maestro-theme";
|
||||
|
||||
/** Every `data-palette` value, in picker order. Graphite is the default. */
|
||||
export const PALETTES = [
|
||||
"graphite",
|
||||
"ice-cyan",
|
||||
"periwinkle",
|
||||
"amber-phosphor",
|
||||
"ember",
|
||||
"orchid",
|
||||
] as const;
|
||||
export type Palette = (typeof PALETTES)[number];
|
||||
const PALETTE_KEY = "maestro-palette";
|
||||
|
||||
function stored(): Theme | null {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "light" || v === "dark" ? v : null;
|
||||
@@ -21,18 +37,46 @@ export function resolvedTheme(): Theme {
|
||||
return stored() ?? systemTheme();
|
||||
}
|
||||
|
||||
/** Apply a pinned theme at boot (no-op when following the system). */
|
||||
function storedPalette(): Palette | null {
|
||||
const v = localStorage.getItem(PALETTE_KEY);
|
||||
return (PALETTES as readonly string[]).includes(v ?? "")
|
||||
? (v as Palette)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The palette in force (stored or the graphite default). */
|
||||
export function resolvedPalette(): Palette {
|
||||
return document.documentElement.dataset.palette as Palette | undefined
|
||||
?? storedPalette()
|
||||
?? "graphite";
|
||||
}
|
||||
|
||||
/** Pin a palette. Graphite is the bare document, so it clears the attribute. */
|
||||
export function setPalette(p: Palette): void {
|
||||
localStorage.setItem(PALETTE_KEY, p);
|
||||
if (p === "graphite") delete document.documentElement.dataset.palette;
|
||||
else document.documentElement.dataset.palette = p;
|
||||
}
|
||||
|
||||
/** Apply the pinned theme + palette at boot (no-ops when unpinned). */
|
||||
export function initTheme(): void {
|
||||
// Dev-only `?theme=light|dark` override for visual review; stripped in prod.
|
||||
if (import.meta.env.DEV) {
|
||||
const q = new URLSearchParams(location.search).get("theme");
|
||||
if (q === "light" || q === "dark") {
|
||||
document.documentElement.dataset.theme = q;
|
||||
applyStoredPalette();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pinned = stored();
|
||||
if (pinned) document.documentElement.dataset.theme = pinned;
|
||||
applyStoredPalette();
|
||||
}
|
||||
|
||||
function applyStoredPalette(): void {
|
||||
const p = storedPalette();
|
||||
if (p && p !== "graphite") document.documentElement.dataset.palette = p;
|
||||
}
|
||||
|
||||
function setTheme(t: Theme): void {
|
||||
@@ -58,3 +102,22 @@ export function createThemeToggle(): HTMLButtonElement {
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** A footer dropdown that picks the colour palette. */
|
||||
export function createPaletteSelect(): HTMLSelectElement {
|
||||
const select = document.createElement("select");
|
||||
select.className = "palette-select";
|
||||
select.setAttribute("aria-label", "Colour palette");
|
||||
select.title = "Colour palette";
|
||||
for (const p of PALETTES) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.value = resolvedPalette();
|
||||
select.addEventListener("change", () => {
|
||||
setPalette(select.value as Palette);
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps everything after the program as text", () => {
|
||||
expect(splitCommand("claude --resume -p hi")).toEqual({
|
||||
it("drops everything after the program", () => {
|
||||
expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
|
||||
tool: "claude",
|
||||
rest: "--resume -p hi",
|
||||
program: "claude",
|
||||
});
|
||||
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" });
|
||||
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
|
||||
});
|
||||
|
||||
it("looks through paths, env assignments and wrappers", () => {
|
||||
@@ -33,13 +33,13 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
||||
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
|
||||
expect(splitCommand("npx opencode").tool).toBe("opencode");
|
||||
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
|
||||
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run");
|
||||
expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
|
||||
});
|
||||
|
||||
it("leaves an unrecognised program as text, mark-less", () => {
|
||||
it("leaves an unrecognised program as its bare name, mark-less", () => {
|
||||
expect(splitCommand("hx src/main.rs")).toEqual({
|
||||
tool: null,
|
||||
rest: "hx src/main.rs",
|
||||
program: "hx",
|
||||
});
|
||||
// A wrapper with nothing after it is not a tool either.
|
||||
expect(splitCommand("sudo").tool).toBeNull();
|
||||
|
||||
+17
-18
@@ -2,11 +2,11 @@
|
||||
*
|
||||
* A session's command line starts with the program that runs it, and that
|
||||
* program is nearly always one of a handful of agents or a shell. Rendering it
|
||||
* as its own mark buys back most of the fragment's width, which the sidebar
|
||||
* spends on the rest of the command line instead.
|
||||
* as its own mark buys back the whole fragment's width — the arguments are
|
||||
* dropped, so a card says which tool runs, not how it was invoked.
|
||||
*
|
||||
* Recognition is deliberately narrow: an unrecognised program keeps its text,
|
||||
* so the fragment never renders a mark that stands for the wrong tool.
|
||||
* Recognition is deliberately narrow: an unrecognised program keeps its name
|
||||
* as text, so the fragment never renders a mark that stands for the wrong tool.
|
||||
*/
|
||||
|
||||
/** Command wrappers that run another program: the tool is what follows. */
|
||||
@@ -48,15 +48,15 @@ export const TOOL_LABELS: Record<string, string> = {
|
||||
export interface CommandParts {
|
||||
/** Icon id, or `null` when the program is not one we have a mark for. */
|
||||
tool: string | null;
|
||||
/** What is left of the command line once the icon stands for the program.
|
||||
* Empty when the icon says everything the command line said. */
|
||||
rest: string;
|
||||
/** The program the command runs, basenamed and stripped of its arguments —
|
||||
* what a card shows when there is no icon to show instead. */
|
||||
program: string;
|
||||
}
|
||||
|
||||
/** Split a command line into the tool its icon stands for and the remainder.
|
||||
/** Split a command line into the tool its icon stands for and the program name.
|
||||
*
|
||||
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised
|
||||
* `hx src/` keeps the whole line as `rest` with no tool. */
|
||||
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
|
||||
* `hx src/` keeps `hx` as the program with no tool. */
|
||||
export function splitCommand(command: string): CommandParts {
|
||||
const words = command.trim().split(/\s+/).filter(Boolean);
|
||||
let i = 0;
|
||||
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const program = basename(word);
|
||||
if (WRAPPERS.has(program)) {
|
||||
const wrapper = basename(word).toLowerCase();
|
||||
if (WRAPPERS.has(wrapper)) {
|
||||
i += 1;
|
||||
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1;
|
||||
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (i >= words.length) return { tool: null, rest: command.trim() };
|
||||
const tool = BY_PROGRAM[basename(words[i])] ?? null;
|
||||
if (!tool) return { tool: null, rest: command.trim() };
|
||||
return { tool, rest: words.slice(i + 1).join(" ") };
|
||||
if (i >= words.length) return { tool: null, program: command.trim() };
|
||||
const program = basename(words[i]);
|
||||
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
|
||||
}
|
||||
|
||||
function basename(word: string): string {
|
||||
const cut = word.lastIndexOf("/");
|
||||
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase();
|
||||
return cut >= 0 ? word.slice(cut + 1) : word;
|
||||
}
|
||||
|
||||
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
||||
|
||||
+24
-1
@@ -86,6 +86,24 @@ export interface DaemonInfo {
|
||||
home_dir?: string;
|
||||
/** Command presets (claude/opencode/zsh + custom), config-resolved. */
|
||||
presets: SpawnPreset[];
|
||||
/** Which commit the daemon was built from. Absent on older daemons. */
|
||||
build?: BuildInfo;
|
||||
/** Identity of the daemon *process*, regenerated on every start — a changed
|
||||
* value across a reconnect means the daemon restarted. Absent on older
|
||||
* daemons, which the restart flow treats as "cannot tell". */
|
||||
instance?: string;
|
||||
}
|
||||
|
||||
/** Build provenance of the running daemon: what the status indicator names in
|
||||
* its tooltip, and what tells an upgrade apart from a plain restart. Every
|
||||
* field is empty when the binary was built outside a git checkout. */
|
||||
export interface BuildInfo {
|
||||
/** Abbreviated commit hash. */
|
||||
commit: string;
|
||||
/** Commit date, RFC 3339. */
|
||||
commit_date: string;
|
||||
/** Whether the tree had uncommitted changes at build time. */
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
/** One deduplicated recent working dir for the spawn form's recents picker
|
||||
@@ -253,7 +271,12 @@ export type ClientControl =
|
||||
| { type: "Error"; code: string; message: string };
|
||||
|
||||
export interface ApiError {
|
||||
error: { code: string; message: string };
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
/** Set only by the kill guard (#79): what blocked the unforced kill. */
|
||||
unsaved_work?: InTheWay;
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionName(s: Session): string {
|
||||
|
||||
Reference in New Issue
Block a user