use std::process::{Command, Stdio};
use std::time::Instant;
use tmux_claude_state::claude_state::ClaudeState;
use tmux_claude_state::monitor::{ClaudeSession, MonitorState};
fn resolve_git_branch(cwd: &str) -> Option<String> {
let output = Command::new("git")
.args(["-C", cwd, "branch", "--show-current"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
} else {
None
}
}
#[derive(Debug, Clone)]
pub struct ManagedSession {
pub pid: u32,
pub pane_id: String,
pub project_name: String,
pub state: ClaudeState,
pub state_changed_at: Instant,
pub marked: bool,
pub title: Option<String>,
pub session_id: Option<String>,
pub model: Option<String>,
pub context_percent: Option<u8>,
pub cwd: String,
pub git_branch: Option<String>,
pub auto_title: Option<String>,
}
impl ManagedSession {
pub fn display_title(&self) -> Option<&str> {
self.title
.as_deref()
.or(self.auto_title.as_deref())
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct SyncDiff {
pub added: Vec<u32>,
pub removed: Vec<u32>,
pub state_changed: Vec<u32>,
}
#[derive(Debug, Clone)]
pub struct PreviewEntry {
pub name: String,
pub pane_id: String,
pub title: Option<String>,
pub content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Input,
Title,
Broadcast,
Scroll,
}
pub struct AppState {
pub sessions: Vec<ManagedSession>,
pub selected_index: usize,
pub own_pid: Option<u32>,
pub preview_contents: Vec<PreviewEntry>,
pub input_mode: InputMode,
pub input_buffer: String,
pub show_help: bool,
pub help_scroll: u16,
pub preview_scroll: u16,
pub preview_height: u16,
pub pending_g: bool,
pub pending_rpc: Vec<crate::rpc::RpcMessage>,
pub esc_source_mode: Option<InputMode>,
pub claudeye_visible: bool,
}
impl AppState {
pub const fn new(own_pid: Option<u32>) -> Self {
Self {
sessions: Vec::new(),
selected_index: 0,
own_pid,
preview_contents: Vec::new(),
input_mode: InputMode::Normal,
input_buffer: String::new(),
show_help: false,
help_scroll: 0,
preview_scroll: 0,
preview_height: 0,
pending_g: false,
pending_rpc: Vec::new(),
esc_source_mode: None,
claudeye_visible: false,
}
}
pub fn sync_with_monitor(&mut self, monitor: &MonitorState) -> SyncDiff {
let mut added = Vec::new();
let mut removed = Vec::new();
let mut state_changed = Vec::new();
let incoming: Vec<&ClaudeSession> = monitor
.sessions
.iter()
.filter(|s| self.own_pid != Some(s.pane.pid))
.collect();
self.sessions.retain(|managed| {
let still_exists = incoming.iter().any(|s| s.pane.pid == managed.pid);
if !still_exists {
removed.push(managed.pid);
}
still_exists
});
for session in &incoming {
if let Some(existing) = self
.sessions
.iter_mut()
.find(|m| m.pid == session.pane.pid)
{
existing.pane_id.clone_from(&session.pane.id);
if existing.state != session.state {
state_changed.push(existing.pid);
existing.state = session.state.clone();
existing.state_changed_at = session.state_changed_at;
}
} else {
added.push(session.pane.pid);
self.sessions.push(ManagedSession {
pid: session.pane.pid,
pane_id: session.pane.id.clone(),
project_name: session.pane.project_name.clone(),
state: session.state.clone(),
state_changed_at: session.state_changed_at,
marked: false,
title: None,
session_id: None,
model: None,
context_percent: None,
cwd: session.pane.cwd.clone(),
git_branch: None,
auto_title: None,
});
}
}
let selected_pid = self.selected_session().map(|s| s.pid);
self.sessions.sort_by(|a, b| a.project_name.cmp(&b.project_name));
if let Some(pid) = selected_pid
&& let Some(pos) = self.sessions.iter().position(|s| s.pid == pid)
{
self.selected_index = pos;
}
if !self.sessions.is_empty() && self.selected_index >= self.sessions.len() {
self.selected_index = self.sessions.len() - 1;
}
if !self.pending_rpc.is_empty() && !added.is_empty() {
self.pending_rpc.retain(|msg| {
let Some(pane_id) = msg.params.get("pane_id").and_then(|v| v.as_str()) else {
return false;
};
self.sessions.iter_mut().find(|s| s.pane_id == pane_id).is_none_or(|session| {
Self::apply_rpc_to_session(session, msg);
false })
});
}
SyncDiff {
added,
removed,
state_changed,
}
}
pub const fn select_next(&mut self) {
if !self.sessions.is_empty() {
self.selected_index = (self.selected_index + 1) % self.sessions.len();
}
self.preview_scroll = 0;
}
pub const fn select_prev(&mut self) {
if !self.sessions.is_empty() {
if self.selected_index == 0 {
self.selected_index = self.sessions.len() - 1;
} else {
self.selected_index -= 1;
}
}
self.preview_scroll = 0;
}
pub fn scroll_preview_up(&mut self, amount: u16, max_scroll: u16) {
self.preview_scroll = self.preview_scroll.saturating_add(amount).min(max_scroll);
}
pub const fn scroll_preview_down(&mut self, amount: u16) {
self.preview_scroll = self.preview_scroll.saturating_sub(amount);
}
pub const fn reset_preview_scroll(&mut self) {
self.preview_scroll = 0;
}
pub fn selected_session(&self) -> Option<&ManagedSession> {
self.sessions.get(self.selected_index)
}
pub fn selected_session_mut(&mut self) -> Option<&mut ManagedSession> {
self.sessions.get_mut(self.selected_index)
}
pub fn selected_pane_id(&self) -> Option<&str> {
self.selected_session().map(|s| s.pane_id.as_str())
}
pub fn toggle_mark(&mut self) {
if let Some(session) = self.sessions.get_mut(self.selected_index) {
session.marked = !session.marked;
}
}
pub fn marked_sessions(&self) -> Vec<&ManagedSession> {
self.sessions.iter().filter(|s| s.marked).collect()
}
pub fn marked_pane_ids(&self) -> Vec<String> {
self.sessions
.iter()
.filter(|s| s.marked)
.map(|s| s.pane_id.clone())
.collect()
}
pub fn refresh_auto_titles(&mut self) {
for session in &mut self.sessions {
if session.title.is_some() {
continue;
}
if let (Some(session_id), cwd) = (&session.session_id, &session.cwd) {
session.auto_title =
crate::auto_title::resolve_auto_title(cwd, session_id);
}
}
}
pub fn refresh_git_branches(&mut self) {
for session in &mut self.sessions {
session.git_branch = resolve_git_branch(&session.cwd);
}
}
pub fn handle_rpc_message(&mut self, msg: &crate::rpc::RpcMessage) {
let Some(pane_id) = msg.params.get("pane_id").and_then(|v| v.as_str()) else {
return;
};
if let Some(session) = self.sessions.iter_mut().find(|s| s.pane_id == pane_id) {
Self::apply_rpc_to_session(session, msg);
} else {
const MAX_PENDING: usize = 20;
if self.pending_rpc.len() < MAX_PENDING {
self.pending_rpc.push(msg.clone());
}
}
}
fn apply_rpc_to_session(session: &mut ManagedSession, msg: &crate::rpc::RpcMessage) {
match msg.method.as_str() {
"session_start" => {
session.session_id =
msg.params.get("session_id").and_then(|v| v.as_str()).map(String::from);
session.model =
msg.params.get("model").and_then(|v| v.as_str()).map(String::from);
}
"status_update" => {
if session.session_id.is_none() {
session.session_id = msg
.params
.get("session_id")
.and_then(|v| v.as_str())
.map(String::from);
}
if let Some(display_name) = msg
.params
.get("model")
.and_then(|m| m.get("display_name"))
.and_then(|v| v.as_str())
{
session.model = Some(display_name.to_string());
}
if let Some(pct) = msg
.params
.get("context_window")
.and_then(|c| c.get("used_percentage"))
.and_then(serde_json::Value::as_u64)
{
#[allow(clippy::cast_possible_truncation)]
let pct = pct as u8;
session.context_percent = Some(pct);
} else {
session.context_percent = Some(0);
}
}
_ => {}
}
}
pub fn serialize_sessions(&self) -> serde_json::Value {
let sessions: Vec<serde_json::Value> = self
.sessions
.iter()
.map(|s| {
let state_name = match s.state {
ClaudeState::Idle => "Idle",
ClaudeState::Working => "Working",
ClaudeState::WaitingForApproval => "WaitingForApproval",
};
serde_json::json!({
"pane_id": s.pane_id,
"pid": s.pid,
"project_name": s.project_name,
"state": state_name,
"elapsed_secs": s.state_changed_at.elapsed().as_secs(),
"model": s.model,
"context_percent": s.context_percent,
"title": s.display_title(),
"session_id": s.session_id,
"git_branch": s.git_branch,
})
})
.collect();
serde_json::json!({
"sessions": sessions,
"visible": self.claudeye_visible,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tmux_claude_state::tmux::PaneInfo;
fn make_session(pid: u32, pane_id: &str, project: &str, state: ClaudeState) -> ClaudeSession {
ClaudeSession {
pane: PaneInfo {
id: pane_id.to_string(),
pid,
cwd: format!("/home/user/{project}"),
project_name: project.to_string(),
},
state,
state_changed_at: Instant::now(),
}
}
fn make_monitor(sessions: Vec<ClaudeSession>) -> MonitorState {
MonitorState {
sessions,
any_claude_focused: false,
}
}
#[test]
fn test_sync_detects_new_sessions() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
make_session(200, "%2", "project-b", ClaudeState::Working),
]);
let diff = app.sync_with_monitor(&monitor);
assert_eq!(diff.added, vec![100, 200]);
assert!(diff.removed.is_empty());
assert!(diff.state_changed.is_empty());
assert_eq!(app.sessions.len(), 2);
}
#[test]
fn test_sync_detects_removed_sessions() {
let mut app = AppState::new(None);
let monitor1 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
make_session(200, "%2", "project-b", ClaudeState::Working),
]);
app.sync_with_monitor(&monitor1);
let monitor2 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
let diff = app.sync_with_monitor(&monitor2);
assert!(diff.added.is_empty());
assert_eq!(diff.removed, vec![200]);
assert_eq!(app.sessions.len(), 1);
}
#[test]
fn test_sync_detects_state_change() {
let mut app = AppState::new(None);
let monitor1 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor1);
let monitor2 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Working),
]);
let diff = app.sync_with_monitor(&monitor2);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert_eq!(diff.state_changed, vec![100]);
assert_eq!(app.sessions[0].state, ClaudeState::Working);
}
#[test]
fn test_sync_excludes_own_pid() {
let mut app = AppState::new(Some(999));
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
make_session(999, "%9", "sidebar", ClaudeState::Working),
]);
let diff = app.sync_with_monitor(&monitor);
assert_eq!(diff.added, vec![100]);
assert_eq!(app.sessions.len(), 1);
assert_eq!(app.sessions[0].pid, 100);
}
#[test]
fn test_select_next_wraps_around() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
make_session(300, "%3", "c", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.selected_index, 0);
app.select_next();
assert_eq!(app.selected_index, 1);
app.select_next();
assert_eq!(app.selected_index, 2);
app.select_next();
assert_eq!(app.selected_index, 0); }
#[test]
fn test_select_prev_wraps_around() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.selected_index, 0);
app.select_prev();
assert_eq!(app.selected_index, 1); app.select_prev();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_select_on_empty() {
let mut app = AppState::new(None);
app.select_next();
assert_eq!(app.selected_index, 0);
app.select_prev();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_selected_session() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Working),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.selected_session().unwrap().pid, 100);
app.select_next();
assert_eq!(app.selected_session().unwrap().pid, 200);
}
#[test]
fn test_selected_pane_id() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.selected_pane_id(), Some("%1"));
}
#[test]
fn test_selected_pane_id_empty() {
let app = AppState::new(None);
assert_eq!(app.selected_pane_id(), None);
}
#[test]
fn test_selected_index_fixed_on_removal() {
let mut app = AppState::new(None);
let monitor1 = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
make_session(300, "%3", "c", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor1);
app.selected_index = 2;
let monitor2 = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor2);
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_preview_entry_with_title() {
let entry = PreviewEntry {
name: "crmux".to_string(),
pane_id: "%1".to_string(),
title: Some("development".to_string()),
content: "hello".to_string(),
};
assert_eq!(entry.name, "crmux");
assert_eq!(entry.title, Some("development".to_string()));
}
#[test]
fn test_preview_entry_without_title() {
let entry = PreviewEntry {
name: "crmux".to_string(),
pane_id: "%1".to_string(),
title: None,
content: "hello".to_string(),
};
assert_eq!(entry.title, None);
}
#[test]
fn test_preview_contents_default_empty() {
let app = AppState::new(None);
assert!(app.preview_contents.is_empty());
}
#[test]
fn test_initial_input_mode_is_normal() {
let app = AppState::new(None);
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_initial_input_buffer_is_empty() {
let app = AppState::new(None);
assert!(app.input_buffer.is_empty());
}
#[test]
fn test_initial_show_help_is_false() {
let app = AppState::new(None);
assert!(!app.show_help);
}
#[test]
fn test_new_session_is_not_marked() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert!(!app.sessions[0].marked);
}
#[test]
fn test_toggle_mark_marks_selected() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.toggle_mark();
assert!(app.sessions[0].marked);
assert!(!app.sessions[1].marked);
}
#[test]
fn test_toggle_mark_unmarks_marked() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.toggle_mark();
assert!(app.sessions[0].marked);
app.toggle_mark();
assert!(!app.sessions[0].marked);
}
#[test]
fn test_toggle_mark_on_empty_does_nothing() {
let mut app = AppState::new(None);
app.toggle_mark(); }
#[test]
fn test_marked_sessions_returns_marked_only() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
make_session(300, "%3", "c", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.selected_index = 0;
app.toggle_mark();
app.selected_index = 2;
app.toggle_mark();
let marked = app.marked_sessions();
assert_eq!(marked.len(), 2);
assert_eq!(marked[0].pid, 100);
assert_eq!(marked[1].pid, 300);
}
#[test]
fn test_marked_sessions_empty_when_none_marked() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert!(app.marked_sessions().is_empty());
}
#[test]
fn test_marked_pane_ids_returns_marked_only() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
make_session(300, "%3", "c", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.selected_index = 0;
app.toggle_mark();
app.selected_index = 2;
app.toggle_mark();
let ids = app.marked_pane_ids();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], "%1");
assert_eq!(ids[1], "%3");
}
#[test]
fn test_marked_pane_ids_empty_when_none_marked() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert!(app.marked_pane_ids().is_empty());
}
#[test]
fn test_mark_preserved_on_sync() {
let mut app = AppState::new(None);
let monitor1 = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor1);
app.toggle_mark();
let monitor2 = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Working),
make_session(200, "%2", "b", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor2);
assert!(app.sessions[0].marked); assert!(!app.sessions[1].marked);
}
#[test]
fn test_new_session_has_no_title() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.sessions[0].title, None);
}
#[test]
fn test_title_preserved_on_sync() {
let mut app = AppState::new(None);
let monitor1 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor1);
app.sessions[0].title = Some("refactoring auth".to_string());
let monitor2 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Working),
]);
app.sync_with_monitor(&monitor2);
assert_eq!(app.sessions[0].title, Some("refactoring auth".to_string()));
}
#[test]
fn test_selected_session_mut() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.selected_session_mut().unwrap().title = Some("testing".to_string());
assert_eq!(app.sessions[0].title, Some("testing".to_string()));
assert_eq!(app.sessions[1].title, None);
}
#[test]
fn test_selected_session_mut_empty() {
let mut app = AppState::new(None);
assert!(app.selected_session_mut().is_none());
}
#[test]
fn test_initial_preview_scroll_is_zero() {
let app = AppState::new(None);
assert_eq!(app.preview_scroll, 0);
}
#[test]
fn test_initial_preview_height_is_zero() {
let app = AppState::new(None);
assert_eq!(app.preview_height, 0);
}
#[test]
fn test_initial_pending_g_is_false() {
let app = AppState::new(None);
assert!(!app.pending_g);
}
#[test]
fn test_scroll_preview_up() {
let mut app = AppState::new(None);
app.scroll_preview_up(10, 90);
assert_eq!(app.preview_scroll, 10);
}
#[test]
fn test_scroll_preview_up_clamps_to_max() {
let mut app = AppState::new(None);
app.scroll_preview_up(100, 90);
assert_eq!(app.preview_scroll, 90);
}
#[test]
fn test_scroll_preview_up_saturating() {
let mut app = AppState::new(None);
app.preview_scroll = 80;
app.scroll_preview_up(20, 90);
assert_eq!(app.preview_scroll, 90);
}
#[test]
fn test_scroll_preview_down() {
let mut app = AppState::new(None);
app.preview_scroll = 20;
app.scroll_preview_down(10);
assert_eq!(app.preview_scroll, 10);
}
#[test]
fn test_scroll_preview_down_clamps_to_zero() {
let mut app = AppState::new(None);
app.preview_scroll = 5;
app.scroll_preview_down(10);
assert_eq!(app.preview_scroll, 0);
}
#[test]
fn test_reset_preview_scroll() {
let mut app = AppState::new(None);
app.preview_scroll = 42;
app.reset_preview_scroll();
assert_eq!(app.preview_scroll, 0);
}
#[test]
fn test_select_next_resets_scroll() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.preview_scroll = 20;
app.select_next();
assert_eq!(app.preview_scroll, 0);
}
#[test]
fn test_select_prev_resets_scroll() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "a", ClaudeState::Idle),
make_session(200, "%2", "b", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.selected_index = 1;
app.preview_scroll = 15;
app.select_prev();
assert_eq!(app.preview_scroll, 0);
}
#[test]
fn test_pane_id_updated_on_sync() {
let mut app = AppState::new(None);
let monitor1 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor1);
assert_eq!(app.sessions[0].pane_id, "%1");
let monitor2 = make_monitor(vec![
make_session(100, "%5", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor2);
assert_eq!(app.sessions[0].pane_id, "%5");
}
#[test]
fn test_new_session_has_no_rpc_fields() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.sessions[0].session_id, None);
assert_eq!(app.sessions[0].model, None);
}
#[test]
fn test_handle_rpc_session_start() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"session_id": "sess-abc",
"model": "claude-sonnet-4-6",
}),
});
assert_eq!(app.sessions[0].session_id, Some("sess-abc".to_string()));
assert_eq!(app.sessions[0].model, Some("claude-sonnet-4-6".to_string()));
}
#[test]
fn test_handle_rpc_session_start_unknown_pane() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"pane_id": "%99",
"session_id": "sess-xyz",
}),
});
assert_eq!(app.sessions[0].session_id, None);
assert_eq!(app.pending_rpc.len(), 1);
}
#[test]
fn test_handle_rpc_missing_pane_id() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"session_id": "sess-abc",
}),
});
assert_eq!(app.sessions[0].session_id, None);
}
#[test]
fn test_handle_rpc_unknown_method() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "unknown_method".to_string(),
params: serde_json::json!({
"pane_id": "%1",
}),
});
assert_eq!(app.sessions[0].session_id, None);
}
#[test]
fn test_rpc_fields_preserved_on_sync() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor1 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor1);
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"session_id": "sess-abc",
"model": "opus",
}),
});
let monitor2 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Working),
]);
app.sync_with_monitor(&monitor2);
assert_eq!(app.sessions[0].session_id, Some("sess-abc".to_string()));
assert_eq!(app.sessions[0].model, Some("opus".to_string()));
}
#[test]
fn test_display_title_manual_over_auto() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.sessions[0].title = Some("manual".to_string());
app.sessions[0].auto_title = Some("auto".to_string());
assert_eq!(app.sessions[0].display_title(), Some("manual"));
}
#[test]
fn test_display_title_auto_when_no_manual() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.sessions[0].auto_title = Some("auto".to_string());
assert_eq!(app.sessions[0].display_title(), Some("auto"));
}
#[test]
fn test_display_title_none_when_both_empty() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.sessions[0].display_title(), None);
}
#[test]
fn test_new_session_has_cwd() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.sessions[0].cwd, "/home/user/project-a");
}
#[test]
fn test_new_session_has_no_git_branch() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.sessions[0].git_branch, None);
}
#[test]
fn test_git_branch_preserved_on_sync() {
let mut app = AppState::new(None);
let monitor1 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor1);
app.sessions[0].git_branch = Some("feature-branch".to_string());
let monitor2 = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Working),
]);
app.sync_with_monitor(&monitor2);
assert_eq!(app.sessions[0].git_branch, Some("feature-branch".to_string()));
}
#[test]
fn test_refresh_git_branches_sets_branch_for_valid_repo() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "crmux", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.sessions[0].cwd = env!("CARGO_MANIFEST_DIR").to_string();
app.refresh_git_branches();
assert!(app.sessions[0].git_branch.is_some());
}
#[test]
fn test_refresh_git_branches_none_for_non_repo() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "tmp", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.sessions[0].cwd = "/tmp".to_string();
app.refresh_git_branches();
assert_eq!(app.sessions[0].git_branch, None);
}
#[test]
fn test_rpc_before_session_is_buffered() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"session_id": "sess-early",
"model": "opus",
}),
});
assert_eq!(app.pending_rpc.len(), 1);
assert_eq!(app.pending_rpc[0].params["pane_id"], "%1");
}
#[test]
fn test_pending_rpc_applied_after_sync() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"session_id": "sess-early",
"model": "opus",
}),
});
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert!(app.pending_rpc.is_empty());
assert_eq!(app.sessions[0].session_id, Some("sess-early".to_string()));
assert_eq!(app.sessions[0].model, Some("opus".to_string()));
}
#[test]
fn test_pending_rpc_unmatched_retained() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
for (pane, sid) in [("%1", "sess-1"), ("%2", "sess-2")] {
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"pane_id": pane,
"session_id": sid,
}),
});
}
assert_eq!(app.pending_rpc.len(), 2);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.pending_rpc.len(), 1);
assert_eq!(app.pending_rpc[0].params["pane_id"], "%2");
assert_eq!(app.sessions[0].session_id, Some("sess-1".to_string()));
}
#[test]
fn test_handle_rpc_status_update_sets_model_display_name() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "status_update".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"model": { "display_name": "Opus" },
}),
});
assert_eq!(app.sessions[0].model, Some("Opus".to_string()));
}
#[test]
fn test_status_update_overwrites_session_start_model() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"session_id": "sess-abc",
"model": "claude-opus-4-6",
}),
});
assert_eq!(app.sessions[0].model, Some("claude-opus-4-6".to_string()));
app.handle_rpc_message(&RpcMessage {
method: "status_update".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"model": { "display_name": "Opus" },
}),
});
assert_eq!(app.sessions[0].model, Some("Opus".to_string()));
}
#[test]
fn test_status_update_without_model_is_noop() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.sessions[0].model = Some("existing".to_string());
app.handle_rpc_message(&RpcMessage {
method: "status_update".to_string(),
params: serde_json::json!({
"pane_id": "%1",
}),
});
assert_eq!(app.sessions[0].model, Some("existing".to_string()));
}
#[test]
fn test_status_update_sets_context_percent() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "status_update".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"model": { "display_name": "Opus" },
"context_window": {
"used_percentage": 50,
},
}),
});
assert_eq!(app.sessions[0].context_percent, Some(50));
}
#[test]
fn test_status_update_context_percent_zero_when_no_used_percentage() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "status_update".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"model": { "display_name": "Opus" },
"context_window": {},
}),
});
assert_eq!(app.sessions[0].context_percent, Some(0));
}
#[test]
fn test_status_update_sets_session_id() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Working),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.sessions[0].session_id, None);
app.handle_rpc_message(&RpcMessage {
method: "status_update".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"session_id": "sess-from-statusline",
"model": { "display_name": "Opus" },
}),
});
assert_eq!(app.sessions[0].session_id, Some("sess-from-statusline".to_string()));
}
#[test]
fn test_status_update_does_not_overwrite_existing_session_id() {
use crate::rpc::RpcMessage;
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Working),
]);
app.sync_with_monitor(&monitor);
app.handle_rpc_message(&RpcMessage {
method: "session_start".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"session_id": "sess-original",
"model": "opus",
}),
});
assert_eq!(app.sessions[0].session_id, Some("sess-original".to_string()));
app.handle_rpc_message(&RpcMessage {
method: "status_update".to_string(),
params: serde_json::json!({
"pane_id": "%1",
"session_id": "sess-new",
"model": { "display_name": "Opus" },
}),
});
assert_eq!(app.sessions[0].session_id, Some("sess-original".to_string()));
}
#[test]
fn test_new_session_has_no_context_percent() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "project-a", ClaudeState::Idle),
]);
app.sync_with_monitor(&monitor);
assert_eq!(app.sessions[0].context_percent, None);
}
#[test]
fn test_claudeye_visible_default_false() {
let app = AppState::new(None);
assert!(!app.claudeye_visible);
}
#[test]
fn test_claudeye_visible_toggle() {
let mut app = AppState::new(None);
app.claudeye_visible = false;
assert!(!app.claudeye_visible);
app.claudeye_visible = true;
assert!(app.claudeye_visible);
}
#[test]
fn test_serialize_sessions_empty() {
let app = AppState::new(None);
let result = app.serialize_sessions();
assert_eq!(result["sessions"], serde_json::json!([]));
assert_eq!(result["visible"], false);
}
#[test]
fn test_serialize_sessions_one() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "crmux", ClaudeState::Working),
]);
app.sync_with_monitor(&monitor);
app.sessions[0].model = Some("Opus".to_string());
app.sessions[0].context_percent = Some(23);
app.sessions[0].title = Some("implementing feature X".to_string());
app.sessions[0].session_id = Some("abc-123".to_string());
app.sessions[0].git_branch = Some("main".to_string());
let result = app.serialize_sessions();
let sessions = result["sessions"].as_array().unwrap();
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!(s["pane_id"], "%1");
assert_eq!(s["pid"], 100);
assert_eq!(s["project_name"], "crmux");
assert_eq!(s["state"], "Working");
assert_eq!(s["model"], "Opus");
assert_eq!(s["context_percent"], 23);
assert_eq!(s["title"], "implementing feature X");
assert_eq!(s["session_id"], "abc-123");
assert_eq!(s["git_branch"], "main");
assert!(s["elapsed_secs"].as_u64().is_some());
assert_eq!(result["visible"], false);
}
#[test]
fn test_serialize_sessions_waiting_for_approval_state() {
let mut app = AppState::new(None);
let monitor = make_monitor(vec![
make_session(100, "%1", "crmux", ClaudeState::WaitingForApproval),
]);
app.sync_with_monitor(&monitor);
let result = app.serialize_sessions();
let sessions = result["sessions"].as_array().unwrap();
assert_eq!(sessions[0]["state"], "WaitingForApproval");
}
#[test]
fn test_serialize_sessions_visible_true() {
let mut app = AppState::new(None);
app.claudeye_visible = true;
let result = app.serialize_sessions();
assert_eq!(result["visible"], true);
}
}