feat: render mobile switcher in client shell

This commit is contained in:
Ogulcan Celik
2026-09-01 15:39:57 +03:00
parent 3e721588ec
commit 0bc6fbae67
22 changed files with 1942 additions and 102 deletions
+7
View File
@@ -4405,6 +4405,13 @@
"string",
"null"
]
},
"source_workspace_id": {
"description": "Workspace whose focused pane supplies the `follow` cwd policy.",
"type": [
"string",
"null"
]
}
},
"type": "object"
+1
View File
@@ -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()),
+3
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
+112 -6
View File
@@ -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(
+8
View File
@@ -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<usize> {
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,
+1
View File
@@ -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,
+2
View File
@@ -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,
+1
View File
@@ -94,6 +94,7 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> {
}
super::runtime::workspace_create(WorkspaceCreateParams {
source_workspace_id: None,
cwd,
focus,
label,
+517 -10
View File
@@ -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::<String>();
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::<String>();
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::<String>()
})
.collect::<Vec<_>>()
.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::<String>()
})
.collect::<Vec<_>>()
.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());
}
}
+5 -2
View File
@@ -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
})?;
+44 -28
View File
@@ -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<FrameData> {
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()?;
+12 -6
View File
@@ -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) {
+999
View File
@@ -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<Line<'static>>,
background: Color,
target: Option<ClientMobileTarget>,
}
impl MobileItem {
fn section(label: impl Into<String>, 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::<Vec<_>>();
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::<usize>();
let pending = counts[..3].iter().map(|(_, _, count)| count).sum::<usize>();
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::<usize>();
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<MobileItem> {
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),
);
}
}
}
+3
View File
@@ -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) => {
+125
View File
@@ -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 = &notification.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, &notification, true, &palette);
assert_eq!(rect, Rect::new(0, 18, 44, 1));
let text = buffer
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
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());
+10 -3
View File
@@ -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)
+17 -46
View File
@@ -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
}
+62
View File
@@ -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<WorkspaceHit>,
@@ -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<String>,
cwd: Option<String>,
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<u16>,
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<WorkspaceEntry> {
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) {
+4
View File
@@ -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,
+7 -1
View File
@@ -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::<Vec<_>>();
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(),
+1
View File
@@ -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,
+1
View File
@@ -20,6 +20,7 @@ mod status;
mod tab_surface;
mod tabs;
mod text;
pub(crate) use text::truncate_end;
mod widgets;
use self::dialogs::{