From becf98166f589d0addb1eca5988668592966a654 Mon Sep 17 00:00:00 2001 From: Valgard Trontheim Date: Thu, 11 Jun 2026 23:32:02 +0200 Subject: [PATCH] feat: jump to session via terminal adapter registry (cmux/tmux/iTerm2) Enter previously jumped to a session only inside tmux. Replace the inline tmux logic with a TerminalJumper registry (src/jump/, one adapter per file): - cmux: read CMUX_WORKSPACE_ID from the agent process env, then `cmux select-workspace --workspace ` - tmux: the existing pane-switch, refactored; "PID not in any pane" is now NotApplicable so other backends can try - iTerm2: match the process's controlling tty to an iTerm2 session and focus it via AppleScript (select pane/tab/window + activate) resolve() walks jumpers() in order; the three-way JumpAttempt (NotApplicable/Jumped/Failed) makes the adapters composable. Parsing and the registry loop are unit-tested (15 new tests). --- AGENTS.md | 38 ++++-- src/app.rs | 103 +------------- src/jump/cmux.rs | 30 ++++ src/jump/iterm2.rs | 55 ++++++++ src/jump/mod.rs | 333 +++++++++++++++++++++++++++++++++++++++++++++ src/jump/tmux.rs | 55 ++++++++ src/lib.rs | 1 + 7 files changed, 510 insertions(+), 105 deletions(-) create mode 100644 src/jump/cmux.rs create mode 100644 src/jump/iterm2.rs create mode 100644 src/jump/mod.rs create mode 100644 src/jump/tmux.rs diff --git a/AGENTS.md b/AGENTS.md index cb51dd2..49e6f55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,7 +274,7 @@ Tracks child processes that have open ports. When a parent session dies but the | Key | Action | |-----|--------| | `↑`/`↓` or `k`/`j` | Select session in list | -| `Enter` | Jump to session terminal (tmux only) | +| `Enter` | Jump to session terminal (cmux / tmux / iTerm2) | | `x` | Kill selected session (SIGKILL) | | `X` | Kill all orphan ports | | `q` | Quit | @@ -351,13 +351,35 @@ cargo clippy # Lint - Remote/SSH monitoring - Notifications/alerts -## tmux Integration - -Session jump (`Enter`) only works when abtop runs inside tmux: -1. On startup, detect if `$TMUX` is set. If not, disable Enter key. -2. To map PID → tmux pane: `tmux list-panes -a -F '#{pane_pid} #{session_name}:#{window_index}.#{pane_index}'` then walk process tree to find which pane owns the agent PID. -3. Jump: `tmux select-pane -t {target}` -4. If mapping fails (PID not in any pane), show transient "pane not found" status message. +## Terminal Jump (`Enter`) + +`Enter` focuses the terminal running the selected session's agent process. +The logic lives in `src/jump/` as a registry of `TerminalJumper` adapters +(one file per backend). `jumpers()` is the single ordered source of truth; +`resolve()` walks it and the first applicable adapter wins. + +Each adapter returns a three-way `JumpAttempt`: +- `NotApplicable` — not this backend's terminal; try the next adapter. +- `Jumped` — focused successfully; stop. +- `Failed(msg)` — this backend owns the process but the focus command errored; + stop and surface `": "` in the status line. + +Order (most specific first), mutually exclusive by controlling tty: + +1. **cmux** (`jump/cmux.rs`) — reads `CMUX_WORKSPACE_ID` (a UUID cmux exports + into every surface, inherited by the agent) from the process environment via + `ps eww`, then `cmux select-workspace --workspace `. +2. **tmux** (`jump/tmux.rs`) — only when abtop itself runs inside tmux (`$TMUX`). + Maps PID → pane via `tmux list-panes -a -F '#{pane_pid} #{session_name}:#{window_index}.#{pane_index}'` + + process-tree descent, then `switch-client` / `select-window` / `select-pane`. + PID in no pane → `NotApplicable` (lets another backend try). +3. **iTerm2** (`jump/iterm2.rs`) — resolves the PID's controlling tty (`ps -o tty=`), + then AppleScript selects the session whose `tty` matches and brings its + window/app to the front. First call triggers a one-time macOS Automation + permission prompt; until granted, `osascript` exits non-zero → `Failed`. + +Parsing/registry logic is unit-tested in `jump/mod.rs`; the thin `ps`/`osascript`/ +`tmux` I/O wrappers are verified manually. ## Privacy diff --git a/src/app.rs b/src/app.rs index 23783a8..1003482 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,6 +26,7 @@ fn sanitize_fallback(prompt: &str, max_len: usize) -> String { /// Outcome of an Enter-key jump attempt. Distinct from `Option` so /// callers (notably `--exit-on-jump`) can tell a real tmux jump apart from /// a no-op (outside tmux, or empty session list). +#[derive(Debug, PartialEq, Eq)] pub enum JumpOutcome { /// Actually switched to a tmux pane. Jumped, @@ -789,65 +790,16 @@ impl App { self.should_quit = true; } - /// Jump to the terminal running the selected session's Claude process. - /// In tmux: switch to the pane. Otherwise: no-op. + /// Jump to the terminal running the selected session's agent process. + /// Delegates to the terminal-jumper registry (cmux / tmux / iTerm2); + /// see [`crate::jump`]. No-op when nothing is selected or no backend + /// recognizes the process. pub fn jump_to_session(&mut self) -> JumpOutcome { if self.sessions.is_empty() { return JumpOutcome::NoOp; } - if std::env::var("TMUX").is_err() { - return JumpOutcome::NoOp; - } let target_pid = self.sessions[self.selected].pid; - match self.jump_via_tmux(target_pid) { - None => JumpOutcome::Jumped, - Some(msg) => JumpOutcome::Failed(msg), - } - } - - fn jump_via_tmux(&self, target_pid: u32) -> Option { - let output = std::process::Command::new("tmux") - .args([ - "list-panes", - "-a", - "-F", - "#{pane_pid} #{session_name}:#{window_index}.#{pane_index}", - ]) - .output() - .ok()?; - let stdout = String::from_utf8_lossy(&output.stdout); - - for line in stdout.lines() { - let mut parts = line.splitn(2, ' '); - let pane_pid: u32 = match parts.next().and_then(|p| p.parse().ok()) { - Some(p) => p, - None => continue, - }; - let pane_target = match parts.next() { - Some(t) => t, - None => continue, - }; - - if is_descendant_of(target_pid, pane_pid) { - // Switch tmux client to the target session (needed for cross-session jumps) - if let Some(session_name) = pane_target.split(':').next() { - let _ = std::process::Command::new("tmux") - .args(["switch-client", "-t", session_name]) - .status(); - } - if let Some(window) = pane_target.split('.').next() { - let _ = std::process::Command::new("tmux") - .args(["select-window", "-t", window]) - .status(); - } - let _ = std::process::Command::new("tmux") - .args(["select-pane", "-t", pane_target]) - .status(); - return None; // success - } - } - - Some("pane not found".to_string()) + crate::jump::run_jump(target_pid) } /// Get the display summary for a session: LLM summary > "..." if pending > raw prompt > "—" @@ -1008,49 +960,6 @@ fn load_summary_cache() -> HashMap { } } -/// Check if `target` PID is a descendant of `ancestor` PID by walking the process tree. -fn is_descendant_of(target: u32, ancestor: u32) -> bool { - if target == ancestor { - return true; - } - // Build a pid->ppid map from ps - let output = match std::process::Command::new("ps") - .args(["-eo", "pid,ppid"]) - .output() - { - Ok(o) => o, - Err(_) => return false, - }; - let stdout = String::from_utf8_lossy(&output.stdout); - let mut ppid_map: HashMap = HashMap::new(); - for line in stdout.lines().skip(1) { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - if let (Ok(pid), Ok(ppid)) = (parts[0].parse::(), parts[1].parse::()) { - ppid_map.insert(pid, ppid); - } - } - } - // Walk up from target to see if we reach ancestor - let mut current = target; - let mut depth = 0; - while depth < 50 { - if let Some(&parent) = ppid_map.get(¤t) { - if parent == ancestor { - return true; - } - if parent == 0 || parent == 1 || parent == current { - return false; - } - current = parent; - depth += 1; - } else { - return false; - } - } - false -} - fn save_summary_cache(summaries: &HashMap) { let path = cache_path(); let _ = std::fs::create_dir_all(cache_dir()); diff --git a/src/jump/cmux.rs b/src/jump/cmux.rs new file mode 100644 index 0000000..eb1cf46 --- /dev/null +++ b/src/jump/cmux.rs @@ -0,0 +1,30 @@ +//! cmux (manaflow-ai) backend. +//! +//! Each cmux surface exports `CMUX_WORKSPACE_ID` (a UUID), inherited by the +//! agent process. We read it from the process environment and focus the +//! workspace via the cmux CLI, which accepts the UUID directly as `--workspace`. + +use super::{pid_env_var, JumpAttempt, TerminalJumper}; +use std::process::Command; + +pub struct CmuxJumper; + +impl TerminalJumper for CmuxJumper { + fn name(&self) -> &'static str { + "cmux" + } + + fn try_jump(&self, pid: u32) -> JumpAttempt { + let Some(workspace) = pid_env_var(pid, "CMUX_WORKSPACE_ID") else { + return JumpAttempt::NotApplicable; + }; + match Command::new("cmux") + .args(["select-workspace", "--workspace", &workspace]) + .output() + { + Ok(o) if o.status.success() => JumpAttempt::Jumped, + Ok(o) => JumpAttempt::Failed(format!("select-workspace exited {}", o.status)), + Err(e) => JumpAttempt::Failed(format!("cmux CLI not runnable ({e})")), + } + } +} diff --git a/src/jump/iterm2.rs b/src/jump/iterm2.rs new file mode 100644 index 0000000..9baf5d9 --- /dev/null +++ b/src/jump/iterm2.rs @@ -0,0 +1,55 @@ +//! iTerm2 backend. +//! +//! Match the process's controlling tty against an iTerm2 session, then focus +//! its pane/window via AppleScript. The tty discriminates iTerm2 from other +//! terminals (a tmux-hosted process has the tmux pty, not an iTerm2 session +//! tty), so this returns `NotApplicable` for non-iTerm2 hosts. +//! +//! Note: the first AppleScript call triggers a one-time macOS Automation +//! permission prompt; until granted, `osascript` exits non-zero and the +//! attempt surfaces as `Failed`. + +use super::{interpret_osascript, pid_tty, JumpAttempt, TerminalJumper}; +use std::process::Command; + +pub struct ITerm2Jumper; + +impl TerminalJumper for ITerm2Jumper { + fn name(&self) -> &'static str { + "iterm2" + } + + fn try_jump(&self, pid: u32) -> JumpAttempt { + let Some(tty) = pid_tty(pid) else { + return JumpAttempt::NotApplicable; + }; + let script = format!( + r#"if application "iTerm2" is running then + tell application "iTerm2" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if tty of s is "{tty}" then + select s + select t + select w + activate + return "FOUND" + end if + end repeat + end repeat + end repeat + end tell +end if +return "NOTFOUND""# + ); + match Command::new("osascript").arg("-e").arg(&script).output() { + Ok(o) if o.status.success() => interpret_osascript(&String::from_utf8_lossy(&o.stdout)), + Ok(o) => JumpAttempt::Failed(format!( + "osascript error: {}", + String::from_utf8_lossy(&o.stderr).trim() + )), + Err(e) => JumpAttempt::Failed(format!("osascript not runnable ({e})")), + } + } +} diff --git a/src/jump/mod.rs b/src/jump/mod.rs new file mode 100644 index 0000000..bce65c7 --- /dev/null +++ b/src/jump/mod.rs @@ -0,0 +1,333 @@ +//! Terminal "jump to session" backends. +//! +//! Each supported terminal multiplexer / emulator is a [`TerminalJumper`], +//! one per submodule. [`resolve`] walks an ordered registry (see [`jumpers`]) +//! and the first adapter that recognizes the process wins — "first applicable +//! adapter wins". +//! +//! The three-way [`JumpAttempt`] is the crux that makes adapters composable: +//! `NotApplicable` means "not my terminal, try the next one", while `Failed` +//! is reserved for a real command error in the adapter that *did* own the +//! process. Only `Failed`/`Jumped` stop the walk. + +mod cmux; +mod iterm2; +mod tmux; + +use crate::app::JumpOutcome; +use std::collections::HashMap; +use std::process::Command; + +/// Result of a single adapter's attempt to jump to a process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JumpAttempt { + /// This adapter's terminal does not host the process — try the next one. + NotApplicable, + /// Successfully focused the process's terminal/pane. + Jumped, + /// This adapter owns the process but the focus command errored. + Failed(String), +} + +/// A terminal backend that can focus the pane/tab/window running a given PID. +pub trait TerminalJumper { + /// Short label, used to prefix failure messages in the status line. + fn name(&self) -> &'static str; + /// Attempt to focus the terminal hosting `pid`. + fn try_jump(&self, pid: u32) -> JumpAttempt; +} + +/// Walk the adapters in order; the first non-`NotApplicable` result decides. +/// All `NotApplicable` → `NoOp` (nothing happened, no error to report). +pub fn resolve(jumpers: &[Box], pid: u32) -> JumpOutcome { + for j in jumpers { + match j.try_jump(pid) { + JumpAttempt::NotApplicable => continue, + JumpAttempt::Jumped => return JumpOutcome::Jumped, + JumpAttempt::Failed(msg) => { + return JumpOutcome::Failed(format!("{}: {}", j.name(), msg)) + } + } + } + JumpOutcome::NoOp +} + +/// The registry: the single ordered source of truth for supported terminals. +/// Order = most specific first: cmux (env-tagged) → tmux (multiplexer) → +/// iTerm2 (emulator). They are mutually exclusive by tty, so order only +/// matters for the multiplexer-inside-emulator case. +pub fn jumpers() -> Vec> { + vec![ + Box::new(cmux::CmuxJumper), + Box::new(tmux::TmuxJumper), + Box::new(iterm2::ITerm2Jumper), + ] +} + +/// Entry point used by the app: run the selected PID through the registry. +pub fn run_jump(pid: u32) -> JumpOutcome { + resolve(&jumpers(), pid) +} + +// --------------------------------------------------------------------------- +// Shared parsing helpers (pure — unit-tested below). +// --------------------------------------------------------------------------- + +/// Parse `ps -o tty= -p ` output into a `/dev/...` path. +/// Returns `None` when the process has no controlling tty (`??` or empty). +fn parse_tty(raw: &str) -> Option { + let t = raw.trim(); + if t.is_empty() || t == "??" { + return None; + } + Some(format!("/dev/{t}")) +} + +/// Extract `VAR=value` from a whitespace-separated `ps eww` environment dump. +fn parse_env_var(ps_output: &str, var: &str) -> Option { + let prefix = format!("{var}="); + ps_output + .split_whitespace() + .find_map(|tok| tok.strip_prefix(&prefix).map(|v| v.to_string())) +} + +/// Map the osascript marker line to a jump attempt. +/// `FOUND` → jumped, anything else (incl. `NOTFOUND`/empty) → not applicable. +fn interpret_osascript(stdout: &str) -> JumpAttempt { + if stdout.trim() == "FOUND" { + JumpAttempt::Jumped + } else { + JumpAttempt::NotApplicable + } +} + +/// Find the `session:window.pane` target whose pane-PID owns the process, +/// given a `tmux list-panes -F '#{pane_pid} #{target}'` dump. +fn find_pane_target(list_output: &str, is_descendant: impl Fn(u32) -> bool) -> Option { + for line in list_output.lines() { + let mut parts = line.splitn(2, ' '); + let pane_pid: u32 = match parts.next().and_then(|p| p.parse().ok()) { + Some(p) => p, + None => continue, + }; + let target = match parts.next() { + Some(t) => t, + None => continue, + }; + if is_descendant(pane_pid) { + return Some(target.to_string()); + } + } + None +} + +// --------------------------------------------------------------------------- +// Shared I/O helpers (thin wrappers around `ps`; the parsing they delegate to +// is unit-tested above). Private to this module tree — adapter submodules +// reach them via `use super::…`. +// --------------------------------------------------------------------------- + +/// Controlling tty of a process as a `/dev/...` path, via `ps -o tty=`. +fn pid_tty(pid: u32) -> Option { + let out = Command::new("ps") + .args(["-o", "tty=", "-p", &pid.to_string()]) + .output() + .ok()?; + parse_tty(&String::from_utf8_lossy(&out.stdout)) +} + +/// Read a single environment variable from a process via `ps eww`. +/// On macOS this returns the (same-user) process environment; values with +/// spaces are not supported, which is fine for the UUID/ID lookups here. +fn pid_env_var(pid: u32, var: &str) -> Option { + let out = Command::new("ps") + .args(["eww", "-p", &pid.to_string()]) + .output() + .ok()?; + parse_env_var(&String::from_utf8_lossy(&out.stdout), var) +} + +/// Walk the process tree (via `ps -eo pid,ppid`) to test whether `target` +/// descends from `ancestor`. +fn is_descendant_of(target: u32, ancestor: u32) -> bool { + if target == ancestor { + return true; + } + let output = match Command::new("ps").args(["-eo", "pid,ppid"]).output() { + Ok(o) => o, + Err(_) => return false, + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let mut ppid_map: HashMap = HashMap::new(); + for line in stdout.lines().skip(1) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + if let (Ok(pid), Ok(ppid)) = (parts[0].parse::(), parts[1].parse::()) { + ppid_map.insert(pid, ppid); + } + } + } + let mut current = target; + let mut depth = 0; + while depth < 50 { + if let Some(&parent) = ppid_map.get(¤t) { + if parent == ancestor { + return true; + } + if parent == 0 || parent == 1 || parent == current { + return false; + } + current = parent; + depth += 1; + } else { + return false; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Mock(&'static str, JumpAttempt); + impl TerminalJumper for Mock { + fn name(&self) -> &'static str { + self.0 + } + fn try_jump(&self, _pid: u32) -> JumpAttempt { + self.1.clone() + } + } + + fn boxed(m: Mock) -> Box { + Box::new(m) + } + + // ---- resolve / registry loop ---- + + #[test] + fn resolve_all_not_applicable_is_noop() { + let js = vec![ + boxed(Mock("a", JumpAttempt::NotApplicable)), + boxed(Mock("b", JumpAttempt::NotApplicable)), + ]; + assert_eq!(resolve(&js, 123), JumpOutcome::NoOp); + } + + #[test] + fn resolve_first_jumped_wins() { + let js = vec![ + boxed(Mock("a", JumpAttempt::Jumped)), + boxed(Mock("b", JumpAttempt::Failed("should not reach".into()))), + ]; + assert_eq!(resolve(&js, 123), JumpOutcome::Jumped); + } + + #[test] + fn resolve_skips_not_applicable_until_jump() { + let js = vec![ + boxed(Mock("a", JumpAttempt::NotApplicable)), + boxed(Mock("b", JumpAttempt::Jumped)), + ]; + assert_eq!(resolve(&js, 123), JumpOutcome::Jumped); + } + + #[test] + fn resolve_failure_is_prefixed_with_adapter_name() { + let js = vec![ + boxed(Mock("a", JumpAttempt::NotApplicable)), + boxed(Mock( + "iterm2", + JumpAttempt::Failed("permission denied".into()), + )), + ]; + assert_eq!( + resolve(&js, 123), + JumpOutcome::Failed("iterm2: permission denied".to_string()) + ); + } + + // ---- parse_tty ---- + + #[test] + fn parse_tty_strips_and_prefixes_dev() { + assert_eq!(parse_tty("ttys009\n").as_deref(), Some("/dev/ttys009")); + } + + #[test] + fn parse_tty_trims_surrounding_whitespace() { + assert_eq!(parse_tty(" ttys010 ").as_deref(), Some("/dev/ttys010")); + } + + #[test] + fn parse_tty_no_controlling_tty_is_none() { + assert_eq!(parse_tty("??"), None); + assert_eq!(parse_tty("?? \n"), None); + assert_eq!(parse_tty(""), None); + assert_eq!(parse_tty(" "), None); + } + + // ---- parse_env_var ---- + + #[test] + fn parse_env_var_finds_value() { + let dump = "FOO=bar CMUX_WORKSPACE_ID=abc-123-DEF BAZ=1"; + assert_eq!( + parse_env_var(dump, "CMUX_WORKSPACE_ID").as_deref(), + Some("abc-123-DEF") + ); + } + + #[test] + fn parse_env_var_missing_is_none() { + assert_eq!(parse_env_var("FOO=bar BAZ=1", "CMUX_WORKSPACE_ID"), None); + } + + #[test] + fn parse_env_var_does_not_match_prefix_substring() { + // "CMUX_WORKSPACE_ID_X" must not satisfy a query for "CMUX_WORKSPACE_ID" + assert_eq!( + parse_env_var("CMUX_WORKSPACE_ID_X=nope", "CMUX_WORKSPACE_ID"), + None + ); + } + + // ---- interpret_osascript ---- + + #[test] + fn interpret_osascript_found_is_jumped() { + assert_eq!(interpret_osascript("FOUND\n"), JumpAttempt::Jumped); + } + + #[test] + fn interpret_osascript_notfound_is_not_applicable() { + assert_eq!( + interpret_osascript("NOTFOUND\n"), + JumpAttempt::NotApplicable + ); + assert_eq!(interpret_osascript(""), JumpAttempt::NotApplicable); + } + + // ---- find_pane_target (tmux) ---- + + #[test] + fn find_pane_target_returns_matching_pane() { + let dump = "111 main:0.0\n222 work:1.2\n"; + let target = find_pane_target(dump, |pid| pid == 222); + assert_eq!(target.as_deref(), Some("work:1.2")); + } + + #[test] + fn find_pane_target_none_when_no_pane_owns_pid() { + let dump = "111 main:0.0\n222 work:1.2\n"; + assert_eq!(find_pane_target(dump, |_| false), None); + } + + #[test] + fn find_pane_target_skips_malformed_lines() { + let dump = "garbage\n\n333 dev:2.0\n"; + let target = find_pane_target(dump, |pid| pid == 333); + assert_eq!(target.as_deref(), Some("dev:2.0")); + } +} diff --git a/src/jump/tmux.rs b/src/jump/tmux.rs new file mode 100644 index 0000000..862bdbf --- /dev/null +++ b/src/jump/tmux.rs @@ -0,0 +1,55 @@ +//! tmux backend. +//! +//! Only applicable when abtop itself runs inside tmux (a `switch-client` needs +//! a tmux context). Maps the agent PID to the owning pane by process descent, +//! then switches client/window/pane. When the PID is in no tmux pane the +//! attempt is `NotApplicable`, letting another backend try. + +use super::{find_pane_target, is_descendant_of, JumpAttempt, TerminalJumper}; +use std::process::Command; + +pub struct TmuxJumper; + +impl TerminalJumper for TmuxJumper { + fn name(&self) -> &'static str { + "tmux" + } + + fn try_jump(&self, pid: u32) -> JumpAttempt { + if std::env::var("TMUX").is_err() { + return JumpAttempt::NotApplicable; + } + let out = match Command::new("tmux") + .args([ + "list-panes", + "-a", + "-F", + "#{pane_pid} #{session_name}:#{window_index}.#{pane_index}", + ]) + .output() + { + Ok(o) => o, + Err(e) => return JumpAttempt::Failed(format!("tmux not runnable ({e})")), + }; + let stdout = String::from_utf8_lossy(&out.stdout); + let Some(target) = find_pane_target(&stdout, |pane_pid| is_descendant_of(pid, pane_pid)) + else { + // PID not in any tmux pane — let another adapter try. + return JumpAttempt::NotApplicable; + }; + if let Some(session_name) = target.split(':').next() { + let _ = Command::new("tmux") + .args(["switch-client", "-t", session_name]) + .status(); + } + if let Some(window) = target.split('.').next() { + let _ = Command::new("tmux") + .args(["select-window", "-t", window]) + .status(); + } + let _ = Command::new("tmux") + .args(["select-pane", "-t", &target]) + .status(); + JumpAttempt::Jumped + } +} diff --git a/src/lib.rs b/src/lib.rs index ab42504..da2dbe1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,6 +56,7 @@ pub mod collector; pub mod config; pub mod demo; pub mod host_info; +pub mod jump; pub mod locale; pub mod model; pub mod setup;