Compare commits

...
Author SHA1 Message Date
Miguel Palhas f02168ffd5 feat(maestro): add SessionView to the protocol and sidecar
Adds `SessionView { Terminal, Structured }` to maestro-protocol, a
defaulted `view` field on `Session` and `CreateSessionRequest`, and
sidecar round-trip tests for both a legacy payload with no `view` key
and a `Structured` session. Nothing reads the field yet; every other
struct literal across the workspace picks up `Default::default()`
(Terminal) to keep compiling.

Gitea #133.
2026-09-04 19:41:29 +01:00
Miguel PalhasandClaude Sonnet 5 f0c272e0b1 feat(maestro): copy terminal selection to clipboard
ci / web (push) Successful in 28s
ci / rust (push) Failing after 2m6s
ci / flake (push) Successful in 2m14s
xterm.js has no built-in copy-on-select; mirror onSelectionChange to
navigator.clipboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:33:24 +01:00
Miguel Palhas 624f6f19c8 Merge remote-tracking branch 'origin/fermata'
ci / web (push) Failing after 13s
ci / flake (push) Successful in 3m12s
ci / rust (push) Failing after 3m26s
2026-09-04 13:32:05 +01:00
Miguel Palhas 08161ae042 Merge remote-tracking branch 'origin/main' into fermata 2026-09-04 13:30:19 +01:00
Miguel PalhasandClaude Sonnet 5 1e3c7b294d fix(maestro): send plain Escape to the pane, move focus.toggle behind the leader
Escape unfocusing the pane made it impossible to send a literal Esc into
the attached session. Removes pane.focusList and its Escape binding, and
rebinds focus.toggle from ctrl-shift-h (claimed by the browser's History
shortcut) to leader-h, consistent with the other leader escape hatches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:30:14 +01:00
Miguel PalhasandClaude Sonnet 5 49ab7e9c81 fix(maestro): suffix branch name on new-branch worktree collision
ci / rust (push) Failing after 3m26s
ci / web (push) Successful in 43s
ci / flake (push) Successful in 3m54s
git worktree add -b fails outright when the branch already exists, even
if the target directory is free. A random fast-forward handle commonly
collides with a branch an earlier, already-removed worktree left behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:49 +01:00
Miguel PalhasandClaude Sonnet 5 0cc25d7e1c fix(maestro): equalize split widths after spawning a session
ci / rust (push) Successful in 9m12s
ci / web (push) Successful in 33s
ci / flake (push) Successful in 2m37s
splitLeaf always halved just the target pane, so repeated splits left
one pane at 50% and the rest squeezed into shrinking fractions instead
of equal columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:03 +01:00
33 changed files with 317 additions and 77 deletions
+2
View File
@@ -51,6 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
}; };
let body = CreateSessionRequest { let body = CreateSessionRequest {
view: Default::default(),
cwd: args.cwd.clone(), cwd: args.cwd.clone(),
command: args.command.clone(), command: args.command.clone(),
env, env,
@@ -236,6 +237,7 @@ mod tests {
fn session(cwd: &str, command: &str, id: u128) -> Session { fn session(cwd: &str, command: &str, id: u128) -> Session {
Session { Session {
view: Default::default(),
id: Uuid::from_u128(id), id: Uuid::from_u128(id),
cwd: PathBuf::from(cwd), cwd: PathBuf::from(cwd),
command: command.into(), command: command.into(),
@@ -91,6 +91,7 @@ async fn spawn_round_trips_through_the_daemon() {
let created = mock let created = mock
.client() .client()
.create_session(&maestro_protocol::CreateSessionRequest { .create_session(&maestro_protocol::CreateSessionRequest {
view: Default::default(),
cwd: "/home/mock".into(), cwd: "/home/mock".into(),
command: "bash".into(), command: "bash".into(),
env: Default::default(), env: Default::default(),
+4
View File
@@ -478,6 +478,7 @@ impl SessionManager {
// fresh tree has nothing in the way anyway. // fresh tree has nothing in the way anyway.
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
view: req.view,
}; };
if let Err(e) = self.inner.sidecar.write(&session) { if let Err(e) = self.inner.sidecar.write(&session) {
@@ -683,6 +684,9 @@ impl SessionManager {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
// Unknown on adoption without a sidecar; matches every
// other field's forward-compat default.
view: maestro_protocol::SessionView::Terminal,
}, },
}; };
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
+40 -12
View File
@@ -111,16 +111,33 @@ impl WorktreeManager {
// Clear registrations whose directory is gone first, so a // Clear registrations whose directory is gone first, so a
// previously-leaked entry doesn't push this branch to a suffix. // previously-leaked entry doesn't push this branch to a suffix.
prune(base_repo)?; prune(base_repo)?;
// Anything occupying the target — a tree we left behind, one the let (branch, path) = if create_branch {
// operator made, an unrelated directory — is sidestepped with a // Worktrees are commonly abandoned without deleting their branch,
// numeric suffix on the directory; the branch stays whatever was // so a random handle can collide with one of its own past runs.
// requested (#77). The daemon never adopts what it finds. // Unlike the `add_existing` arm below, the suffix goes on the
let mut path = self.target_path(base_repo, branch); // branch name itself — the path is derived from it anyway.
let mut n = 1; let mut candidate = branch.to_string();
while path.symlink_metadata().is_ok() { let mut path = self.target_path(base_repo, &candidate);
n += 1; let mut n = 1;
path = self.target_path_suffixed(base_repo, branch, n); while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
} n += 1;
candidate = format!("{branch}-{n}");
path = self.target_path(base_repo, &candidate);
}
(candidate, path)
} else {
// Existing branch: the name must stay exact, so only the
// directory — anything occupying it, ours or the operator's — is
// sidestepped with a numeric suffix (#77). The daemon never
// adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
(branch.to_string(), path)
};
// The parent (`<repo>/worktrees` or the override root) may not exist // The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not. // yet; the target itself must not.
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -129,10 +146,10 @@ impl WorktreeManager {
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]); cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch { if create_branch {
cmd.arg("-b").arg(branch).arg(&path); cmd.arg("-b").arg(&branch).arg(&path);
} else { } else {
// Existing branch: `worktree add <path> <branch>` with no `-b`. // Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(branch); cmd.arg(&path).arg(&branch);
} }
let out = cmd.output()?; let out = cmd.output()?;
check(out)?; check(out)?;
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
Some(candidate.to_path_buf()) Some(candidate.to_path_buf())
} }
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["show-ref", "--verify", "--quiet"])
.arg(format!("refs/heads/{branch}"))
.status()
.is_ok_and(|s| s.success())
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory /// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists. /// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> { fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
@@ -42,6 +42,7 @@ async fn fixture(ttl: Duration) -> Fixture {
async fn spawn_sleeping(manager: &SessionManager) -> Uuid { async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
manager manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: Default::default(), env: Default::default(),
+1
View File
@@ -75,6 +75,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
let s = h let s = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd_path, cwd: cwd_path,
command: "bash --noprofile --norc".into(), command: "bash --noprofile --norc".into(),
env: HashMap::new(), env: HashMap::new(),
+1
View File
@@ -50,6 +50,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
}; };
manager manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: Default::default(), env: Default::default(),
@@ -19,6 +19,7 @@ use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest { fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
+10
View File
@@ -174,6 +174,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
assert!(empty.is_empty()); assert!(empty.is_empty());
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -390,6 +391,7 @@ async fn patch_session_renames_it() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -432,6 +434,7 @@ async fn post_focus_stamps_last_focused_at() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -465,6 +468,7 @@ async fn delete_owned_session_kills_it() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -486,6 +490,7 @@ async fn delete_owned_session_kills_it() {
async fn spawn_via_manager(h: &Harness) -> Uuid { async fn spawn_via_manager(h: &Harness) -> Uuid {
h.manager h.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -595,6 +600,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -647,6 +653,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -680,6 +687,7 @@ async fn attach_ws_streams_pty_bytes() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(), command: "bash --noprofile --norc".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -748,6 +756,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
let created = h let created = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -792,6 +801,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
h.manager h.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
+1
View File
@@ -51,6 +51,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
}; };
manager manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: Default::default(), env: Default::default(),
@@ -64,6 +64,7 @@ impl Fx {
fn request(&self, command: &str) -> CreateSessionRequest { fn request(&self, command: &str) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: self.tmp.path().to_path_buf(), cwd: self.tmp.path().to_path_buf(),
command: command.into(), command: command.into(),
env: HashMap::new(), env: HashMap::new(),
@@ -22,6 +22,7 @@ async fn create_then_kill_round_trip() {
let session = fx let session = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -63,6 +64,7 @@ async fn create_appends_to_spawn_history() {
let session = fx let session = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -92,6 +94,7 @@ async fn create_without_history_logs_nothing() {
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
fx.manager fx.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -114,6 +117,7 @@ async fn recover_adopts_existing_tmux_sessions() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -153,6 +157,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
// The shell prints the marker before doing anything else and // The shell prints the marker before doing anything else and
// then sleeps so the session stays alive. Before NAP-253 this // then sleeps so the session stays alive. Before NAP-253 this
@@ -200,6 +205,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
// Print a line, then switch to the alternate screen the way a // Print a line, then switch to the alternate screen the way a
// full-screen program does on startup. Only the second marker is // full-screen program does on startup. Only the second marker is
@@ -258,6 +264,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
// Alternate screen, then button-event tracking (?1002) with SGR // Alternate screen, then button-event tracking (?1002) with SGR
// coordinates (?1006) — what tmux and agent TUIs actually request. // coordinates (?1006) — what tmux and agent TUIs actually request.
@@ -311,6 +318,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(), command: "bash --noprofile --norc".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -373,6 +381,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(), command: "bash --noprofile --norc".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -423,6 +432,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -488,6 +498,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
fx.manager fx.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -580,6 +591,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -617,6 +629,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -680,6 +693,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -729,6 +743,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -774,6 +789,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -814,6 +830,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -855,6 +872,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -916,6 +934,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -976,6 +995,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1010,6 +1030,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let first = fx let first = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1027,6 +1048,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let second = fx let second = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: inner_worktree.clone(), cwd: inner_worktree.clone(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1065,6 +1087,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1103,6 +1126,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1128,6 +1152,7 @@ async fn set_focused_true_stamps_last_focused_at() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1176,6 +1201,7 @@ async fn attaching_stamps_last_focused_at() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
+33 -1
View File
@@ -7,7 +7,7 @@ use std::path::PathBuf;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use maestro_daemon::sidecar::SidecarStore; use maestro_daemon::sidecar::SidecarStore;
use maestro_protocol::{Session, SessionStatus}; use maestro_protocol::{Session, SessionStatus, SessionView};
use serde_json::Value; use serde_json::Value;
use tempfile::TempDir; use tempfile::TempDir;
use uuid::Uuid; use uuid::Uuid;
@@ -32,6 +32,7 @@ fn sample(id: Uuid) -> Session {
current_cwd: Some(PathBuf::from("/tmp/work/crates")), current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
view: SessionView::Terminal,
} }
} }
@@ -103,6 +104,37 @@ fn load_all_returns_empty_when_directory_missing() {
assert!(store.load_all().unwrap().is_empty()); assert!(store.load_all().unwrap().is_empty());
} }
#[test]
fn sidecar_missing_view_key_reads_as_terminal() {
// A pre-#133 sidecar on disk has no `view` field at all.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut value = serde_json::to_value(sample(id)).unwrap();
value.as_object_mut().unwrap().remove("view");
std::fs::write(store.path_for(id), serde_json::to_vec(&value).unwrap()).unwrap();
let loaded = store.read(id).expect("read").expect("present");
assert_eq!(loaded.view, SessionView::Terminal);
}
#[test]
fn sidecar_structured_view_round_trips() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut s = sample(id);
s.view = SessionView::Structured;
store.write(&s).expect("write");
let loaded = store.read(id).expect("read");
assert_eq!(
loaded.as_ref().map(|s| s.view),
Some(SessionView::Structured)
);
}
#[test] #[test]
fn write_creates_parent_directory() { fn write_creates_parent_directory() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
+58
View File
@@ -27,6 +27,7 @@ async fn bring_up() -> (Daemon, TestClient) {
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest { fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
command: command.into(), command: command.into(),
env: HashMap::new(), env: HashMap::new(),
@@ -49,6 +50,7 @@ fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool)
/// Spawn request carrying a worktree spec against `base`. /// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest { fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: base.to_path_buf(), cwd: base.to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -143,6 +145,7 @@ async fn kill_removes_clean_worktree() {
init_repo(base.path()); init_repo(base.path());
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(), cwd: base.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -196,6 +199,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
.join("unsaved"); .join("unsaved");
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(), cwd: base.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -254,6 +258,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
run_git(&["branch", "feature/existing"], base.path()); run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(), cwd: base.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -497,6 +502,57 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
); );
} }
/// A random handle can reuse the name of a branch left behind by an earlier,
/// already-removed worktree — `git worktree add -b` refuses outright in that
/// case, even though the directory is free. The daemon suffixes the branch
/// itself (not just the directory) and both land on `-2`.
#[tokio::test(flavor = "multi_thread")]
async fn colliding_branch_name_gets_suffixed_too() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// No worktree directory in the way, but the branch already exists.
run_git(&["branch", "handle"], base.path());
let s = client
.spawn_session(&worktree_req(base.path(), "handle", true))
.await
.expect("spawn onto a taken branch name");
let suffixed = base.path().join("worktrees").join("handle-2");
assert_eq!(
s.cwd, suffixed,
"the directory must follow the suffixed branch name"
);
assert!(suffixed.is_dir());
let registrations = git_worktree_list(base.path());
assert!(
registrations.iter().any(|l| l.contains("handle-2")),
"the -2 branch's worktree must be registered: {registrations:?}"
);
let branches = String::from_utf8(
std::process::Command::new("git")
.arg("-C")
.arg(base.path())
.args(["branch", "--list"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
branches.contains("handle-2"),
"the branch itself must be named handle-2: {branches}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
/// #77/#88: the target path holds a plain directory the daemon never made and /// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds /// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone. /// beside it and leaves every byte of it alone.
@@ -703,6 +759,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let _dirty = client let _dirty = client
.spawn_session(&CreateSessionRequest { .spawn_session(&CreateSessionRequest {
view: Default::default(),
cwd: dirty_repo.path().to_path_buf(), cwd: dirty_repo.path().to_path_buf(),
command: "sh -c 'echo unsaved > scratch.txt'".into(), command: "sh -c 'echo unsaved > scratch.txt'".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -751,6 +808,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest { fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
command: command.into(), command: command.into(),
env: HashMap::new(), env: HashMap::new(),
+1
View File
@@ -268,6 +268,7 @@ mod tests {
fn session(cwd: &str) -> Session { fn session(cwd: &str) -> Session {
Session { Session {
view: Default::default(),
id: Uuid::from_u128(1), id: Uuid::from_u128(1),
cwd: PathBuf::from(cwd), cwd: PathBuf::from(cwd),
command: "bash".into(), command: "bash".into(),
+45
View File
@@ -116,6 +116,24 @@ pub struct Session {
/// sorts a never-focused session after every session that has one. /// sorts a never-focused session after every session that has one.
#[serde(default)] #[serde(default)]
pub last_focused_at: Option<DateTime<Utc>>, pub last_focused_at: Option<DateTime<Utc>>,
/// Whether this session's process is a plain terminal (PTY bytes through
/// tmux) or a structured agent driven over ACP rows (#133). Fixed at
/// spawn time and never changes for the life of the session. Defaults to
/// `Terminal` on the wire so every existing client and payload from a
/// daemon that predates the field keeps working untouched.
#[serde(default)]
pub view: SessionView,
}
/// A session's process shape (#133): a plain terminal, or a structured agent
/// driven over ACP rows. Chosen at spawn and immutable for the session's
/// life. `Terminal` is the default — every sidecar and `CreateSessionRequest`
/// written before this field existed meant a terminal session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SessionView {
#[default]
Terminal,
Structured,
} }
/// Default for [`Session::last_activity_at`] when an older payload omits it. /// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -352,6 +370,11 @@ pub struct CreateSessionRequest {
pub metadata: HashMap<String, Value>, pub metadata: HashMap<String, Value>,
#[serde(default)] #[serde(default)]
pub worktree: Option<WorktreeSpec>, pub worktree: Option<WorktreeSpec>,
/// The process shape to spawn (#133). Defaulted so every existing client
/// and `curl` against the API keeps requesting a `Terminal` session
/// untouched.
#[serde(default)]
pub view: SessionView,
} }
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory. /// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
@@ -862,6 +885,7 @@ mod tests {
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
view: SessionView::Terminal,
} }
} }
@@ -1073,6 +1097,7 @@ mod tests {
branch: "feat/foo".into(), branch: "feat/foo".into(),
create_branch: true, create_branch: true,
}), }),
view: SessionView::Terminal,
}; };
assert_eq!(req, round_trip(&req)); assert_eq!(req, round_trip(&req));
} }
@@ -1087,6 +1112,26 @@ mod tests {
assert!(req.worktree.is_none()); assert!(req.worktree.is_none());
assert!(req.env.is_empty()); assert!(req.env.is_empty());
assert!(req.metadata.is_empty()); assert!(req.metadata.is_empty());
assert_eq!(req.view, SessionView::Terminal);
}
#[test]
fn session_view_defaults_to_terminal_on_legacy_payload() {
// A pre-#133 daemon or client omits `view`; it must deserialize to
// `Terminal` rather than failing, and `Structured` round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.view, SessionView::Terminal);
let mut structured = sample_session();
structured.view = SessionView::Structured;
assert_eq!(structured, round_trip(&structured));
} }
/// A client that predates the new/existing discriminator sends no /// A client that predates the new/existing discriminator sends no
+1
View File
@@ -31,6 +31,7 @@ use uuid::Uuid;
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`]. /// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
Session { Session {
view: Default::default(),
id, id,
cwd: cwd.into(), cwd: cwd.into(),
command: command.into(), command: command.into(),
+7
View File
@@ -1882,6 +1882,7 @@ mod tests {
sessions: ids sessions: ids
.iter() .iter()
.map(|id| Session { .map(|id| Session {
view: Default::default(),
id: *id, id: *id,
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -2063,6 +2064,7 @@ mod tests {
let mut app = make_app(); let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session { sessions: vec![Session {
view: Default::default(),
id: sid, id: sid,
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -2090,6 +2092,7 @@ mod tests {
use chrono::Utc; use chrono::Utc;
use maestro_protocol::{Session, SessionStatus}; use maestro_protocol::{Session, SessionStatus};
Session { Session {
view: Default::default(),
id: sid, id: sid,
command: "bash".into(), command: "bash".into(),
cwd: cwd.into(), cwd: cwd.into(),
@@ -2777,6 +2780,7 @@ mod tests {
); );
app.dispatch(Action::OpenSpawnModal); app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: std::path::PathBuf::from("/srv/work"), cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(), command: "bash".into(),
env: Default::default(), env: Default::default(),
@@ -3195,6 +3199,7 @@ mod tests {
use chrono::Utc; use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus}; use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session { let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
view: Default::default(),
id: Uuid::from_u128(n), id: Uuid::from_u128(n),
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -3475,6 +3480,7 @@ mod tests {
let sid = Uuid::from_u128(7); let sid = Uuid::from_u128(7);
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session { sessions: vec![Session {
view: Default::default(),
id: sid, id: sid,
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -3511,6 +3517,7 @@ mod tests {
}; };
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session { sessions: vec![Session {
view: Default::default(),
id: sid, id: sid,
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
+1
View File
@@ -207,6 +207,7 @@ mod tests {
fn session(id: u128, command: &str) -> Session { fn session(id: u128, command: &str) -> Session {
Session { Session {
view: Default::default(),
id: Uuid::from_u128(id), id: Uuid::from_u128(id),
command: command.into(), command: command.into(),
cwd: std::path::PathBuf::from("/tmp"), cwd: std::path::PathBuf::from("/tmp"),
+1
View File
@@ -793,6 +793,7 @@ mod tests {
fn session(n: u128, command: &str) -> Session { fn session(n: u128, command: &str) -> Session {
Session { Session {
view: Default::default(),
id: Uuid::from_u128(n), id: Uuid::from_u128(n),
cwd: PathBuf::from("/home/me/dev/api"), cwd: PathBuf::from("/home/me/dev/api"),
command: command.to_string(), command: command.to_string(),
+1
View File
@@ -694,6 +694,7 @@ impl DraftPane {
} }
let worktree = self.build_worktree()?; let worktree = self.build_worktree()?;
Ok(CreateSessionRequest { Ok(CreateSessionRequest {
view: Default::default(),
cwd, cwd,
command: command.to_string(), command: command.to_string(),
env: Default::default(), env: Default::default(),
+1
View File
@@ -108,6 +108,7 @@ impl FastForwardDefaults {
return None; return None;
} }
Some(CreateSessionRequest { Some(CreateSessionRequest {
view: Default::default(),
cwd: project.path.clone(), cwd: project.path.clone(),
command: command.to_string(), command: command.to_string(),
env: Default::default(), env: Default::default(),
+2
View File
@@ -484,6 +484,7 @@ mod tests {
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata.insert("name".into(), serde_json::Value::String(name.into())); metadata.insert("name".into(), serde_json::Value::String(name.into()));
Session { Session {
view: Default::default(),
id: Uuid::from_u128(id), id: Uuid::from_u128(id),
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"), cwd: std::path::PathBuf::from("/tmp"),
@@ -1250,6 +1251,7 @@ mod tests {
#[test] #[test]
fn falls_back_to_command_when_name_metadata_is_absent() { fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session { let s = Session {
view: Default::default(),
id: Uuid::from_u128(1), id: Uuid::from_u128(1),
command: "vim".into(), command: "vim".into(),
cwd: std::path::PathBuf::from("/tmp"), cwd: std::path::PathBuf::from("/tmp"),
+4 -3
View File
@@ -307,9 +307,10 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list. than jumping to the end of the list.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
same chord back): an attached terminal owns every plain keystroke. The TUI uses by default — and the same chord back): an attached terminal owns every plain
`ctrl-w h`. keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
uses `ctrl-w h`.
Everything else is the same key on both clients: Everything else is the same key on both clients:
+12 -4
View File
@@ -35,10 +35,18 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
back to a short hash of the branch. back to a short hash of the branch.
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
a free one. The suffix names the **directory only**. The branch is whatever was a free one.
requested; a second worktree of the same branch is not what happens here, because
git refuses to check the same branch out twice and the branch picker already For a *new* branch (`create_branch: true`), the suffix goes on the branch name
marks branches that are checked out elsewhere. itself, and the directory follows since it is derived from the branch — a
random handle can otherwise reuse the name of a branch some earlier, already
removed worktree left behind, and `git worktree add -b` refuses outright even
though the directory is free.
For an *existing* branch (`create_branch: false`) the name must stay exact, so
only the directory is suffixed — a second worktree of the same branch is not
what happens here, because git refuses to check the same branch out twice and
the branch picker already marks branches that are checked out elsewhere.
### The `worktree_root` override ### The `worktree_root` override
+7 -20
View File
@@ -4,8 +4,6 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js"; import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.js";
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -14,15 +12,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js"; import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js"; import { WebglPolicy } from "./webglRenderer.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are /** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */ * rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy(); const webglPolicy = new WebglPolicy();
@@ -221,15 +210,6 @@ export function createAttachConnection(
} }
} }
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a // Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes: // line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback. // • normal buffer → term.scrollLines walks the scrollback.
@@ -470,6 +450,12 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => { const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows }); sendControl({ type: "Resize", cols, rows });
}); });
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null; const visual = window.visualViewport ?? null;
@@ -525,6 +511,7 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize); visual?.removeEventListener("resize", onResize);
dataDisp.dispose(); dataDisp.dispose();
resizeDisp.dispose(); resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl(); releaseWebgl();
try { try {
ws.close(); ws.close();
-4
View File
@@ -53,10 +53,6 @@ export function requestOpenCommandBar(): void {
* connection pool. */ * connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane"; export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session /** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */ * ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
+12 -14
View File
@@ -133,31 +133,28 @@ describe("CommandSet", () => {
expect(run).toHaveBeenCalledTimes(2); expect(run).toHaveBeenCalledTimes(2);
}); });
it("keeps plain keys deaf inside a form field, and chorded ones live", () => { it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
const plain = vi.fn(); const plain = vi.fn();
const chorded = vi.fn(); const chorded = vi.fn();
const set = new CommandSet( const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }), defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
{ leader: DEFAULT_LEADER },
); );
const input = el("<input>"); const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false); expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled(); expect(plain).not.toHaveBeenCalled();
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true); // focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
// left for the pane's shell/app instead.
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
pressOn(set, terminal, "a", { ctrlKey: true });
expect(pressOn(set, terminal, "h")).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1); expect(chorded).toHaveBeenCalledTimes(1);
}); });
it("keeps pane commands live inside the terminal's textarea", () => { it("leaves a plain Escape for the pane instead of binding it", () => {
const run = vi.fn(); const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>'); const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
const e = key("Escape"); expect(pressOn(set, textarea, "Escape")).toBe(false);
Object.defineProperty(e, "target", { value: textarea });
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
// xterm is told to swallow by the return value, not by the browser default.
expect(e.defaultPrevented).toBe(false);
}); });
}); });
@@ -173,6 +170,7 @@ describe("the leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s])); const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]); expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]); expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
// Commands that do not hang off the leader are untouched. // Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]); expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
}); });
+2 -8
View File
@@ -93,7 +93,8 @@ const SPECS: readonly SpecSource[] = [
id: "focus.toggle", id: "focus.toggle",
label: "Move the keyboard between the session list and the pane", label: "Move the keyboard between the session list and the pane",
group: "Session list", group: "Session list",
keys: ["ctrl-shift-h"], keys: [`${DEFAULT_LEADER} h`],
leaderKeys: (leader) => [`${leader} h`],
scope: "global", scope: "global",
}, },
{ {
@@ -145,13 +146,6 @@ const SPECS: readonly SpecSource[] = [
keys: ["tab"], keys: ["tab"],
scope: "list", scope: "list",
}, },
{
id: "pane.focusList",
label: "Hand the keyboard back to the session list, from the pane",
group: "Session list",
keys: ["esc"],
scope: "pane",
},
{ {
id: "help.toggle", id: "help.toggle",
label: "Show or hide this help", label: "Show or hide this help",
+18
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
emptyLayout, emptyLayout,
equalizeRatios,
leaves, leaves,
locateSession, locateSession,
openSessions, openSessions,
@@ -44,6 +45,23 @@ describe("splitLeaf", () => {
}); });
}); });
describe("equalizeRatios", () => {
it("resets nested splits to equal width, not just the newest one", () => {
const tab = tabWithOneLeaf("s1");
const first = leaves(tab.root)[0].id;
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
const second = leaves(tab.root)[1].id;
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
equalizeRatios(tab.root);
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
expect(root.ratio).toBeCloseTo(1 / 3);
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
expect(inner.ratio).toBeCloseTo(1 / 2);
});
});
describe("removeLeaf", () => { describe("removeLeaf", () => {
it("collapses the parent split into the surviving sibling", () => { it("collapses the parent split into the surviving sibling", () => {
const tab = tabWithOneLeaf("s1"); const tab = tabWithOneLeaf("s1");
+11
View File
@@ -118,6 +118,17 @@ export function removeLeaf(tab: Tab, leafId: string): void {
tab.root = tab.root ? rec(tab.root) : null; tab.root = tab.root ? rec(tab.root) : null;
} }
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
export function addTab(layout: Layout): Tab { export function addTab(layout: Layout): Tab {
const tab: Tab = { const tab: Tab = {
id: freshId("t"), id: freshId("t"),
+7 -11
View File
@@ -35,7 +35,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js"; import { createPreviewSchedule } from "./previewSchedule.js";
import { import {
FOCUS_PANE_EVENT, FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT, OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT, OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT, SESSION_SELECT_EVENT,
@@ -440,10 +439,11 @@ const spawnKeys = new CommandSet(
}), }),
); );
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the // Leader-h moves the keyboard between the session list and the pane, the web
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached // counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
// terminal owns every plain keystroke, so nothing unmodified could ever get // leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
// the focus back out of it. // is already claimed by the browser (history), so nothing unmodified — or
// modified with a common browser chord — could get the focus back out of it.
// //
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off // on `/api/info`): it holds the key as a partial chord and takes select-all off
@@ -528,8 +528,8 @@ window.addEventListener(
); );
// Tab in the session list hands the keyboard to the pane; Escape in the pane // Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
// hands it back — the pair the TUI gets from `tab` and `esc`. // above) hands it back the other way.
document.addEventListener(FOCUS_PANE_EVENT, () => { document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops // Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs. // being provisional rather than snapping back the moment the list blurs.
@@ -537,10 +537,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
if (previewed) focusSession(previewed); if (previewed) focusSession(previewed);
pool.focusTerminal(); pool.focusTerminal();
}); });
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the /** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely * hash router and the sidebar's click-driven path (#133), which can't rely
+4
View File
@@ -10,6 +10,7 @@ import {
activeTab, activeTab,
addTab, addTab,
closeTab, closeTab,
equalizeRatios,
findLeaf, findLeaf,
freshId, freshId,
type Dir, type Dir,
@@ -457,6 +458,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
dropPreview(); dropPreview();
const tab = activeTab(layout); const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render(); render();
} }
@@ -542,6 +544,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
target.sessionId = sessionId; target.sessionId = sessionId;
} else { } else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
} }
render(); render();
opts.onOpenSessionsChange?.(); opts.onOpenSessionsChange?.();
@@ -815,6 +818,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) removeLeaf(tab, self); if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id); else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after); tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render(); render();
} }