use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::Sender;
use std::time::{Duration, Instant};
use anyhow::Result;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::Rect;
use serde_json::{json, Value};
use crate::detect;
use crate::event::AppEvent;
use crate::ids::PaneId;
use crate::ipc::api::{self, ApiRequest, EventBus};
use crate::layout::{Axis, Dir, TileLayout};
use crate::persist::{self, SessionSnapshot};
use crate::terminal::pty::Pane;
use crate::ui::theme::{State, Theme};
mod dispatch;
mod git;
mod input;
mod keys;
mod modules;
mod picker;
mod settings;
pub use keys::Cmd;
pub use picker::{FolderPicker, Row};
pub use settings::{SettingsTab, SettingsUi};
const ACTIVITY_WINDOW: Duration = Duration::from_millis(700);
pub const SIDEBAR_WIDTH_DEFAULT: u16 = 26;
pub const SIDEBAR_WIDTH_MIN: u16 = 18;
pub const SIDEBAR_WIDTH_MAX: u16 = 44;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Normal,
Prefix,
}
pub struct Tab {
pub layout: TileLayout,
pub git: Option<Box<crate::git::GitView>>,
}
impl Tab {
fn panes(layout: TileLayout) -> Tab {
Tab { layout, git: None }
}
pub fn is_git(&self) -> bool {
self.git.is_some()
}
}
pub struct Workspace {
pub name: String,
pub cwd: PathBuf,
pub branch: Option<String>,
pub git_ahead_behind: Option<(u32, u32)>,
pub worktree: Option<crate::git::WorktreeMembership>,
pub tabs: Vec<Tab>,
pub active_tab: usize,
}
#[derive(Clone)]
pub struct AgentSession {
pub agent: String,
pub session_id: String,
}
pub struct PaneStatus {
pub state: State,
pub agent: String,
pub last_activity: Instant,
pub seen: bool,
pub agent_session: Option<AgentSession>,
prev_working: bool,
done: bool,
notify_armed: bool,
}
impl PaneStatus {
fn new(agent: String) -> Self {
PaneStatus {
state: State::Idle,
agent,
last_activity: Instant::now(),
seen: true,
agent_session: None,
prev_working: false,
done: false,
notify_armed: true,
}
}
}
pub struct App {
pub panes: HashMap<PaneId, Pane>,
pub status: HashMap<PaneId, PaneStatus>,
pub workspaces: Vec<Workspace>,
pub active_ws: usize,
pub theme: Theme,
pub catalog: &'static crate::i18n::Catalog,
pub config: crate::config::Config,
pub keymap: std::collections::HashMap<String, Cmd>,
pub settings: Option<SettingsUi>,
pub picker: Option<FolderPicker>,
pub picker_rects: Vec<(usize, Rect)>,
pub help_open: bool,
pub worktree_prompt: Option<String>,
pub worktree_repo: Option<PathBuf>,
pub worktree_error: Option<String>,
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_width: u16,
pub zoomed: bool,
pub should_quit: bool,
pub spinner: u64,
pub session_dirty: bool,
pub events: EventBus,
pub last_cursor: Option<(u16, u16)>,
pub detach_requested: bool,
pub pending_notify: Vec<String>,
pub downsample: bool,
last_cwd_at: Instant,
pub resumable: Vec<crate::agent::SessionInfo>,
pub dismissed_sessions: HashSet<String>,
last_sessions_at: Instant,
pub nodes_scroll: usize,
pub agents_scroll: usize,
pub nodes_area: Rect,
pub agents_area: Rect,
pub agents_active_only: bool,
pub last_active_ws_shown: usize,
pub hover: Option<(u16, u16)>,
app_tx: Sender<AppEvent>,
pub last_pane_area: Rect,
pub pane_rects: Vec<(PaneId, Rect)>,
pub tab_rects: Vec<(usize, Rect)>,
pub tab_close_rects: Vec<(usize, Rect)>,
pub ws_rects: Vec<(usize, Rect)>,
pub node_branch_rects: Vec<(usize, Rect)>,
pub git_section_rects: Vec<(crate::git::Section, Rect)>,
pub agents_filter_rects: Vec<(bool, Rect)>,
pub agent_rects: Vec<(PaneId, Rect)>,
pub session_rects: Vec<(usize, Rect)>,
pub session_del_rects: Vec<(usize, Rect)>,
pub new_ws_rect: Option<Rect>,
pub tab_prev_rect: Option<Rect>,
pub tab_next_rect: Option<Rect>,
pub pane_close_rect: Option<Rect>,
pub settings_icon_rect: Option<Rect>,
pub settings_close_rect: Option<Rect>,
pub settings_modal_rect: Option<Rect>,
pub settings_tab_rects: Vec<(SettingsTab, Rect)>,
pub settings_ctl_rects: Vec<(usize, Rect)>,
pub settings_arrow_rects: Vec<(usize, i32, Rect)>,
pub modules: crate::module::ModuleRegistry,
pub module_logs: Vec<crate::module::ModuleCommandLog>,
pub module_panes: HashMap<PaneId, crate::module::ModulePaneRecord>,
}
impl App {
pub fn new(cols: u16, rows: u16, app_tx: Sender<AppEvent>) -> Result<App> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let name = ws_name(&cwd);
let config = crate::config::load();
crate::layout::set_gaps(config.layout.col_gap, config.layout.row_gap);
let theme = crate::ui::theme::by_name(&config.theme);
let catalog = crate::i18n::by_code(&config.language);
let sidebar_width = config.sidebar_width();
let shell = crate::platform::resolve_shell(&config.shell);
let keymap = keys::build_keymap(&config.keybindings);
let id = PaneId::alloc();
let pane = Pane::spawn(id, cols, rows, cwd.clone(), app_tx.clone(), None, &shell)?;
let command = pane.command.clone();
let mut panes = HashMap::new();
panes.insert(id, pane);
let mut status = HashMap::new();
status.insert(id, PaneStatus::new(command));
Ok(App {
panes,
status,
workspaces: vec![Workspace {
name,
worktree: worktree_membership(&cwd),
cwd,
branch: None,
git_ahead_behind: None,
tabs: vec![Tab::panes(TileLayout::new(id))],
active_tab: 0,
}],
active_ws: 0,
theme,
catalog,
config,
keymap,
settings: None,
picker: None,
picker_rects: Vec::new(),
help_open: false,
worktree_prompt: None,
worktree_repo: None,
worktree_error: None,
mode: Mode::Normal,
sidebar_visible: true,
sidebar_width,
zoomed: false,
should_quit: false,
spinner: 0,
session_dirty: true,
events: api::new_bus(),
last_cursor: None,
detach_requested: false,
pending_notify: Vec::new(),
downsample: false,
last_cwd_at: Instant::now(),
resumable: Vec::new(),
dismissed_sessions: HashSet::new(),
last_sessions_at: Instant::now(),
nodes_scroll: 0,
agents_scroll: 0,
agents_active_only: false,
nodes_area: Rect::ZERO,
agents_area: Rect::ZERO,
last_active_ws_shown: 0,
hover: None,
app_tx,
last_pane_area: Rect::ZERO,
pane_rects: Vec::new(),
tab_rects: Vec::new(),
ws_rects: Vec::new(),
node_branch_rects: Vec::new(),
git_section_rects: Vec::new(),
agents_filter_rects: Vec::new(),
agent_rects: Vec::new(),
session_rects: Vec::new(),
session_del_rects: Vec::new(),
tab_close_rects: Vec::new(),
new_ws_rect: None,
tab_prev_rect: None,
tab_next_rect: None,
pane_close_rect: None,
settings_icon_rect: None,
settings_close_rect: None,
settings_modal_rect: None,
settings_tab_rects: Vec::new(),
settings_ctl_rects: Vec::new(),
settings_arrow_rects: Vec::new(),
modules: crate::module::registry::load(),
module_logs: Vec::new(),
module_panes: HashMap::new(),
})
}
pub fn restore_or_new(cols: u16, rows: u16, app_tx: Sender<AppEvent>) -> Result<App> {
if let Some(snap) = persist::load() {
if let Some(mut app) = App::from_snapshot(snap, app_tx.clone()) {
app.refetch_git_tabs();
return Ok(app);
}
}
App::new(cols, rows, app_tx)
}
fn from_snapshot(snap: SessionSnapshot, app_tx: Sender<AppEvent>) -> Option<App> {
let config = crate::config::load();
let keymap = keys::build_keymap(&config.keybindings);
let shell = crate::platform::resolve_shell(&config.shell);
let modules = crate::module::registry::load();
let mut panes = HashMap::new();
let mut status = HashMap::new();
let mut module_panes: HashMap<PaneId, crate::module::ModulePaneRecord> = HashMap::new();
let mut workspaces = Vec::new();
for ws in snap.workspaces {
let mut tabs = Vec::new();
for tab in ws.tabs {
if tab.git {
if crate::git::local::is_repo(&ws.cwd) {
let view = crate::git::GitView::new(ws.cwd.clone());
let placeholder = PaneId::alloc();
tabs.push(Tab {
layout: TileLayout::new(placeholder),
git: Some(Box::new(view)),
});
}
continue;
}
let mut remap = HashMap::new();
for (raw, ps) in &tab.panes {
let id = PaneId::alloc();
let restored = ps
.module
.as_ref()
.and_then(|(mid, ep)| restore_module_pane(&modules, mid, ep, id, &app_tx));
let (pane, module_rec) = match restored {
Some((p, rec)) => (p, Some(rec)),
None => (
Pane::spawn(
id,
80,
24,
ps.cwd.clone(),
app_tx.clone(),
ps.screen.as_deref(),
&shell,
)
.ok()?,
None,
),
};
if let Some(rec) = module_rec {
module_panes.insert(id, rec);
}
let cmd = pane.command.clone();
let mut st = PaneStatus::new(cmd);
if let Some((agent, sid)) = &ps.agent_session {
st.agent = agent.clone();
st.agent_session = Some(AgentSession {
agent: agent.clone(),
session_id: sid.clone(),
});
if let Some(resume) = crate::agent::resume_command(agent, sid) {
pane.send(resume.as_bytes());
}
}
panes.insert(id, pane);
status.insert(id, st);
remap.insert(*raw, id);
}
let layout = TileLayout::from_tree(&tab.tree, &remap, tab.focus)?;
tabs.push(Tab::panes(layout));
}
if tabs.is_empty() {
continue;
}
let active_tab = ws.active_tab.min(tabs.len() - 1);
workspaces.push(Workspace {
name: ws.name,
worktree: worktree_membership(&ws.cwd),
cwd: ws.cwd,
branch: None,
git_ahead_behind: None,
tabs,
active_tab,
});
}
if workspaces.is_empty() {
return None;
}
let active_ws = snap.active_ws.min(workspaces.len() - 1);
crate::layout::set_gaps(config.layout.col_gap, config.layout.row_gap);
let theme = crate::ui::theme::by_name(&config.theme);
let catalog = crate::i18n::by_code(&config.language);
let sidebar_width = config.sidebar_width();
Some(App {
panes,
status,
workspaces,
active_ws,
theme,
catalog,
config,
keymap,
settings: None,
picker: None,
picker_rects: Vec::new(),
help_open: false,
worktree_prompt: None,
worktree_repo: None,
worktree_error: None,
mode: Mode::Normal,
sidebar_visible: true,
sidebar_width,
zoomed: false,
should_quit: false,
spinner: 0,
session_dirty: false,
events: api::new_bus(),
last_cursor: None,
detach_requested: false,
pending_notify: Vec::new(),
downsample: false,
last_cwd_at: Instant::now(),
resumable: Vec::new(),
dismissed_sessions: HashSet::new(),
last_sessions_at: Instant::now(),
nodes_scroll: 0,
agents_scroll: 0,
agents_active_only: false,
nodes_area: Rect::ZERO,
agents_area: Rect::ZERO,
last_active_ws_shown: 0,
hover: None,
app_tx,
last_pane_area: Rect::ZERO,
pane_rects: Vec::new(),
tab_rects: Vec::new(),
ws_rects: Vec::new(),
node_branch_rects: Vec::new(),
git_section_rects: Vec::new(),
agents_filter_rects: Vec::new(),
agent_rects: Vec::new(),
session_rects: Vec::new(),
session_del_rects: Vec::new(),
tab_close_rects: Vec::new(),
new_ws_rect: None,
tab_prev_rect: None,
tab_next_rect: None,
pane_close_rect: None,
settings_icon_rect: None,
settings_close_rect: None,
settings_modal_rect: None,
settings_tab_rects: Vec::new(),
settings_ctl_rects: Vec::new(),
settings_arrow_rects: Vec::new(),
modules,
module_logs: Vec::new(),
module_panes,
})
}
pub fn set_color_mode(&mut self, truecolor: bool) {
if !truecolor {
self.downsample = true;
self.theme = self.theme.to_256();
}
}
pub fn set_sidebar_width(&mut self, cols: u16) {
self.sidebar_width = cols.clamp(SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX);
self.config.sidebar_width = self.sidebar_width;
crate::config::save(&self.config);
}
pub fn ws(&self) -> &Workspace {
&self.workspaces[self.active_ws]
}
pub fn layout(&self) -> &TileLayout {
let ws = self.ws();
&ws.tabs[ws.active_tab].layout
}
fn layout_mut(&mut self) -> &mut TileLayout {
let ws = &mut self.workspaces[self.active_ws];
let at = ws.active_tab;
&mut ws.tabs[at].layout
}
pub fn focused(&self) -> Option<&Pane> {
self.panes.get(&self.layout().focus)
}
fn focused_cwd(&self) -> PathBuf {
self.focused()
.map(|p| p.cwd.clone())
.unwrap_or_else(|| self.ws().cwd.clone())
}
fn spawn_into(&mut self, cwd: PathBuf) -> Option<PaneId> {
let id = PaneId::alloc();
let shell = crate::platform::resolve_shell(&self.config.shell);
match Pane::spawn(id, 80, 24, cwd, self.app_tx.clone(), None, &shell) {
Ok(pane) => {
let cmd = pane.command.clone();
self.panes.insert(id, pane);
self.status.insert(id, PaneStatus::new(cmd));
self.zoomed = false;
self.session_dirty = true;
self.emit_event(
"pane.created",
serde_json::json!({"pane": id.0.to_string()}),
);
Some(id)
}
Err(_) => None,
}
}
fn split(&mut self, axis: Axis) {
let cwd = self.focused_cwd();
if let Some(id) = self.spawn_into(cwd) {
self.layout_mut().split_focused(axis, id);
}
}
fn new_tab(&mut self) {
let cwd = self.ws().cwd.clone();
if let Some(id) = self.spawn_into(cwd) {
let ws = &mut self.workspaces[self.active_ws];
ws.tabs.push(Tab::panes(TileLayout::new(id)));
ws.active_tab = ws.tabs.len() - 1;
let tab = self.ws().active_tab + 1;
self.emit_event("tab.created", serde_json::json!({"tab": tab.to_string()}));
}
}
fn new_workspace(&mut self) {
let cwd = self.focused_cwd();
self.create_workspace_at(cwd);
}
pub fn create_workspace_at(&mut self, cwd: PathBuf) {
let name = ws_name(&cwd);
let branch = git_branch(&cwd);
if let Some(id) = self.spawn_into(cwd.clone()) {
self.workspaces.push(Workspace {
name,
worktree: worktree_membership(&cwd),
cwd,
branch,
git_ahead_behind: None,
tabs: vec![Tab::panes(TileLayout::new(id))],
active_tab: 0,
});
self.active_ws = self.workspaces.len() - 1;
let node = self.active_ws;
self.emit_event(
"node.created",
serde_json::json!({"node": node.to_string()}),
);
}
}
pub fn create_worktree(
&mut self,
repo: &std::path::Path,
branch: &str,
) -> Result<PathBuf, String> {
let branch = branch.trim();
if branch.is_empty() {
return Err("a branch name is required".into());
}
if !crate::git::local::is_repo(repo) {
return Err("not a git repository".into());
}
let repo_name = crate::git::local::worktrees(repo)
.ok()
.and_then(|wts| {
wts.into_iter()
.find(|w| w.is_main)
.map(|w| ws_name(&w.path))
})
.unwrap_or_else(|| ws_name(repo));
let base = persist::config_dir().join("worktrees").join(repo_name);
let _ = std::fs::create_dir_all(&base);
let slug = branch.replace(['/', ' '], "-");
let mut path = base.join(&slug);
let mut n = 2;
while path.exists() {
path = base.join(format!("{slug}-{n}"));
n += 1;
}
crate::git::local::worktree_add(repo, &path, branch)?;
self.create_workspace_at(path.clone());
Ok(path)
}
pub fn open_worktree_prompt(&mut self) {
let cwd = self.ws().cwd.clone();
if crate::git::local::is_repo(&cwd) {
self.worktree_repo = Some(cwd);
self.worktree_prompt = Some(String::new());
}
}
pub fn handle_worktree_prompt_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.worktree_prompt = None;
self.worktree_repo = None;
self.worktree_error = None;
}
KeyCode::Enter => {
let branch = self.worktree_prompt.clone().unwrap_or_default();
if let Some(repo) = self.worktree_repo.clone() {
match self.create_worktree(&repo, &branch) {
Ok(_) => {
self.worktree_prompt = None;
self.worktree_repo = None;
self.worktree_error = None;
}
Err(e) => self.worktree_error = Some(e),
}
} else {
self.worktree_prompt = None;
}
}
KeyCode::Backspace => {
if let Some(b) = self.worktree_prompt.as_mut() {
b.pop();
}
self.worktree_error = None;
}
KeyCode::Char(c) => {
if let Some(b) = self.worktree_prompt.as_mut() {
b.push(c);
}
self.worktree_error = None;
}
_ => {}
}
}
fn switch_tab(&mut self, i: usize) {
let ws = &mut self.workspaces[self.active_ws];
if i < ws.tabs.len() {
ws.active_tab = i;
}
}
fn cycle_tab(&mut self, delta: isize) {
let ws = &mut self.workspaces[self.active_ws];
let n = ws.tabs.len() as isize;
if n > 0 {
ws.active_tab = (((ws.active_tab as isize + delta) % n + n) % n) as usize;
}
}
fn refresh_cwds(&mut self) {
let updates: Vec<(PaneId, PathBuf)> = self
.panes
.iter()
.filter_map(|(id, p)| {
p.child_pid
.and_then(crate::platform::process_cwd)
.map(|c| (*id, c))
})
.collect();
for (id, cwd) in updates {
if let Some(p) = self.panes.get_mut(&id) {
p.cwd = cwd;
}
}
let branches: Vec<(usize, Option<String>)> = self
.workspaces
.iter()
.enumerate()
.map(|(wi, ws)| (wi, git_branch(&ws.cwd)))
.collect();
for (wi, branch) in branches {
if let Some(ws) = self.workspaces.get_mut(wi) {
ws.branch = branch;
}
}
}
fn refresh_resumable(&mut self) {
let open: HashSet<(String, PathBuf)> = self
.status
.iter()
.filter(|(_, s)| crate::agent::is_resumable(&s.agent))
.filter_map(|(id, s)| self.panes.get(id).map(|p| (s.agent.clone(), p.cwd.clone())))
.collect();
let dismissed = &self.dismissed_sessions;
self.resumable = crate::agent::recent_sessions(12)
.into_iter()
.filter(|s| {
!dismissed.contains(&s.session_id)
&& !open.contains(&(s.agent.clone(), s.cwd.clone()))
})
.collect();
}
pub fn dismiss_session(&mut self, idx: usize) {
if idx >= self.resumable.len() {
return;
}
let s = self.resumable.remove(idx);
self.dismissed_sessions.insert(s.session_id);
}
pub fn resume_session(&mut self, idx: usize) {
let Some(s) = self.resumable.get(idx).cloned() else {
return;
};
let Some(resume) = crate::agent::resume_command(&s.agent, &s.session_id) else {
return;
};
let Some(id) = self.spawn_into(s.cwd.clone()) else {
return;
};
let tab = Tab::panes(TileLayout::new(id));
let target = if self.config.layout.resume_in_new_node {
self.workspaces.iter().position(|w| w.cwd == s.cwd)
} else {
Some(self.active_ws)
};
if let Some(wi) = target {
self.active_ws = wi;
let ws = &mut self.workspaces[wi];
ws.tabs.push(tab);
ws.active_tab = ws.tabs.len() - 1;
} else {
let branch = git_branch(&s.cwd);
self.workspaces.push(Workspace {
name: ws_name(&s.cwd),
cwd: s.cwd.clone(),
branch,
git_ahead_behind: None,
worktree: worktree_membership(&s.cwd),
tabs: vec![tab],
active_tab: 0,
});
self.active_ws = self.workspaces.len() - 1;
}
if let Some(st) = self.status.get_mut(&id) {
st.agent = s.agent.clone();
st.agent_session = Some(AgentSession {
agent: s.agent.clone(),
session_id: s.session_id.clone(),
});
}
if let Some(p) = self.panes.get(&id) {
p.send(resume.as_bytes());
}
self.mode = Mode::Normal;
self.resumable.retain(|r| r.session_id != s.session_id);
}
fn focus_pane_global(&mut self, id: PaneId) {
let mut found = None;
for (wi, ws) in self.workspaces.iter().enumerate() {
for (ti, tab) in ws.tabs.iter().enumerate() {
if tab.layout.leaves().contains(&id) {
found = Some((wi, ti));
}
}
}
if let Some((wi, ti)) = found {
self.active_ws = wi;
self.workspaces[wi].active_tab = ti;
self.workspaces[wi].tabs[ti].layout.focus = id;
self.mode = Mode::Normal;
}
}
fn cycle_workspace(&mut self, delta: isize) {
let n = self.workspaces.len() as isize;
if n > 0 {
self.active_ws = (((self.active_ws as isize + delta) % n + n) % n) as usize;
}
}
fn focus_dir(&mut self, dir: Dir) {
let area = self.last_pane_area;
self.layout_mut().focus_dir(area, dir);
}
fn close_pane(&mut self, id: PaneId) {
self.panes.remove(&id);
self.status.remove(&id);
self.module_panes.remove(&id); self.session_dirty = true;
if self.layout_mut().remove(id) {
self.close_active_tab();
}
self.emit_event("pane.closed", serde_json::json!({"pane": id.0.to_string()}));
}
fn close_active_tab(&mut self) {
let ws = &mut self.workspaces[self.active_ws];
if ws.active_tab < ws.tabs.len() {
ws.tabs.remove(ws.active_tab);
}
if ws.tabs.is_empty() {
self.close_active_ws();
} else if ws.active_tab >= ws.tabs.len() {
ws.active_tab = ws.tabs.len() - 1;
}
}
fn close_active_ws(&mut self) {
if self.active_ws < self.workspaces.len() {
self.workspaces.remove(self.active_ws);
}
if self.workspaces.is_empty() {
self.should_quit = true;
} else if self.active_ws >= self.workspaces.len() {
self.active_ws = self.workspaces.len() - 1;
}
}
fn close_workspace(&mut self, index: usize) {
if index >= self.workspaces.len() {
return;
}
let ids: Vec<PaneId> = self.workspaces[index]
.tabs
.iter()
.flat_map(|t| t.layout.leaves())
.collect();
for id in ids {
self.panes.remove(&id);
self.status.remove(&id);
self.module_panes.remove(&id);
}
self.workspaces.remove(index);
if self.workspaces.is_empty() {
self.should_quit = true;
} else if self.active_ws >= self.workspaces.len() {
self.active_ws = self.workspaces.len() - 1;
}
self.session_dirty = true;
self.emit_event(
"node.closed",
serde_json::json!({"node": index.to_string()}),
);
}
fn close_tab(&mut self, index: usize) {
let ids: Vec<PaneId> = {
let ws = &self.workspaces[self.active_ws];
if index >= ws.tabs.len() {
return;
}
ws.tabs[index].layout.leaves()
};
for id in ids {
self.panes.remove(&id);
self.status.remove(&id);
self.module_panes.remove(&id);
}
let ws = &mut self.workspaces[self.active_ws];
ws.tabs.remove(index);
if ws.tabs.is_empty() {
self.close_active_ws();
} else if ws.active_tab >= ws.tabs.len() {
ws.active_tab = ws.tabs.len() - 1;
} else if ws.active_tab > index {
ws.active_tab -= 1;
}
self.session_dirty = true;
self.emit_event(
"tab.closed",
serde_json::json!({"tab": (index + 1).to_string()}),
);
}
}
fn ws_name(cwd: &std::path::Path) -> String {
cwd.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_string()
}
fn worktree_membership(cwd: &std::path::Path) -> Option<crate::git::WorktreeMembership> {
crate::git::local::common_dir(cwd)
.map(|common_dir| crate::git::WorktreeMembership { common_dir })
}
fn restore_module_pane(
modules: &crate::module::ModuleRegistry,
mid: &str,
ep: &str,
id: PaneId,
app_tx: &Sender<AppEvent>,
) -> Option<(Pane, crate::module::ModulePaneRecord)> {
let m = modules.find(mid).filter(|m| m.is_runnable())?;
let argv = m
.manifest
.panes
.iter()
.find(|p| p.id == ep)
.map(|p| p.command.clone())?;
let ctx = serde_json::json!({ "invocation_source": "restore" });
let mut env = crate::module::runtime::base_env(m, &ctx);
env.push(("BOHAY_MODULE_ENTRYPOINT_ID".to_string(), ep.to_string()));
let pane = Pane::spawn_command(id, 80, 24, m.root.clone(), app_tx.clone(), &argv, &env).ok()?;
Some((
pane,
crate::module::ModulePaneRecord {
module_id: mid.to_string(),
entrypoint: ep.to_string(),
},
))
}
fn git_branch(cwd: &std::path::Path) -> Option<String> {
let mut dir = Some(cwd);
while let Some(d) = dir {
let dot_git = d.join(".git");
let head = if dot_git.is_dir() {
dot_git.join("HEAD")
} else if dot_git.is_file() {
let txt = std::fs::read_to_string(&dot_git).ok()?;
let rel = txt.strip_prefix("gitdir:")?.trim();
let gitdir = d.join(rel);
gitdir.join("HEAD")
} else {
dir = d.parent();
continue;
};
let content = std::fs::read_to_string(head).ok()?;
let content = content.trim();
return Some(match content.strip_prefix("ref: refs/heads/") {
Some(branch) => branch.to_string(),
None => content.chars().take(7).collect(), });
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use crate::persist::TEST_ENV_LOCK as ENV_GUARD;
fn key(c: char, m: KeyModifiers) -> AppEvent {
AppEvent::Key(KeyEvent::new(KeyCode::Char(c), m))
}
#[test]
fn prefix_chord_variants() {
let chords = [
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('@'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Null, KeyModifiers::NONE), ];
for chord in chords {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.handle_event(AppEvent::Key(chord));
assert_eq!(
app.mode,
Mode::Prefix,
"chord {:?} should arm the prefix",
chord.code
);
app.handle_event(key('v', KeyModifiers::NONE));
assert_eq!(
app.layout().len(),
2,
"prefix+v should split after {:?}",
chord.code
);
}
}
#[test]
fn session_roundtrip() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('v', KeyModifiers::NONE));
assert_eq!(app.layout().len(), 2);
let json = serde_json::to_string(&persist::snapshot(&app)).unwrap();
let snap: SessionSnapshot = serde_json::from_str(&json).unwrap();
let (tx2, _rx2) = mpsc::channel();
let restored = App::from_snapshot(snap, tx2).expect("restore");
assert_eq!(restored.workspaces.len(), 1);
assert_eq!(restored.layout().len(), 2);
}
#[test]
fn splits_both_directions() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let area = Rect::new(0, 0, 80, 24);
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('v', KeyModifiers::NONE));
let r = app.layout().panes(area);
assert_eq!(r.len(), 2);
assert_eq!(r[0].rect.y, r[1].rect.y);
assert_ne!(r[0].rect.x, r[1].rect.x);
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('s', KeyModifiers::NONE));
let r = app.layout().panes(area);
assert_eq!(r.len(), 3);
let stacked = r.iter().any(|a| {
r.iter()
.any(|b| a.rect.x == b.rect.x && a.rect.y != b.rect.y)
});
assert!(stacked, "horizontal-divider split not produced by `s`");
}
#[test]
fn border_only_when_split() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let render_text = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(100, 30)).unwrap();
term.draw(|f| crate::ui::render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
assert!(
!render_text(&mut app).contains('┃'),
"single pane should have no border"
);
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('v', KeyModifiers::NONE));
assert!(
render_text(&mut app).contains('┃'),
"split panes should be bordered"
);
}
#[test]
fn click_focuses_pane() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('v', KeyModifiers::NONE)); let leaves = app.layout().leaves();
let (a, b) = (leaves[0], leaves[1]);
assert_eq!(app.layout().focus, b);
app.pane_rects = vec![(a, Rect::new(0, 0, 10, 10)), (b, Rect::new(10, 0, 10, 10))];
app.handle_event(AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 3,
row: 3,
modifiers: KeyModifiers::NONE,
}));
assert_eq!(app.layout().focus, a); }
#[test]
fn close_tab_removes_it_and_its_panes() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('c', KeyModifiers::NONE)); assert_eq!(app.ws().tabs.len(), 2);
let before = app.panes.len();
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('X', KeyModifiers::NONE)); assert_eq!(app.ws().tabs.len(), 1);
assert!(app.panes.len() < before);
}
#[test]
fn picker_w_creates_a_worktree_only_on_a_repo() {
let mk = |path: &str, is_repo: bool| crate::app::FolderPicker {
path: std::path::PathBuf::from(path),
entries: Vec::new(),
cursor: 0,
creating: None,
error: None,
is_repo,
};
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.picker = Some(mk("/tmp/some-repo", true));
app.handle_picker_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert!(app.picker.is_none(), "picker closes");
assert!(app.worktree_prompt.is_some(), "branch prompt opens");
assert_eq!(
app.worktree_repo,
Some(std::path::PathBuf::from("/tmp/some-repo"))
);
let (tx2, _rx2) = std::sync::mpsc::channel();
let mut app2 = App::new(80, 24, tx2).unwrap();
app2.picker = Some(mk("/tmp/plain", false));
app2.handle_picker_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert!(app2.picker.is_some(), "non-repo: picker stays open");
assert!(app2.worktree_prompt.is_none(), "non-repo: no prompt");
}
#[test]
fn worktree_prompt_surfaces_errors_instead_of_silently_failing() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.worktree_repo = Some(std::path::PathBuf::from("/definitely/not/a/repo"));
app.worktree_prompt = Some("feature".to_string());
app.handle_worktree_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
app.worktree_error.is_some(),
"the failure is shown, not swallowed"
);
assert!(
app.worktree_prompt.is_some(),
"prompt stays open so you can retry"
);
assert!(
app.worktree_repo.is_some(),
"target repo is retained for the retry"
);
app.handle_worktree_prompt_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(app.worktree_error.is_none(), "editing clears the error");
app.handle_worktree_prompt_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.worktree_prompt.is_none() && app.worktree_repo.is_none());
}
#[test]
fn closing_last_pane_quits_and_ignores_further_events() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let id = app.layout().focus;
app.handle_event(AppEvent::PtyExit(id)); assert!(app.should_quit, "closing the last pane quits the session");
assert!(app.workspaces.is_empty());
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('c', KeyModifiers::NONE));
app.handle_event(AppEvent::PtyExit(id));
}
#[test]
fn agents_list_is_global() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('c', KeyModifiers::NONE)); let ids: Vec<PaneId> = app.panes.keys().copied().collect();
app.status.get_mut(&ids[0]).unwrap().agent = "claude".into();
app.status.get_mut(&ids[1]).unwrap().agent = "codex".into();
let mut term = Terminal::new(TestBackend::new(110, 40)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("claude"), "claude agent missing");
assert!(
text.contains("codex"),
"second-tab agent missing from global list"
);
}
#[test]
fn tabbar_scrolls_when_full() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
for _ in 0..4 {
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('c', KeyModifiers::NONE));
}
let mut term = Terminal::new(TestBackend::new(50, 16)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains('‹') || text.contains('›'),
"scroll arrows missing when tabs overflow"
);
assert!(
text.contains('5'),
"active tab (5) not visible after scroll"
);
}
#[test]
fn agent_session_persists_and_resumes() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let focus = app.layout().focus;
let (reply, _r) = mpsc::channel();
app.handle_api(&ApiRequest {
id: "1".into(),
method: "pane.report_session".into(),
params: json!({"pane": focus.0.to_string(), "agent": "claude", "session_id": "abc-123"}),
reply,
});
assert!(app.status.get(&focus).unwrap().agent_session.is_some());
let json = serde_json::to_string(&persist::snapshot(&app)).unwrap();
let snap: SessionSnapshot = serde_json::from_str(&json).unwrap();
let (tx2, _rx2) = mpsc::channel();
let restored = App::from_snapshot(snap, tx2).expect("restore");
let rid = restored.layout().focus;
let sess = restored
.status
.get(&rid)
.unwrap()
.agent_session
.as_ref()
.unwrap();
assert_eq!(sess.agent, "claude");
assert_eq!(sess.session_id, "abc-123");
}
#[test]
fn resume_session_opens_pane() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let before_panes = app.panes.len();
let before_ws = app.workspaces.len();
app.resumable = vec![crate::agent::SessionInfo {
agent: "claude".into(),
session_id: "abc".into(),
cwd: std::env::temp_dir().join("bohay-resume-test"),
updated: std::time::SystemTime::now(),
}];
app.resume_session(0);
assert_eq!(app.panes.len(), before_panes + 1, "a pane was spawned");
assert_eq!(
app.workspaces.len(),
before_ws + 1,
"a new node for the cwd"
);
let s = app.status.get(&app.layout().focus).unwrap();
assert_eq!(s.agent, "claude");
assert_eq!(s.agent_session.as_ref().unwrap().session_id, "abc");
assert!(app.resumable.is_empty(), "session dropped from the list");
}
#[test]
fn sidebar_lists_scroll() {
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
use ratatui::Terminal;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
for _ in 0..9 {
app.new_workspace(); }
app.active_ws = 0;
app.last_active_ws_shown = 0;
let mut term = Terminal::new(TestBackend::new(80, 18)).unwrap();
let mut draw = |app: &mut App| {
term.draw(|f| crate::ui::render(f, app))
.map(|_| ())
.unwrap()
};
draw(&mut app);
assert!(app.nodes_area.height > 0, "the nodes list was measured");
assert_eq!(app.nodes_scroll, 0);
let na = app.nodes_area;
let mut wheel = |app: &mut App, kind| {
app.handle_event(AppEvent::Mouse(MouseEvent {
kind,
column: na.x + 2,
row: na.y + 1,
modifiers: KeyModifiers::NONE,
}));
};
wheel(&mut app, MouseEventKind::ScrollDown);
wheel(&mut app, MouseEventKind::ScrollDown);
draw(&mut app);
assert_eq!(app.nodes_scroll, 2, "wheel scrolled the nodes list down");
for _ in 0..5 {
wheel(&mut app, MouseEventKind::ScrollUp);
}
draw(&mut app);
assert_eq!(app.nodes_scroll, 0, "scroll clamps at the top");
app.active_ws = 9;
draw(&mut app);
assert!(
app.nodes_scroll > 0,
"the active node was scrolled into view"
);
}
#[test]
fn session_delete_button_dismisses() {
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
let sess = |id: &str, p: &str| crate::agent::SessionInfo {
agent: "claude".into(),
session_id: id.into(),
cwd: PathBuf::from(p),
updated: std::time::SystemTime::now(),
};
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.resumable = vec![sess("s0", "/p/a"), sess("s1", "/p/b")];
let mut term = Terminal::new(TestBackend::new(80, 20)).unwrap();
let mut draw = |app: &mut App| {
term.draw(|f| crate::ui::render(f, app))
.map(|_| ())
.unwrap()
};
draw(&mut app);
assert!(app.session_del_rects.is_empty());
let row = app.session_rects.iter().find(|(i, _)| *i == 1).unwrap().1;
app.hover = Some((row.x + 2, row.y));
draw(&mut app);
assert_eq!(app.session_del_rects.len(), 1, "hover reveals one ✕");
let xr = app.session_del_rects[0].1;
app.handle_event(AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: xr.x + 1,
row: xr.y,
modifiers: KeyModifiers::NONE,
}));
assert!(
app.resumable.iter().all(|s| s.session_id != "s1"),
"session removed from the sidebar list"
);
assert!(
app.dismissed_sessions.contains("s1"),
"stays dismissed across rescans"
);
}
#[test]
fn settings_modal_interactions() {
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
let _env = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let tmp = std::env::temp_dir().join(format!("bohay-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::env::set_var("BOHAY_HOME", &tmp);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
assert!(app.settings.is_none());
app.open_settings();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert_eq!(app.settings_tab_rects.len(), 7, "seven tabs");
assert!(
!app.settings_ctl_rects.is_empty(),
"theme tab lists palettes"
);
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("Settings") && text.contains("Theme") && text.contains("Agents"));
assert_eq!(app.config.theme, "noir");
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Down,
KeyModifiers::NONE,
)));
assert_eq!(app.config.theme, crate::ui::theme::THEMES[1]);
let click = |app: &mut App, x, y| {
app.handle_event(AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::NONE,
}));
};
let layout = app
.settings_tab_rects
.iter()
.find(|(t, _)| *t == SettingsTab::Layout)
.unwrap()
.1;
click(&mut app, layout.x + 1, layout.y);
assert_eq!(app.settings.as_ref().unwrap().tab, SettingsTab::Layout);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let before = app.config.layout.show_titles;
let row = app
.settings_ctl_rects
.iter()
.find(|(i, _)| *i == 3)
.unwrap()
.1;
click(&mut app, row.x + 2, row.y);
assert_ne!(
app.config.layout.show_titles, before,
"click toggles pane titles"
);
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::NONE,
)));
assert!(app.settings.is_none());
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn ui_renders_in_the_selected_language() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
let text = |term: &Terminal<TestBackend>| -> String {
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
app.catalog = crate::i18n::by_code("en");
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(text(&term).contains("NODES"), "EN header");
app.catalog = crate::i18n::by_code("es");
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let es = text(&term);
assert!(es.contains("NODOS"), "translated header appears");
assert!(!es.contains("NODES"), "English header replaced");
app.catalog = crate::i18n::by_code("zh");
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(text(&term).contains('节'), "CJK header renders");
}
#[test]
fn modals_render_in_the_selected_language() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.catalog = crate::i18n::by_code("es");
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
let text = |term: &Terminal<TestBackend>| -> String {
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(text(&term).contains("Menú"), "menu button translated");
app.open_folder_picker();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(
text(&term).contains("Abrir esta carpeta"),
"picker rows translated"
);
assert!(
text(&term).contains("Abrir espacio"),
"picker title translated"
);
app.close_folder_picker();
app.help_open = true;
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(
text(&term).contains("Cerrar panel"),
"cheat-sheet command labels translated"
);
}
#[test]
fn settings_modal_widens_to_fit_wide_language_tabs() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.catalog = crate::i18n::by_code("ja");
app.open_settings();
let mut term = Terminal::new(TestBackend::new(120, 30)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert_eq!(
app.settings_tab_rects.len(),
7,
"all 7 tabs render (none clipped)"
);
assert!(
app.settings_tab_rects
.iter()
.any(|(t, _)| *t == SettingsTab::Language),
"the Language tab is present"
);
}
#[test]
fn settings_language_tab_swaps_catalog_and_persists() {
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
let _env = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let tmp = std::env::temp_dir().join(format!("bohay-lang-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::env::set_var("BOHAY_HOME", &tmp);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.open_settings();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert_eq!(app.config.language, "en");
let lang = app
.settings_tab_rects
.iter()
.find(|(t, _)| *t == SettingsTab::Language)
.unwrap()
.1;
app.handle_event(AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: lang.x + 1,
row: lang.y,
modifiers: KeyModifiers::NONE,
}));
assert_eq!(app.settings.as_ref().unwrap().tab, SettingsTab::Language);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Down,
KeyModifiers::NONE,
)));
assert_ne!(
app.config.language, "en",
"a non-default language is selected"
);
assert_eq!(
app.catalog.nodes,
crate::i18n::by_code(&app.config.language).nodes,
"catalog swapped live"
);
assert_eq!(
crate::config::load().language,
app.config.language,
"persisted to config.json"
);
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn arrow_keys_focus_panes_and_rebinding_works() {
let _env = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let tmp = std::env::temp_dir().join(format!("bohay-keys-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::env::set_var("BOHAY_HOME", &tmp);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('v', KeyModifiers::NONE));
let right = app.layout().focus;
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Left,
KeyModifiers::NONE,
)));
assert_ne!(
app.layout().focus,
right,
"← moved focus off the right pane"
);
app.open_settings();
app.handle_event(key('4', KeyModifiers::NONE)); assert_eq!(app.settings.as_ref().unwrap().tab, SettingsTab::Keys);
let idx = Cmd::ALL.iter().position(|c| *c == Cmd::NewTab).unwrap();
if let Some(ui) = app.settings.as_mut() {
ui.cursor = idx;
}
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
))); assert!(app.settings.as_ref().unwrap().capturing);
app.handle_event(key('t', KeyModifiers::NONE)); assert!(!app.settings.as_ref().unwrap().capturing);
assert_eq!(app.key_for(Cmd::NewTab), "t");
app.close_settings();
let tabs = app.ws().tabs.len();
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('t', KeyModifiers::NONE));
assert_eq!(app.ws().tabs.len(), tabs + 1, "rebound key works");
app.handle_event(key(' ', KeyModifiers::CONTROL));
app.handle_event(key('c', KeyModifiers::NONE));
assert_eq!(app.ws().tabs.len(), tabs + 1, "old default freed");
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn settings_slider_arrows_step_both_ways() {
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
let _env = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let tmp = std::env::temp_dir().join(format!("bohay-slider-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::env::set_var("BOHAY_HOME", &tmp);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.open_settings();
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Tab,
KeyModifiers::NONE,
))); term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let left = app
.settings_arrow_rects
.iter()
.find(|(_, d, _)| *d < 0)
.unwrap()
.2;
let right = app
.settings_arrow_rects
.iter()
.find(|(_, d, _)| *d > 0)
.unwrap()
.2;
let click = |app: &mut App, r: Rect| {
app.handle_event(AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: r.x,
row: r.y,
modifiers: KeyModifiers::NONE,
}));
};
let start = app.sidebar_width;
click(&mut app, left);
assert!(app.sidebar_width < start, "left arrow decreases width");
let low = app.sidebar_width;
click(&mut app, right);
assert!(app.sidebar_width > low, "right arrow increases width");
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&tmp);
}
#[cfg(windows)]
#[test]
fn settings_shell_choice_cycles_and_persists() {
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
let _env = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let tmp = std::env::temp_dir().join(format!("bohay-shell-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::env::set_var("BOHAY_HOME", &tmp);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.open_settings();
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Char('2'),
KeyModifiers::NONE,
))); term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert_eq!(app.config.shell, "default");
let row = app
.settings_ctl_rects
.iter()
.find(|(i, _)| *i == 5)
.unwrap()
.1;
app.handle_event(AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: row.x + 2,
row: row.y,
modifiers: KeyModifiers::NONE,
}));
assert_ne!(
app.config.shell, "default",
"clicking the Shell row cycles it"
);
assert_eq!(crate::config::load().shell, app.config.shell);
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn notification_queued_on_blocked_transition() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let id = app.layout().focus;
if let Some(p) = app.panes.get(&id) {
if let Ok(mut e) = p.engine.lock() {
let mut buf = vec![b'\n'; 30];
buf.extend_from_slice(b"Do you want to proceed? (y/n) ");
e.advance(&buf);
}
}
app.config.notifications.enabled = true;
app.config.notifications.on_blocked = true;
app.status.get_mut(&id).unwrap().state = State::Idle; app.detect_tick(std::time::Instant::now());
assert!(
app.pending_notify.iter().any(|m| m.contains("blocked")),
"blocked transition queues a notification: {:?}",
app.pending_notify
);
app.pending_notify.clear();
app.config.notifications.enabled = false;
app.status.get_mut(&id).unwrap().state = State::Idle; app.detect_tick(std::time::Instant::now());
assert!(
app.pending_notify.is_empty(),
"disabled notifications stay silent"
);
}
#[test]
fn done_bell_fires_once_until_focused() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.config.notifications.enabled = true;
app.config.notifications.on_done = true;
let id = app.layout().focus;
let bogus = PaneId::alloc();
app.layout_mut().focus = bogus;
let now = std::time::Instant::now();
let idle_at = now - ACTIVITY_WINDOW - Duration::from_millis(50);
let arm_working_then_idle = |app: &mut App| {
let s = app.status.get_mut(&id).unwrap();
s.state = State::Working;
s.prev_working = true;
s.last_activity = idle_at; };
arm_working_then_idle(&mut app);
app.detect_tick(now);
assert_eq!(app.pending_notify.len(), 1, "first completion rings once");
app.pending_notify.clear();
app.status.get_mut(&id).unwrap().last_activity = now; app.detect_tick(now);
app.status.get_mut(&id).unwrap().last_activity = idle_at; app.detect_tick(now);
assert!(app.pending_notify.is_empty(), "flapping does not re-ring");
app.layout_mut().focus = id;
app.detect_tick(now);
app.layout_mut().focus = bogus;
arm_working_then_idle(&mut app);
app.detect_tick(now);
assert_eq!(
app.pending_notify.len(),
1,
"after the user looks, a new completion rings"
);
}
}
#[cfg(test)]
mod cwd_test {
use super::*;
use std::sync::mpsc;
#[test]
#[ignore] fn cwd_follows_cd() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
std::thread::sleep(Duration::from_millis(800));
let id = app.layout().focus;
let mut got = String::new();
for i in 0..60 {
if i % 5 == 0 {
app.panes.get(&id).unwrap().send(b"cd /tmp\r");
}
std::thread::sleep(Duration::from_millis(100));
app.refresh_cwds();
got = app.panes.get(&id).unwrap().cwd.display().to_string();
if got.contains("tmp") {
break;
}
}
assert!(got.contains("tmp"), "cwd did not follow cd: got '{got}'");
assert!(
app.ws().name.contains("tmp"),
"ws name not updated: '{}'",
app.ws().name
);
}
}