aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-05 18:32:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-05 18:32:42 +0300
commitf7b0ee2f758440db3a3ff4cc727f879b420255d7 (patch)
tree764149deada51f6196f3eecda46374ec45ff097a /src
parent6885c8873971d79332376137a14572f8440ef42e (diff)
feat: add mobile terminal layout
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs36
-rw-r--r--src/app/input/mouse.rs335
-rw-r--r--src/app/input/navigate.rs18
-rw-r--r--src/app/input/sidebar.rs10
-rw-r--r--src/app/input/terminal.rs1
-rw-r--r--src/app/mod.rs4
-rw-r--r--src/app/state.rs14
-rw-r--r--src/ui.rs129
-rw-r--r--src/ui/mobile.rs884
9 files changed, 1408 insertions, 23 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 0b187fed..3b904a89 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -8,7 +8,7 @@ use crate::events::AppEvent;
use crate::layout::{find_in_direction, NavDirection, PaneId};
use crate::pane::EffectiveStateChange;
-use super::state::{AppState, Mode, ToastKind, ToastNotification};
+use super::state::{AppState, Mode, ToastKind, ToastNotification, ViewLayout};
fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool {
matches!(new_state, AgentState::Idle)
@@ -135,7 +135,16 @@ impl AppState {
}
pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) {
- if self.sidebar_collapsed || idx >= self.workspaces.len() {
+ if idx >= self.workspaces.len() {
+ return;
+ }
+
+ if self.view.layout == ViewLayout::Mobile && self.mode == Mode::Navigate {
+ self.ensure_mobile_workspace_visible(idx);
+ return;
+ }
+
+ if self.sidebar_collapsed {
return;
}
@@ -164,6 +173,25 @@ impl AppState {
}
}
+ fn ensure_mobile_workspace_visible(&mut self, idx: usize) {
+ let viewport = crate::ui::mobile_switcher_areas(self).viewport;
+ if viewport.height == 0 {
+ return;
+ }
+
+ let row_range = crate::ui::mobile_switcher_workspace_doc_range(idx);
+ let visible_start = self.mobile_switcher_scroll;
+ let visible_end = visible_start.saturating_add(viewport.height as usize);
+ if row_range.start < visible_start {
+ self.mobile_switcher_scroll = row_range.start;
+ } else if row_range.end > visible_end {
+ self.mobile_switcher_scroll = row_range.end.saturating_sub(viewport.height as usize);
+ }
+ self.mobile_switcher_scroll = self
+ .mobile_switcher_scroll
+ .min(crate::ui::mobile_switcher_max_scroll(self));
+ }
+
pub fn switch_tab(&mut self, idx: usize) {
if let Some(ws_idx) = self.active {
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
@@ -747,10 +775,10 @@ mod tests {
#[test]
fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() {
let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]);
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 26, 14));
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
state.switch_workspace(7);
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 26, 14));
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
assert!(state
.view
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 79237f52..185393f0 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -6,7 +6,7 @@ use tracing::warn;
use crate::{
app::state::{
AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
- MenuListState, Mode, TabPressState, WorkspacePressState,
+ MenuListState, Mode, TabPressState, ViewLayout, WorkspacePressState,
},
layout::{PaneInfo, SplitBorder},
selection::Selection,
@@ -35,7 +35,8 @@ impl AppState {
return self.handle_settings_mouse(mouse);
}
- let launcher_enabled = !self.sidebar_collapsed
+ let launcher_enabled = self.view.layout != ViewLayout::Mobile
+ && !self.sidebar_collapsed
&& matches!(
self.mode,
Mode::Terminal
@@ -84,6 +85,10 @@ impl AppState {
return None;
}
+ if self.view.layout == ViewLayout::Mobile && self.handle_mobile_mouse(mouse) {
+ return None;
+ }
+
let sidebar = self.view.sidebar_rect;
let in_sidebar = mouse.column >= sidebar.x
&& mouse.column < sidebar.x + sidebar.width
@@ -676,6 +681,88 @@ impl AppState {
None
}
+ fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> bool {
+ if self.mode == Mode::Navigate {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => {
+ self.scroll_mobile_switcher_at(mouse.column, mouse.row, -1);
+ return true;
+ }
+ MouseEventKind::ScrollDown => {
+ self.scroll_mobile_switcher_at(mouse.column, mouse.row, 1);
+ return true;
+ }
+ MouseEventKind::Down(MouseButton::Left) => {}
+ _ => return true,
+ }
+ } else if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ return false;
+ }
+
+ if self.mode != Mode::Navigate {
+ if !matches!(self.mode, Mode::Terminal | Mode::Resize) {
+ return false;
+ }
+ if rect_contains(self.view.mobile_menu_hit_area, mouse.column, mouse.row) {
+ self.mobile_switcher_scroll = 0;
+ self.mode = Mode::Navigate;
+ return true;
+ }
+ return false;
+ }
+
+ let areas = crate::ui::mobile_switcher_areas(self);
+ if rect_contains(areas.close, mouse.column, mouse.row) {
+ self.mode = Mode::Terminal;
+ return true;
+ }
+
+ match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) {
+ Some(crate::ui::MobileSwitcherTarget::NewWorkspace) => {
+ self.request_new_workspace = true;
+ }
+ Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => {
+ self.switch_workspace(ws_idx);
+ self.mode = Mode::Terminal;
+ }
+ Some(crate::ui::MobileSwitcherTarget::NewTab) => {
+ open_new_tab_dialog(self);
+ }
+ Some(crate::ui::MobileSwitcherTarget::Tab(tab_idx)) => {
+ self.switch_tab(tab_idx);
+ self.mode = Mode::Terminal;
+ }
+ Some(crate::ui::MobileSwitcherTarget::Agent {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ }) => {
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ self.focus_pane(pane_id);
+ self.mode = Mode::Terminal;
+ }
+ Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => {
+ let actions = global_menu_actions(self);
+ if let Some(action) = actions.get(action_idx).copied() {
+ apply_global_menu_action(self, action);
+ }
+ }
+ None => {}
+ }
+
+ true
+ }
+
+ fn scroll_mobile_switcher_at(&mut self, _col: u16, _row: u16, delta: i16) {
+ let max_scroll = crate::ui::mobile_switcher_max_scroll(self);
+ apply_scroll(
+ &mut self.mobile_switcher_scroll,
+ delta.saturating_mul(2),
+ max_scroll,
+ );
+ }
+
pub(super) fn screen_rect(&self) -> Rect {
let sidebar = self.view.sidebar_rect;
let terminal = self.view.terminal_area;
@@ -1090,6 +1177,23 @@ pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRoutin
}
}
+fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
+ rect.width > 0
+ && rect.height > 0
+ && col >= rect.x
+ && col < rect.x + rect.width
+ && row >= rect.y
+ && row < rect.y + rect.height
+}
+
+fn apply_scroll(scroll: &mut usize, delta: i16, max_scroll: usize) {
+ if delta.is_negative() {
+ *scroll = scroll.saturating_sub(delta.unsigned_abs() as usize);
+ } else {
+ *scroll = scroll.saturating_add(delta as usize).min(max_scroll);
+ }
+}
+
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
@@ -1100,7 +1204,7 @@ mod tests {
};
use super::*;
use crate::{
- app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout},
workspace::Workspace,
};
@@ -1369,6 +1473,231 @@ mod tests {
}
#[test]
+ fn mobile_switch_button_opens_switcher_and_workspace_row_switches_workspace() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
+ assert_eq!(app.state.view.layout, ViewLayout::Mobile);
+
+ let switch = app.state.view.mobile_menu_hit_area;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ switch.x + 1,
+ switch.y + 1,
+ ));
+
+ assert_eq!(app.state.mode, Mode::Navigate);
+
+ let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ viewport.x + 2,
+ viewport.y + 4,
+ ));
+
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn mobile_workspace_panel_scroll_reaches_extra_workspaces() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = (0..12)
+ .map(|idx| Workspace::test_new(&format!("ws-{idx}")))
+ .collect();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
+ let switch = app.state.view.mobile_menu_hit_area;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ switch.x + 1,
+ switch.y + 1,
+ ));
+ assert_eq!(app.state.mode, Mode::Navigate);
+
+ let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
+ app.handle_mouse(mouse(
+ MouseEventKind::ScrollDown,
+ viewport.x + 2,
+ viewport.y,
+ ));
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
+ assert_eq!(app.state.mobile_switcher_scroll, 2);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ viewport.x + 2,
+ viewport.y + 2,
+ ));
+
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn mobile_global_scroll_reaches_tabs_and_switches_tab() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("one");
+ ws.test_add_tab(Some("two"));
+ ws.test_add_tab(Some("three"));
+ ws.test_add_tab(Some("four"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 12));
+ let switch = app.state.view.mobile_menu_hit_area;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ switch.x + 1,
+ switch.y + 1,
+ ));
+
+ let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::ScrollDown,
+ viewport.x + 2,
+ viewport.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::ScrollDown,
+ viewport.x + 2,
+ viewport.y,
+ ));
+ assert_eq!(app.state.mobile_switcher_scroll, 4);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ viewport.x + 2,
+ viewport.y + 4,
+ ));
+ assert_eq!(app.state.workspaces[0].active_tab, 2);
+ }
+
+ #[test]
+ fn mobile_switcher_action_rows_create_workspace_and_open_tab_dialog() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("one");
+ ws.test_add_tab(Some("logs"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
+ let switch = app.state.view.mobile_menu_hit_area;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ switch.x + 1,
+ switch.y + 1,
+ ));
+ let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ viewport.x + 2,
+ viewport.y + 1,
+ ));
+ assert!(app.state.request_new_workspace);
+
+ app.state.request_new_workspace = false;
+ app.state.mode = Mode::Navigate;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ viewport.x + 2,
+ viewport.y + 5,
+ ));
+ assert_eq!(app.state.mode, Mode::RenameTab);
+ assert!(app.state.creating_new_tab);
+ }
+
+ #[test]
+ fn mobile_switcher_swallows_non_left_mouse_events() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("one")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
+ let switch = app.state.view.mobile_menu_hit_area;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ switch.x + 1,
+ switch.y + 1,
+ ));
+ assert_eq!(app.state.mode, Mode::Navigate);
+
+ let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Right),
+ viewport.x + 2,
+ viewport.y + 2,
+ ));
+
+ assert_eq!(app.state.mode, Mode::Navigate);
+ assert!(app.state.context_menu.is_none());
+ }
+
+ #[test]
+ fn mobile_switch_button_does_not_bypass_rename_modal() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("one")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::RenameTab;
+ app.state.creating_new_tab = true;
+ app.state.name_input = "new tab".into();
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
+ let switch = app.state.view.mobile_menu_hit_area;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ switch.x + 1,
+ switch.y + 1,
+ ));
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ assert!(!app.state.creating_new_tab);
+ assert!(!app.state.request_new_tab);
+ }
+
+ #[test]
+ fn mobile_switcher_close_returns_to_terminal() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("one")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
+ let switch = app.state.view.mobile_menu_hit_area;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ switch.x + 1,
+ switch.y + 1,
+ ));
+ assert_eq!(app.state.mode, Mode::Navigate);
+
+ let close = crate::ui::mobile_switcher_areas(&app.state).close;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ close.x + 1,
+ close.y,
+ ));
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() {
let input_state = crate::pane::InputState {
alternate_screen: true,
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index d55eedea..546228cc 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -650,6 +650,24 @@ mod tests {
}
#[test]
+ fn mobile_workspace_keyboard_navigation_keeps_selected_row_visible() {
+ let mut state = state_with_workspaces(&["a", "b", "c", "d"]);
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Navigate;
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 44, 8));
+ assert_eq!(state.mobile_switcher_scroll, 0);
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.selected, 1);
+ assert_eq!(state.mobile_switcher_scroll, 1);
+ }
+
+ #[test]
fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT));
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index f3ae2b06..fe8f14c1 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -1,6 +1,6 @@
use ratatui::layout::Rect;
-use crate::app::state::{AppState, Mode};
+use crate::app::state::{AppState, Mode, ViewLayout};
use super::ScrollbarClickTarget;
@@ -170,6 +170,10 @@ impl AppState {
}
pub(crate) fn global_launcher_rect(&self) -> Rect {
+ if self.view.layout == ViewLayout::Mobile {
+ return self.view.mobile_menu_hit_area;
+ }
+
let footer = self.sidebar_footer_rect();
let width = if self.update_available.is_some() {
8
@@ -983,7 +987,7 @@ mod tests {
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20));
let right = app.state.view.tab_scroll_right_hit_area;
assert!(right.width > 0);
@@ -1019,7 +1023,7 @@ mod tests {
app.state.selected = 0;
app.state.tab_scroll = usize::MAX;
app.state.tab_scroll_follow_active = false;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20));
let last_idx = app.state.workspaces[0].tabs.len() - 1;
let target = app.state.view.tab_hit_areas[last_idx];
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 910a49bd..bbfcd323 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -46,6 +46,7 @@ impl App {
}
if self.state.is_prefix(&key_event) {
+ self.state.mobile_switcher_scroll = 0;
self.state.mode = Mode::Navigate;
return None;
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 1a287a38..35f502a5 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -294,7 +294,9 @@ impl App {
agent_panel_scroll: 0,
tab_scroll: 0,
tab_scroll_follow_active: true,
+ mobile_switcher_scroll: 0,
view: state::ViewState {
+ layout: state::ViewLayout::Desktop,
sidebar_rect: Rect::default(),
workspace_card_areas: Vec::new(),
tab_bar_rect: Rect::default(),
@@ -303,6 +305,8 @@ impl App {
tab_scroll_right_hit_area: Rect::default(),
new_tab_hit_area: Rect::default(),
terminal_area: Rect::default(),
+ mobile_header_rect: Rect::default(),
+ mobile_menu_hit_area: Rect::default(),
pane_infos: Vec::new(),
split_borders: Vec::new(),
},
diff --git a/src/app/state.rs b/src/app/state.rs
index facb5187..831d8b7d 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -329,7 +329,14 @@ pub struct WorkspaceCardArea {
/// Computed view geometry — derived from AppState + terminal size.
/// Updated before each render, consumed by render and mouse handling.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ViewLayout {
+ Desktop,
+ Mobile,
+}
+
pub struct ViewState {
+ pub layout: ViewLayout,
pub sidebar_rect: Rect,
pub workspace_card_areas: Vec<WorkspaceCardArea>,
pub tab_bar_rect: Rect,
@@ -338,6 +345,8 @@ pub struct ViewState {
pub tab_scroll_right_hit_area: Rect,
pub new_tab_hit_area: Rect,
pub terminal_area: Rect,
+ pub mobile_header_rect: Rect,
+ pub mobile_menu_hit_area: Rect,
pub pane_infos: Vec<PaneInfo>,
pub split_borders: Vec<SplitBorder>,
}
@@ -618,6 +627,7 @@ pub struct AppState {
pub agent_panel_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
+ pub mobile_switcher_scroll: usize,
// View geometry (computed before render, consumed by render + mouse)
pub view: ViewState,
pub(crate) drag: Option<DragState>,
@@ -746,7 +756,9 @@ impl AppState {
agent_panel_scroll: 0,
tab_scroll: 0,
tab_scroll_follow_active: true,
+ mobile_switcher_scroll: 0,
view: ViewState {
+ layout: ViewLayout::Desktop,
sidebar_rect: Rect::default(),
workspace_card_areas: Vec::new(),
tab_bar_rect: Rect::default(),
@@ -755,6 +767,8 @@ impl AppState {
tab_scroll_right_hit_area: Rect::default(),
new_tab_hit_area: Rect::default(),
terminal_area: Rect::default(),
+ mobile_header_rect: Rect::default(),
+ mobile_menu_hit_area: Rect::default(),
pane_infos: Vec::new(),
split_borders: Vec::new(),
},
diff --git a/src/ui.rs b/src/ui.rs
index 4299019f..4350a6d7 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -8,6 +8,7 @@ use ratatui::{
mod dialogs;
mod keybind_help;
mod menus;
+mod mobile;
mod onboarding;
mod panes;
mod release_notes;
@@ -24,6 +25,10 @@ use self::menus::{
render_context_menu, render_global_launcher_menu, render_navigate_overlay,
render_resize_overlay,
};
+use self::mobile::{
+ compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height,
+ render_mobile_header, render_mobile_panel, render_mobile_toast_banner,
+};
pub(crate) use self::onboarding::onboarding_welcome_continue_rect;
use self::onboarding::render_onboarding_overlay;
use self::panes::{compute_pane_infos, render_panes};
@@ -53,9 +58,14 @@ pub(crate) use self::{
};
pub(crate) use self::{
keybind_help::keybind_help_lines,
+ mobile::{
+ mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at,
+ mobile_switcher_workspace_doc_range, MobileSwitcherTarget,
+ },
tabs::compute_tab_bar_view,
widgets::{centered_popup_rect, modal_stack_areas},
};
+use crate::app::state::ViewLayout;
use crate::app::{AppState, Mode};
const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator
@@ -87,6 +97,11 @@ pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect
}
fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) {
+ if is_mobile_width(area) {
+ compute_mobile_view(app, area, resize_panes);
+ return;
+ }
+
let sidebar_w = if app.sidebar_collapsed {
COLLAPSED_WIDTH
} else {
@@ -146,6 +161,7 @@ fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) {
let pane_infos = compute_pane_infos(app, terminal_area, resize_panes);
app.view = crate::app::ViewState {
+ layout: ViewLayout::Desktop,
sidebar_rect: sidebar_area,
workspace_card_areas,
tab_bar_rect,
@@ -154,6 +170,50 @@ fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) {
tab_scroll_right_hit_area: tab_bar_view.scroll_right_hit_area,
new_tab_hit_area: tab_bar_view.new_tab_hit_area,
terminal_area,
+ mobile_header_rect: Rect::default(),
+ mobile_menu_hit_area: Rect::default(),
+ pane_infos,
+ split_borders,
+ };
+}
+
+fn compute_mobile_view(app: &mut AppState, area: Rect, resize_panes: bool) {
+ let header_h = area.height.min(2);
+ let (header_rect, terminal_area) = if area.height > header_h {
+ let [header_rect, terminal_area] =
+ Layout::vertical([Constraint::Length(header_h), Constraint::Min(1)]).areas(area);
+ (header_rect, terminal_area)
+ } else {
+ (area, Rect::default())
+ };
+
+ if app.mode == Mode::Navigate {
+ let switcher_viewport_h = area.height.saturating_sub(header_h + 1);
+ let max_scroll = mobile_switcher_max_scroll_for_height(app, switcher_viewport_h);
+ app.mobile_switcher_scroll = app.mobile_switcher_scroll.min(max_scroll);
+ }
+
+ let split_borders = app
+ .active
+ .and_then(|i| app.workspaces.get(i))
+ .map(|ws| ws.layout.splits(terminal_area))
+ .unwrap_or_default();
+
+ let pane_infos = compute_pane_infos(app, terminal_area, resize_panes);
+ let header_hits = compute_mobile_header_hit_areas(app, header_rect);
+
+ app.view = crate::app::ViewState {
+ layout: ViewLayout::Mobile,
+ sidebar_rect: Rect::default(),
+ workspace_card_areas: Vec::new(),
+ tab_bar_rect: Rect::default(),
+ tab_hit_areas: Vec::new(),
+ tab_scroll_left_hit_area: Rect::default(),
+ tab_scroll_right_hit_area: Rect::default(),
+ new_tab_hit_area: Rect::default(),
+ terminal_area,
+ mobile_header_rect: header_rect,
+ mobile_menu_hit_area: header_hits.menu,
pane_infos,
split_borders,
};
@@ -165,17 +225,24 @@ pub fn render(app: &AppState, frame: &mut Frame) {
let tab_bar_area = app.view.tab_bar_rect;
let terminal_area = app.view.terminal_area;
- if app.sidebar_collapsed {
+ if app.view.layout == ViewLayout::Mobile {
+ render_mobile_header(app, frame, app.view.mobile_header_rect);
+ } else if app.sidebar_collapsed {
render_sidebar_collapsed(app, frame, sidebar_area);
} else {
render_sidebar(app, frame, sidebar_area);
}
- render_tab_bar(app, frame, tab_bar_area);
+ if app.view.layout != ViewLayout::Mobile {
+ render_tab_bar(app, frame, tab_bar_area);
+ }
render_panes(app, frame, terminal_area);
match app.mode {
Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()),
+ Mode::Navigate if app.view.layout == ViewLayout::Mobile => {
+ render_mobile_panel(app, frame, frame.area())
+ }
Mode::Navigate => render_navigate_overlay(app, frame, terminal_area),
Mode::Resize => render_resize_overlay(app, frame, terminal_area),
Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area),
@@ -195,13 +262,23 @@ pub fn render(app: &AppState, frame: &mut Frame) {
render_config_diagnostic(frame, terminal_area, message, &app.palette);
}
if let Some(toast) = &app.toast {
- render_toast_notification(
- frame,
- terminal_area,
- toast,
- has_config_diagnostic,
- &app.palette,
- );
+ if app.view.layout == ViewLayout::Mobile {
+ render_mobile_toast_banner(
+ frame,
+ frame.area(),
+ toast,
+ has_config_diagnostic,
+ &app.palette,
+ );
+ } else {
+ render_toast_notification(
+ frame,
+ terminal_area,
+ toast,
+ has_config_diagnostic,
+ &app.palette,
+ );
+ }
}
}
@@ -235,7 +312,11 @@ mod tests {
use super::release_notes::{release_notes_lines, release_notes_preview_lines};
use super::scrollbar::scrollbar_thumb;
use super::*;
- use crate::{app::state::Palette, layout::PaneInfo, workspace::Workspace};
+ use crate::{
+ app::state::{Palette, ViewLayout},
+ layout::PaneInfo,
+ workspace::Workspace,
+ };
use ratatui::{backend::TestBackend, Terminal};
use ratatui::{style::Color, text::Line};
@@ -279,6 +360,28 @@ mod tests {
}
#[test]
+ fn mobile_width_uses_header_and_full_width_terminal() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+
+ compute_view(&mut app, Rect::new(0, 0, 44, 20));
+
+ assert_eq!(app.view.layout, ViewLayout::Mobile);
+ assert_eq!(app.view.sidebar_rect, Rect::default());
+ assert_eq!(app.view.tab_bar_rect, Rect::default());
+ assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2));
+ assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18));
+ assert_eq!(app.view.mobile_menu_hit_area.height, 2);
+ assert_eq!(
+ app.view.mobile_menu_hit_area.x + app.view.mobile_menu_hit_area.width,
+ 44
+ );
+ }
+
+ #[test]
fn collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() {
let mut app = crate::app::state::AppState::test_new();
app.sidebar_collapsed = true;
@@ -423,7 +526,7 @@ mod tests {
fn tab_bar_shows_scroll_controls_when_tabs_overflow() {
let mut app = crate::app::state::AppState::test_new();
let mut ws = Workspace::test_new("test");
- for name in ["alpha", "beta", "gamma", "delta"] {
+ for name in ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"] {
ws.test_add_tab(Some(name));
}
@@ -434,7 +537,7 @@ mod tests {
app.tab_scroll_follow_active = false;
app.tab_scroll = 2;
- compute_view(&mut app, Rect::new(0, 0, 44, 20));
+ compute_view(&mut app, Rect::new(0, 0, 65, 20));
assert!(app.view.tab_scroll_left_hit_area.width > 0);
assert!(app.view.tab_scroll_right_hit_area.width > 0);
@@ -479,7 +582,7 @@ mod tests {
app.tab_scroll_follow_active = false;
app.tab_scroll = usize::MAX;
- compute_view(&mut app, Rect::new(0, 0, 52, 20));
+ compute_view(&mut app, Rect::new(0, 0, 65, 20));
let last_idx = app.workspaces[0].tabs.len() - 1;
assert!(app.view.tab_hit_areas[last_idx].width > 0);
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
new file mode 100644
index 00000000..79f97136
--- /dev/null
+++ b/src/ui/mobile.rs
@@ -0,0 +1,884 @@
+use ratatui::{
+ layout::{Alignment, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Clear, Paragraph},
+ Frame,
+};
+
+use super::sidebar::agent_panel_entries;
+use super::status::{agent_icon, state_dot};
+use crate::app::state::{Palette, ToastKind, ToastNotification};
+use crate::app::AppState;
+use crate::detect::AgentState;
+use crate::layout::PaneId;
+
+pub(crate) const MOBILE_WIDTH_THRESHOLD: u16 = 64;
+const SWITCH_BUTTON_WIDTH: u16 = 10;
+
+#[derive(Debug, Clone, Copy, Default)]
+pub(crate) struct MobileHeaderHitAreas {
+ pub menu: Rect,
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub(crate) struct MobileSwitcherAreas {
+ pub close: Rect,
+ pub viewport: Rect,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum MobileSwitcherTarget {
+ NewWorkspace,
+ Workspace(usize),
+ NewTab,
+ Tab(usize),
+ Agent {
+ ws_idx: usize,
+ tab_idx: usize,
+ pane_id: PaneId,
+ },
+ Menu(usize),
+}
+
+pub(crate) fn is_mobile_width(area: Rect) -> bool {
+ area.width > 0 && area.width <= MOBILE_WIDTH_THRESHOLD
+}
+
+pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas {
+ if area.width == 0 || area.height == 0 {
+ return MobileHeaderHitAreas::default();
+ }
+
+ let width = SWITCH_BUTTON_WIDTH.min(area.width);
+ let switch = Rect::new(
+ area.x + area.width.saturating_sub(width),
+ area.y,
+ width,
+ area.height,
+ );
+
+ MobileHeaderHitAreas { menu: switch }
+}
+
+pub(crate) fn mobile_switcher_areas(app: &AppState) -> MobileSwitcherAreas {
+ let screen = mobile_screen_rect(app);
+ if screen.width == 0 || screen.height <= 2 {
+ return MobileSwitcherAreas::default();
+ }
+
+ let header_h = screen.height.min(2);
+ let close_w = 10u16.min(screen.width);
+ let close = Rect::new(
+ screen.x + screen.width.saturating_sub(close_w),
+ screen.y,
+ close_w,
+ header_h,
+ );
+ let viewport = Rect::new(
+ screen.x,
+ screen.y + header_h + 1,
+ screen.width,
+ screen.height.saturating_sub(header_h + 1),
+ );
+
+ MobileSwitcherAreas { close, viewport }
+}
+
+pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_height: u16) -> usize {
+ mobile_switcher_content_height(app).saturating_sub(viewport_height as usize)
+}
+
+pub(crate) fn mobile_switcher_workspace_doc_range(idx: usize) -> std::ops::Range<usize> {
+ let start = 2 + idx * 2;
+ start..start + 2
+}
+
+pub(crate) fn mobile_switcher_max_scroll(app: &AppState) -> usize {
+ mobile_switcher_max_scroll_for_height(app, mobile_switcher_areas(app).viewport.height)
+}
+
+pub(crate) fn mobile_switcher_target_at(
+ app: &AppState,
+ col: u16,
+ row: u16,
+) -> Option<MobileSwitcherTarget> {
+ let areas = mobile_switcher_areas(app);
+ let content = inset_for_left_scrollbar(areas.viewport);
+ if !rect_contains(content, col, row) {
+ return None;
+ }
+
+ let doc_row = app
+ .mobile_switcher_scroll
+ .saturating_add(row.saturating_sub(areas.viewport.y) as usize);
+ let mut cursor = 0usize;
+
+ cursor += 1; // spaces title
+ if doc_row == cursor {
+ return Some(MobileSwitcherTarget::NewWorkspace);
+ }
+ cursor += 1;
+ let spaces_end = cursor + app.workspaces.len() * 2;
+ if doc_row >= cursor && doc_row < spaces_end {
+ return Some(MobileSwitcherTarget::Workspace((doc_row - cursor) / 2));
+ }
+ cursor = spaces_end;
+
+ if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) {
+ cursor += 1; // tabs title
+ if doc_row == cursor {
+ return Some(MobileSwitcherTarget::NewTab);
+ }
+ cursor += 1;
+ let tabs_end = cursor + ws.tabs.len();
+ if doc_row >= cursor && doc_row < tabs_end {
+ return Some(MobileSwitcherTarget::Tab(doc_row - cursor));
+ }
+ cursor = tabs_end;
+ }
+
+ cursor += 1; // agents title
+ let agents = agent_panel_entries(app);
+ let agents_end = cursor + agents.len() * 2;
+ if doc_row >= cursor && doc_row < agents_end {
+ let idx = (doc_row - cursor) / 2;
+ return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent {
+ ws_idx: entry.ws_idx,
+ tab_idx: entry.tab_idx,
+ pane_id: entry.pane_id,
+ });
+ }
+ cursor = agents_end;
+
+ cursor += 1; // menu title
+ let menu_idx = doc_row.checked_sub(cursor)?;
+ (menu_idx < app.global_menu_labels().len()).then_some(MobileSwitcherTarget::Menu(menu_idx))
+}
+
+pub(crate) fn render_mobile_header(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+
+ let p = &app.palette;
+ fill_rect(frame, area, Style::default().bg(p.panel_bg));
+
+ let switch = app.view.mobile_menu_hit_area;
+ let status_w = switch.x.saturating_sub(area.x).saturating_sub(1);
+ let status = Rect::new(area.x, area.y, status_w, area.height);
+
+ render_header_status(app, frame, status);
+ render_switch_button(app, frame, switch);
+}
+
+pub(crate) fn render_mobile_toast_banner(
+ frame: &mut Frame,
+ area: Rect,
+ toast: &ToastNotification,
+ offset_for_warning: bool,
+ p: &Palette,
+) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+
+ let dot_color = match toast.kind {
+ ToastKind::NeedsAttention => p.red,
+ ToastKind::Finished => p.blue,
+ ToastKind::UpdateInstalled => p.accent,
+ };
+ let y = area.y
+ + area
+ .height
+ .saturating_sub(1 + if offset_for_warning { 1 } else { 0 });
+ let banner = Rect::new(area.x, y, area.width, 1);
+ let bg = p.surface0;
+
+ frame.render_widget(Clear, banner);
+ fill_rect(frame, banner, Style::default().bg(bg));
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(" ", Style::default().bg(bg)),
+ Span::styled("●", Style::default().fg(dot_color).bg(bg)),
+ Span::styled(" ", Style::default().bg(bg)),
+ Span::styled(
+ mobile_toast_title(toast),
+ Style::default()
+ .fg(p.text)
+ .bg(bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(" · ", Style::default().fg(p.overlay0).bg(bg)),
+ Span::styled(&toast.context, Style::default().fg(p.overlay0).bg(bg)),
+ ])),
+ banner,
+ );
+}
+
+pub(crate) fn render_mobile_panel(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+
+ let p = &app.palette;
+ frame.render_widget(Clear, area);
+ fill_rect(frame, area, Style::default().bg(p.panel_bg));
+
+ let areas = mobile_switcher_areas(app);
+ frame.render_widget(
+ Paragraph::new(" switch").style(
+ Style::default()
+ .fg(p.text)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Rect::new(area.x, area.y, areas.close.x.saturating_sub(area.x), 1),
+ );
+ render_close_button(app, frame, areas.close);
+
+ if area.height > areas.close.height {
+ draw_horizontal_rule(
+ frame,
+ Rect::new(area.x, area.y + areas.close.height, area.width, 1),
+ p,
+ );
+ }
+
+ render_mobile_switcher_content(app, frame, areas.viewport);
+}
+
+fn render_header_status(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let p = &app.palette;
+ let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else {
+ frame.render_widget(Paragraph::new(" no workspace"), area);
+ return;
+ };
+
+ let (state, seen) = ws.aggregate_state();
+ let (dot, dot_style) = if matches!(state, AgentState::Working) {
+ (
+ super::spinner_frame(app.spinner_tick),
+ Style::default().fg(p.yellow),
+ )
+ } else {
+ state_dot(state, seen, p)
+ };
+ let tab_label = format!("tab {}/{}", ws.active_tab + 1, ws.tabs.len());
+ let row1 = Rect::new(area.x, area.y, area.width, 1);
+ let tab_w = (tab_label.chars().count() as u16 + 1).min(area.width);
+ let name_w = area.width.saturating_sub(tab_w);
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(dot, dot_style.bg(p.panel_bg)),
+ Span::raw(" "),
+ Span::styled(
+ truncate(&ws.display_name(), name_w.saturating_sub(4) as usize),
+ Style::default()
+ .fg(p.text)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ])),
+ Rect::new(row1.x, row1.y, name_w, 1),
+ );
+ frame.render_widget(
+ Paragraph::new(tab_label)
+ .style(Style::default().fg(p.overlay1).bg(p.panel_bg))
+ .alignment(Alignment::Right),
+ Rect::new(row1.x + name_w, row1.y, tab_w, 1),
+ );
+
+ if area.height > 1 {
+ frame.render_widget(
+ Paragraph::new(agent_priority_label(app))
+ .style(Style::default().fg(p.overlay1).bg(p.panel_bg)),
+ Rect::new(area.x, area.y + 1, area.width, 1),
+ );
+ }
+}
+
+fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let p = &app.palette;
+ fill_rect(frame, area, Style::default().bg(p.surface0));
+ for y in area.y..area.y + area.height {
+ frame.buffer_mut()[(area.x, y)]
+ .set_symbol("│")
+ .set_style(Style::default().fg(p.surface_dim).bg(p.surface0));
+ }
+ let label_y = if area.height > 1 { area.y + 1 } else { area.y };
+ frame.render_widget(
+ Paragraph::new("switch")
+ .style(
+ Style::default()
+ .fg(p.text)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD),
+ )
+ .alignment(Alignment::Center),
+ Rect::new(area.x + 1, label_y, area.width.saturating_sub(1), 1),
+ );
+}
+
+fn render_close_button(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let p = &app.palette;
+ fill_rect(frame, area, Style::default().bg(p.surface0));
+ for y in area.y..area.y + area.height {
+ frame.buffer_mut()[(area.x, y)]
+ .set_symbol("│")
+ .set_style(Style::default().fg(p.surface_dim).bg(p.surface0));
+ }
+ frame.render_widget(
+ Paragraph::new("close")
+ .style(
+ Style::default()
+ .fg(p.overlay1)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD),
+ )
+ .alignment(Alignment::Center),
+ Rect::new(area.x + 1, area.y, area.width.saturating_sub(1), 1),
+ );
+ if area.height > 1 {
+ frame.render_widget(
+ Paragraph::new("×")
+ .style(
+ Style::default()
+ .fg(p.text)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD),
+ )
+ .alignment(Alignment::Center),
+ Rect::new(area.x + 1, area.y + 1, area.width.saturating_sub(1), 1),
+ );
+ }
+}
+
+fn mobile_switcher_content_height(app: &AppState) -> usize {
+ let spaces_h = 2 + app.workspaces.len() * 2;
+ let tabs_h = app
+ .active
+ .and_then(|idx| app.workspaces.get(idx))
+ .map(|ws| 2 + ws.tabs.len())
+ .unwrap_or(0);
+ let agents_h = 1 + agent_panel_entries(app).len() * 2;
+ let menu_h = 1 + app.global_menu_labels().len();
+ spaces_h + tabs_h + agents_h + menu_h
+}
+
+fn render_mobile_switcher_content(app: &AppState, frame: &mut Frame, viewport: Rect) {
+ if viewport.width == 0 || viewport.height == 0 {
+ return;
+ }
+
+ let p = &app.palette;
+ let total_height = mobile_switcher_content_height(app);
+ render_left_scrollbar(
+ frame,
+ viewport,
+ total_height,
+ viewport.height as usize,
+ app.mobile_switcher_scroll,
+ p,
+ );
+ let content = inset_for_left_scrollbar(viewport);
+ if content == Rect::default() {
+ return;
+ }
+
+ let mut doc_y = 0usize;
+ render_section_title_at(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ "spaces",
+ p,
+ );
+ doc_y += 1;
+ render_action_row_at(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ "+ new workspace",
+ p,
+ );
+ doc_y += 1;
+ for (idx, ws) in app.workspaces.iter().enumerate() {
+ let active = Some(idx) == app.active;
+ let selected = idx == app.selected;
+ let bg = mobile_item_bg(selected, active, p);
+ let (state, seen) = ws.aggregate_state();
+ let (dot, dot_style) = state_dot(state, seen, p);
+ let title = Line::from(vec![
+ Span::styled(" ", Style::default().bg(bg)),
+ Span::styled(dot, dot_style.bg(bg)),
+ Span::styled(" ", Style::default().bg(bg)),
+ Span::styled(
+ truncate(&ws.display_name(), content.width.saturating_sub(5) as usize),
+ Style::default()
+ .fg(p.text)
+ .bg(bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]);
+ let detail = format!(
+ " {} · tab {}/{}",
+ ws.branch().unwrap_or_else(|| "shell".into()),
+ ws.active_tab + 1,
+ ws.tabs.len()
+ );
+ render_two_line_item(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ bg,
+ title,
+ truncate(&detail, content.width as usize),
+ p.overlay0,
+ );
+ doc_y += 2;
+ }
+
+ if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) {
+ render_section_title_at(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ "tabs",
+ p,
+ );
+ doc_y += 1;
+ render_action_row_at(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ "+ new tab",
+ p,
+ );
+ doc_y += 1;
+ for (idx, tab) in ws.tabs.iter().enumerate() {
+ let active = idx == ws.active_tab;
+ let bg = mobile_item_bg(false, active, p);
+ let label = if tab.is_auto_named() {
+ format!("tab {}", idx + 1)
+ } else {
+ format!("{} · {}", idx + 1, tab.display_name())
+ };
+ let title = Line::from(vec![
+ Span::styled(" ", Style::default().bg(bg)),
+ Span::styled(
+ truncate(&label, content.width.saturating_sub(3) as usize),
+ Style::default()
+ .fg(p.text)
+ .bg(bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]);
+ render_one_line_item(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ bg,
+ title,
+ );
+ doc_y += 1;
+ }
+ }
+
+ let focused_agent = app.active.and_then(|ws_idx| {
+ let ws = app.workspaces.get(ws_idx)?;
+ ws.focused_pane_id()
+ .map(|pane_id| (ws_idx, ws.active_tab, pane_id))
+ });
+ let entries = agent_panel_entries(app);
+ render_section_title_at(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ "agents",
+ p,
+ );
+ doc_y += 1;
+ for entry in &entries {
+ let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| {
+ entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id
+ });
+ let bg = mobile_item_bg(false, active, p);
+ let (icon, icon_style) = agent_icon(entry.state, entry.seen, app.spinner_tick, p);
+ let title = Line::from(vec![
+ Span::styled(" ", Style::default().bg(bg)),
+ Span::styled(icon, icon_style.bg(bg)),
+ Span::styled(" ", Style::default().bg(bg)),
+ Span::styled(
+ truncate(
+ &entry.primary_label,
+ content.width.saturating_sub(5) as usize,
+ ),
+ Style::default()
+ .fg(p.text)
+ .bg(bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]);
+ let detail = format!(
+ " {} · {}",
+ super::status::state_label(entry.state, entry.seen),
+ entry.agent_label.as_deref().unwrap_or("agent")
+ );
+ render_two_line_item(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ bg,
+ title,
+ truncate(&detail, content.width as usize),
+ p.overlay0,
+ );
+ doc_y += 2;
+ }
+
+ render_section_title_at(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ "menu",
+ p,
+ );
+ doc_y += 1;
+ for label in app.global_menu_labels() {
+ if let Some(y) = visible_y(viewport, app.mobile_switcher_scroll, doc_y) {
+ frame.render_widget(
+ Paragraph::new(format!(" {label}"))
+ .style(Style::default().fg(p.overlay1).bg(p.panel_bg)),
+ Rect::new(content.x, y, content.width, 1),
+ );
+ }
+ doc_y += 1;
+ }
+}
+
+fn render_section_title_at(
+ frame: &mut Frame,
+ viewport: Rect,
+ content: Rect,
+ doc_y: usize,
+ scroll: usize,
+ title: &str,
+ p: &Palette,
+) {
+ let Some(y) = visible_y(viewport, scroll, doc_y) else {
+ return;
+ };
+ render_section_title(
+ frame,
+ Rect::new(content.x, y, content.width.saturating_sub(1), 1),
+ title,
+ p,
+ );
+}
+
+fn render_action_row_at(
+ frame: &mut Frame,
+ viewport: Rect,
+ content: Rect,
+ doc_y: usize,
+ scroll: usize,
+ label: &str,
+ p: &Palette,
+) {
+ let Some(y) = visible_y(viewport, scroll, doc_y) else {
+ return;
+ };
+ render_action_row(frame, Rect::new(content.x, y, content.width, 1), label, p);
+}
+
+fn render_one_line_item(
+ frame: &mut Frame,
+ viewport: Rect,
+ content: Rect,
+ doc_y: usize,
+ scroll: usize,
+ bg: ratatui::style::Color,
+ title: Line<'_>,
+) {
+ fill_visible_doc_rect(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ 1,
+ Style::default().bg(bg),
+ scroll,
+ );
+ if let Some(y) = visible_y(viewport, scroll, doc_y) {
+ frame.render_widget(
+ Paragraph::new(title),
+ Rect::new(content.x, y, content.width, 1),
+ );
+ }
+}
+
+fn render_two_line_item(
+ frame: &mut Frame,
+ viewport: Rect,
+ content: Rect,
+ doc_y: usize,
+ scroll: usize,
+ bg: ratatui::style::Color,
+ title: Line<'_>,
+ detail: String,
+ detail_fg: ratatui::style::Color,
+) {
+ fill_visible_doc_rect(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ 2,
+ Style::default().bg(bg),
+ scroll,
+ );
+ if let Some(y) = visible_y(viewport, scroll, doc_y) {
+ frame.render_widget(
+ Paragraph::new(title),
+ Rect::new(content.x, y, content.width, 1),
+ );
+ }
+ if let Some(y) = visible_y(viewport, scroll, doc_y + 1) {
+ frame.render_widget(
+ Paragraph::new(detail).style(Style::default().fg(detail_fg).bg(bg)),
+ Rect::new(content.x, y, content.width, 1),
+ );
+ }
+}
+
+fn visible_y(viewport: Rect, scroll: usize, doc_y: usize) -> Option<u16> {
+ let offset = doc_y.checked_sub(scroll)?;
+ (offset < viewport.height as usize).then_some(viewport.y + offset as u16)
+}
+
+fn fill_visible_doc_rect(
+ frame: &mut Frame,
+ viewport: Rect,
+ content: Rect,
+ doc_y: usize,
+ height: usize,
+ style: Style,
+ scroll: usize,
+) {
+ for offset in 0..height {
+ if let Some(y) = visible_y(viewport, scroll, doc_y + offset) {
+ fill_rect(frame, Rect::new(content.x, y, content.width, 1), style);
+ }
+ }
+}
+
+fn mobile_item_bg(selected: bool, active: bool, p: &Palette) -> ratatui::style::Color {
+ if selected {
+ p.surface0
+ } else if active {
+ p.surface_dim
+ } else {
+ p.panel_bg
+ }
+}
+
+fn inset_for_left_scrollbar(area: Rect) -> Rect {
+ if area.width <= 1 {
+ return Rect::default();
+ }
+ Rect::new(area.x + 1, area.y, area.width - 1, area.height)
+}
+
+fn render_left_scrollbar(
+ frame: &mut Frame,
+ area: Rect,
+ total_rows: usize,
+ visible_rows: usize,
+ scroll: usize,
+ p: &Palette,
+) {
+ if area.width == 0 || area.height == 0 || visible_rows == 0 || total_rows <= visible_rows {
+ return;
+ }
+
+ let track = Rect::new(area.x, area.y, 1, area.height);
+ let max_scroll = total_rows.saturating_sub(visible_rows);
+ let thumb_len = ((track.height as usize * visible_rows).div_ceil(total_rows))
+ .max(1)
+ .min(track.height as usize) as u16;
+ let travel = track.height.saturating_sub(thumb_len);
+ let thumb_top = if max_scroll == 0 {
+ track.y
+ } else {
+ track.y + ((travel as usize * scroll.min(max_scroll)) / max_scroll) as u16
+ };
+
+ for y in track.y..track.y + track.height {
+ let is_thumb = y >= thumb_top && y < thumb_top + thumb_len;
+ frame.buffer_mut()[(track.x, y)]
+ .set_symbol(if is_thumb { "▌" } else { "│" })
+ .set_style(
+ Style::default()
+ .fg(if is_thumb { p.accent } else { p.surface_dim })
+ .bg(p.panel_bg),
+ );
+ }
+}
+
+fn render_section_title(frame: &mut Frame, area: Rect, title: &str, p: &Palette) {
+ frame.render_widget(
+ Paragraph::new(format!(" {title} ")).style(
+ Style::default()
+ .fg(p.overlay1)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
+ ),
+ Rect::new(area.x, area.y, area.width, 1),
+ );
+}
+
+fn render_action_row(frame: &mut Frame, area: Rect, label: &str, p: &Palette) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ frame.render_widget(
+ Paragraph::new(format!(" {label}")).style(
+ Style::default()
+ .fg(p.accent)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ area,
+ );
+}
+
+fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
+ rect.width > 0
+ && rect.height > 0
+ && col >= rect.x
+ && col < rect.x + rect.width
+ && row >= rect.y
+ && row < rect.y + rect.height
+}
+
+fn mobile_screen_rect(app: &AppState) -> Rect {
+ let header = app.view.mobile_header_rect;
+ let terminal = app.view.terminal_area;
+ let x = header.x.min(terminal.x);
+ let y = header.y.min(terminal.y);
+ let right = (header.x + header.width).max(terminal.x + terminal.width);
+ let bottom = (header.y + header.height).max(terminal.y + terminal.height);
+ Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y))
+}
+
+fn agent_priority_label(app: &AppState) -> String {
+ let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else {
+ return " no agents".to_string();
+ };
+ let mut blocked = 0usize;
+ let mut working = 0usize;
+ let mut done = 0usize;
+ for detail in ws.pane_details() {
+ match (detail.state, detail.seen) {
+ (AgentState::Blocked, _) => blocked += 1,
+ (AgentState::Working, _) => working += 1,
+ (AgentState::Idle, false) => done += 1,
+ _ => {}
+ }
+ }
+ if blocked > 0 {
+ format!(" ◉ {blocked} blocked")
+ } else if working > 0 {
+ format!(" {working} working")
+ } else if done > 0 {
+ format!(" {done} done")
+ } else {
+ " all idle".to_string()
+ }
+}
+
+fn mobile_toast_title(toast: &ToastNotification) -> String {
+ match toast.kind {
+ ToastKind::NeedsAttention => toast
+ .title
+ .strip_suffix(" needs attention")
+ .map(|agent| format!("{agent} waiting"))
+ .unwrap_or_else(|| toast.title.clone()),
+ ToastKind::Finished => toast
+ .title
+ .strip_suffix(" finished")
+ .map(|agent| format!("{agent} done"))
+ .unwrap_or_else(|| toast.title.clone()),
+ ToastKind::UpdateInstalled => "update ready".to_string(),
+ }
+}
+
+fn fill_rect(frame: &mut Frame, area: Rect, style: Style) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let buf = frame.buffer_mut();
+ for y in area.y..area.y + area.height {
+ for x in area.x..area.x + area.width {
+ buf[(x, y)].set_symbol(" ");
+ buf[(x, y)].set_style(style);
+ }
+ }
+}
+
+fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let buf = frame.buffer_mut();
+ for x in area.x..area.x + area.width {
+ buf[(x, area.y)]
+ .set_symbol("─")
+ .set_style(Style::default().fg(p.surface_dim).bg(p.panel_bg));
+ }
+}
+
+fn truncate(text: &str, max_width: usize) -> String {
+ let len = text.chars().count();
+ if len <= max_width {
+ return text.to_string();
+ }
+ if max_width == 0 {
+ return String::new();
+ }
+ if max_width == 1 {
+ return "…".to_string();
+ }
+ let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect();
+ format!("{prefix}…")
+}