use ratatui::crossterm::event::{KeyCode, KeyEvent};
use crate::app::{App, SwitcherRow, SwitcherTarget};
impl App {
pub fn open_switcher(&mut self) {
self.switcher = true;
self.switcher_cursor = 0;
self.switcher_scroll = 0;
}
pub fn switcher_move(&mut self, delta: i32) {
let n = self.switcher_targets().len();
if n == 0 {
return;
}
let next = (self.switcher_cursor as i32 + delta).clamp(0, n as i32 - 1);
self.switcher_cursor = next as usize;
}
pub fn close_switcher(&mut self) {
self.switcher = false;
}
pub fn toggle_switcher(&mut self) {
if self.switcher {
self.close_switcher();
} else {
self.open_switcher();
}
}
pub fn switcher_rows(&self) -> Vec<SwitcherRow> {
let mut rows = Vec::new();
let mut agents = Vec::new();
for ws in self.workspaces.iter() {
for (ti, tab) in ws.tabs.iter().enumerate() {
for id in tab.layout.leaves() {
if let Some(s) = self.status.get(&id) {
if self.manifests.is_agent(&s.agent) || s.agent_session.is_some() {
agents.push(SwitcherRow::Agent {
target: SwitcherTarget::Pane(id),
state: s.state,
title: s.agent.clone(),
location: format!("{} · {}", ws.name, ti + 1),
});
}
}
}
}
}
if !agents.is_empty() {
rows.push(SwitcherRow::Header(self.catalog.agents.to_string()));
rows.append(&mut agents);
}
rows.push(SwitcherRow::Header(self.catalog.workspaces.to_string()));
for (i, ws) in self.workspaces.iter().enumerate() {
rows.push(SwitcherRow::Node {
target: SwitcherTarget::Workspace(i),
name: ws.name.clone(),
branch: ws.branch.clone(),
active: i == self.active_ws,
});
}
rows.push(SwitcherRow::Action {
target: SwitcherTarget::NewWorkspace,
label: format!("+ {}", self.catalog.cmd_new_workspace),
});
rows
}
fn switcher_targets(&self) -> Vec<SwitcherTarget> {
self.switcher_rows()
.into_iter()
.filter_map(|r| match r {
SwitcherRow::Header(_) => None,
SwitcherRow::Agent { target, .. }
| SwitcherRow::Node { target, .. }
| SwitcherRow::Action { target, .. } => Some(target),
})
.collect()
}
pub fn agent_state_counts(&self) -> [usize; 4] {
use crate::ui::theme::State;
let mut c = [0usize; 4];
for ws in &self.workspaces {
for tab in &ws.tabs {
for id in tab.layout.leaves() {
if let Some(s) = self.status.get(&id) {
if self.manifests.is_agent(&s.agent) || s.agent_session.is_some() {
match s.state {
State::Blocked => c[0] += 1,
State::Working => c[1] += 1,
State::Done => c[2] += 1,
_ => c[3] += 1,
}
}
}
}
}
}
c
}
pub fn switcher_activate(&mut self, target: SwitcherTarget) {
self.close_switcher();
match target {
SwitcherTarget::Pane(id) => self.focus_pane_global(id),
SwitcherTarget::Workspace(i) => {
if i < self.workspaces.len() {
self.active_ws = i;
}
}
SwitcherTarget::NewWorkspace => self.open_folder_picker(),
}
}
pub fn switcher_click(&mut self, col: u16, row: u16) {
let hit = self
.switcher_rects
.iter()
.find(|(_, r)| col >= r.x && col < r.right() && row >= r.y && row < r.bottom())
.map(|(t, _)| *t);
match hit {
Some(t) => self.switcher_activate(t),
None => self.close_switcher(),
}
}
pub fn switcher_key(&mut self, key: KeyEvent) {
let targets = self.switcher_targets();
let n = targets.len();
match key.code {
KeyCode::Esc => self.close_switcher(),
KeyCode::Up | KeyCode::Char('k') => {
self.switcher_cursor = self.switcher_cursor.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if n > 0 {
self.switcher_cursor = (self.switcher_cursor + 1).min(n - 1);
}
}
KeyCode::Enter => {
if let Some(t) = targets.get(self.switcher_cursor).copied() {
self.switcher_activate(t);
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use crate::app::App;
#[test]
fn compact_mode_and_switcher_jump() {
use ratatui::{backend::TestBackend, Terminal};
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.create_workspace_at(std::env::temp_dir());
assert!(app.workspaces.len() >= 2);
app.active_ws = 0;
let mut wide = Terminal::new(TestBackend::new(100, 30)).unwrap();
wide.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(!app.compact, "100 cols is not compact");
let mut narrow = Terminal::new(TestBackend::new(40, 60)).unwrap();
narrow.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(app.compact, "40 cols is compact");
assert!(
app.switcher_button_rect.is_some(),
"the ≡ switcher button is shown"
);
app.open_switcher();
narrow.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let targets: Vec<_> = app
.switcher_rows()
.into_iter()
.filter_map(|r| match r {
crate::app::SwitcherRow::Node { target, .. } => Some(target),
_ => None,
})
.collect();
assert!(targets.len() >= 2, "both nodes offered");
app.switcher_activate(crate::app::SwitcherTarget::Workspace(1));
assert_eq!(app.active_ws, 1, "switcher jumped to node 1");
assert!(!app.switcher, "activating closes the overlay");
}
#[test]
fn compact_summary_drops_least_urgent_first() {
use crate::ui::switcher::compact_agent_summary;
use crate::ui::theme::State;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
for _ in 0..3 {
app.split(crate::layout::Axis::Col);
}
let ids: Vec<_> = app.ws().tabs[0].layout.leaves();
assert!(ids.len() >= 4, "four panes to host four agents");
let states = [State::Blocked, State::Working, State::Done, State::Idle];
for (id, st) in ids.iter().take(4).zip(states.iter()) {
let s = app
.status
.entry(*id)
.or_insert_with(|| crate::app::PaneStatus::new(String::new()));
s.agent = "claude".to_string();
s.state = *st;
}
assert_eq!(app.agent_state_counts(), [1, 1, 1, 1]);
let wide = compact_agent_summary(&app, 40);
assert_eq!(wide.spans.len(), 4, "all states shown when wide");
let narrow = compact_agent_summary(&app, 4);
assert_eq!(narrow.spans.len(), 1, "least-urgent dropped when narrow");
}
}