use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tokio::sync::mpsc;
use tokio::time::{self, MissedTickBehavior};
use crate::config::Config;
use crate::events::{AppMsg, ClaudeSessionMode, Command, SessionSpec, SpecOptions};
use crate::store::Store;
use crate::tmux::attach::{
clear_ctrl_q_bound, clear_quick_jump_bound, clear_session_cycle_bound, ensure_ctrl_q_bound,
ensure_quick_jump_bound, ensure_session_cycle_bound,
};
use crate::tmux::control::Notification;
use crate::tmux::control_client::ControlClient;
use crate::tmux::detector::{DetectContext, DetectorRegistry, Status};
use crate::tmux::session::SessionView;
use crate::tmux::status_bar::{self, BarSession};
use crate::tmux::{CreateSpec, SessionMetadata, TmuxClient};
use crate::util::collision::resolve_name_collision;
use crate::util::hysteresis::Smoother;
struct GlobalsGuard {
socket: Option<String>,
installed: bool,
cq_installed: bool,
cycle_installed: bool,
quick_jump_installed: bool,
}
impl Drop for GlobalsGuard {
fn drop(&mut self) {
if self.installed {
status_bar::uninstall_globals(self.socket.as_deref());
}
if self.cq_installed {
clear_ctrl_q_bound(self.socket.as_deref());
}
if self.cycle_installed {
clear_session_cycle_bound(self.socket.as_deref());
}
if self.quick_jump_installed {
clear_quick_jump_bound(self.socket.as_deref());
}
}
}
pub fn spawn(
client: Arc<dyn TmuxClient>,
socket: Option<String>,
config: Config,
store: Arc<Store>,
mut cmd_rx: mpsc::UnboundedReceiver<Command>,
evt_tx: mpsc::UnboundedSender<AppMsg>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let registry = DetectorRegistry::default_stack();
let mut smoothers: HashMap<String, Smoother> = HashMap::new();
let mut focused: Option<String> = None;
let mut last_bar_state: Vec<BarSession> = Vec::new();
let mut globals = GlobalsGuard {
socket: socket.clone(),
installed: false,
cq_installed: false,
cycle_installed: false,
quick_jump_installed: false,
};
ensure_ctrl_q_bound(socket.as_deref());
globals.cq_installed = true;
ensure_session_cycle_bound(socket.as_deref());
globals.cycle_installed = true;
ensure_quick_jump_bound(socket.as_deref());
globals.quick_jump_installed = true;
let (_control_guard, mut notifs) = match ControlClient::spawn(socket.as_deref()).await {
Ok((guard, rx)) => (Some(guard), Some(rx)),
Err(e) => {
tracing::warn!("tmux control mode unavailable: {}", e);
let _ = evt_tx.send(AppMsg::Warn(format!("live refresh off: {}", e)));
(None, None)
}
};
let mut preview_tick = time::interval(Duration::from_millis(1000));
preview_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
preview_tick.tick().await;
let _ = do_refresh(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
socket.as_deref(),
&mut last_bar_state,
&mut globals,
&evt_tx,
None,
)
.await;
loop {
tokio::select! {
maybe_cmd = cmd_rx.recv() => {
let Some(cmd) = maybe_cmd else { break };
match cmd {
Command::ListNow => {
let views = refresh_all(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
)
.await;
match views {
Ok(views) => {
smoothers.retain(|name, _| views.iter().any(|v| v.name() == name));
let state: Vec<BarSession> = views
.iter()
.map(|v| BarSession {
internal: v.name().to_string(),
display: v.display().to_string(),
attached: v.session.attached,
})
.collect();
if !bar_state_equal(&state, &last_bar_state) {
sync_status_bar(socket.as_deref(), &state, &mut globals);
last_bar_state = state;
}
if evt_tx
.send(AppMsg::SessionsRefreshed {
sessions: views,
select_after: None,
})
.is_err()
{
break;
}
}
Err(e) => {
if evt_tx.send(AppMsg::Warn(format!("list: {}", e))).is_err() {
break;
}
}
}
}
Command::FocusPreview { name } => {
focused = Some(name);
let _ = do_refresh(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
socket.as_deref(),
&mut last_bar_state,
&mut globals,
&evt_tx,
None,
)
.await;
}
Command::KillSession(internal) => {
match client.kill_session(&internal).await {
Ok(()) => {
if focused.as_deref() == Some(internal.as_str()) {
focused = None;
}
let _ = do_refresh(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
socket.as_deref(),
&mut last_bar_state,
&mut globals,
&evt_tx,
None,
)
.await;
}
Err(e) => {
let _ = evt_tx.send(AppMsg::Warn(format!("kill: {}", e)));
}
}
}
Command::DeleteRecent(id) => {
if let Err(e) = store.delete_recent(id) {
tracing::warn!("delete_recent({}): {}", id, e);
}
}
Command::RestartSession(internal) => {
match client.get_session_metadata(&internal).await {
Ok(Some(meta)) => {
let spec = metadata_to_spec(meta);
if let Err(e) = client.kill_session(&internal).await {
let _ = evt_tx.send(AppMsg::Warn(format!("restart kill: {}", e)));
continue;
}
if focused.as_deref() == Some(internal.as_str()) {
focused = None;
}
match create_session(&*client, &config, spec.clone()).await {
Ok(new_internal) => {
focused = Some(new_internal.clone());
if let Err(e) = store.upsert_recent(&spec) {
tracing::warn!("store upsert on restart: {}", e);
}
let _ = evt_tx
.send(AppMsg::Warn(format!("restarted {}", spec.name)));
let _ = do_refresh(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
socket.as_deref(),
&mut last_bar_state,
&mut globals,
&evt_tx,
Some(new_internal),
)
.await;
}
Err(e) => {
let _ =
evt_tx.send(AppMsg::Warn(format!("restart create: {}", e)));
}
}
}
Ok(None) => {
let _ = evt_tx.send(AppMsg::Warn(
"cannot restart: session predates metadata support".to_string(),
));
}
Err(e) => {
let _ = evt_tx.send(AppMsg::Warn(format!("restart read: {}", e)));
}
}
}
Command::RenameSession {
internal,
new_display,
} => match client.set_display_name(&internal, &new_display).await {
Ok(()) => {
let _ = do_refresh(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
socket.as_deref(),
&mut last_bar_state,
&mut globals,
&evt_tx,
None,
)
.await;
}
Err(e) => {
let _ = evt_tx.send(AppMsg::Warn(format!("rename: {}", e)));
}
},
Command::CreateSession(spec) => {
let spec = match resolve_collision(&*client, &config, spec).await {
Ok(resolved) => resolved,
Err(e) => {
let _ = evt_tx.send(AppMsg::Warn(format!("create: {}", e)));
continue;
}
};
match create_session(&*client, &config, spec.clone()).await {
Ok(internal_name) => {
focused = Some(internal_name.clone());
if let Err(e) = store.upsert_recent(&spec) {
tracing::warn!("store upsert_recent: {}", e);
}
let _ = evt_tx.send(AppMsg::Warn(format!("created {}", internal_name)));
let _ = do_refresh(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
socket.as_deref(),
&mut last_bar_state,
&mut globals,
&evt_tx,
Some(internal_name),
)
.await;
}
Err(e) => {
let _ = evt_tx.send(AppMsg::Warn(format!("create: {}", e)));
}
}
}
Command::Attach { .. } => {
tracing::warn!("tmux_actor received Attach — ignored; app task handles attach");
}
Command::SetTheme { .. }
| Command::SaveDivider(_)
| Command::SaveSidebar(_)
| Command::SaveSessionHistory(_)
| Command::SaveBannerFont(_)
| Command::InsertSection { .. }
| Command::RenameSection { .. } => {
tracing::warn!("tmux_actor received UI-only command — should be intercepted by app");
}
Command::Shutdown => break,
}
}
maybe_notif = async {
match notifs.as_mut() {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => {
let Some(notif) = maybe_notif else {
tracing::warn!("tmux control notification stream closed");
notifs = None;
continue;
};
let should_refresh = matches!(
notif,
Notification::SessionsChanged
| Notification::SessionChanged { .. }
| Notification::SessionRenamed { .. }
| Notification::SessionClosed { .. }
| Notification::SessionWindowChanged { .. }
| Notification::WindowAdd { .. }
| Notification::WindowClose { .. }
| Notification::WindowRenamed { .. }
);
if matches!(notif, Notification::Exit) {
tracing::warn!(
"tmux control subprocess exited — commands-only mode"
);
notifs = None;
continue;
}
if should_refresh {
let _ = do_refresh(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
socket.as_deref(),
&mut last_bar_state,
&mut globals,
&evt_tx,
None,
)
.await;
}
}
_ = preview_tick.tick() => {
let _ = do_refresh(
&*client,
&config,
®istry,
&mut smoothers,
focused.as_deref(),
socket.as_deref(),
&mut last_bar_state,
&mut globals,
&evt_tx,
None,
)
.await;
}
}
}
drop(globals);
})
}
fn build_internal_name(prefix: &str, display: &str) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let suffix = format!("{:08x}", nanos as u32);
let slug = slugify(display);
let slug = if slug.is_empty() {
"session".to_string()
} else {
slug
};
format!("{}{}-{}", prefix, slug, suffix)
}
pub(crate) fn slugify(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last_dash = false;
for c in s.chars() {
if c.is_alphanumeric() || c == '_' {
for lower in c.to_lowercase() {
out.push(lower);
}
last_dash = false;
} else if !last_dash && !out.is_empty() {
out.push('-');
last_dash = true;
}
}
out.trim_end_matches('-').to_string()
}
pub(crate) fn slug_from_internal<'a>(internal: &'a str, prefix: &str) -> Option<&'a str> {
let after_prefix = if prefix.is_empty() {
internal
} else {
internal.strip_prefix(prefix)?
};
let dash = after_prefix.rfind('-')?;
let (slug, rest) = after_prefix.split_at(dash);
let suffix = rest.strip_prefix('-')?;
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
Some(slug)
} else {
None
}
}
fn build_agent_command(agent: &str, options: &SpecOptions, args: &str) -> String {
let args = args.trim();
match agent {
"claude" => {
let mut parts: Vec<String> = vec!["claude".into()];
match options.claude.session_mode {
ClaudeSessionMode::New => {}
ClaudeSessionMode::Continue => parts.push("--continue".into()),
ClaudeSessionMode::Resume => parts.push("--resume".into()),
}
if options.claude.skip_permissions {
parts.push("--dangerously-skip-permissions".into());
}
if !args.is_empty() {
parts.push(args.to_string());
}
parts.join(" ")
}
"codex" => {
let mut parts: Vec<String> = vec!["codex".into()];
if options.codex.yolo {
parts.push("--yolo".into());
}
if !args.is_empty() {
parts.push(args.to_string());
}
parts.join(" ")
}
_ => args.to_string(),
}
}
async fn create_session(
client: &dyn TmuxClient,
config: &Config,
spec: SessionSpec,
) -> crate::error::Result<String> {
let internal = build_internal_name(&config.session_prefix, &spec.name);
let command = build_agent_command(&spec.agent, &spec.options, &spec.args);
let metadata = Some(spec_to_metadata(&spec));
let create = CreateSpec {
name: internal.clone(),
display_name: Some(spec.name.clone()),
path: spec.path.clone(),
command,
metadata,
};
client.create_session(&create).await.map(|_| internal)
}
fn spec_to_metadata(spec: &SessionSpec) -> SessionMetadata {
SessionMetadata {
display_name: spec.name.clone(),
path: spec.path.clone(),
agent: spec.agent.clone(),
args: spec.args.clone(),
claude_session_mode: match spec.options.claude.session_mode {
ClaudeSessionMode::New => "New".to_string(),
ClaudeSessionMode::Continue => "Continue".to_string(),
ClaudeSessionMode::Resume => "Resume".to_string(),
},
claude_skip_permissions: spec.options.claude.skip_permissions,
codex_yolo: spec.options.codex.yolo,
}
}
fn metadata_to_spec(meta: SessionMetadata) -> SessionSpec {
use crate::events::{ClaudeOptions, CodexOptions};
SessionSpec {
name: meta.display_name,
path: meta.path,
agent: meta.agent,
args: meta.args,
options: SpecOptions {
claude: ClaudeOptions {
session_mode: match meta.claude_session_mode.as_str() {
"Continue" => ClaudeSessionMode::Continue,
"Resume" => ClaudeSessionMode::Resume,
_ => ClaudeSessionMode::New,
},
skip_permissions: meta.claude_skip_permissions,
},
codex: CodexOptions {
yolo: meta.codex_yolo,
},
},
}
}
async fn resolve_collision(
client: &dyn TmuxClient,
config: &Config,
mut spec: SessionSpec,
) -> crate::error::Result<SessionSpec> {
let sessions = client.list_sessions().await?;
let existing: Vec<String> = sessions
.into_iter()
.filter(|s| config.manages(&s.name))
.map(|s| s.display_name.unwrap_or(s.name))
.collect();
spec.name = resolve_name_collision(&spec.name, &existing);
Ok(spec)
}
#[allow(clippy::too_many_arguments)]
async fn do_refresh(
client: &dyn TmuxClient,
config: &Config,
registry: &DetectorRegistry,
smoothers: &mut HashMap<String, Smoother>,
focused: Option<&str>,
socket: Option<&str>,
last_bar_state: &mut Vec<BarSession>,
globals: &mut GlobalsGuard,
evt_tx: &mpsc::UnboundedSender<AppMsg>,
select_after: Option<String>,
) -> crate::error::Result<()> {
ensure_ctrl_q_bound(socket);
ensure_session_cycle_bound(socket);
ensure_quick_jump_bound(socket);
let views = refresh_all(client, config, registry, smoothers, focused).await?;
smoothers.retain(|name, _| views.iter().any(|v| v.name() == name));
let state: Vec<BarSession> = views
.iter()
.map(|v| BarSession {
internal: v.name().to_string(),
display: v.display().to_string(),
attached: v.session.attached,
})
.collect();
if !bar_state_equal(&state, last_bar_state) {
sync_status_bar(socket, &state, globals);
*last_bar_state = state;
}
let _ = evt_tx.send(AppMsg::SessionsRefreshed {
sessions: views,
select_after,
});
Ok(())
}
fn bar_state_equal(a: &[BarSession], b: &[BarSession]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(x, y)| {
x.internal == y.internal && x.display == y.display && x.attached == y.attached
})
}
fn sync_status_bar(socket: Option<&str>, sessions: &[BarSession], globals: &mut GlobalsGuard) {
if !globals.installed && !sessions.is_empty() {
if let Err(e) = status_bar::install_globals(socket, sessions) {
tracing::warn!("status bar: install_globals failed: {}", e);
return;
}
globals.installed = true;
} else if globals.installed {
if let Err(e) = status_bar::install_globals(socket, sessions) {
tracing::warn!("status bar: rebind jump keys failed: {}", e);
}
}
for entry in sessions {
if let Err(e) = status_bar::configure_session(socket, &entry.internal, sessions) {
tracing::warn!(
"status bar: configure_session {} failed: {}",
entry.internal,
e
);
}
}
}
async fn refresh_all(
client: &dyn TmuxClient,
config: &Config,
registry: &DetectorRegistry,
smoothers: &mut HashMap<String, Smoother>,
focused: Option<&str>,
) -> crate::error::Result<Vec<SessionView>> {
let raw = client.list_sessions().await?;
let sessions: Vec<_> = raw
.into_iter()
.filter(|s| config.manages(&s.name))
.collect();
let now = SystemTime::now();
let mut out = Vec::with_capacity(sessions.len());
for s in sessions {
let ansi = match client.capture_pane(&s.name).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("capture-pane {} failed: {}", s.name, e);
Vec::new()
}
};
let plain = crate::tmux::detector::strip_ansi(&ansi);
let prev = smoothers.get(&s.name).map(|sm| sm.current());
let ctx = DetectContext::from_parts(&ansi, &plain, s.last_activity, now, prev, &s.name);
let detected = registry.detect(&ctx);
let smoothed = smoothers
.entry(s.name.clone())
.or_default()
.observe(detected);
let preview = if Some(s.name.as_str()) == focused {
Some(Arc::from(ansi.into_boxed_slice()))
} else {
None
};
out.push(SessionView::new(
s,
if smoothed == Status::Unknown {
Status::Idle
} else {
smoothed
},
preview,
));
}
Ok(out)
}
#[cfg(test)]
mod build_cmd_tests {
use super::*;
use crate::events::{ClaudeOptions, CodexOptions};
fn opts() -> SpecOptions {
SpecOptions::default()
}
#[test]
fn claude_with_no_options_is_bare() {
assert_eq!(build_agent_command("claude", &opts(), ""), "claude");
}
#[test]
fn claude_continue_adds_flag() {
let mut o = opts();
o.claude.session_mode = ClaudeSessionMode::Continue;
assert_eq!(build_agent_command("claude", &o, ""), "claude --continue");
}
#[test]
fn claude_resume_skip_permissions_combines() {
let o = SpecOptions {
claude: ClaudeOptions {
session_mode: ClaudeSessionMode::Resume,
skip_permissions: true,
},
codex: CodexOptions::default(),
};
assert_eq!(
build_agent_command("claude", &o, ""),
"claude --resume --dangerously-skip-permissions"
);
}
#[test]
fn claude_with_extra_args_appends() {
let o = SpecOptions {
claude: ClaudeOptions {
skip_permissions: true,
..Default::default()
},
codex: CodexOptions::default(),
};
assert_eq!(
build_agent_command("claude", &o, "--model=opus"),
"claude --dangerously-skip-permissions --model=opus"
);
}
#[test]
fn codex_yolo() {
let o = SpecOptions {
codex: CodexOptions { yolo: true },
..Default::default()
};
assert_eq!(build_agent_command("codex", &o, ""), "codex --yolo");
}
#[test]
fn terminal_ignores_options_runs_args() {
let o = SpecOptions {
claude: ClaudeOptions {
skip_permissions: true,
..Default::default()
},
..Default::default()
};
assert_eq!(
build_agent_command("terminal", &o, "vim .zshrc"),
"vim .zshrc"
);
assert_eq!(build_agent_command("terminal", &opts(), ""), "");
}
#[test]
fn slugify_lowercases_and_dashes() {
assert_eq!(slugify("My Rocket Fox"), "my-rocket-fox");
assert_eq!(slugify("Foo.Bar_baz"), "foo-bar_baz");
assert_eq!(slugify(" leading space"), "leading-space");
assert_eq!(slugify("multi spaces"), "multi-spaces");
assert_eq!(slugify("trailing!!!"), "trailing");
}
#[test]
fn slug_from_internal_strips_prefix_and_hex_suffix() {
assert_eq!(
slug_from_internal("bosun-raycast-1e18ae00", "bosun-"),
Some("raycast")
);
assert_eq!(
slug_from_internal("bosun-my-rocket-fox-a1b2c3d4", "bosun-"),
Some("my-rocket-fox")
);
assert_eq!(slug_from_internal("raycast-1e18ae00", ""), Some("raycast"));
}
#[test]
fn slug_from_internal_rejects_non_hex_suffix() {
assert_eq!(slug_from_internal("bosun-foo-zzzzzzzz", "bosun-"), None);
assert_eq!(slug_from_internal("bosun-foo-abc", "bosun-"), None);
assert_eq!(slug_from_internal("other-foo-12345678", "bosun-"), None);
}
}