From f02168ffd5f5ca652aa265acb7791691a621524f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 19:41:29 +0100 Subject: [PATCH] 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. --- crates/maestro-cli/src/sessions.rs | 2 + .../maestro-cli/tests/against_mock_daemon.rs | 1 + crates/maestro-daemon/src/session.rs | 4 ++ .../maestro-daemon/tests/activity_report.rs | 1 + crates/maestro-daemon/tests/attach.rs | 1 + crates/maestro-daemon/tests/autoname.rs | 1 + .../maestro-daemon/tests/blocked_work_cost.rs | 1 + crates/maestro-daemon/tests/local_api.rs | 10 +++++ crates/maestro-daemon/tests/rename.rs | 1 + .../maestro-daemon/tests/session_decisions.rs | 1 + .../maestro-daemon/tests/session_manager.rs | 26 +++++++++++ crates/maestro-daemon/tests/sidecar.rs | 34 +++++++++++++- crates/maestro-e2e/tests/e2e.rs | 7 +++ crates/maestro-protocol/src/display.rs | 1 + crates/maestro-protocol/src/lib.rs | 45 +++++++++++++++++++ crates/maestro-testing/src/lib.rs | 1 + crates/maestro-tui/src/app.rs | 7 +++ crates/maestro-tui/src/catalog.rs | 1 + crates/maestro-tui/src/command_bar.rs | 1 + crates/maestro-tui/src/draft.rs | 1 + crates/maestro-tui/src/fast_forward.rs | 1 + crates/maestro-tui/src/sidebar.rs | 2 + 22 files changed, 149 insertions(+), 1 deletion(-) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index cac1729..b5a8652 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -51,6 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result Session { Session { + view: Default::default(), id: Uuid::from_u128(id), cwd: PathBuf::from(cwd), command: command.into(), diff --git a/crates/maestro-cli/tests/against_mock_daemon.rs b/crates/maestro-cli/tests/against_mock_daemon.rs index 58066f2..1c1c397 100644 --- a/crates/maestro-cli/tests/against_mock_daemon.rs +++ b/crates/maestro-cli/tests/against_mock_daemon.rs @@ -91,6 +91,7 @@ async fn spawn_round_trips_through_the_daemon() { let created = mock .client() .create_session(&maestro_protocol::CreateSessionRequest { + view: Default::default(), cwd: "/home/mock".into(), command: "bash".into(), env: Default::default(), diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index f1633bc..7d17d38 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -478,6 +478,7 @@ impl SessionManager { // fresh tree has nothing in the way anyway. blocked_work: None, last_focused_at: None, + view: req.view, }; if let Err(e) = self.inner.sidecar.write(&session) { @@ -683,6 +684,9 @@ impl SessionManager { current_cwd: None, blocked_work: 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")); diff --git a/crates/maestro-daemon/tests/activity_report.rs b/crates/maestro-daemon/tests/activity_report.rs index e494856..dda80fa 100644 --- a/crates/maestro-daemon/tests/activity_report.rs +++ b/crates/maestro-daemon/tests/activity_report.rs @@ -42,6 +42,7 @@ async fn fixture(ttl: Duration) -> Fixture { async fn spawn_sleeping(manager: &SessionManager) -> Uuid { manager .create(CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: Default::default(), diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index 1cd9f53..1648613 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -75,6 +75,7 @@ async fn create_bash_session(h: &Harness) -> Uuid { let s = h .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd_path, command: "bash --noprofile --norc".into(), env: HashMap::new(), diff --git a/crates/maestro-daemon/tests/autoname.rs b/crates/maestro-daemon/tests/autoname.rs index c183dd4..491ff5b 100644 --- a/crates/maestro-daemon/tests/autoname.rs +++ b/crates/maestro-daemon/tests/autoname.rs @@ -50,6 +50,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid { }; manager .create(CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: Default::default(), diff --git a/crates/maestro-daemon/tests/blocked_work_cost.rs b/crates/maestro-daemon/tests/blocked_work_cost.rs index a04a923..35ce151 100644 --- a/crates/maestro-daemon/tests/blocked_work_cost.rs +++ b/crates/maestro-daemon/tests/blocked_work_cost.rs @@ -19,6 +19,7 @@ use tempfile::TempDir; fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest { CreateSessionRequest { + view: Default::default(), cwd: cwd.to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index a1eeed3..4ef6071 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -174,6 +174,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() { assert!(empty.is_empty()); let req = CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), @@ -390,6 +391,7 @@ async fn patch_session_renames_it() { let session = h .manager .create(CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), @@ -432,6 +434,7 @@ async fn post_focus_stamps_last_focused_at() { let session = h .manager .create(CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), @@ -465,6 +468,7 @@ async fn delete_owned_session_kills_it() { let session = h .manager .create(CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), @@ -486,6 +490,7 @@ async fn delete_owned_session_kills_it() { async fn spawn_via_manager(h: &Harness) -> Uuid { h.manager .create(CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), @@ -595,6 +600,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the let session = h .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 60".into(), env: HashMap::new(), @@ -647,6 +653,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() { let session = h .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 60".into(), env: HashMap::new(), @@ -680,6 +687,7 @@ async fn attach_ws_streams_pty_bytes() { let session = h .manager .create(CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "bash --noprofile --norc".into(), env: HashMap::new(), @@ -748,6 +756,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() { let created = h .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -792,6 +801,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() { h.manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), diff --git a/crates/maestro-daemon/tests/rename.rs b/crates/maestro-daemon/tests/rename.rs index 1b48655..6a6cae1 100644 --- a/crates/maestro-daemon/tests/rename.rs +++ b/crates/maestro-daemon/tests/rename.rs @@ -51,6 +51,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid { }; manager .create(CreateSessionRequest { + view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: Default::default(), diff --git a/crates/maestro-daemon/tests/session_decisions.rs b/crates/maestro-daemon/tests/session_decisions.rs index 04f4516..d7e2d20 100644 --- a/crates/maestro-daemon/tests/session_decisions.rs +++ b/crates/maestro-daemon/tests/session_decisions.rs @@ -64,6 +64,7 @@ impl Fx { fn request(&self, command: &str) -> CreateSessionRequest { CreateSessionRequest { + view: Default::default(), cwd: self.tmp.path().to_path_buf(), command: command.into(), env: HashMap::new(), diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 650dc34..51cf0fc 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -22,6 +22,7 @@ async fn create_then_kill_round_trip() { let session = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -63,6 +64,7 @@ async fn create_appends_to_spawn_history() { let session = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -92,6 +94,7 @@ async fn create_without_history_logs_nothing() { let cwd = TempDir::new().unwrap(); fx.manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -114,6 +117,7 @@ async fn recover_adopts_existing_tmux_sessions() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -153,6 +157,7 @@ async fn create_captures_initial_output_in_replay_buffer() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), // The shell prints the marker before doing anything else and // 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 .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), // Print a line, then switch to the alternate screen the way a // 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 .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), // Alternate screen, then button-event tracking (?1002) with SGR // 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 .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "bash --noprofile --norc".into(), env: HashMap::new(), @@ -373,6 +381,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "bash --noprofile --norc".into(), env: HashMap::new(), @@ -423,6 +432,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -488,6 +498,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() { fx.manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -580,6 +591,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -617,6 +629,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -680,6 +693,7 @@ async fn killing_refuses_until_the_branch_is_pushed() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -729,6 +743,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -774,6 +789,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -814,6 +830,7 @@ async fn killing_an_ignored_only_worktree_succeeds() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -855,6 +872,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -916,6 +934,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -976,6 +995,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -1010,6 +1030,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() { let first = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: repo.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -1027,6 +1048,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() { let second = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: inner_worktree.clone(), command: "sleep 30".into(), env: HashMap::new(), @@ -1065,6 +1087,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -1103,6 +1126,7 @@ async fn a_freshly_created_session_has_never_been_focused() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -1128,6 +1152,7 @@ async fn set_focused_true_stamps_last_focused_at() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -1176,6 +1201,7 @@ async fn attaching_stamps_last_focused_at() { let s = fx .manager .create(CreateSessionRequest { + view: Default::default(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index 2934d3a..4aa7ff2 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; use maestro_daemon::sidecar::SidecarStore; -use maestro_protocol::{Session, SessionStatus}; +use maestro_protocol::{Session, SessionStatus, SessionView}; use serde_json::Value; use tempfile::TempDir; use uuid::Uuid; @@ -32,6 +32,7 @@ fn sample(id: Uuid) -> Session { current_cwd: Some(PathBuf::from("/tmp/work/crates")), blocked_work: 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()); } +#[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] fn write_creates_parent_directory() { let dir = TempDir::new().unwrap(); diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index d883908..6fc5a15 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -27,6 +27,7 @@ async fn bring_up() -> (Daemon, TestClient) { fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest { CreateSessionRequest { + view: Default::default(), cwd: cwd.to_path_buf(), command: command.into(), 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`. fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest { CreateSessionRequest { + view: Default::default(), cwd: base.to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -143,6 +145,7 @@ async fn kill_removes_clean_worktree() { init_repo(base.path()); let req = CreateSessionRequest { + view: Default::default(), cwd: base.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -196,6 +199,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() { .join("unsaved"); let req = CreateSessionRequest { + view: Default::default(), cwd: base.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -254,6 +258,7 @@ async fn spawn_on_existing_branch_checks_it_out() { run_git(&["branch", "feature/existing"], base.path()); let req = CreateSessionRequest { + view: Default::default(), cwd: base.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -754,6 +759,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() { let _dirty = client .spawn_session(&CreateSessionRequest { + view: Default::default(), cwd: dirty_repo.path().to_path_buf(), command: "sh -c 'echo unsaved > scratch.txt'".into(), env: HashMap::new(), @@ -802,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 { CreateSessionRequest { + view: Default::default(), cwd: cwd.to_path_buf(), command: command.into(), env: HashMap::new(), diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index 9aac71b..f7d38b8 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -268,6 +268,7 @@ mod tests { fn session(cwd: &str) -> Session { Session { + view: Default::default(), id: Uuid::from_u128(1), cwd: PathBuf::from(cwd), command: "bash".into(), diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 465a3b2..ead867b 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -116,6 +116,24 @@ pub struct Session { /// sorts a never-focused session after every session that has one. #[serde(default)] pub last_focused_at: Option>, + /// 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. @@ -352,6 +370,11 @@ pub struct CreateSessionRequest { pub metadata: HashMap, #[serde(default)] pub worktree: Option, + /// 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. @@ -862,6 +885,7 @@ mod tests { current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), blocked_work: None, last_focused_at: None, + view: SessionView::Terminal, } } @@ -1073,6 +1097,7 @@ mod tests { branch: "feat/foo".into(), create_branch: true, }), + view: SessionView::Terminal, }; assert_eq!(req, round_trip(&req)); } @@ -1087,6 +1112,26 @@ mod tests { assert!(req.worktree.is_none()); assert!(req.env.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 diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 5a192fa..35c233d 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -31,6 +31,7 @@ use uuid::Uuid; /// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`]. pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { Session { + view: Default::default(), id, cwd: cwd.into(), command: command.into(), diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 22a26ec..b52d4fd 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1882,6 +1882,7 @@ mod tests { sessions: ids .iter() .map(|id| Session { + view: Default::default(), id: *id, command: "bash".into(), cwd: std::path::PathBuf::from("/"), @@ -2063,6 +2064,7 @@ mod tests { let mut app = make_app(); app.catalog.apply_event(CatalogEvent::Snapshot { sessions: vec![Session { + view: Default::default(), id: sid, command: "bash".into(), cwd: std::path::PathBuf::from("/"), @@ -2090,6 +2092,7 @@ mod tests { use chrono::Utc; use maestro_protocol::{Session, SessionStatus}; Session { + view: Default::default(), id: sid, command: "bash".into(), cwd: cwd.into(), @@ -2777,6 +2780,7 @@ mod tests { ); app.dispatch(Action::OpenSpawnModal); let req = CreateSessionRequest { + view: Default::default(), cwd: std::path::PathBuf::from("/srv/work"), command: "bash".into(), env: Default::default(), @@ -3195,6 +3199,7 @@ mod tests { use chrono::Utc; use maestro_protocol::{CatalogEvent, Session, SessionStatus}; let make = |n: u128, name: &str, focused: Option>| Session { + view: Default::default(), id: Uuid::from_u128(n), command: "bash".into(), cwd: std::path::PathBuf::from("/"), @@ -3475,6 +3480,7 @@ mod tests { let sid = Uuid::from_u128(7); app.catalog.apply_event(CatalogEvent::Snapshot { sessions: vec![Session { + view: Default::default(), id: sid, command: "bash".into(), cwd: std::path::PathBuf::from("/"), @@ -3511,6 +3517,7 @@ mod tests { }; app.catalog.apply_event(CatalogEvent::Snapshot { sessions: vec![Session { + view: Default::default(), id: sid, command: "bash".into(), cwd: std::path::PathBuf::from("/"), diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index 36f39d5..9a78c84 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -207,6 +207,7 @@ mod tests { fn session(id: u128, command: &str) -> Session { Session { + view: Default::default(), id: Uuid::from_u128(id), command: command.into(), cwd: std::path::PathBuf::from("/tmp"), diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs index 3b2081b..883e3d1 100644 --- a/crates/maestro-tui/src/command_bar.rs +++ b/crates/maestro-tui/src/command_bar.rs @@ -793,6 +793,7 @@ mod tests { fn session(n: u128, command: &str) -> Session { Session { + view: Default::default(), id: Uuid::from_u128(n), cwd: PathBuf::from("/home/me/dev/api"), command: command.to_string(), diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index e89e562..a9ccee7 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -694,6 +694,7 @@ impl DraftPane { } let worktree = self.build_worktree()?; Ok(CreateSessionRequest { + view: Default::default(), cwd, command: command.to_string(), env: Default::default(), diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs index b4b6004..cd87800 100644 --- a/crates/maestro-tui/src/fast_forward.rs +++ b/crates/maestro-tui/src/fast_forward.rs @@ -108,6 +108,7 @@ impl FastForwardDefaults { return None; } Some(CreateSessionRequest { + view: Default::default(), cwd: project.path.clone(), command: command.to_string(), env: Default::default(), diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index e739907..c22d3aa 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -484,6 +484,7 @@ mod tests { let mut metadata = HashMap::new(); metadata.insert("name".into(), serde_json::Value::String(name.into())); Session { + view: Default::default(), id: Uuid::from_u128(id), command: "bash".into(), cwd: std::path::PathBuf::from("/tmp"), @@ -1250,6 +1251,7 @@ mod tests { #[test] fn falls_back_to_command_when_name_metadata_is_absent() { let s = Session { + view: Default::default(), id: Uuid::from_u128(1), command: "vim".into(), cwd: std::path::PathBuf::from("/tmp"),