aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/mobile.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/mobile.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/mobile.rs')
-rw-r--r--src/client/shell/mobile.rs999
1 files changed, 999 insertions, 0 deletions
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<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),
+ );
+ }
+ }
+}