diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index fe157172..23718955 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -4405,6 +4405,13 @@ "string", "null" ] + }, + "source_workspace_id": { + "description": "Workspace whose focused pane supplies the `follow` cwd policy.", + "type": [ + "string", + "null" + ] } }, "type": "object" diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 66a79dfc..47faec05 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -50,6 +50,7 @@ fn request_uses_dot_method_names() { let request = Request { id: "req_1".into(), method: Method::WorkspaceCreate(WorkspaceCreateParams { + source_workspace_id: None, cwd: Some("/tmp".into()), focus: true, label: Some("api".into()), diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs index bfc63252..1b7132c4 100644 --- a/src/api/schema/workspaces.rs +++ b/src/api/schema/workspaces.rs @@ -6,6 +6,9 @@ use super::common::AgentStatus; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct WorkspaceCreateParams { + /// Workspace whose focused pane supplies the `follow` cwd policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default)] diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index d9e689e3..63d172bb 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -41,12 +41,25 @@ impl App { id: String, params: WorkspaceCreateParams, ) -> String { + let source_workspace_index = if params.cwd.is_some() { + None + } else { + match params.source_workspace_id.as_deref() { + Some(workspace_id) => match self + .parse_workspace_id(workspace_id) + .filter(|index| self.state.workspaces.get(*index).is_some()) + { + Some(index) => Some(index), + None => return workspace_not_found(id, workspace_id), + }, + None => self.workspace_creation_source(), + } + }; let cwd = params.cwd.map(PathBuf::from).unwrap_or_else(|| { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - self.resolve_new_terminal_cwd(follow_cwd) + source_workspace_index.map_or_else( + || self.resolve_new_terminal_cwd(None), + |index| self.resolved_new_workspace_cwd_from(index), + ) }); let extra_env = match super::env::normalize_launch_env(params.env) { Ok(env) => env, @@ -360,7 +373,11 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace}; + use crate::{ + api::schema::{ErrorResponse, SuccessResponse}, + config::Config, + workspace::Workspace, + }; // `new_cwd = follow` must anchor on the focused pane for every creation // surface. Splits and tabs already do; a new workspace must follow the @@ -419,6 +436,7 @@ mod tests { let response = app.handle_workspace_create( "req".into(), WorkspaceCreateParams { + source_workspace_id: None, cwd: None, focus: false, label: None, @@ -444,6 +462,94 @@ mod tests { let _ = std::fs::remove_dir_all(&focused_cwd); } + #[tokio::test] + async fn workspace_create_uses_explicit_source_workspace() { + use super::super::test_support::{exiting_test_command, shutdown_test_runtimes}; + use crate::config::ShellModeConfig; + + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.default_shell = exiting_test_command().into(); + app.state.shell_mode = ShellModeConfig::NonLogin; + app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("source")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.ensure_test_terminals(); + shutdown_test_runtimes(&mut app); + + let source_cwd = + std::env::temp_dir().join(format!("herdr-ws-explicit-source-{}", std::process::id())); + std::fs::create_dir_all(&source_cwd).unwrap(); + let pane_id = app.state.workspaces[1].focused_pane_id().unwrap(); + let terminal_id = app.state.workspaces[1] + .terminal_id(pane_id) + .cloned() + .unwrap(); + app.state.terminals.get_mut(&terminal_id).unwrap().cwd = source_cwd.clone(); + let source_workspace_id = app.public_workspace_id(1); + + let response = app.handle_workspace_create( + "req".into(), + WorkspaceCreateParams { + source_workspace_id: Some(source_workspace_id), + cwd: None, + focus: false, + label: None, + env: Default::default(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!( + success.result, + ResponseResult::WorkspaceCreated { .. } + )); + assert_eq!( + crate::worktree::canonical_or_original(&app.state.workspaces[2].identity_cwd), + crate::worktree::canonical_or_original(&source_cwd) + ); + + let invalid = app.handle_workspace_create( + "invalid".into(), + WorkspaceCreateParams { + source_workspace_id: Some("w_999".into()), + cwd: None, + focus: false, + label: None, + env: Default::default(), + }, + ); + let error: ErrorResponse = serde_json::from_str(&invalid).unwrap(); + assert_eq!(error.error.code, "workspace_not_found"); + + let captured = app.handle_workspace_create( + "captured".into(), + WorkspaceCreateParams { + source_workspace_id: Some("w_999".into()), + cwd: Some(source_cwd.display().to_string()), + focus: false, + label: None, + env: Default::default(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&captured).unwrap(); + assert!(matches!( + success.result, + ResponseResult::WorkspaceCreated { .. } + )); + assert_eq!( + crate::worktree::canonical_or_original(&app.state.workspaces[3].identity_cwd), + crate::worktree::canonical_or_original(&source_cwd) + ); + shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(&source_cwd); + } + fn app_with_linked_worktree() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( diff --git a/src/app/creation.rs b/src/app/creation.rs index d531e733..435f0a95 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -81,6 +81,13 @@ impl App { resolve_new_terminal_cwd(&self.state.new_terminal_cwd, follow_cwd) } + pub(crate) fn resolved_new_workspace_cwd_from(&self, ws_idx: usize) -> PathBuf { + let follow_cwd = self + .focused_pane_cwd_in_workspace(ws_idx) + .or_else(|| self.seed_cwd_from_workspace(ws_idx)); + self.resolve_new_terminal_cwd(follow_cwd) + } + pub(super) fn workspace_creation_source(&self) -> Option { if self.state.mode == Mode::Navigate && self.state.workspaces.get(self.state.selected).is_some() @@ -110,6 +117,7 @@ impl App { self.runtime_workspace_create( request_id, crate::api::schema::WorkspaceCreateParams { + source_workspace_id: None, cwd: None, focus: true, label: None, diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index a442d6c2..13b1fedd 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -1010,6 +1010,7 @@ impl App { self.runtime_workspace_create( "tui.workspace.create_named", crate::api::schema::WorkspaceCreateParams { + source_workspace_id: None, cwd: Some(cwd.display().to_string()), focus: true, label, diff --git a/src/app/mod.rs b/src/app/mod.rs index 13586c47..841a3f1b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1025,6 +1025,7 @@ impl App { self.runtime_workspace_create( "tui.workspace.create", crate::api::schema::WorkspaceCreateParams { + source_workspace_id: None, cwd: None, focus: true, label: None, @@ -1064,6 +1065,7 @@ impl App { self.runtime_workspace_create( "tui.workspace.create_cwd", crate::api::schema::WorkspaceCreateParams { + source_workspace_id: None, cwd: Some(cwd.display().to_string()), focus: true, label: None, diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs index c24c9b73..525398b4 100644 --- a/src/cli/workspace.rs +++ b/src/cli/workspace.rs @@ -94,6 +94,7 @@ fn workspace_create(args: &[String]) -> std::io::Result { } super::runtime::workspace_create(WorkspaceCreateParams { + source_workspace_id: None, cwd, focus, label, diff --git a/src/client/shell.rs b/src/client/shell.rs index ecfe7483..477a57cd 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -8,6 +8,7 @@ mod context_menu; mod copy_mode; mod global_menu; mod input; +mod mobile; mod mouse; mod notifications; mod overlay_input; @@ -293,6 +294,8 @@ mod tests { agent_order: Vec::new(), workspaces: vec![ClientShellWorkspace { workspace_id: "ws_1".into(), + active_tab_id: "tab_1".into(), + new_workspace_cwd: "/repo".into(), number: 1, label: "client-shell".into(), custom_label: false, @@ -3474,6 +3477,8 @@ mod tests { }); snapshot.workspaces.push(ClientShellWorkspace { workspace_id: "ws_2".into(), + active_tab_id: "tab_ws2".into(), + new_workspace_cwd: "/repo/feature".into(), number: 2, label: "repo-feature".into(), custom_label: false, @@ -4245,7 +4250,7 @@ detach = "prefix+x" .iter() .map(|cell| cell.symbol.as_str()) .collect::(); - assert!(!mobile_text.contains("update ready")); + assert!(mobile_text.contains("update ready")); } #[test] @@ -5200,13 +5205,13 @@ detach = "prefix+x" snapshot.workspaces.push(second); let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot)); + state.mode = ClientShellMode::Navigate; state.navigate_workspace_id = Some("ws_2".into()); - let mut rename = ClientShellInput::default(); - state.record_binding( - crate::input::KeybindMatch::Action(crate::input::KeybindAction::RenameWorkspace), - &mut rename, - ); + let rename = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT), + )]); + assert!(rename.actions.is_empty()); assert!(matches!( state.overlay.as_ref(), Some(ClientShellOverlay::Rename(ClientRenameOverlay { @@ -5252,7 +5257,7 @@ detach = "prefix+x" } #[test] - fn named_workspace_overlay_uses_projected_pane_cwd() { + fn named_workspace_overlay_targets_projected_source_workspace() { let mut config = Config::default(); config.ui.prompt_new_workspace_name = true; let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); @@ -5266,9 +5271,12 @@ detach = "prefix+x" state.overlay.as_ref(), Some(ClientShellOverlay::Rename(ClientRenameOverlay { input: value, - target: ClientRenameTarget::NewWorkspace { cwd, .. }, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + .. + }, .. - })) if value == "repo" && cwd.as_deref() == Some("/repo") + })) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1") )); let create = state.handle_input_bytes(b"\r"); let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { @@ -5277,7 +5285,9 @@ detach = "prefix+x" assert!(matches!( &request.method, crate::api::schema::Method::WorkspaceCreate(params) - if params.cwd.as_deref() == Some("/repo") && params.label.is_none() + if params.source_workspace_id.as_deref() == Some("ws_1") + && params.cwd.as_deref() == Some("/repo") + && params.label.is_none() )); } @@ -6360,4 +6370,501 @@ detach = "prefix+x" ClientSurfaceSize { cols: 44, rows: 18 } ); } + + #[test] + fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.mouse_capture = false; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(44, 20).expect("mobile header"); + assert!(!state.hits.mobile_switch.is_empty()); + let switch = state.hits.mobile_switch; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: switch.x, + row: switch.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Navigate); + state.compose(44, 20).expect("mobile switcher"); + assert!(!state.hits.mobile_close.is_empty()); + assert!(!state.hits.mobile_targets.is_empty()); + } + + #[test] + fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi".into()), + display_agent: Some("pi".into()), + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 1, + state_labels: vec![("blocked".into(), "waiting".into())], + tokens: Vec::new(), + focused: true, + }); + projected.workspaces[0].agent_status = AgentStatus::Blocked; + state.set_snapshot(Box::new(projected)); + let mut projected_surface = surface(); + for cell in &mut projected_surface.frame.cells { + cell.symbol = "X".to_owned(); + } + state.set_pane_surface(projected_surface); + + let header = state.compose(44, 20).expect("mobile header"); + let header_text = header + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(header_text.contains("client-shell")); + assert!(header_text.contains("tab 1")); + assert!(header_text.contains("blocked")); + assert!(header_text.contains("switch")); + assert_eq!(state.hits.mobile_switch, Rect::new(34, 0, 10, 2)); + + let click = |rect: Rect| { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let opened = state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + assert!(opened.repaint); + assert_eq!(state.mode, ClientShellMode::Navigate); + let switcher = state.compose(44, 20).expect("mobile switcher"); + let switcher_text = switcher + .cells + .chunks(switcher.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + !switcher_text.contains('X'), + "switcher must clear the pane surface" + ); + for expected in [ + "switch", + "close", + "agents", + "spaces", + "+ new workspace", + "tabs", + "+ new tab", + "menu", + "settings", + "detach", + ] { + assert!(switcher_text.contains(expected), "missing {expected}"); + } + let workspace_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect) + }) + .expect("workspace hit"); + let focused = state.handle_raw_events(vec![click(workspace_hit)]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(params) + if params.workspace_id == "ws_1" + ) + ))); + + state.compose(44, 20).expect("restored mobile header"); + state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + state.compose(44, 20).expect("agent switcher"); + let agent_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect) + }) + .expect("agent hit"); + let focused = state.handle_raw_events(vec![click(agent_hit)]); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_1" + ) + ))); + + state.compose(44, 20).expect("restored mobile header"); + state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + state.compose(44, 20).expect("tab switcher"); + let tab_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect) + }) + .expect("tab hit"); + let focused = state.handle_raw_events(vec![click(tab_hit)]); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(params) + if params.tab_id == "tab_1" + ) + ))); + } + + #[test] + fn mobile_background_workspace_uses_its_own_active_tab_status() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + projected.tabs.push(ClientShellTab { + tab_id: "tab_7".into(), + workspace_id: "ws_1".into(), + number: 7, + label: "logs".into(), + custom_label: true, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + projected.workspaces.push(ClientShellWorkspace { + workspace_id: "ws_2".into(), + active_tab_id: "tab_3".into(), + new_workspace_cwd: "/feature".into(), + number: 2, + label: "background".into(), + custom_label: true, + branch: Some("feature".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + for (number, tab_id, label) in [(1, "tab_2", "one"), (7, "tab_3", "two")] { + projected.tabs.push(ClientShellTab { + tab_id: tab_id.into(), + workspace_id: "ws_2".into(), + number, + label: label.into(), + custom_label: true, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_2".into()); + let frame = state.compose(44, 20).expect("mobile switcher"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("feature · tab two · 2/2"), "{text}"); + assert!(text.contains("2 · logs"), "{text}"); + assert!(!text.contains("7 · logs"), "{text}"); + } + + #[test] + fn mobile_switcher_create_and_menu_rows_reuse_client_actions() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.prompt_new_workspace_name = true; + config.prompt_new_tab_name = true; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let click = |rect: Rect| { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile create switcher"); + let new_tab = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewTab).then_some(*rect) + }) + .expect("new tab hit"); + state.handle_raw_events(vec![click(new_tab)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewTab { .. }, + .. + })) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile workspace switcher"); + let new_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect) + }) + .expect("new workspace hit"); + state.handle_raw_events(vec![click(new_workspace)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewWorkspace { .. }, + .. + })) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile menu switcher"); + let settings = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Menu(0)).then_some(*rect) + }) + .expect("settings hit"); + state.handle_raw_events(vec![click(settings)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + } + + #[test] + fn mobile_menu_keeps_inert_notes_open_and_cancel_without_workspace_in_navigate() { + let mut source_config = Config::default(); + source_config.ui.prompt_new_workspace_name = true; + let config = ClientShellConfig::from_config(&source_config); + let mut projected = snapshot(); + projected.latest_release_notes_available = true; + projected.release_notes = None; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile switcher"); + let inert_notes = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Menu(3)).then_some(*rect) + }) + .expect("what's new row"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: inert_notes.x, + row: inert_notes.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert!(state.overlay.is_none()); + assert!(!state.mobile_switcher_suspended); + + let mut empty = snapshot(); + empty.focused_workspace_id = None; + empty.focused_tab_id = None; + empty.focused_pane_id = None; + empty.workspaces.clear(); + empty.tabs.clear(); + empty.panes.clear(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&source_config)); + state.set_snapshot(Box::new(empty)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("empty mobile switcher"); + let new_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect) + }) + .expect("new workspace row"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_workspace.x, + row: new_workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Rename(_)))); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Navigate); + } + + #[test] + fn mobile_previous_workspace_action_wraps_across_expanded_entries() { + let mut projected = snapshot(); + for index in 2..=3 { + projected.workspaces.push(ClientShellWorkspace { + workspace_id: format!("ws_{index}"), + active_tab_id: format!("tab_{index}"), + new_workspace_cwd: "/tmp".into(), + number: index, + label: format!("workspace-{index}"), + custom_label: true, + branch: None, + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(44, 20).expect("mobile layout"); + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousWorkspace), + &mut outcome, + ); + assert!(outcome.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_3" + ) + ))); + } + + #[test] + fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + for index in 2..=8 { + projected.workspaces.push(ClientShellWorkspace { + workspace_id: format!("ws_{index}"), + active_tab_id: format!("tab_{index}"), + new_workspace_cwd: "/tmp".into(), + number: index, + label: format!("workspace-{index}"), + custom_label: true, + branch: None, + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(44, 10).expect("mobile header"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: state.hits.mobile_switch.x, + row: state.hits.mobile_switch.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(44, 10).expect("mobile switcher"); + let wheel = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 20, + row: 8, + modifiers: KeyModifiers::empty(), + })]); + assert!(wheel.repaint); + assert_eq!(state.mobile_switcher_scroll, 2); + state.compose(44, 10).expect("wheel position stays stable"); + assert_eq!(state.mobile_switcher_scroll, 2); + for _ in 0..7 { + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Down, + KeyModifiers::empty(), + ))]); + } + state.compose(44, 10).expect("revealed mobile selection"); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8")); + assert!(state.mobile_switcher_scroll > 2); + assert!(state.hits.mobile_targets.iter().any(|(_, target)| { + matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8") + })); + let close = state.hits.mobile_close; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + + state.compose(80, 20).expect("desktop transition"); + assert!(state.hits.mobile_switch.is_empty()); + assert!(state.hits.mobile_close.is_empty()); + assert!(state.hits.mobile_targets.is_empty()); + + state.mode = ClientShellMode::Navigate; + let short = state.compose(44, 2).expect("short mobile switcher"); + assert_eq!(short.cells[0].symbol, "─"); + assert!(state.hits.mobile_close.is_empty()); + assert!(state.hits.mobile_targets.is_empty()); + } } diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 1352a6a6..60dc4e81 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -72,6 +72,7 @@ impl ClientShellState { self.push_endpoint_method( crate::api::schema::Method::WorkspaceCreate( crate::api::schema::WorkspaceCreateParams { + source_workspace_id: self.workspace_action_id(), cwd: None, focus: true, label: None, @@ -125,6 +126,8 @@ impl ClientShellState { return; } if action == crate::input::KeybindAction::WorkspacePicker { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; self.mode = ClientShellMode::Navigate; self.navigate_workspace_id = self .snapshot @@ -704,7 +707,7 @@ impl ClientShellState { })) } KeybindAction::SwitchWorkspace(index) => { - let entries = render::workspace_entries(snapshot, &self.collapsed_groups); + let entries = self.navigation_workspace_entries(snapshot); Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id: snapshot .workspaces @@ -714,7 +717,7 @@ impl ClientShellState { })) } KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace => { - let entries = render::workspace_entries(snapshot, &self.collapsed_groups); + let entries = self.navigation_workspace_entries(snapshot); let current = entries.iter().position(|entry| { snapshot.workspaces[entry.index].workspace_id == focused_workspace })?; diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 007aab23..6cb21683 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -19,15 +19,6 @@ fn restore_mode_bar( } } -fn clipboard_feedback_starts_at_top(position: crate::config::ToastClipboardPosition) -> bool { - matches!( - position, - crate::config::ToastClipboardPosition::TopLeft - | crate::config::ToastClipboardPosition::TopCenter - | crate::config::ToastClipboardPosition::TopRight - ) -} - impl ClientShellState { pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option { self.last_composed_size = Some((cols, rows)); @@ -295,33 +286,38 @@ impl ClientShellState { ); } if let Some(notification) = self.visible_notification.as_ref() { - let notification_area = if layout.mobile_header.is_empty() { - Rect::new(0, 0, cols, rows) + self.hits.notification_toast = if layout.mobile_header.is_empty() { + notifications::render_visible_notification( + &mut composed, + Rect::new(0, 0, cols, rows), + notification, + self.config.toast_position, + u16::from(has_config_diagnostic), + &self.config.palette, + ) } else { - layout.pane_surface + notifications::render_mobile_notification_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + notification, + has_config_diagnostic, + &self.config.palette, + ) }; - self.hits.notification_toast = notifications::render_visible_notification( - &mut composed, - notification_area, - notification, - self.config.toast_position, - u16::from(has_config_diagnostic), - &self.config.palette, - ); } frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } if let Some(feedback) = self.copy_feedback.as_ref() { let cursor = frame.cursor.clone(); let mut composed = frame.to_ratatui_buffer()?; - let base_offset = - if clipboard_feedback_starts_at_top(self.config.clipboard_toast_position) { - u16::from(has_config_diagnostic) - } else { - 0 - }; + let base_offset = u16::from(has_config_diagnostic); + let feedback_area = if layout.mobile_header.is_empty() { + layout.pane_surface + } else { + Rect::new(0, 0, cols, rows) + }; let offset = crate::ui::copy_feedback_offset_for_toast( - layout.pane_surface, + feedback_area, feedback, base_offset, self.config.clipboard_toast_position, @@ -329,7 +325,7 @@ impl ClientShellState { ); crate::ui::render_copy_feedback_buffer( &mut composed, - layout.pane_surface, + feedback_area, feedback, offset, self.config.clipboard_toast_position, @@ -372,6 +368,26 @@ impl ClientShellState { }); } } + if !layout.mobile_header.is_empty() + && self.mode == ClientShellMode::Navigate + && self.overlay.is_none() + { + let mut composed = frame.to_ratatui_buffer()?; + super::mobile::render_mobile_switcher( + &mut composed, + Rect::new(0, 0, cols, rows), + snapshot, + &self.config, + self.navigate_workspace_id.as_deref(), + &mut self.mobile_switcher_scroll, + &mut self.reveal_mobile_workspace, + &mut self.hits, + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + self.hits.panes.clear(); + self.hits.pane_splits.clear(); + self.hits.popup = None; + } restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); if let Some(overlay) = self.overlay.as_ref() { let mut composed = frame.to_ratatui_buffer()?; diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 5e266228..d6d6fecb 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -159,6 +159,7 @@ impl ClientShellState { | RawInputEvent::Unsupported => {} } } + outcome.repaint |= self.resume_mobile_switcher_if_ready(); outcome } @@ -373,7 +374,7 @@ impl ClientShellState { } } - fn copy_or_terminal_mode(&self) -> ClientShellMode { + pub(super) fn copy_or_terminal_mode(&self) -> ClientShellMode { if self.copy_mode.as_ref().is_some_and(|copy_mode| { self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str()) }) { @@ -431,7 +432,7 @@ impl ClientShellState { ) }) { let valid = self.snapshot.as_deref().is_some_and(|snapshot| { - render::workspace_entries(snapshot, &self.collapsed_groups) + self.navigation_workspace_entries(snapshot) .get(index) .is_some() }); @@ -573,7 +574,7 @@ impl ClientShellState { let indexed_target_exists = match &binding { KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => { self.snapshot.as_deref().is_some_and(|snapshot| { - render::workspace_entries(snapshot, &self.collapsed_groups) + self.navigation_workspace_entries(snapshot) .get(*index) .is_some() }) @@ -613,7 +614,6 @@ impl ClientShellState { } else { if !preserve_navigate { self.mode = ClientShellMode::Terminal; - self.navigate_workspace_id = None; } self.record_binding(binding, outcome); } @@ -630,7 +630,8 @@ impl ClientShellState { let Some(snapshot) = self.snapshot.as_deref() else { return; }; - let entries = render::workspace_entries(snapshot, &self.collapsed_groups); + let mobile = self.mobile_layout_active(); + let entries = self.navigation_workspace_entries(snapshot); if entries.is_empty() { return; } @@ -643,12 +644,17 @@ impl ClientShellState { .position(|entry| snapshot.workspaces[entry.index].workspace_id == selected) }) .unwrap_or(0); - let next = (current as isize + delta).rem_euclid(entries.len() as isize) as usize; + let next = if mobile { + (current as isize + delta).clamp(0, entries.len().saturating_sub(1) as isize) as usize + } else { + (current as isize + delta).rem_euclid(entries.len() as isize) as usize + }; self.navigate_workspace_id = Some( snapshot.workspaces[entries[next].index] .workspace_id .clone(), ); + self.reveal_mobile_workspace = mobile; } fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) { diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs new file mode 100644 index 00000000..014f51a0 --- /dev/null +++ b/src/client/shell/mobile.rs @@ -0,0 +1,999 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph, Widget}, +}; + +use super::render::{display_width, put_segment, put_text}; +use super::*; + +const MOBILE_BUTTON_WIDTH: u16 = 10; + +struct MobileItem { + lines: Vec>, + background: Color, + target: Option, +} + +impl MobileItem { + fn section(label: impl Into, palette: &Palette) -> Self { + Self { + lines: vec![Line::from(Span::styled( + format!(" {} ", label.into()), + Style::default() + .fg(palette.overlay1) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ))], + background: palette.panel_bg, + target: None, + } + } + + fn action(label: &'static str, target: ClientMobileTarget, palette: &Palette) -> Self { + Self { + lines: vec![Line::from(Span::styled( + label, + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ))], + background: palette.panel_bg, + target: Some(target), + } + } +} + +pub(super) fn render_mobile_header( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + let button_width = MOBILE_BUTTON_WIDTH.min(area.width); + let button = Rect::new( + area.right().saturating_sub(button_width), + area.y, + button_width, + area.height, + ); + hits.mobile_switch = button; + let status_width = button.x.saturating_sub(area.x).saturating_sub(1); + let status = Rect::new(area.x, area.y, status_width, area.height); + render_header_status(buffer, status, snapshot, config); + render_header_button(buffer, button, snapshot, config); +} + +fn render_header_status( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + let Some(workspace) = snapshot.focused_workspace_id.as_deref().and_then(|id| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == id) + }) else { + put_text( + buffer, + area.x, + area.y, + area.width, + " no workspace", + Style::default().fg(palette.text).bg(palette.panel_bg), + ); + return; + }; + let tab_status = compact_tab_status(snapshot, workspace); + let tab_width = display_width(&tab_status).saturating_add(1).min(area.width); + let name_width = area.width.saturating_sub(tab_width); + put_text( + buffer, + area.x, + area.y, + name_width.min(3), + &format!( + " {} ", + status_icon(workspace.agent_status, config.status_indicators) + ), + Style::default() + .fg(status_color(workspace.agent_status, palette)) + .bg(palette.panel_bg), + ); + put_text( + buffer, + area.x.saturating_add(3), + area.y, + name_width.saturating_sub(3), + &crate::ui::truncate_end(&workspace.label, usize::from(name_width.saturating_sub(4))), + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.right().saturating_sub(tab_width).saturating_add(1), + area.y, + tab_width.saturating_sub(1), + &tab_status, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + if area.height > 1 { + render_agent_summary( + buffer, + Rect::new(area.x, area.y + 1, area.width, 1), + snapshot, + config, + ); + } +} + +fn render_header_button( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.surface0)); + for y in area.y..area.bottom() { + put_text( + buffer, + area.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.surface0), + ); + } + let label_y = if area.height > 1 { area.y + 1 } else { area.y }; + let label = "switch"; + let label_width = display_width(label); + put_text( + buffer, + area.x + .saturating_add(1) + .saturating_add(area.width.saturating_sub(1 + label_width) / 2), + label_y, + area.width.saturating_sub(1), + label, + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + if snapshot + .agents + .iter() + .any(|agent| agent.agent_status == crate::api::schema::AgentStatus::Blocked) + { + put_text( + buffer, + area.right().saturating_sub(1), + area.y, + 1, + status_icon( + crate::api::schema::AgentStatus::Blocked, + config.status_indicators, + ), + Style::default().fg(palette.red).bg(palette.surface0), + ); + } +} + +fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + .collect::>(); + let active = tabs + .iter() + .position(|tab| tab.tab_id == workspace.active_tab_id) + .unwrap_or(0); + let label = tabs + .get(active) + .map(|tab| tab.label.as_str()) + .unwrap_or("1"); + if tabs.len() <= 1 { + format!("tab {label}") + } else { + format!("tab {label} · {}/{}", active + 1, tabs.len()) + } +} + +fn render_agent_summary( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + use crate::api::schema::AgentStatus; + let counts = [ + (AgentStatus::Blocked, "blocked"), + (AgentStatus::Done, "done"), + (AgentStatus::Working, "working"), + (AgentStatus::Idle, "idle"), + ] + .map(|(status, label)| { + ( + status, + label, + snapshot + .agents + .iter() + .filter(|agent| agent.agent_status == status) + .count(), + ) + }); + let total = counts.iter().map(|(_, _, count)| count).sum::(); + let pending = counts[..3].iter().map(|(_, _, count)| count).sum::(); + if total == 0 { + put_text( + buffer, + area.x, + area.y, + area.width, + " no agents", + Style::default() + .fg(config.palette.overlay1) + .bg(config.palette.panel_bg), + ); + return; + } + if pending == 0 { + put_text( + buffer, + area.x, + area.y, + area.width, + " all idle", + Style::default() + .fg(config.palette.overlay1) + .bg(config.palette.panel_bg), + ); + return; + } + + let mut x = area.x.saturating_add(1); + let mut shown = 0usize; + let mut omitted = false; + for (status, label, count) in counts { + if count == 0 { + continue; + } + let symbol = match (config.status_indicators, status) { + (crate::config::StatusIndicatorStyle::Dots, AgentStatus::Blocked) => Some("◉"), + (crate::config::StatusIndicatorStyle::Dots, AgentStatus::Done) => Some("●"), + (crate::config::StatusIndicatorStyle::Dots, _) => None, + _ => Some(status_icon(status, config.status_indicators)), + }; + let text = symbol.map_or_else( + || format!("{count} {label}"), + |symbol| format!("{symbol} {count} {label}"), + ); + let separator = if shown == 0 { "" } else { " · " }; + let needed = display_width(separator).saturating_add(display_width(&text)); + if area.right().saturating_sub(x) < needed { + omitted = true; + break; + } + if !separator.is_empty() { + x = put_segment( + buffer, + x, + area.y, + area.right(), + separator, + Style::default() + .fg(config.palette.overlay0) + .bg(config.palette.panel_bg), + ); + } + let color = if shown == 0 { + match status { + AgentStatus::Done => config.palette.blue, + _ => status_color(status, &config.palette), + } + } else { + config.palette.overlay1 + }; + x = put_segment( + buffer, + x, + area.y, + area.right(), + &text, + Style::default() + .fg(color) + .bg(config.palette.panel_bg) + .add_modifier(if shown == 0 { + Modifier::BOLD + } else { + Modifier::empty() + }), + ); + shown += 1; + } + if omitted && area.right().saturating_sub(x) >= 2 { + put_text( + buffer, + x, + area.y, + 2, + " …", + Style::default() + .fg(config.palette.overlay0) + .bg(config.palette.panel_bg), + ); + } +} + +pub(super) fn render_mobile_switcher( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + scroll: &mut usize, + reveal_workspace: &mut bool, + hits: &mut ShellHitMap, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + Clear.render(area, buffer); + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + hits.mobile_switch = Rect::default(); + if area.height <= 2 { + *scroll = 0; + put_text( + buffer, + area.x, + area.y, + area.width, + &"─".repeat(usize::from(area.width)), + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + return; + } + let header_height = area.height.min(2); + let close_width = MOBILE_BUTTON_WIDTH.min(area.width); + let close = Rect::new( + area.right().saturating_sub(close_width), + area.y, + close_width, + header_height, + ); + hits.mobile_close = close; + put_text( + buffer, + area.x, + area.y, + close.x.saturating_sub(area.x), + " switch", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + render_close_button(buffer, close, palette); + let rule_y = area.y + header_height; + put_text( + buffer, + area.x, + rule_y, + area.width, + &"─".repeat(usize::from(area.width)), + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + let viewport = Rect::new( + area.x, + rule_y.saturating_add(1), + area.width, + area.height.saturating_sub(header_height + 1), + ); + if viewport.is_empty() { + *scroll = 0; + return; + } + + let items = mobile_items( + snapshot, + config, + selected_workspace_id, + viewport.width.saturating_sub(1), + ); + let total_rows = items.iter().map(|item| item.lines.len()).sum::(); + let max_scroll = total_rows.saturating_sub(usize::from(viewport.height)); + *scroll = (*scroll).min(max_scroll); + if *reveal_workspace { + if let Some(selected_workspace_id) = selected_workspace_id { + let mut start = 0usize; + for item in &items { + let end = start.saturating_add(item.lines.len()); + if matches!( + item.target.as_ref(), + Some(ClientMobileTarget::Workspace(workspace_id)) + if workspace_id == selected_workspace_id + ) { + if start < *scroll { + *scroll = start; + } else if end > (*scroll).saturating_add(usize::from(viewport.height)) { + *scroll = end + .saturating_sub(usize::from(viewport.height)) + .min(max_scroll); + } + break; + } + start = end; + } + } + *reveal_workspace = false; + } + hits.mobile_max_scroll = max_scroll; + let content = if viewport.width > 1 { + Rect::new( + viewport.x + 1, + viewport.y, + viewport.width - 1, + viewport.height, + ) + } else { + Rect::default() + }; + if max_scroll > 0 { + render_left_scrollbar(buffer, viewport, total_rows, *scroll, palette); + } + if content.is_empty() { + return; + } + + let viewport_start = *scroll; + let viewport_end = viewport_start.saturating_add(usize::from(viewport.height)); + let mut document_row = 0usize; + for item in items { + let item_start = document_row; + let item_end = item_start.saturating_add(item.lines.len()); + let visible_start = item_start.max(viewport_start); + let visible_end = item_end.min(viewport_end); + if visible_start < visible_end { + let y = viewport.y + u16::try_from(visible_start - viewport_start).unwrap_or(u16::MAX); + let height = u16::try_from(visible_end - visible_start).unwrap_or(u16::MAX); + let rect = Rect::new(content.x, y, content.width, height); + buffer.set_style(rect, Style::default().bg(item.background)); + for row in visible_start..visible_end { + let line = item.lines[row - item_start].clone(); + Paragraph::new(line).render( + Rect::new( + content.x, + viewport.y + u16::try_from(row - viewport_start).unwrap_or(u16::MAX), + content.width, + 1, + ), + buffer, + ); + } + if let Some(target) = item.target { + hits.mobile_targets.push((rect, target)); + } + } + document_row = item_end; + } +} + +fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) { + if area.is_empty() { + return; + } + buffer.set_style(area, Style::default().bg(palette.surface0)); + for y in area.y..area.bottom() { + put_text( + buffer, + area.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.surface0), + ); + } + let label_width = 5; + let label_x = area + .x + .saturating_add(1) + .saturating_add(area.width.saturating_sub(1 + label_width) / 2); + put_text( + buffer, + label_x, + area.y, + area.width.saturating_sub(1), + "close", + Style::default() + .fg(palette.overlay1) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + if area.height > 1 { + put_text( + buffer, + area.x.saturating_add(area.width / 2), + area.y + 1, + 1, + "×", + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + } +} + +fn mobile_items( + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + content_width: u16, +) -> Vec { + let palette = &config.palette; + let mut items = Vec::new(); + let ordered_agents = + super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort); + if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() { + let title = snapshot + .agent_view_label + .as_deref() + .map(|label| format!("agents · {label}")) + .unwrap_or_else(|| "agents".to_owned()); + items.push(MobileItem::section(title, palette)); + if ordered_agents.is_empty() { + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + " no matching agents", + Style::default() + .fg(palette.overlay0) + .bg(palette.panel_bg) + .add_modifier(Modifier::DIM), + ))], + background: palette.panel_bg, + target: None, + }); + } + for pane_id in ordered_agents { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + else { + continue; + }; + let workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == agent.workspace_id); + let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .unwrap_or("agent"); + let primary = workspace + .map(|workspace| workspace.label.as_str()) + .unwrap_or(agent_label); + let mut detail = Vec::new(); + let workspace_tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + if let Some(tab) = tab.filter(|tab| tab.custom_label || workspace_tab_count > 1) { + detail.push(tab.label.clone()); + } + let status_key = status_text(agent.agent_status); + detail.push( + agent + .state_labels + .iter() + .find(|(key, _)| key == status_key) + .map(|(_, label)| label.clone()) + .unwrap_or_else(|| { + if agent.agent_status == crate::api::schema::AgentStatus::Unknown { + "idle".to_owned() + } else { + status_key.to_owned() + } + }), + ); + detail.push(agent_label.to_owned()); + let background = if agent.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled(" ", Style::default().bg(background)), + Span::styled( + status_icon(agent.agent_status, config.status_indicators), + Style::default() + .fg(status_color(agent.agent_status, palette)) + .bg(background), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + primary, + usize::from(content_width.saturating_sub(5)), + ), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + crate::ui::truncate_end( + &format!(" {}", detail.join(" · ")), + usize::from(content_width), + ), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())), + }); + } + } + + items.push(MobileItem::section("spaces", palette)); + items.push(MobileItem::action( + " + new workspace", + ClientMobileTarget::NewWorkspace, + palette, + )); + for entry in super::render::workspace_entries(snapshot, &HashSet::new()) { + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); + let background = if selected { + palette.surface0 + } else if workspace.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + let connector = if entry.indented { + if entry.last_child { + "└─ " + } else { + "├─ " + } + } else { + "" + }; + let name = if entry.indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let branch = workspace.branch.as_deref().unwrap_or("shell"); + let detail_prefix = if entry.indented { + if entry.last_child { + " " + } else { + " │ " + } + } else { + " " + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled( + format!(" {connector}"), + Style::default().fg(palette.overlay0).bg(background), + ), + Span::styled( + status_icon(workspace.agent_status, config.status_indicators), + Style::default() + .fg(status_color(workspace.agent_status, palette)) + .bg(background), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + name, + usize::from(content_width.saturating_sub(if entry.indented { + 8 + } else { + 5 + })), + ), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + crate::ui::truncate_end( + &format!( + "{detail_prefix}{branch} · {}", + compact_tab_status(snapshot, workspace) + ), + usize::from(content_width), + ), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Workspace( + workspace.workspace_id.clone(), + )), + }); + } + + if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() { + items.push(MobileItem::section("tabs", palette)); + items.push(MobileItem::action( + " + new tab", + ClientMobileTarget::NewTab, + palette, + )); + for (index, tab) in snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .enumerate() + { + let background = if tab.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + let label = if tab.custom_label { + format!("{} · {}", index + 1, tab.label) + } else { + format!("tab {}", tab.label) + }; + let label = format!( + " {}", + crate::ui::truncate_end(&label, usize::from(content_width.saturating_sub(3)),) + ); + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + label, + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ))], + background, + target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())), + }); + } + } + + items.push(MobileItem::section("menu", palette)); + for (index, (label, _)) in super::global_menu::global_menu_items(snapshot) + .into_iter() + .enumerate() + { + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + format!(" {label}"), + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ))], + background: palette.panel_bg, + target: Some(ClientMobileTarget::Menu(index)), + }); + } + items +} + +impl ClientShellState { + pub(super) fn handle_mobile_mouse( + &mut self, + mouse: crossterm::event::MouseEvent, + outcome: &mut ClientShellInput, + ) -> bool { + let mobile = self + .last_composed_size + .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()); + if !mobile || self.overlay.is_some() { + return false; + } + use crossterm::event::{MouseButton, MouseEventKind}; + let point = (mouse.column, mouse.row); + if self.mode != ClientShellMode::Navigate { + if matches!( + self.mode, + ClientShellMode::Terminal | ClientShellMode::Resize + ) && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.mobile_switch, point) + { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + return true; + } + return false; + } + + match mouse.kind { + MouseEventKind::ScrollUp => { + self.mobile_switcher_scroll = self.mobile_switcher_scroll.saturating_sub(2); + outcome.repaint = true; + return true; + } + MouseEventKind::ScrollDown => { + self.mobile_switcher_scroll = self + .mobile_switcher_scroll + .saturating_add(2) + .min(self.hits.mobile_max_scroll); + outcome.repaint = true; + return true; + } + MouseEventKind::Down(MouseButton::Left) => {} + _ => return true, + } + + if super::contains(self.hits.mobile_close, point) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + outcome.repaint = true; + return true; + } + let target = self + .hits + .mobile_targets + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, target)| target.clone()); + match target { + Some(ClientMobileTarget::NewWorkspace) => { + self.mobile_switcher_suspended = true; + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + outcome, + ); + } + Some( + target @ (ClientMobileTarget::Workspace(_) + | ClientMobileTarget::Tab(_) + | ClientMobileTarget::Agent(_)), + ) => { + let method = match target { + ClientMobileTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ) + } + ClientMobileTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id, + }) + } + ClientMobileTarget::Agent(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } + ClientMobileTarget::NewWorkspace + | ClientMobileTarget::NewTab + | ClientMobileTarget::Menu(_) => return true, + }; + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + self.push_endpoint_method(method, outcome); + } + Some(ClientMobileTarget::NewTab) => { + self.mobile_switcher_suspended = true; + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + outcome, + ); + } + Some(ClientMobileTarget::Menu(index)) => { + let actionable = self.snapshot.as_deref().is_some_and(|snapshot| { + super::global_menu::global_menu_items(snapshot) + .get(index) + .is_some_and(|(_, action)| { + *action != super::global_menu::ClientGlobalMenuAction::WhatsNew + || snapshot.release_notes.is_some() + }) + }); + if actionable { + self.mobile_switcher_suspended = true; + self.activate_global_menu_item(index, outcome); + } + } + None => {} + } + outcome.repaint = true; + true + } +} + +fn render_left_scrollbar( + buffer: &mut Buffer, + viewport: Rect, + total_rows: usize, + scroll: usize, + palette: &Palette, +) { + if viewport.is_empty() { + return; + } + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: total_rows + .saturating_sub(usize::from(viewport.height)) + .saturating_sub(scroll), + max_offset_from_bottom: total_rows.saturating_sub(usize::from(viewport.height)), + viewport_rows: usize::from(viewport.height), + }; + let track = Rect::new(viewport.x, viewport.y, 1, viewport.height); + for y in track.y..track.bottom() { + put_text( + buffer, + track.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + } + if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) { + for y in thumb.top..thumb.top.saturating_add(thumb.len) { + put_text( + buffer, + track.x, + y, + 1, + "▌", + Style::default().fg(palette.accent).bg(palette.panel_bg), + ); + } + } +} diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index ee31dfbb..c9c1bff4 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -807,6 +807,9 @@ impl ClientShellState { self.focus_visible_notification(outcome); return; } + if self.handle_mobile_mouse(mouse, outcome) { + return; + } if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { match self.chrome_drag.as_ref() { Some(ClientChromeDrag::SidebarWidth) => { diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs index 612dd275..0310e57d 100644 --- a/src/client/shell/notifications.rs +++ b/src/client/shell/notifications.rs @@ -4,6 +4,104 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Widget}, }; +pub(super) fn render_mobile_notification_banner( + buffer: &mut Buffer, + area: Rect, + notification: &ClientVisibleNotification, + offset_for_warning: bool, + palette: &Palette, +) -> Rect { + if area.is_empty() { + return Rect::default(); + } + let warning_offset = u16::from(offset_for_warning); + let y = area.y + + area + .height + .saturating_sub(1u16.saturating_add(warning_offset)); + let rect = Rect::new(area.x, y, area.width, 1); + let background = palette.surface0; + Clear.render(rect, buffer); + buffer.set_style(rect, Style::default().bg(background)); + let event = ¬ification.event; + let title = match event.kind { + SemanticNotificationKind::NeedsAttention => event + .title + .strip_suffix(" needs attention") + .map(|agent| format!("{agent} waiting")) + .unwrap_or_else(|| event.title.clone()), + SemanticNotificationKind::Finished => event + .title + .strip_suffix(" finished") + .map(|agent| format!("{agent} done")) + .unwrap_or_else(|| event.title.clone()), + SemanticNotificationKind::UpdateInstalled => "update ready".to_owned(), + SemanticNotificationKind::Custom => event.title.clone(), + }; + let dot_color = match event.kind { + SemanticNotificationKind::NeedsAttention => palette.red, + SemanticNotificationKind::Finished => palette.blue, + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + palette.accent + } + }; + let mut x = rect.x; + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " ", + Style::default().bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + "●", + Style::default().fg(dot_color).bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " ", + Style::default().bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + &title, + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ); + if let Some(body) = event.body.as_deref().filter(|body| !body.is_empty()) { + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " · ", + Style::default().fg(palette.overlay0).bg(background), + ); + super::render::put_text( + buffer, + x, + rect.y, + rect.right().saturating_sub(x), + body, + Style::default().fg(palette.overlay0).bg(background), + ); + } + rect +} + pub(super) fn render_visible_notification( buffer: &mut Buffer, area: Rect, @@ -267,6 +365,33 @@ mod tests { } } + #[test] + fn mobile_notification_is_a_bottom_banner_with_released_title() { + let palette = crate::app::client_palette_from_config(&Config::default()); + let mut notification = notification(); + notification.event.kind = SemanticNotificationKind::NeedsAttention; + notification.event.title = "pi needs attention".into(); + notification.event.body = Some("workspace · tab 1".into()); + let area = Rect::new(0, 0, 44, 20); + let mut buffer = Buffer::empty(area); + for cell in &mut buffer.content { + cell.set_symbol("X"); + } + let rect = + render_mobile_notification_banner(&mut buffer, area, ¬ification, true, &palette); + assert_eq!(rect, Rect::new(0, 18, 44, 1)); + let text = buffer + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(text.contains("pi waiting")); + assert!(text.contains("workspace · tab 1")); + assert!(buffer.content[18 * 44..19 * 44] + .iter() + .all(|cell| cell.symbol() != "X")); + } + #[test] fn notification_rect_stays_inside_short_nonzero_area() { let palette = crate::app::client_palette_from_config(&Config::default()); diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index e5b04875..840d1372 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -294,10 +294,14 @@ impl ClientShellState { } pub(super) fn open_new_workspace_overlay(&mut self) { + let source_workspace_id = self.workspace_action_id(); let cwd = self.snapshot.as_deref().and_then(|snapshot| { - let pane_id = snapshot.focused_pane_id.as_deref()?; - let pane = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id)?; - pane.foreground_cwd.clone().or_else(|| pane.cwd.clone()) + let workspace_id = source_workspace_id.as_deref()?; + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .map(|workspace| workspace.new_workspace_cwd.clone()) }); let suggested_name = cwd .as_deref() @@ -309,6 +313,7 @@ impl ClientShellState { input: suggested_name.clone(), replace_on_type: true, target: ClientRenameTarget::NewWorkspace { + source_workspace_id, cwd, suggested_name, }, @@ -960,10 +965,12 @@ impl ClientShellState { let trimmed = rename.input.trim(); let method = match rename.target { ClientRenameTarget::NewWorkspace { + source_workspace_id, cwd, suggested_name, } => Some(crate::api::schema::Method::WorkspaceCreate( crate::api::schema::WorkspaceCreateParams { + source_workspace_id, cwd, focus: true, label: (!trimmed.is_empty() && trimmed != suggested_name) diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index ee25dc0d..0e7f3908 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -218,7 +218,13 @@ pub(super) fn render_shell( ) -> ShellHitMap { let mut hits = ShellHitMap::default(); if layout.mobile_header.height > 0 { - render_mobile_header(buffer, layout.mobile_header, snapshot, &config.palette); + super::mobile::render_mobile_header( + buffer, + layout.mobile_header, + snapshot, + config, + &mut hits, + ); } if layout.sidebar.width > 0 { if state.sidebar_collapsed { @@ -270,48 +276,6 @@ pub(super) fn render_shell( hits } -fn render_mobile_header( - buffer: &mut Buffer, - area: Rect, - snapshot: &ClientShellSnapshot, - palette: &Palette, -) { - buffer.set_style(area, Style::default().bg(palette.panel_bg)); - let workspace = snapshot - .focused_workspace_id - .as_deref() - .and_then(|id| snapshot.workspaces.iter().find(|ws| ws.workspace_id == id)) - .map(|ws| ws.label.as_str()) - .unwrap_or("Herdr"); - put_text( - buffer, - area.x, - area.y, - area.width, - &format!(" ☰ {workspace}"), - Style::default() - .fg(palette.text) - .bg(palette.panel_bg) - .add_modifier(Modifier::BOLD), - ); - if area.height > 1 { - let tab = snapshot - .focused_tab_id - .as_deref() - .and_then(|id| snapshot.tabs.iter().find(|tab| tab.tab_id == id)) - .map(|tab| tab.label.as_str()) - .unwrap_or("terminal"); - put_text( - buffer, - area.x + 1, - area.y + 1, - area.width.saturating_sub(1), - tab, - Style::default().fg(palette.overlay1).bg(palette.panel_bg), - ); - } -} - fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { let width = display_width(text).min(area.width); put_text( @@ -324,19 +288,26 @@ fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: St ); } -fn put_segment(buffer: &mut Buffer, x: u16, y: u16, right: u16, text: &str, style: Style) -> u16 { +pub(super) fn put_segment( + buffer: &mut Buffer, + x: u16, + y: u16, + right: u16, + text: &str, + style: Style, +) -> u16 { let width = display_width(text).min(right.saturating_sub(x)); put_text(buffer, x, y, width, text, style); x.saturating_add(width) } -fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { +pub(super) fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { if width == 0 || y >= buffer.area.bottom() || x >= buffer.area.right() { return; } buffer.set_stringn(x, y, text, width as usize, style); } -fn display_width(text: &str) -> u16 { +pub(super) fn display_width(text: &str) -> u16 { UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16 } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index b1bb7f0e..032de683 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -49,6 +49,16 @@ pub(super) struct ClientShellLayout { pub pane_surface: Rect, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ClientMobileTarget { + NewWorkspace, + Workspace(String), + NewTab, + Tab(String), + Agent(String), + Menu(usize), +} + #[derive(Default)] pub(super) struct ShellHitMap { pub(super) workspaces: Vec, @@ -73,6 +83,10 @@ pub(super) struct ShellHitMap { pub(super) new_tab: Rect, pub(super) tab_scroll_left: Rect, pub(super) tab_scroll_right: Rect, + pub(super) mobile_switch: Rect, + pub(super) mobile_close: Rect, + pub(super) mobile_targets: Vec<(Rect, ClientMobileTarget)>, + pub(super) mobile_max_scroll: usize, pub(super) global_launcher: Rect, pub(super) notification_toast: Rect, pub(super) global_menu_rows: Vec<(Rect, usize)>, @@ -241,6 +255,7 @@ pub(super) enum ClientShellOverlayKind { #[derive(Debug)] pub(super) enum ClientRenameTarget { NewWorkspace { + source_workspace_id: Option, cwd: Option, suggested_name: String, }, @@ -758,6 +773,9 @@ pub(crate) struct ClientShellState { pub(super) workspace_scroll: usize, pub(super) agent_scroll: usize, pub(super) tab_scroll: usize, + pub(super) mobile_switcher_scroll: usize, + pub(super) reveal_mobile_workspace: bool, + pub(super) mobile_switcher_suspended: bool, pub(super) reveal_focused_tab: bool, pub(super) last_tab_bar_width: Option, pub(super) last_composed_size: Option<(u16, u16)>, @@ -879,6 +897,9 @@ impl ClientShellState { workspace_scroll: 0, agent_scroll: 0, tab_scroll: 0, + mobile_switcher_scroll: 0, + reveal_mobile_workspace: false, + mobile_switcher_suspended: false, reveal_focused_tab: true, last_tab_bar_width: None, last_composed_size: None, @@ -936,6 +957,41 @@ impl ClientShellState { .count() } + pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool { + if !self.mobile_switcher_suspended || self.overlay.is_some() { + return false; + } + self.mobile_switcher_suspended = false; + if self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_ref()) + .is_some() + { + self.mode = self.copy_or_terminal_mode(); + self.navigate_workspace_id = None; + } else { + self.mode = ClientShellMode::Navigate; + } + true + } + + pub(super) fn mobile_layout_active(&self) -> bool { + self.last_composed_size + .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()) + } + + pub(super) fn navigation_workspace_entries( + &self, + snapshot: &ClientShellSnapshot, + ) -> Vec { + if self.mobile_layout_active() { + render::workspace_entries(snapshot, &HashSet::new()) + } else { + render::workspace_entries(snapshot, &self.collapsed_groups) + } + } + pub(super) fn layout(&self, cols: u16, rows: u16) -> ClientShellLayout { self.config.layout( cols, @@ -980,6 +1036,9 @@ impl ClientShellState { self.workspace_scroll = 0; self.agent_scroll = 0; self.tab_scroll = 0; + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mobile_switcher_suspended = false; self.reveal_focused_tab = true; self.last_tab_bar_width = None; self.last_composed_size = None; @@ -1113,6 +1172,7 @@ impl ClientShellState { }) { self.navigate_workspace_id = snapshot.focused_workspace_id.clone(); + self.reveal_mobile_workspace = self.mobile_layout_active(); } let pane_exists = |pane_id: &String| snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id); @@ -1179,6 +1239,7 @@ impl ClientShellState { } } self.snapshot = Some(snapshot); + self.resume_mobile_switcher_if_ready(); } pub(crate) fn set_pane_surface(&mut self, surface: PaneSurfaceFrame) { @@ -1312,6 +1373,7 @@ impl ClientShellState { } self.popup_terminal_id = next_popup; self.pane_surface = Some(surface); + self.resume_mobile_switcher_if_ready(); } pub(crate) fn tick_popup_pending(&mut self, now: std::time::Instant) { diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 9a6b8333..d9813526 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -875,6 +875,8 @@ pub struct ClientShellTabStatusSegment { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ClientShellWorkspace { pub workspace_id: String, + pub active_tab_id: String, + pub new_workspace_cwd: String, pub number: usize, pub label: String, pub custom_label: bool, @@ -1975,6 +1977,8 @@ mod tests { agent_order: Vec::new(), workspaces: vec![ClientShellWorkspace { workspace_id: "w1".into(), + active_tab_id: "w1:t1".into(), + new_workspace_cwd: "/tmp".into(), number: 1, label: "shell".into(), custom_label: false, diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 9e1e7bab..ce31b5b3 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -14,11 +14,17 @@ pub(super) fn snapshot( .workspaces .into_iter() .zip(&app.state.workspaces) - .map(|(workspace, state)| { + .enumerate() + .map(|(workspace_index, (workspace, state))| { let mut tokens = workspace.tokens.into_iter().collect::>(); tokens.sort_by(|left, right| left.0.cmp(&right.0)); protocol::ClientShellWorkspace { workspace_id: workspace.workspace_id, + active_tab_id: workspace.active_tab_id, + new_workspace_cwd: app + .resolved_new_workspace_cwd_from(workspace_index) + .display() + .to_string(), number: workspace.number, label: workspace.label, custom_label: state.custom_name.is_some(), diff --git a/src/server/headless.rs b/src/server/headless.rs index d4cd2c27..b13df48d 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -993,6 +993,7 @@ impl HeadlessServer { self.dispatch_headless_runtime_mutation( id, api::schema::Method::WorkspaceCreate(api::schema::WorkspaceCreateParams { + source_workspace_id: None, cwd, focus: true, label, diff --git a/src/ui.rs b/src/ui.rs index 0ee2a271..13af355c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,6 +20,7 @@ mod status; mod tab_surface; mod tabs; mod text; +pub(crate) use text::truncate_end; mod widgets; use self::dialogs::{