diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 136d61c9..dda45138 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -267,6 +267,17 @@ pub enum ClientRequest { req_id: u64, id: AgentId, }, + /// The user moved a session's card onto another checkout, of its own + /// project or another one: the card menu's **Move to…**, or a drag + /// onto another band or project tab. + /// The row moves at once and a live session follows, resumed there: + /// straight away when idle, at its turn's end when mid-turn. Answered + /// with `Ack`. + MoveAgent { + req_id: u64, + id: AgentId, + worktree: WorktreeId, + }, /// Queue a message on the Claude Cloud session a row launched /// (`claude -p --cloud `). Fire-and-forget by nature: the /// CLI acknowledges the send and returns, and the reply only ever diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index 0bb7712d..596c14d8 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -114,6 +114,17 @@ struct PrewarmEntry { buffered_hooks: Vec<(HookEvent, Option)>, } +/// A relocation waiting on its agent's turn end (`Daemon::pending_moves`). +#[derive(Debug, Clone)] +struct PendingMove { + target: Worktree, + /// Respawn on the relocation notice, so the turn carries on in the + /// target: `nebula worktree`, which the agent ran mid-task. A move the + /// user made resumes silent, since the turn it waited out never asked + /// to move. + notice: bool, +} + pub struct Daemon { sessions: Mutex>>, status_machines: Mutex>, @@ -161,7 +172,8 @@ pub struct Daemon { /// on the turn-end hook (kill + respawn resumed in the target), cleared /// by any other spawn of the agent, and consulted by the cwd reparent so /// the old checkout's cwd can't drag the row back in the meantime. - pending_moves: Mutex>, + /// A user's mid-turn `move_agent` waits here too. + pending_moves: Mutex>, /// Serializes the check-and-spawn inside [`Daemon::ensure_session`]. /// Attach (the request loop) and the worktree prewarm sweep (its own /// task) can both reach for the same dead session; without this they @@ -1563,10 +1575,13 @@ impl Daemon { // reports until it respawns is the old checkout's. self.last_cwd.lock().unwrap().remove(id); if alive { - self.pending_moves - .lock() - .unwrap() - .insert(id.clone(), target.clone()); + self.pending_moves.lock().unwrap().insert( + id.clone(), + PendingMove { + target: target.clone(), + notice: true, + }, + ); } self.store.set_agent_worktree(id, &target.id)?; self.broadcast_agent(id)?; @@ -1578,6 +1593,50 @@ impl Daemon { Ok((target, outcome)) } + /// The user moved this session's card onto another checkout, of its + /// own project or another one (the card menu's **Move to…**, or a drag + /// onto another band or project tab). The row moves now. A live session mid-turn follows at that turn's + /// end, as `nebula worktree` does but resumed silent: that turn never + /// asked to move. An idle one is killed and resumed in the target at + /// once, since no turn end is coming to wait for. A dead one boots + /// there on its next launch. + pub fn move_agent(self: &Arc, id: &AgentId, worktree: &WorktreeId) -> Result<()> { + let agent = self.store.get_agent(id)?.context("agent not found")?; + if agent.archived { + bail!("agent is archived"); + } + let target = self + .store + .get_worktree(worktree)? + .context("worktree not found")?; + if target.id == agent.worktree_id { + return Ok(()); + } + let alive = self.session(&SessionRef::Agent(id.clone())).is_some(); + let mid_turn = matches!( + agent.status, + AgentStatus::Running | AgentStatus::NeedsFeedback + ); + // Same invalidation as `enter_worktree`: every cwd this process + // reports until it respawns is the old checkout's. + self.last_cwd.lock().unwrap().remove(id); + if alive && mid_turn { + self.pending_moves.lock().unwrap().insert( + id.clone(), + PendingMove { + target: target.clone(), + notice: false, + }, + ); + } + self.store.set_agent_worktree(id, &target.id)?; + self.broadcast_agent(id)?; + if alive && !mid_turn { + self.relocate_into(id, &target, false); + } + Ok(()) + } + /// The turn an agent ran `nebula worktree` in has ended: make the /// process match its row. Kill it and respawn it resumed in the target, /// with a prompt naming the checkout it now runs in so the conversation @@ -1607,10 +1666,11 @@ impl Daemon { if !turn_over { return; } - let Some(target) = self.pending_moves.lock().unwrap().remove(id) else { + let Some(PendingMove { target, notice }) = self.pending_moves.lock().unwrap().remove(id) + else { return; }; - if self.relocate_into(id, &target) { + if self.relocate_into(id, &target, notice) { // Working from the moment it boots, on the notice: seeded with // the launch reprieve so its startup progress-clear cannot // green it out before that turn begins (see `create_agent`). @@ -1623,10 +1683,11 @@ impl Daemon { } } - /// The kill-and-respawn of [`Self::complete_pending_move`]. True when - /// the respawn opened on the relocation notice — the one outcome that - /// carries on the turn the status machine held. - fn relocate_into(self: &Arc, id: &AgentId, target: &Worktree) -> bool { + /// The kill-and-respawn of [`Self::complete_pending_move`] and of an + /// idle [`Self::move_agent`]. `notice` asks for the relocation notice. + /// True when the respawn opened on it: the one outcome that carries on + /// the turn the status machine held. + fn relocate_into(self: &Arc, id: &AgentId, target: &Worktree, notice: bool) -> bool { let agent = match self.store.get_agent(id) { Ok(Some(agent)) if !agent.archived && agent.worktree_id == target.id => agent, // Archived, deleted, or moved elsewhere by hand since: the @@ -1646,7 +1707,7 @@ impl Daemon { // itself refuses with the entry's reason, so the notice degrades // to none rather than failing the move. let prompt = resolve_harness(agent.kind, agent.custom_harness.as_deref()) - .map(|harness| relocation_prompt(harness.relocation_prompt, target)) + .map(|harness| relocation_prompt(notice && harness.relocation_prompt, target)) .unwrap_or(None); let spawned = self.spawn_agent_session_with( &agent, @@ -5434,7 +5495,13 @@ mod tests { .pending_moves .lock() .unwrap() - .insert(a1.clone(), feat); + .insert( + a1.clone(), + PendingMove { + target: feat, + notice: true, + }, + ); daemon.reparent_agent_by_cwd(&a1, "/nebula-test/p", Some("s1"), false); assert_eq!( @@ -5572,7 +5639,13 @@ mod tests { .pending_moves .lock() .unwrap() - .insert(a1.clone(), feat); + .insert( + a1.clone(), + PendingMove { + target: feat, + notice: true, + }, + ); let status = |id: &AgentId| daemon.store.get_agent(id).unwrap().unwrap().status; daemon.apply_hook_event(&a1, HookEvent::Stop, Some("s1".into())); @@ -5583,6 +5656,95 @@ mod tests { assert_eq!(status(&a1), AgentStatus::Finished); } + /// A dead session's move is the row alone, into its own project's + /// checkouts or another project's; the one it is already in is a no-op. + #[test] + fn move_agent_rehomes_a_dead_row_across_checkouts_and_projects() { + let daemon = test_daemon(); + seed_projects(&daemon, &["p", "q"]); + seed_worktree(&daemon, "p", "root", "/nebula-test/p", true); + seed_worktree(&daemon, "p", "feat", "/nebula-test/p-feat", false); + seed_worktree(&daemon, "q", "other", "/nebula-test/q", true); + seed_agent(&daemon, "a1", "root", Some("s1")); + let a1 = AgentId("a1".into()); + + daemon.move_agent(&a1, &WorktreeId("feat".into())).unwrap(); + assert_eq!(agent_worktree(&daemon, "a1"), "feat"); + assert!(!daemon.relocation_pending(&a1), "nothing runs to follow"); + + daemon.move_agent(&a1, &WorktreeId("feat".into())).unwrap(); + assert_eq!(agent_worktree(&daemon, "a1"), "feat"); + + daemon.move_agent(&a1, &WorktreeId("other".into())).unwrap(); + assert_eq!(agent_worktree(&daemon, "a1"), "other"); + } + + /// A live session: idle, it is respawned in the target at once; mid + /// turn, it waits for the turn end and then resumes silent, so the + /// held Stop finishes the row instead of a notice carrying it on. + #[tokio::test] + async fn move_agent_relocates_an_idle_session_now_and_a_busy_one_at_turn_end() { + let daemon = test_daemon(); + let (dir, main) = run_worktree(&daemon); + let feat_dir = tempfile::tempdir().unwrap(); + let feat = Worktree { + id: WorktreeId::generate(), + project_id: main.project_id.clone(), + path: feat_dir.path().to_path_buf(), + branch: "feat".into(), + is_main: false, + sort_order: 1, + }; + daemon.store.insert_worktree(&feat).unwrap(); + let _cmd = EnvGuard::set(env::AGENT_CMD, "/bin/cat"); + let EntityId::Agent(id) = daemon + .create_agent(CreateAgentSpec { + worktree: main.id.clone(), + name: "a".into(), + kind: AgentKind::Claude, + custom_harness: None, + model: None, + effort: None, + auto_title: false, + cloud_prompt: None, + starting_prompt: None, + pr_url: None, + issue_url: None, + }) + .await + .unwrap() + else { + panic!("a create makes an agent"); + }; + let sref = SessionRef::Agent(id.clone()); + let status = |id: &AgentId| daemon.store.get_agent(id).unwrap().unwrap().status; + + // Mid-turn: the row moves, the PTY waits for the turn's end. + daemon.apply_hook_event(&id, HookEvent::UserPromptSubmit, Some("s1".into())); + daemon.move_agent(&id, &feat.id).unwrap(); + assert_eq!(agent_worktree(&daemon, &id.to_string()), feat.id.to_string()); + assert!(daemon.relocation_pending(&id)); + let first = daemon.session(&sref).expect("still the first PTY"); + daemon.apply_hook_event(&id, HookEvent::Stop, Some("s1".into())); + daemon.complete_pending_move(&id, &HookEvent::Stop); + assert!(!daemon.relocation_pending(&id)); + let second = daemon.session(&sref).expect("respawned in the target"); + assert!(!Arc::ptr_eq(&first, &second), "a new PTY"); + assert_eq!( + status(&id), + AgentStatus::Finished, + "no notice carries the turn on" + ); + + // Idle: back to the root checkout straight away. + daemon.move_agent(&id, &main.id).unwrap(); + assert!(!daemon.relocation_pending(&id)); + assert_eq!(agent_worktree(&daemon, &id.to_string()), main.id.to_string()); + let third = daemon.session(&sref).expect("respawned in the root"); + assert!(!Arc::ptr_eq(&second, &third), "a new PTY"); + drop(dir); + } + #[tokio::test] async fn enter_worktree_creates_the_checkout_in_nebulas_layout() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 27d5b918..b0babacf 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -529,6 +529,13 @@ async fn handle_client(daemon: Arc, stream: UnixStream) -> Result<()> { ClientRequest::RestartAgent { req_id, id } => { reply_done(&out_tx, req_id, daemon.restart_agent(&id).await).await; } + ClientRequest::MoveAgent { + req_id, + id, + worktree, + } => { + reply_done(&out_tx, req_id, daemon.move_agent(&id, &worktree)).await; + } ClientRequest::SendCloudMessage { req_id, id, diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 6c8e5be3..c9431340 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -301,6 +301,15 @@ pub enum MenuAction { /// settings — `launcher::duplicate_agent`, what `⇧P` runs on the card /// under the cursor. Carries the id, as the row's other verbs do. DuplicateAgent(AgentId), + /// A card menu's **Move to…**: the MOVE PICKER of the checkouts + /// session `id` can move to, what `m` on the card opens. + MoveAgentPicker(AgentId), + /// A MOVE PICKER row, or a card dropped on another band: move the + /// session into `worktree` (`ClientRequest::MoveAgent`). + MoveAgent { + id: AgentId, + worktree: WorktreeId, + }, EditLink(LinkId), DeleteLink(LinkId), DeleteWorktree(WorktreeId), @@ -408,6 +417,22 @@ impl MenuItem { } } +/// A session card held under the pressed mouse button ([`App::card_drag`]). +#[derive(Debug, Clone, PartialEq)] +pub struct CardDrag { + pub agent: AgentId, + /// The cell the press landed on: the drag starts once the pointer + /// leaves it, so a plain click never becomes one. + pub from: (u16, u16), + pub active: bool, + /// The band under the pointer, when it is another checkout the card + /// can move to: where the release drops it. + pub over: Option, + /// The PROJECT TAB under the pointer instead: `over` is then that + /// project's first checkout, its root unless the card is already there. + pub over_tab: Option, +} + #[derive(Debug, Clone)] pub struct ContextMenu { /// Optional title rendered in the border (used by picker-style menus). @@ -627,6 +652,13 @@ pub enum PendingAction { /// is answered. ArchiveAgent(AgentId), DeleteAgent(AgentId), + /// A session card dropped on another band or PROJECT TAB, with the + /// **Confirm drag move** SETTING on: move it once the dialog is + /// answered. + MoveAgent { + id: AgentId, + worktree: WorktreeId, + }, CloseTerminal(TerminalId), DeleteWorktree(WorktreeId), /// A row delete that empties a linked worktree — the last card of the @@ -3351,6 +3383,9 @@ pub struct App { /// mouse-down, so the edge tracks the pointer instead of jumping by /// one depending on which of the two grab rows was caught. pub launcher_pane_drag: Option, + /// A session card pressed on the grid, which a drag carries onto + /// another band to move the session there (`MoveAgent`). + pub card_drag: Option, /// That edge is under the mouse, or being dragged: its grip lights up. /// Only ever set in terminals that report plain mouse motion; /// elsewhere the grip rests until a drag takes hold. @@ -3869,6 +3904,7 @@ impl App { launcher_scroll_in: None, launcher_reveal: false, launcher_pane_drag: None, + card_drag: None, hover_launcher_pane: false, hover_crumb: None, launcher_tabs: Vec::new(), @@ -4499,6 +4535,7 @@ impl App { /// with no button named is still the drag. pub fn mouse_held(&self) -> bool { let splitter = self.launcher_pane_drag.is_some() + || self.card_drag.is_some() || match &self.overlay { Some(Overlay::Diff(view)) => view.files_drag.is_some(), Some(Overlay::Tree(view)) => view.files_drag.is_some(), diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index 37f460a0..b58f3d5b 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -412,6 +412,7 @@ pub enum SettingKind { OpenCommand, RememberHarness, HideUninstalledHarnesses, + ConfirmDragMove, } /// One harness field row in the Agents tab. The tab renders one section @@ -503,6 +504,7 @@ impl SettingKind { | SettingKind::CardIssueNumber => (2026, 9, 24), SettingKind::ExpandAllWorktrees | SettingKind::FollowNewSession => (2026, 9, 26), SettingKind::HighlightCurrentCard => (2026, 9, 28), + SettingKind::ConfirmDragMove => (2026, 10, 2), } } @@ -629,6 +631,12 @@ pub const SETTINGS_TABS: &[SettingsTab] = &[ hint: "Every worktree gets a band on the grid, even an empty one (d deletes it); deleting a last card keeps the worktree unless Delete emptied worktree is on", group: "", }, + SettingSpec { + kind: SettingKind::ConfirmDragMove, + label: "Confirm drag move", + hint: "A session card dropped on another worktree or project tab asks before it moves (off = the drop moves it at once; m's picker never asks)", + group: "", + }, ]), }, SettingsTab { @@ -1302,6 +1310,12 @@ pub struct Config { /// outranks it — a launch that enters the new session's pane has to /// go there. pub follow_new_session: bool, + /// CONFIRM DRAG MOVE: a session card dragged onto another band or + /// PROJECT TAB asks before the session moves, since a drop is easy to + /// make by accident and the move restarts the CLI. On by default, a + /// config predating the key too. The MOVE PICKER (`m`) never asks: a + /// pick and Enter are already deliberate. + pub confirm_drag_move: bool, /// Whether each new QUICK PROMPT starts aimed at a fresh worktree /// rather than the checkout under the grid's cursor (the project's /// ROOT BRANCH when nothing is aimed at — `launcher::target_for`). @@ -1468,6 +1482,7 @@ impl Default for Config { quick_prompt_kind: AgentKind::Claude.as_str().into(), quick_prompt_focus: false, follow_new_session: true, + confirm_drag_move: true, quick_prompt_new_worktree: false, keybindings: BTreeMap::new(), skipped: BTreeSet::new(), @@ -2247,6 +2262,7 @@ impl Config { SettingKind::QuickPromptKind => self.quick_prompt_kind.clone(), SettingKind::QuickPromptFocus => on_off(self.quick_prompt_focus).into(), SettingKind::FollowNewSession => on_off(self.follow_new_session).into(), + SettingKind::ConfirmDragMove => on_off(self.confirm_drag_move).into(), SettingKind::QuickPromptNewWorktree => on_off(self.quick_prompt_new_worktree).into(), } } @@ -2371,6 +2387,9 @@ impl Config { SettingKind::FollowNewSession => { self.follow_new_session = !self.follow_new_session; } + SettingKind::ConfirmDragMove => { + self.confirm_drag_move = !self.confirm_drag_move; + } SettingKind::QuickPromptNewWorktree => { self.quick_prompt_new_worktree = !self.quick_prompt_new_worktree; } @@ -3926,6 +3945,23 @@ mod tests { /// FOLLOW NEW SESSION starts on, sits under the QUICK PROMPT's Focus /// row on the Agents tab, toggles like any bool and persists under its /// own key; a config predating the key reads as on. + #[test] + fn confirm_drag_move_is_on_by_default_and_persists() { + let mut cfg = Config::default(); + assert!(cfg.confirm_drag_move, "a drop asks before it moves"); + let (tab, row) = locate(SettingKind::ConfirmDragMove).unwrap(); + assert_eq!(SETTINGS_TABS[tab].title, "Sessions"); + cfg.cycle(tab, row, 0); + assert_eq!(cfg.value_label(SettingKind::ConfirmDragMove), "off"); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + cfg.save_to(&path).unwrap(); + assert!(!load_from(&path).confirm_drag_move, "off survives a save"); + let cfg: Config = serde_json::from_str("{}").unwrap(); + assert!(cfg.confirm_drag_move, "a missing key reads as on"); + } + #[test] fn follow_new_session_is_on_by_default_and_persists() { let mut cfg = Config::default(); diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 4f16747d..10765779 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -3414,6 +3414,7 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec) { Action::OpenPullRequest => launcher::open_pull_request(app, out), Action::OpenIssue => launcher::open_issue(app, out), Action::DuplicateSession => launcher::duplicate_session(app), + Action::MoveSession => launcher::move_session(app), Action::OpenGhosttyTab => open_ghostty_tab(app), // Shift+Enter / Shift+O: the selected worktree's OPEN COMMAND, from // any panel — the cursor's worktree is the context wherever the @@ -4985,6 +4986,7 @@ fn menu_items_for_session(a: &nebula_core::Agent) -> Vec { MenuItem::new("Follow-up prompt", MenuAction::FollowUp), MenuItem::new("Restart", MenuAction::RestartAgent(a.id.clone())), MenuItem::new("Duplicate", MenuAction::DuplicateAgent(a.id.clone())), + MenuItem::new("Move to…", MenuAction::MoveAgentPicker(a.id.clone())), MenuItem::new("Rename", MenuAction::RenameAgent(a.id.clone())), MenuItem::new("Archive", MenuAction::ArchiveAgent(a.id.clone())), MenuItem::destructive("Delete", MenuAction::DeleteAgent(a.id.clone())), @@ -6879,6 +6881,7 @@ fn run_pending_action(app: &mut App, action: PendingAction, out: &mut Vec archive_agent_now(app, id, out), PendingAction::DeleteAgent(id) => delete_agent(app, id, out), + PendingAction::MoveAgent { id, worktree } => launcher::move_agent(app, id, worktree, out), PendingAction::CloseTerminal(id) => close_terminal(app, id, out), PendingAction::DeleteLink(id) => { send(app, out, |req_id| ClientRequest::DeleteLink { req_id, id }); @@ -7088,6 +7091,8 @@ fn run_menu_action(app: &mut App, action: MenuAction, out: &mut Vec open_prompt(app, PromptKind::CloudMessage { id }), MenuAction::DuplicateAgent(id) => launcher::duplicate_agent(app, id), + MenuAction::MoveAgentPicker(id) => launcher::open_move_picker(app, id), + MenuAction::MoveAgent { id, worktree } => launcher::move_agent(app, id, worktree, out), MenuAction::RenameAgent(id) => open_prompt(app, PromptKind::RenameAgent { id }), MenuAction::ArchiveAgent(id) => archive_agent(app, id), MenuAction::UnarchiveAgent(id) => activate::unarchive(app, id, out), @@ -7390,8 +7395,9 @@ fn restore_session(app: &mut App, out: &mut Vec) { } } -/// Land the selection on `select_when_seen` — a session just created, or -/// moved into another worktree of this project: directly when its row is +/// Land the selection on `select_when_seen`: a session just created, or +/// one moved into another worktree (of another project too, whose grid +/// it then opens). Directly when its row is /// visible under the selected worktree, else by switching to the worktree it /// landed under first. Clears the pending follow once it lands; a no-op /// until the session's upsert has arrived. @@ -7426,6 +7432,19 @@ fn land_pending_selection(app: &mut App, out: &mut Vec) { .map(|t| t.worktree_id.clone()), }; if let Some(wt_id) = landed_worktree { + // Moved into another project (the MOVE PICKER, a drop on a + // PROJECT TAB): the grid goes there with it. + let project = app + .tree + .worktrees + .iter() + .find(|w| w.id == wt_id) + .map(|w| w.project_id.clone()); + if let Some(project) = project { + if app.selected_project().map(|p| &p.id) != Some(&project) { + select_project_row_by_id(app, &project); + } + } if select_worktree_by_id(app, &wt_id, out) { if let Some(index) = app .visible_session_rows() @@ -9609,6 +9628,7 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) app.term_selection = None; app.next_drag_autoscroll = None; app.term_mouse_grab = None; + app.card_drag = None; match app.hit_at(mouse.column, mouse.row) { Some(HitTarget::LauncherPaneSplitter) => { // A second press on the edge within the double-click @@ -9634,7 +9654,13 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) // worktree; a second click is Enter, down into the PANE // beside the cards — which comes back first if it was // folded away. - Some(HitTarget::LauncherCard(at)) => launcher::click_card(app, at, out), + // The press also arms a drag of the card onto another + // band, which moves its session there. Armed first: the + // card is found by the grid as drawn, before the click. + Some(HitTarget::LauncherCard(at)) => { + launcher::press_card(app, at, (mouse.column, mouse.row)); + launcher::click_card(app, at, out); + } // A BAND's rule: the cursor lands on the band, as `j`/`k` // walking onto it do. Some(HitTarget::LauncherBand(i)) => launcher::click_band(app, i, out), @@ -9773,7 +9799,9 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) app.dirty = true; } MouseEventKind::Drag(MouseButton::Left) => { - if let Some(grab) = app.launcher_pane_drag { + if app.card_drag.is_some() { + launcher::drag_card(app, (mouse.column, mouse.row)); + } else if let Some(grab) = app.launcher_pane_drag { let at = app.launcher_pane_side().along(mouse.column, mouse.row); app.set_launcher_pane(at + grab); // A press that became a drag is not the first half of a @@ -9806,6 +9834,8 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) let pane_ended = app.launcher_pane_drag.take().is_some(); if pane_ended { app.dirty = true; + } else if app.card_drag.is_some() { + launcher::drop_card(app, out); } else if let Some(sref) = app.term_mouse_grab.take() { // The release closes the program's button — except under // press-only tracking (`?9h`), which has no release report. @@ -10314,8 +10344,8 @@ fn handle_server_event(app: &mut App, event: ServerEvent, out: &mut Vec open_issue(app, out), // `⇧P` on a card: another session with its settings, nothing typed. Action::DuplicateSession => duplicate_session(app), + // `m` on a card: the checkouts it can move to. + Action::MoveSession => move_session(app), // The fold is a preference the view keeps, and its key is the way // out of the pane first ([`fold_key`]). Action::ToggleLauncherPane => { @@ -1738,6 +1740,152 @@ pub(super) fn click_card(app: &mut App, at: CardRef, out: &mut Vec Some(CardDrag { + agent: row.agent.id.clone(), + from, + active: false, + over: None, + over_tab: None, + }), + _ => None, + }; +} + +/// The held card moved to `pos`: the band under the pointer becomes the +/// drop target, unless it is the card's own checkout or a stand-in. A +/// PROJECT TAB under it aims at that project's first checkout, its root. +pub(super) fn drag_card(app: &mut App, pos: (u16, u16)) { + let Some(drag) = &app.card_drag else { + return; + }; + if !drag.active && pos == drag.from { + return; + } + let band = |i: usize| view::bands(app).get(i).map(|b| b.worktree.clone()); + let mut over_tab = None; + let over = match app.hit_at(pos.0, pos.1) { + Some(HitTarget::LauncherCard(at)) => band(at.band), + Some( + HitTarget::LauncherBand(i) + | HitTarget::LauncherBandMore(i) + | HitTarget::LauncherStripLeft(i) + | HitTarget::LauncherStripRight(i), + ) => band(i), + Some(HitTarget::LauncherBandPr(w)) => Some(w), + // The air under a band, down to the next one or the grid's foot: + // the band above it, so an empty band is a target past its rule. + Some(HitTarget::PanelBg(Focus::Sessions)) => app + .hits + .iter() + .filter_map(|(r, hit)| match hit { + HitTarget::LauncherBand(i) + if r.y <= pos.1 && (r.x..r.x + r.width).contains(&pos.0) => + { + Some((r.y, *i)) + } + _ => None, + }) + .max() + .and_then(|(_, i)| band(i)), + Some(HitTarget::LauncherTab(project)) => { + let first = picker_checkouts(app, &project) + .first() + .map(|w| w.id.clone()); + over_tab = Some(project); + first + } + _ => None, + }; + let home = app + .tree + .agents + .iter() + .find(|a| a.id == drag.agent) + .map(|a| a.worktree_id.clone()); + let over = over.filter(|w| Some(w) != home.as_ref() && !app.is_placeholder_worktree(w)); + if let Some(drag) = &mut app.card_drag { + drag.active = true; + drag.over = over; + drag.over_tab = over_tab; + } + app.dirty = true; +} + +/// The button let go: a drag that ended over another band moves the +/// session there ([`move_agent`]), asking first while the **Confirm drag +/// move** SETTING is on ([`confirm_move`]). A press that never left its +/// card was the click it already ran as. +pub(super) fn drop_card(app: &mut App, out: &mut Vec) { + let Some(drag) = app.card_drag.take() else { + return; + }; + if !drag.active { + return; + } + // A drag is not the first half of a double-click on the card. + app.last_session_click = None; + app.dirty = true; + if let Some(worktree) = drag.over { + if crate::config::Config::load().confirm_drag_move { + confirm_move(app, drag.agent, worktree); + } else { + move_agent(app, drag.agent, worktree, out); + } + } +} + +/// The dialog a drop opens before [`move_agent`] runs, saying where the +/// session goes and that its CLI restarts there. +fn confirm_move(app: &mut App, id: AgentId, worktree: WorktreeId) { + let Some((name, dest, mid_turn)) = move_plan(app, &id, &worktree) else { + return; + }; + let when = if mid_turn { + " once its current turn ends" + } else { + "" + }; + app.overlay = Some(Overlay::Confirm(ConfirmDialog { + title: "Move session".into(), + message: format!( + "Move '{name}' to {dest}? It restarts there{when}, on the same conversation." + ), + action: PendingAction::MoveAgent { id, worktree }, + area: ratatui::layout::Rect::default(), + })); +} + +/// Moving session `id` into `worktree` as the flash and the confirm name +/// it: the session's name, where it goes (`project ▸ branch` when the +/// project changes) and whether it waits for its turn to end first. None +/// when there is nothing to move. +fn move_plan(app: &App, id: &AgentId, worktree: &WorktreeId) -> Option<(String, String, bool)> { + let agent = app.tree.agents.iter().find(|a| &a.id == id)?; + if &agent.worktree_id == worktree { + return None; + } + let target = app.tree.worktrees.iter().find(|w| &w.id == worktree)?; + let home = app.tree.worktrees.iter().find(|w| w.id == agent.worktree_id); + let dest = match app.tree.projects.iter().find(|p| p.id == target.project_id) { + Some(p) if home.is_none_or(|h| h.project_id != p.id) => { + format!("{} ▸ {}", p.name, target.branch) + } + _ => target.branch.clone(), + }; + let mid_turn = agent.alive + && matches!( + agent.status, + nebula_core::AgentStatus::Running | nebula_core::AgentStatus::NeedsFeedback + ); + Some((agent.name.clone(), dest, mid_turn)) +} + /// A click on a BAND's rule: the cursor onto that band, the pane on its /// remembered card — what `j`/`k` walking onto it do — unfolding the pane /// as a click on a card does. A second click on the same rule opens the @@ -2245,6 +2393,125 @@ fn toggle_new_worktree(app: &mut App, launch: QuickLaunch, input: TextInput) { } } +/// `project`'s checkouts as the pickers list them: the ROOT WORKTREE +/// first and the rest most recently worked in first, as the WORKTREES +/// PANEL lists them, never a stand-in git is still cutting. +fn picker_checkouts<'a>(app: &'a App, project: &ProjectId) -> Vec<&'a nebula_core::Worktree> { + use std::cmp::Reverse; + let mut checkouts: Vec<_> = app + .tree + .worktrees + .iter() + .filter(|w| &w.project_id == project && !app.is_placeholder_worktree(&w.id)) + .collect(); + let now = crate::app::now_ms(); + checkouts.sort_by_key(|w| { + let r = crate::app::worktree_recency(&app.tree, &w.id, now); + ( + Reverse(w.is_main), + Reverse(r.interacted), + Reverse(r.stamped), + ) + }); + checkouts +} + +/// `m` on a card: the MOVE PICKER for the session under the cursor. +/// +/// INPUT PARITY: the card menu's **Move to…** opens the same +/// [`open_move_picker`]. +pub(super) fn move_session(app: &mut App) { + let aimed = !(app.launcher_grid() && app.launcher_unaimed); + let Some(agent) = app.selected_session().filter(|_| aimed) else { + app.flash = Some("no session card to move".into()); + return; + }; + open_move_picker(app, agent.id); +} + +/// The MOVE PICKER: every other checkout of session `id`'s project, then +/// every other project's as `project ▸ branch`, each in [`picker_checkouts`] +/// order. Letters narrow the list; a pick moves the session there +/// ([`move_agent`]). +pub(super) fn open_move_picker(app: &mut App, id: AgentId) { + let Some(agent) = app.tree.agents.iter().find(|a| a.id == id) else { + return; + }; + if agent.archived { + app.flash = Some("an archived session stays where it is: unarchive it first".into()); + return; + } + let Some(project) = app + .tree + .worktrees + .iter() + .find(|w| w.id == agent.worktree_id) + .map(|w| w.project_id.clone()) + else { + app.flash = Some("worktree no longer exists".into()); + return; + }; + let others = app.tree.projects.iter().filter(|p| p.id != project); + let mut items = Vec::new(); + for (name, p) in std::iter::once((None, &project)).chain(others.map(|p| (Some(&p.name), &p.id))) + { + for w in picker_checkouts(app, p) { + if w.id == agent.worktree_id { + continue; + } + let root = if w.is_main { " (root)" } else { "" }; + let at = name.map(|n| format!("{n} ▸ ")).unwrap_or_default(); + items.push(MenuItem::new( + format!("{at}{}{root}", w.branch), + MenuAction::MoveAgent { + id: id.clone(), + worktree: w.id.clone(), + }, + )); + } + } + if items.is_empty() { + app.flash = Some("no other worktree to move to".into()); + return; + } + app.overlay = Some(Overlay::Menu(ContextMenu { + title: Some("Move to".into()), + items: items.clone(), + at: None, + hover: 0, + area: ratatui::layout::Rect::default(), + parent: None, + filter: Some(MenuFilter { + query: String::new(), + all: items, + }), + })); +} + +/// Move session `id` into checkout `worktree`: a MOVE PICKER row, or the +/// card dropped on another band. The DAEMON moves the row and the grid's +/// cursor follows it; the flash says when the session itself follows. +pub(super) fn move_agent( + app: &mut App, + id: AgentId, + worktree: WorktreeId, + out: &mut Vec, +) { + let Some((name, dest, mid_turn)) = move_plan(app, &id, &worktree) else { + return; + }; + app.flash = Some(if mid_turn { + format!("{name} moves to {dest} when its turn ends") + } else { + format!("moved {name} to {dest}") + }); + super::send(app, out, |req_id| ClientRequest::MoveAgent { + req_id, + id, + worktree, + }); +} + /// The WORKTREE PICKER for the box `back` owes — `^T`, or a click on the /// branch in the box's details row (`worktree main ^T`), dropped down /// from that branch over the box, which stays on screen under it as it @@ -2262,7 +2529,6 @@ fn toggle_new_worktree(app: &mut App, launch: QuickLaunch, input: TextInput) { /// at the row ([`pick_launch_worktree`]). A PR SESSION has nothing to /// pick: the DAEMON runs it in the pull request's own checkout. fn open_worktree_picker(app: &mut App, back: QuickReturn) { - use std::cmp::Reverse; if back.launch.pr.is_some() { app.flash = Some("quick prompt: a PR session runs in the pull request's own checkout".into()); @@ -2272,21 +2538,7 @@ fn open_worktree_picker(app: &mut App, back: QuickReturn) { app.flash = Some("project no longer exists".into()); return; }; - let mut checkouts: Vec<_> = app - .tree - .worktrees - .iter() - .filter(|w| w.project_id == project && !app.is_placeholder_worktree(&w.id)) - .collect(); - let now = crate::app::now_ms(); - checkouts.sort_by_key(|w| { - let r = crate::app::worktree_recency(&app.tree, &w.id, now); - ( - Reverse(w.is_main), - Reverse(r.interacted), - Reverse(r.stamped), - ) - }); + let checkouts = picker_checkouts(app, &project); let tick = |on: bool| if on { " ✓" } else { "" }; let row = |label: String, target: QuickTarget| { @@ -5603,15 +5855,22 @@ mod tests { }); } - /// `m` opens no menu — not a card's, and with no card selected not - /// the project's either. The menus are the right button's alone. + /// `m` opens no context menu: on a card it is the MOVE PICKER, and with + /// no card selected it opens nothing, not the project's menu either. + /// The menus are the right button's alone. #[test] fn m_opens_no_menu() { with_default_config(|| { let mut app = two_sessions(); draw(&mut app); key(&mut app, KeyCode::Char('m'), KeyModifiers::NONE); - assert!(app.overlay.is_none(), "on a card: {:?}", app.overlay); + let title = match &app.overlay { + Some(Overlay::Menu(m)) => m.title.as_deref(), + _ => None, + }; + assert_eq!(title, Some("Move to"), "on a card: {:?}", app.overlay); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert!(app.overlay.is_none()); keys(&mut app, &[KeyCode::Esc, KeyCode::Esc]); assert!(app.launcher_unaimed); @@ -10277,4 +10536,235 @@ mod tests { assert_eq!(app.launcher_expanded.as_ref(), Some(&bands[0].worktree)); }); } + + /// A cell on the rule of `worktree`'s band, as drawn. + fn band_rule_cell(app: &App, worktree: &str) -> (u16, u16) { + let bands = crate::launcher::bands(app); + app.hits + .iter() + .find_map(|(rect, hit)| match hit { + HitTarget::LauncherBand(i) if bands[*i].worktree.0 == worktree => { + Some((rect.x + 1, rect.y)) + } + _ => None, + }) + .expect("the band's rule was drawn") + } + + fn sent_move(out: &[ClientRequest]) -> Option<(String, String)> { + out.iter().find_map(|r| match r { + ClientRequest::MoveAgent { id, worktree, .. } => { + Some((id.0.clone(), worktree.0.clone())) + } + _ => None, + }) + } + + /// `m` on a card lists the project's other checkouts, then the other + /// projects', and a pick moves the session there; a mid-turn one is + /// told it follows when its turn ends. + #[test] + fn m_on_a_card_picks_a_checkout_to_move_the_session_to() { + with_default_config(|| { + let mut app = two_sessions(); + draw_at(&mut app, 130, 50); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + draw_at(&mut app, 130, 50); + let (x, y) = row_cell(&app, 0); + click_at(&mut app, x, y); + assert_eq!(app.selected_session().unwrap().id.0, "a2"); + + key(&mut app, KeyCode::Char('m'), KeyModifiers::NONE); + let Some(Overlay::Menu(menu)) = &app.overlay else { + panic!("m opens the move picker"); + }; + assert_eq!(menu.title.as_deref(), Some("Move to")); + let labels: Vec<&str> = menu.items.iter().map(|i| i.label.as_str()).collect(); + assert_eq!( + labels, + ["main (root)", "web ▸ main (root)"], + "every checkout but its own, its own project's first" + ); + + let out = key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(sent_move(&out), Some(("a2".into(), "w1".into()))); + assert_eq!( + app.flash.as_deref(), + Some("polish-nav moves to main when its turn ends") + ); + + // Another project's checkout, picked by typing its name. + key(&mut app, KeyCode::Char('m'), KeyModifiers::NONE); + for c in "web".chars() { + key(&mut app, KeyCode::Char(c), KeyModifiers::NONE); + } + let out = key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(sent_move(&out), Some(("a2".into(), "w2root".into()))); + assert_eq!( + app.flash.as_deref(), + Some("polish-nav moves to web ▸ main when its turn ends") + ); + }); + } + + /// A card dragged onto another project's tab lights the tab and, let + /// go there and confirmed, moves its session into that project's root; + /// the daemon's upsert takes the grid into that project with it. + #[test] + fn dragging_a_card_onto_a_project_tab_moves_it_into_that_project() { + with_default_config(|| { + let mut app = two_tabs(); + draw_at(&mut app, 130, 50); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + draw_at(&mut app, 130, 50); + let (x, y) = row_cell(&app, 0); + mouse(&mut app, MouseEventKind::Down(MouseButton::Left), x, y); + draw_at(&mut app, 130, 50); + let (tx, ty) = app + .hits + .iter() + .find_map(|(rect, hit)| match hit { + HitTarget::LauncherTab(p) if p.0 == "p2" => Some((rect.x + 1, rect.y)), + _ => None, + }) + .expect("web's tab was drawn"); + mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), tx, ty); + assert_eq!( + app.card_drag.as_ref().and_then(|d| d.over.clone()), + Some(WorktreeId("w2root".into())) + ); + assert!(crate::launcher::project_tabs(&app) + .iter() + .any(|t| t.drop && t.id.0 == "p2")); + + let mut out = Vec::new(); + let up = MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: tx, + row: ty, + modifiers: KeyModifiers::NONE, + }; + handle_terminal_event(&mut app, crossterm::event::Event::Mouse(up), &mut out); + let out = key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(sent_move(&out), Some(("a2".into(), "w2root".into()))); + + let mut moved = app + .tree + .agents + .iter() + .find(|a| a.id.0 == "a2") + .unwrap() + .clone(); + moved.worktree_id = WorktreeId("w2root".into()); + hse( + &mut app, + ServerEvent::EntityUpserted { + entity: Entity::Agent(moved), + }, + ); + assert_eq!(app.selected_project().map(|p| p.id.0.as_str()), Some("p2")); + assert_eq!(app.selected_session().map(|a| a.id.0), Some("a2".into())); + }); + } + + /// A card dragged onto another band lights that band's rule and, let + /// go there, asks, then moves its session; a plain click moves nothing. + #[test] + fn dragging_a_card_onto_another_band_moves_its_session() { + with_default_config(|| { + let mut app = two_sessions(); + draw_at(&mut app, 130, 50); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + draw_at(&mut app, 130, 50); + let (x, y) = row_cell(&app, 0); + assert!(sent_move(&click_at(&mut app, x, y)).is_none()); + assert!(app.card_drag.is_none(), "the release ends the press"); + + mouse(&mut app, MouseEventKind::Down(MouseButton::Left), x, y); + draw_at(&mut app, 130, 50); + let (bx, by) = band_rule_cell(&app, "w1"); + mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), bx, by); + assert_eq!( + app.card_drag.as_ref().and_then(|d| d.over.clone()), + Some(WorktreeId("w1".into())) + ); + let term = draw_at(&mut app, 130, 50); + let screen: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(screen.contains("release to move here")); + + let mut out = Vec::new(); + let up = MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: bx, + row: by, + modifiers: KeyModifiers::NONE, + }; + handle_terminal_event(&mut app, crossterm::event::Event::Mouse(up), &mut out); + assert!(sent_move(&out).is_none(), "the drop asks first"); + assert!(app.card_drag.is_none()); + let Some(Overlay::Confirm(c)) = &app.overlay else { + panic!("the drop's confirm: {:?}", app.overlay); + }; + assert_eq!( + c.message, + "Move 'polish-nav' to main? It restarts there once its current turn ends, \ + on the same conversation." + ); + let out = key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(sent_move(&out), Some(("a2".into(), "w1".into()))); + }); + } + + /// With **Confirm drag move** off the drop moves at once, and the air + /// under the last band is that band's: an empty band is a target past + /// its rule. + #[test] + fn a_drop_under_the_last_band_moves_there_without_asking_when_confirm_is_off() { + with_config_json(r#"{"confirm_drag_move": false}"#, || { + let mut app = two_sessions(); + draw_at(&mut app, 130, 50); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + draw_at(&mut app, 130, 50); + let (x, y) = row_cell(&app, 1); + mouse(&mut app, MouseEventKind::Down(MouseButton::Left), x, y); + draw_at(&mut app, 130, 50); + let bands = crate::launcher::bands(&app); + let (last, foot) = app + .hits + .iter() + .filter_map(|(r, hit)| match hit { + HitTarget::LauncherBand(i) => Some((*i, r.y + r.height)), + _ => None, + }) + .max_by_key(|(_, foot)| *foot) + .expect("bands were drawn"); + assert_eq!(bands[last].worktree.0, "w2", "feat's band is last"); + assert!(matches!( + app.hit_at(x, foot + 1), + Some(HitTarget::PanelBg(Focus::Sessions)) + )); + + let mut out = Vec::new(); + for kind in [ + MouseEventKind::Drag(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + let event = MouseEvent { + kind, + column: x, + row: foot + 1, + modifiers: KeyModifiers::NONE, + }; + handle_terminal_event(&mut app, crossterm::event::Event::Mouse(event), &mut out); + } + assert!(app.overlay.is_none(), "no confirm"); + assert_eq!(sent_move(&out), Some(("a1".into(), "w2".into()))); + }); + } } diff --git a/crates/nebula-tui/src/keymap.rs b/crates/nebula-tui/src/keymap.rs index 8b250543..a5e085da 100644 --- a/crates/nebula-tui/src/keymap.rs +++ b/crates/nebula-tui/src/keymap.rs @@ -132,6 +132,10 @@ pub enum Action { /// the box on the Agents tab defaults; the shifted key opens it on /// the card. DuplicateSession, + /// `m`: move the card under the cursor onto another checkout, of its + /// project or another one, picked from a list. Dragging the card onto + /// another band or project tab does the same. + MoveSession, // files FindFile, Grep, @@ -563,6 +567,15 @@ pub const ACTIONS: &[ActionSpec] = &[ scope: Scope::Global, defaults: &["shift+p"], }, + ActionSpec { + action: Action::MoveSession, + id: "move_session", + label: "Move session", + hint: "Pick another worktree, of this project or another, for the selected session; it resumes there, after its current turn when it is mid-turn. Dragging the card onto another band or project tab does the same", + group: "SESSIONS", + scope: Scope::Global, + defaults: &["m"], + }, // ---- FILES ---- ActionSpec { action: Action::FindFile, diff --git a/crates/nebula-tui/src/launcher.rs b/crates/nebula-tui/src/launcher.rs index 6fd8e785..e1a39c86 100644 --- a/crates/nebula-tui/src/launcher.rs +++ b/crates/nebula-tui/src/launcher.rs @@ -1532,6 +1532,9 @@ pub struct ProjectTab { /// The header's own cursor is on it: the PROJECT TABS have the keys /// ([`App::launcher_tab_cursor`]) and the grid is showing this one. pub focused: bool, + /// A session card is dragged over it: the release moves the session + /// into this project. + pub drop: bool, } /// The PROJECT TABS across the LAUNCHER VIEW's header: every project @@ -1557,6 +1560,9 @@ pub fn project_tabs(app: &App) -> Vec { tally: project_tally(app, id), active: active.as_ref() == Some(id), focused: app.launcher_tab_cursor.as_ref() == Some(id), + drop: app.card_drag.as_ref().is_some_and(|d| { + d.active && d.over.is_some() && d.over_tab.as_ref() == Some(id) + }), }) }) .collect() diff --git a/crates/nebula-tui/src/ui/launcher_view.rs b/crates/nebula-tui/src/ui/launcher_view.rs index 4326d85f..2a1cf856 100644 --- a/crates/nebula-tui/src/ui/launcher_view.rs +++ b/crates/nebula-tui/src/ui/launcher_view.rs @@ -523,7 +523,7 @@ fn project_chip( } // The header's own cursor: the pad and the name as one accent block, // so where Enter would go reads apart from which tab is lit. - let (pad, name) = if tab.focused { + let (pad, name) = if tab.focused || tab.drop { let cursor = Style::default().bg(th.accent).fg(th.on_accent); (cursor, cursor.add_modifier(Modifier::BOLD)) } else { @@ -1611,7 +1611,15 @@ fn draw_band_rule( expanded, .. } = rule; - let edge = if lit { th.accent } else { th.edge }; + // A session card dragged over this band: the rule lights and says + // the release moves the card here. + let drop = app + .card_drag + .as_ref() + .is_some_and(|d| { + d.active && d.over_tab.is_none() && d.over.as_ref() == Some(&band.worktree) + }); + let edge = if lit || drop { th.accent } else { th.edge }; let dash = |n: usize| Span::styled("─".repeat(n), Style::default().fg(edge)); // The right end first, since the left gives way to it. @@ -1663,6 +1671,14 @@ fn draw_band_rule( } spans }; + let right = if drop { + vec![Span::styled( + "release to move here", + Style::default().fg(th.accent).add_modifier(Modifier::BOLD), + )] + } else { + right + }; let right_w: usize = right.iter().map(|s| s.width()).sum(); // The checkout, as the cards used to paint it: the glyph in its diff --git a/docs/configuration.md b/docs/configuration.md index b3e6d58a..9de3b207 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,6 +92,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `recent_prompts` | bool | `false` | — (retired) | Through 0.37, **Recent prompts** (Settings → Experimental): on, a session's last prompts were listed under its row. Every card carries its session's newest prompt now, whatever the key says, so this build never reads it and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | | `recent_prompts_count` | integer | `3` | — (retired) | Through 0.37, **Recent prompts shown**: how many of those prompts that build listed, `1` to `5`. Never read, no row, loaded and written back as stored. | | `show_key_combos` | bool | `false` | — (retired) | Through 0.37, **Key combo display** (Settings → Experimental): on, each key pressed on the grid was spelled at the bottom left of the screen with what it did. The KEY COMBO DISPLAY is always on now, whatever the key says — every press shows, `j - Move down`, and clears itself three seconds on ([Keys](keys.md#chips-and-readouts)) — so this build never reads it and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | +| `confirm_drag_move` | bool | `true` | Sessions | CONFIRM DRAG MOVE: a session card dragged onto another worktree's band or another PROJECT TAB asks before the session moves (the move restarts its CLI there, resumed on the same conversation). Off, the drop moves it at once. The MOVE PICKER (`m`) never asks. See [Keys](keys.md). | | `remember_harness` | bool | `false` | Experimental | REMEMBER HARNESS: a launch walked through the NEW SESSION PICKER (`n`), the PR SESSION picker or the QUICK PROMPT's `Tab` picker makes its harness the default the next launch starts on — the picker opens on that row and `p` launches it — and a model or effort drilled into through the submenus becomes that harness's own Model / Effort default. It writes the Agents tab's own rows (`quick_prompt_kind`, `_model`, `_effort`), so the tab always shows what the next launch will be; a pick that already is the default writes nothing, and an AGENT PRESET launch changes nothing, its harness being the preset's. Off, a pick is one session's: the NEW SESSION PICKER keeps opening on the `quick_prompt_kind` harness and the PR SESSION picker on its first row. See [Sessions](sessions.md#the-new-session-picker). | | `pr_issue_counts` | bool | `true` | — (retired) | Through 0.37, **PR & issue counts** (Settings → Experimental): off, the GRID's header dropped the `3 prs · 2 issues` beside the session count and the other projects' issues were never swept. The header always counts now — what is waiting on a repo is read off it without opening `v` or `i`, a click on either count opens that list, a count is left out until its list has landed, and a list cut off at the fetch cap counts `100+` — so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | | `quick_prompt_kind` | string | `"claude"` | Agents | Which AGENT KIND the QUICK PROMPT (`p`) launches: `claude`, `codex`, `cursor`, `pi`, `muse`, `grok` or `opencode`. Its model and effort come from that kind's own defaults below, so this is one name, not a third pair. A kind switched off here is stepped around. The NEW SESSION PICKER opens on it too, and with **Remember harness** on (Experimental) every picker-walked launch rewrites it. | diff --git a/docs/keys.md b/docs/keys.md index 761cc398..f091651d 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -50,6 +50,7 @@ The grid isn't the only view. With a session selected: | The grid (a card) | `Shift+V` | open the card's pull request in the browser — the `#42 title` line under its name, the pull request on the branch its session runs in — without stepping into the session or opening the PULL REQUESTS MODAL. `v` lists the project's pull requests in nebula; `Shift+V` goes to GitHub. It is marked read on the way out, as when it opens from anywhere else. A card with no pull request yet says so and names its branch (`Shift+R` asks GitHub again); with no card selected (after `Esc`) nothing opens. A click on that `#42 title` line, on any card, does the same — the cursor lands on the card first — and the line is underlined while the pointer rests on it, the way the header's tabs are. The card's right-click menu has it as **Open pull request**; rebindable as `open_pull_request` | | The grid (a card) | `Shift+I` | open the GitHub issue the card's session was started from — an ISSUE SESSION, launched out of the ISSUES MODAL — in the browser. `i` lists the project's issues in nebula; `Shift+I` goes to GitHub. A card that was not started from an issue says so; with no card selected (after `Esc`) nothing opens. The card's right-click menu has it as **Open issue**; with **Card issue number** on (Settings → Appearance, off by default) the card shows the issue's `#15` at the right of its second row, and a click on it does the same; rebindable as `open_issue` | | The grid (a card) | `Shift+P` | open the QUICK PROMPT set to launch what the card runs — the same harness, model and effort, into the same checkout, for the same issue when the card is an ISSUE SESSION — so the task is all there is to type: Enter there sends it with those settings, exactly as a spec picked through the box's `Tab` / `^O` would, and every picker of the box still works on it. `p` opens the box on the Agents tab defaults, in the checkout under the cursor; `Shift+P` opens it on the card. The card's conversation is not copied (the new session is a fresh CLI). A cloud card comes up as a cloud box on its model and effort. With no card selected (after `Esc`) nothing opens. The card's right-click menu has it as **Duplicate**; rebindable as `duplicate_session` | +| The grid (a card) | `m` | open the MOVE PICKER: every other checkout of the card's project, root first, then every other project's as `project ▸ branch` (type a project's name to narrow to it). Enter on one moves the session there: the card jumps to that band at once, and the CLI is killed and resumed in the new checkout (same conversation, via its resume flag). An idle session moves straight away; one mid-turn finishes its turn first, then follows. Dragging the card onto another band's rule or cards does the same, and the band under the pointer reads `release to move here`; dropped on another PROJECT TAB, it moves into that project's root, and the tab lights while the card is over it. The air under a band, down to the next one, counts as that band, so an empty band takes a drop past its rule. A drop asks first (`Move 'name' to branch?`, Enter moves, Esc keeps it) unless **Confirm drag move** is off (Settings → Sessions, `confirm_drag_move`); the picker never asks. A session moved to another project takes the grid there with it. With no card selected (after `Esc`) nothing opens. The card's right-click menu has it as **Move to…**; rebindable as `move_session` | | The grid | `Shift+T` | `t`'s terminal outside nebula: a new Ghostty tab in the selected worktree's directory (the project's clone when it has no worktrees yet): nebula hands the directory to Ghostty.app with `open -a`, which Ghostty takes like a folder dropped on its Dock icon — a tab in the front window, or a new window if `macos-dock-drop-behavior = new-window` is set. Without Ghostty.app in `/Applications` or `~/Applications`, off macOS, or over ssh, the key does nothing at all, not even a footer message. Rebindable as `open_ghostty_tab` in Settings → Hotkeys | | The grid | `f` / `F` / `b` | find file / find in files (`git grep`) / file tree browser, all scoped to the selected worktree — `Enter` opens the file in an editor modal (at the matched line, for `F`; a `.md` from `f` opens read-first in the file tabs instead); in `f` and `b`, `Ctrl+y` copies the path | | PALETTE, FILE FINDER (and its grep view), TREE BROWSER, BRANCH SWITCHER | `↑`/`↓` or `Ctrl+n`/`Ctrl+p` | move the selection inside a fuzzy overlay. `j`/`k` deliberately do *not* — they are typed into the query, so a branch or filename with a `j` in it stays reachable — which makes these five the one place the app-wide `j`/`k` convention above stops at the modal's edge. `Esc` is two-staged here as well: the first press clears what you typed, and only the second closes | diff --git a/scripts/shot/scenes/move-session-done.keys b/scripts/shot/scenes/move-session-done.keys new file mode 100644 index 00000000..bb1653d7 --- /dev/null +++ b/scripts/shot/scenes/move-session-done.keys @@ -0,0 +1,44 @@ +# move-session, then Enter on the picker's row: the finished session moves into that worktree at once. +p +Fix the login redirect loop when the session cookie has expired +C-n +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +p +Sort the changelog entries by date, newest first +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +F12 +F12 +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 diff --git a/scripts/shot/scenes/move-session-done.setup.sh b/scripts/shot/scenes/move-session-done.setup.sh new file mode 100644 index 00000000..0a7ebda8 --- /dev/null +++ b/scripts/shot/scenes/move-session-done.setup.sh @@ -0,0 +1 @@ +. "$HERE/scenes/launcher.setup.sh" diff --git a/scripts/shot/scenes/move-session-drag-below.keys b/scripts/shot/scenes/move-session-drag-below.keys new file mode 100644 index 00000000..458dbd26 --- /dev/null +++ b/scripts/shot/scenes/move-session-drag-below.keys @@ -0,0 +1,57 @@ +# move-session-drag-done, then the root's other card dragged into the blank grid under the emptied +# worktree's band: the air under a band is that band's, so the drop asks to move it there. +p +Fix the login redirect loop when the session cookie has expired +C-n +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +p +Sort the changelog entries by date, newest first +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +F12 +F12 +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +drag 10 17 30 5 +F12 +F12 +F12 +F12 +Enter +F12 +F12 +F12 +drag 10 6 10 19 +F12 +F12 diff --git a/scripts/shot/scenes/move-session-drag-below.setup.sh b/scripts/shot/scenes/move-session-drag-below.setup.sh new file mode 100644 index 00000000..0a7ebda8 --- /dev/null +++ b/scripts/shot/scenes/move-session-drag-below.setup.sh @@ -0,0 +1 @@ +. "$HERE/scenes/launcher.setup.sh" diff --git a/scripts/shot/scenes/move-session-drag-done.keys b/scripts/shot/scenes/move-session-drag-done.keys new file mode 100644 index 00000000..e7ca2b68 --- /dev/null +++ b/scripts/shot/scenes/move-session-drag-done.keys @@ -0,0 +1,53 @@ +# move-session-drag, then Enter on its confirm: the move goes ahead. +p +Fix the login redirect loop when the session cookie has expired +C-n +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +p +Sort the changelog entries by date, newest first +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +F12 +F12 +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +drag 10 17 30 5 +F12 +F12 +F12 +F12 +Enter +F12 +F12 +F12 diff --git a/scripts/shot/scenes/move-session-drag-done.setup.sh b/scripts/shot/scenes/move-session-drag-done.setup.sh new file mode 100644 index 00000000..0a7ebda8 --- /dev/null +++ b/scripts/shot/scenes/move-session-drag-done.setup.sh @@ -0,0 +1 @@ +. "$HERE/scenes/launcher.setup.sh" diff --git a/scripts/shot/scenes/move-session-drag.keys b/scripts/shot/scenes/move-session-drag.keys new file mode 100644 index 00000000..ce07ac87 --- /dev/null +++ b/scripts/shot/scenes/move-session-drag.keys @@ -0,0 +1,50 @@ +# move-session-done, then the running card dragged from its worktree's band onto the root's rule: the +# drop asks before it moves. +p +Fix the login redirect loop when the session cookie has expired +C-n +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +p +Sort the changelog entries by date, newest first +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +F12 +F12 +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +drag 10 17 30 5 +F12 +F12 +F12 +F12 diff --git a/scripts/shot/scenes/move-session-drag.setup.sh b/scripts/shot/scenes/move-session-drag.setup.sh new file mode 100644 index 00000000..0a7ebda8 --- /dev/null +++ b/scripts/shot/scenes/move-session-drag.setup.sh @@ -0,0 +1 @@ +. "$HERE/scenes/launcher.setup.sh" diff --git a/scripts/shot/scenes/move-session-project-done.keys b/scripts/shot/scenes/move-session-project-done.keys new file mode 100644 index 00000000..a5c9a0fa --- /dev/null +++ b/scripts/shot/scenes/move-session-project-done.keys @@ -0,0 +1,59 @@ +# move-session-project, then Enter: the session moves into the demo project, and the grid follows it there. +p +Fix the login redirect loop when the session cookie has expired +C-n +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +p +Sort the changelog entries by date, newest first +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +F12 +F12 +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +demo +F12 +F12 +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 diff --git a/scripts/shot/scenes/move-session-project-done.setup.sh b/scripts/shot/scenes/move-session-project-done.setup.sh new file mode 100644 index 00000000..0a7ebda8 --- /dev/null +++ b/scripts/shot/scenes/move-session-project-done.setup.sh @@ -0,0 +1 @@ +. "$HERE/scenes/launcher.setup.sh" diff --git a/scripts/shot/scenes/move-session-project.keys b/scripts/shot/scenes/move-session-project.keys new file mode 100644 index 00000000..5b595888 --- /dev/null +++ b/scripts/shot/scenes/move-session-project.keys @@ -0,0 +1,48 @@ +# move-session-done, then m again and "demo" typed: the MOVE PICKER narrowed to another project's checkouts. +p +Fix the login redirect loop when the session cookie has expired +C-n +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +p +Sort the changelog entries by date, newest first +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +F12 +F12 +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +demo +F12 +F12 diff --git a/scripts/shot/scenes/move-session-project.setup.sh b/scripts/shot/scenes/move-session-project.setup.sh new file mode 100644 index 00000000..0a7ebda8 --- /dev/null +++ b/scripts/shot/scenes/move-session-project.setup.sh @@ -0,0 +1 @@ +. "$HERE/scenes/launcher.setup.sh" diff --git a/scripts/shot/scenes/move-session.keys b/scripts/shot/scenes/move-session.keys new file mode 100644 index 00000000..2546d1ac --- /dev/null +++ b/scripts/shot/scenes/move-session.keys @@ -0,0 +1,32 @@ +# Two launches: the first into a fresh worktree (^N; it keeps running), the second into the root (it +# finishes). Then m on the card under the cursor: the MOVE PICKER lists the project's other checkouts. +p +Fix the login redirect loop when the session cookie has expired +C-n +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +p +Sort the changelog entries by date, newest first +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +m +F12 +F12 diff --git a/scripts/shot/scenes/move-session.setup.sh b/scripts/shot/scenes/move-session.setup.sh new file mode 100644 index 00000000..0a7ebda8 --- /dev/null +++ b/scripts/shot/scenes/move-session.setup.sh @@ -0,0 +1 @@ +. "$HERE/scenes/launcher.setup.sh"