use std::collections::{HashMap, HashSet};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::widgets::TableState;
use crate::discovery;
use crate::helpers::{
create_aggregate_session, dirs_home, fire_notification, fire_webhook, kill_process,
};
use crate::hooks::{HookEvent, HookRegistry};
use crate::launch::{self, LaunchRequest};
use crate::monitor;
use crate::process;
use crate::session::{ClaudeSession, SessionStatus};
use crate::terminals;
use crate::theme::Theme;
pub const SORT_COLUMNS: &[&str] = &["Status", "Context", "Cost", "$/hr", "Elapsed"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusFilter {
All,
NeedsInput,
Processing,
WaitingInput,
Unknown,
Idle,
Finished,
}
impl StatusFilter {
pub fn next(self) -> Self {
match self {
Self::All => Self::NeedsInput,
Self::NeedsInput => Self::Processing,
Self::Processing => Self::WaitingInput,
Self::WaitingInput => Self::Unknown,
Self::Unknown => Self::Idle,
Self::Idle => Self::Finished,
Self::Finished => Self::All,
}
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"all" => Some(Self::All),
"needsinput" | "needs-input" => Some(Self::NeedsInput),
"processing" => Some(Self::Processing),
"waiting" | "waitinginput" | "waiting-input" => Some(Self::WaitingInput),
"unknown" => Some(Self::Unknown),
"idle" => Some(Self::Idle),
"finished" => Some(Self::Finished),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::NeedsInput => "Needs Input",
Self::Processing => "Processing",
Self::WaitingInput => "Waiting",
Self::Unknown => "Unknown",
Self::Idle => "Idle",
Self::Finished => "Finished",
}
}
fn matches(self, status: SessionStatus) -> bool {
match self {
Self::All => true,
Self::NeedsInput => status == SessionStatus::NeedsInput,
Self::Processing => status == SessionStatus::Processing,
Self::WaitingInput => status == SessionStatus::WaitingInput,
Self::Unknown => status == SessionStatus::Unknown,
Self::Idle => status == SessionStatus::Idle,
Self::Finished => status == SessionStatus::Finished,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusFilter {
All,
Attention,
OverBudget,
HighContext,
UnknownTelemetry,
Conflict,
}
impl FocusFilter {
pub fn next(self) -> Self {
match self {
Self::All => Self::Attention,
Self::Attention => Self::OverBudget,
Self::OverBudget => Self::HighContext,
Self::HighContext => Self::UnknownTelemetry,
Self::UnknownTelemetry => Self::Conflict,
Self::Conflict => Self::All,
}
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"all" => Some(Self::All),
"attention" => Some(Self::Attention),
"overbudget" | "over-budget" => Some(Self::OverBudget),
"highcontext" | "high-context" => Some(Self::HighContext),
"unknowntelemetry" | "unknown-telemetry" => Some(Self::UnknownTelemetry),
"conflict" | "conflicts" => Some(Self::Conflict),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::Attention => "Attention",
Self::OverBudget => "Over Budget",
Self::HighContext => "High Context",
Self::UnknownTelemetry => "Unknown Telemetry",
Self::Conflict => "Conflict",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchField {
Cwd,
Prompt,
Resume,
}
impl LaunchField {
fn next(self) -> Self {
match self {
Self::Cwd => Self::Prompt,
Self::Prompt => Self::Resume,
Self::Resume => Self::Resume,
}
}
fn prev(self) -> Self {
match self {
Self::Cwd => Self::Cwd,
Self::Prompt => Self::Cwd,
Self::Resume => Self::Prompt,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Cwd => "cwd",
Self::Prompt => "prompt",
Self::Resume => "resume",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchForm {
pub field: LaunchField,
pub cwd: String,
pub prompt: String,
pub resume: String,
}
impl Default for LaunchForm {
fn default() -> Self {
Self {
field: LaunchField::Cwd,
cwd: ".".into(),
prompt: String::new(),
resume: String::new(),
}
}
}
impl LaunchForm {
pub fn active_buffer(&self) -> &str {
match self.field {
LaunchField::Cwd => &self.cwd,
LaunchField::Prompt => &self.prompt,
LaunchField::Resume => &self.resume,
}
}
fn active_buffer_mut(&mut self) -> &mut String {
match self.field {
LaunchField::Cwd => &mut self.cwd,
LaunchField::Prompt => &mut self.prompt,
LaunchField::Resume => &mut self.resume,
}
}
fn advance(&mut self) {
self.field = self.field.next();
}
fn retreat(&mut self) {
self.field = self.field.prev();
}
fn is_last_field(&self) -> bool {
self.field == LaunchField::Resume
}
pub fn status_hint(&self) -> String {
format!(
"New session [{}] Enter next, Tab move, Ctrl+Enter launch, Esc cancel",
self.field.label()
)
}
fn request(&self) -> Result<LaunchRequest, String> {
launch::prepare(
&self.cwd,
Some(self.prompt.as_str()),
Some(self.resume.as_str()),
)
}
pub fn summary(&self) -> String {
let cwd = compact_value(&self.cwd, ".");
let prompt = if self.prompt.trim().is_empty() {
"skip".to_string()
} else {
"set".to_string()
};
let resume = compact_value(&self.resume, "skip");
format!("cwd={cwd} | prompt={prompt} | resume={resume}")
}
}
fn compact_value(value: &str, empty_label: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return empty_label.to_string();
}
const MAX_LEN: usize = 24;
if trimmed.chars().count() <= MAX_LEN {
trimmed.to_string()
} else {
let prefix: String = trimmed.chars().take(MAX_LEN - 1).collect();
format!("{prefix}…")
}
}
pub struct App {
pub sessions: Vec<ClaudeSession>,
pub table_state: TableState,
pub should_quit: bool,
pub status_msg: String,
pub pending_kill: Option<u32>,
pub input_mode: bool,
pub input_buffer: String,
pub input_target_pid: Option<u32>,
pub notify: bool,
pub prev_statuses: HashMap<u32, SessionStatus>,
pub show_help: bool,
pub sort_column: usize,
pub auto_approve: HashSet<u32>,
pub pending_auto_approve: Option<u32>,
pub pending_override_reason: Option<u32>,
pub finished_at: HashMap<u32, std::time::Instant>, pub debug: bool,
pub debug_timings: DebugTimings,
pub grouped_view: bool,
pub detail_panel: bool, pub webhook_url: Option<String>,
pub webhook_filter: Option<Vec<String>>, pub launch_mode: bool, pub launch_form: LaunchForm,
pub search_mode: bool,
pub search_buffer: String,
pub search_query: String,
pub status_filter: StatusFilter,
pub focus_filter: FocusFilter,
pub budget_usd: Option<f64>, pub kill_on_budget: bool, pub budget_warned: HashSet<u32>, pub budget_killed: HashSet<u32>, pub theme: Theme,
pub weekly_summary: crate::history::WeeklySummary,
pub weekly_summary_tick: u32, pub hooks: HookRegistry,
pub daily_limit: Option<f64>,
pub weekly_limit: Option<f64>,
pub daily_alert_fired: bool, pub weekly_alert_fired: bool,
pub context_warn_threshold: u8, pub context_warned: HashSet<u32>, pub needs_input_since: HashMap<u32, std::time::Instant>, pub conflict_pids: HashSet<u32>, pub conflict_alerted: HashSet<String>, pub file_conflict_pids: HashSet<u32>, pub file_conflicts: HashMap<String, Vec<u32>>, pub file_conflict_alerted: HashSet<String>, pub file_conflicts_enabled: bool, pub auto_deny_file_conflicts: bool, pub demo_mode: bool,
pub demo_tick: u32,
pub demo_highlight: Option<crate::demo::DemoHighlightState>,
pub session_recordings: HashMap<u32, String>, pub rules: Vec<crate::rules::AutoRule>,
pub auto_actions_fired: HashMap<u32, std::time::Instant>, pub last_rule_action: Option<String>, pub health_thresholds: crate::config::HealthThresholds,
pub brain_config: Option<crate::config::BrainConfig>,
pub brain_engine: Option<crate::brain::engine::BrainEngine>,
pub idle_config: crate::config::IdleConfig,
pub last_user_interaction: std::time::Instant,
pub idle_mode_active: bool,
pub idle_tasks_launched: Vec<String>,
pub idle_report: Vec<String>,
#[cfg(feature = "coord")]
pub coord_leases: Vec<crate::coord::types::Lease>,
#[cfg(feature = "coord")]
pub coord_handoffs: Vec<crate::coord::types::Handoff>,
#[cfg(feature = "coord")]
pub coord_lease_sessions: HashSet<String>,
#[cfg(feature = "coord")]
pub coord_handoff_sessions: HashSet<String>,
#[cfg(feature = "coord")]
pub coord_interrupt_targets: HashSet<String>,
#[cfg(feature = "coord")]
pub coord_pending_interrupts: Vec<crate::coord::types::Interrupt>,
#[cfg(feature = "coord")]
pub coord_tick: u32,
#[cfg(feature = "relay")]
pub show_peers_panel: bool,
#[cfg(feature = "relay")]
#[allow(dead_code)]
pub relay_peers: Vec<crate::ui::peers::PeerDisplayInfo>,
#[cfg(feature = "relay")]
pub remote_sessions: Vec<crate::session::ClaudeSession>,
pub show_skills: bool,
pub skills_tab: SkillsTab,
pub skills_selected: usize,
pub skills: Vec<crate::skills::DiscoveredSkill>,
pub shared_skill_keys: std::collections::HashSet<String>,
pub skills_status_msg: Option<String>,
pub hive_listener_running: bool,
pub hive_identity: Option<String>,
pub hive_known_peers: Vec<(String, Option<String>)>,
pub hive_last_invite: Option<HiveInvite>,
pub hive_join_input_mode: bool,
pub hive_join_buffer: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillsTab {
Skills,
Hive,
}
impl SkillsTab {
pub fn toggle(self) -> Self {
match self {
Self::Skills => Self::Hive,
Self::Hive => Self::Skills,
}
}
}
#[derive(Debug, Clone)]
pub struct HiveInvite {
pub relay_code: String,
pub invite_link: String,
pub word_phrase: String,
}
#[derive(Default, Clone)]
pub struct DebugTimings {
pub scan_ms: f64,
pub ps_ms: f64,
pub jsonl_ms: f64,
pub total_ms: f64,
history: Vec<(f64, f64, f64, f64)>,
}
impl DebugTimings {
pub fn record(&mut self, scan: f64, ps: f64, jsonl: f64, total: f64) {
self.scan_ms = scan;
self.ps_ms = ps;
self.jsonl_ms = jsonl;
self.total_ms = total;
self.history.push((scan, ps, jsonl, total));
if self.history.len() > 10 {
self.history.remove(0);
}
}
pub fn avg_total_ms(&self) -> f64 {
if self.history.is_empty() {
return 0.0;
}
self.history.iter().map(|h| h.3).sum::<f64>() / self.history.len() as f64
}
pub fn format(&self) -> String {
format!(
"tick: {:.1}ms (avg {:.1}ms) | scan: {:.1}ms | ps: {:.1}ms | jsonl: {:.1}ms",
self.total_ms,
self.avg_total_ms(),
self.scan_ms,
self.ps_ms,
self.jsonl_ms,
)
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
pub fn new() -> Self {
let mut app = Self {
sessions: Vec::new(),
table_state: TableState::default(),
should_quit: false,
status_msg: String::new(),
pending_kill: None,
input_mode: false,
input_buffer: String::new(),
input_target_pid: None,
notify: false,
prev_statuses: HashMap::new(),
show_help: false,
sort_column: 0,
auto_approve: HashSet::new(),
pending_auto_approve: None,
pending_override_reason: None,
finished_at: HashMap::new(),
debug: false,
debug_timings: DebugTimings::default(),
grouped_view: false,
detail_panel: false,
webhook_url: None,
webhook_filter: None,
launch_mode: false,
launch_form: LaunchForm::default(),
search_mode: false,
search_buffer: String::new(),
search_query: String::new(),
status_filter: StatusFilter::All,
focus_filter: FocusFilter::All,
budget_usd: None,
kill_on_budget: false,
budget_warned: HashSet::new(),
budget_killed: HashSet::new(),
theme: Theme::from_mode(crate::theme::ThemeMode::Dark),
weekly_summary: crate::history::weekly_summary(),
weekly_summary_tick: 0,
hooks: HookRegistry::new(),
daily_limit: None,
weekly_limit: None,
daily_alert_fired: false,
weekly_alert_fired: false,
context_warn_threshold: 75,
context_warned: HashSet::new(),
needs_input_since: HashMap::new(),
conflict_pids: HashSet::new(),
conflict_alerted: HashSet::new(),
file_conflict_pids: HashSet::new(),
file_conflicts: HashMap::new(),
file_conflict_alerted: HashSet::new(),
file_conflicts_enabled: true,
auto_deny_file_conflicts: false,
demo_mode: false,
demo_tick: 0,
demo_highlight: None,
session_recordings: HashMap::new(),
rules: Vec::new(),
auto_actions_fired: HashMap::new(),
last_rule_action: None,
health_thresholds: crate::config::HealthThresholds::default(),
brain_config: None,
brain_engine: None,
idle_config: crate::config::IdleConfig::default(),
last_user_interaction: std::time::Instant::now(),
idle_mode_active: false,
idle_tasks_launched: Vec::new(),
idle_report: Vec::new(),
#[cfg(feature = "coord")]
coord_leases: Vec::new(),
#[cfg(feature = "coord")]
coord_handoffs: Vec::new(),
#[cfg(feature = "coord")]
coord_lease_sessions: HashSet::new(),
#[cfg(feature = "coord")]
coord_handoff_sessions: HashSet::new(),
#[cfg(feature = "coord")]
coord_interrupt_targets: HashSet::new(),
#[cfg(feature = "coord")]
coord_pending_interrupts: Vec::new(),
#[cfg(feature = "coord")]
coord_tick: 0,
#[cfg(feature = "relay")]
show_peers_panel: false,
#[cfg(feature = "relay")]
relay_peers: Vec::new(),
#[cfg(feature = "relay")]
remote_sessions: Vec::new(),
show_skills: false,
skills_tab: SkillsTab::Skills,
skills_selected: 0,
skills: Vec::new(),
shared_skill_keys: std::collections::HashSet::new(),
skills_status_msg: None,
hive_listener_running: false,
hive_identity: None,
hive_known_peers: Vec::new(),
hive_last_invite: None,
hive_join_input_mode: false,
hive_join_buffer: String::new(),
};
#[cfg(feature = "coord")]
app.coord_refresh();
app.refresh();
if app.visible_session_count() > 0 {
app.table_state.select(Some(0));
}
app
}
pub fn refresh(&mut self) {
let tick_start = std::time::Instant::now();
if self.demo_mode {
self.refresh_demo();
if self.debug {
let total_elapsed = tick_start.elapsed();
self.debug_timings
.record(0.0, 0.0, 0.0, total_elapsed.as_secs_f64() * 1000.0);
}
return;
}
let scan_start = std::time::Instant::now();
let discovered = discovery::scan_sessions();
let scan_elapsed = scan_start.elapsed();
let mut existing: HashMap<u32, ClaudeSession> =
self.sessions.drain(..).map(|s| (s.pid, s)).collect();
let mut new_pids: Vec<u32> = Vec::new();
let mut sessions: Vec<ClaudeSession> = discovered
.into_iter()
.map(|new| {
if let Some(mut prev) = existing.remove(&new.pid) {
prev.elapsed = new.elapsed;
prev.started_at = new.started_at;
prev
} else {
new_pids.push(new.pid);
new
}
})
.collect();
let ps_start = std::time::Instant::now();
process::fetch_and_enrich(&mut sessions);
let ps_elapsed = ps_start.elapsed();
for session in &mut sessions {
if session.jsonl_path.is_none() {
discovery::resolve_jsonl_paths(std::slice::from_mut(session));
}
}
discovery::scan_subagents(&mut sessions);
discovery::resolve_worktree_ids(&mut sessions);
for session in &mut sessions {
session.prev_cost_usd = session.cost_usd;
}
let jsonl_start = std::time::Instant::now();
for session in &mut sessions {
monitor::update_tokens(session);
}
let jsonl_elapsed = jsonl_start.elapsed();
for session in &mut sessions {
if session.prev_cost_usd > 0.001 {
let delta = session.cost_usd - session.prev_cost_usd;
if delta > 0.001 {
session.burn_rate_per_hr = delta * 1800.0;
} else {
session.burn_rate_per_hr *= 0.5;
if session.burn_rate_per_hr < 0.01 {
session.burn_rate_per_hr = 0.0;
}
}
}
}
if let Some(budget) = self.budget_usd {
for session in &sessions {
let pct = session.cost_usd / budget * 100.0;
if (80.0..100.0).contains(&pct) && !self.budget_warned.contains(&session.pid) {
self.budget_warned.insert(session.pid);
self.status_msg = format!(
"BUDGET WARNING: {} at {:.0}% (${:.2}/${:.2})",
session.display_name(),
pct,
session.cost_usd,
budget
);
fire_notification(&format!("{} budget {:.0}%", session.display_name(), pct));
self.hooks.fire(HookEvent::BudgetWarning, session);
}
if pct >= 100.0 && !self.budget_killed.contains(&session.pid) {
self.budget_killed.insert(session.pid);
if self.kill_on_budget {
let _ = kill_process(session.pid);
self.status_msg = format!(
"BUDGET EXCEEDED: Killed {} (${:.2}/${:.2})",
session.display_name(),
session.cost_usd,
budget
);
} else {
self.status_msg = format!(
"BUDGET EXCEEDED: {} at ${:.2}/{:.2} — use --kill-on-budget to auto-kill",
session.display_name(),
session.cost_usd,
budget
);
}
fire_notification(&format!("{} exceeded budget!", session.display_name()));
self.hooks.fire(HookEvent::BudgetExceeded, session);
}
}
}
if self.context_warn_threshold > 0 {
let threshold = self.context_warn_threshold as f64;
for session in &sessions {
let pct = session.context_percent();
if pct >= threshold && !self.context_warned.contains(&session.pid) {
self.context_warned.insert(session.pid);
self.status_msg = format!(
"CONTEXT HIGH: {} at {:.0}% of context window",
session.display_name(),
pct
);
fire_notification(&format!(
"{} context at {:.0}%",
session.display_name(),
pct
));
self.hooks.fire(HookEvent::ContextHigh, session);
} else if pct < threshold && self.context_warned.contains(&session.pid) {
self.context_warned.remove(&session.pid);
}
}
}
for session in &mut sessions {
session.record_activity();
session.decay_score =
crate::health::compute_decay_score(session, &self.health_thresholds);
}
let now = std::time::Instant::now();
for session in &sessions {
if session.status == SessionStatus::Finished
&& !self.finished_at.contains_key(&session.pid)
{
self.finished_at.insert(session.pid, now);
crate::history::record_session(session);
}
}
sessions.retain(|s| {
if s.status == SessionStatus::Finished {
if let Some(&t) = self.finished_at.get(&s.pid) {
return now.duration_since(t).as_secs() < 30;
}
}
true
});
let expired: Vec<u32> = self
.finished_at
.iter()
.filter(|(_, t)| now.duration_since(**t).as_secs() >= 60)
.map(|(pid, _)| *pid)
.collect();
for pid in &expired {
let session_file = dirs_home()
.join(".claude/sessions")
.join(format!("{pid}.json"));
let _ = std::fs::remove_file(session_file);
}
self.finished_at
.retain(|_, t| now.duration_since(*t).as_secs() < 60);
self.apply_sort(&mut sessions);
for session in &sessions {
let prev = self.prev_statuses.get(&session.pid).copied();
let changed = prev.is_some() && prev != Some(session.status);
if !changed {
continue;
}
crate::logger::log(
"DEBUG",
&format!(
"session {}: status {} -> {}",
session.display_name(),
prev.unwrap(),
session.status
),
);
if self.notify && session.status == SessionStatus::NeedsInput {
fire_notification(&session.project_name);
}
if let Some(ref url) = self.webhook_url {
let new_status = session.status.to_string();
let should_fire = match &self.webhook_filter {
Some(filter) => filter.iter().any(|f| f.eq_ignore_ascii_case(&new_status)),
None => true,
};
if should_fire {
crate::logger::log(
"DEBUG",
&format!(
"webhook fired for {} -> {}",
session.display_name(),
new_status
),
);
fire_webhook(
url,
session,
prev.map(|p| p.to_string()).unwrap_or_default(),
);
}
}
self.hooks.fire_with_status(
HookEvent::StatusChange,
session,
&prev.unwrap().to_string(),
&session.status.to_string(),
);
match session.status {
SessionStatus::NeedsInput => {
self.hooks.fire(HookEvent::NeedsInput, session);
}
SessionStatus::Finished => {
self.hooks.fire(HookEvent::Finished, session);
}
SessionStatus::Idle => {
self.hooks.fire(HookEvent::Idle, session);
}
_ => {}
}
}
for session in sessions.iter().filter(|s| new_pids.contains(&s.pid)) {
self.hooks.fire(HookEvent::SessionStart, session);
}
let now_instant = std::time::Instant::now();
for session in &sessions {
if session.status == SessionStatus::NeedsInput {
self.needs_input_since
.entry(session.pid)
.or_insert(now_instant);
} else {
self.needs_input_since.remove(&session.pid);
}
}
let active_pids: HashSet<u32> = sessions.iter().map(|s| s.pid).collect();
self.needs_input_since
.retain(|pid, _| active_pids.contains(pid));
self.conflict_pids.clear();
let mut wt_sessions: HashMap<&str, Vec<u32>> = HashMap::new();
for session in &sessions {
if session.status != SessionStatus::Finished {
let key = session.worktree_id.as_deref().unwrap_or(&session.cwd);
wt_sessions.entry(key).or_default().push(session.pid);
}
}
for (wt, pids) in &wt_sessions {
if pids.len() >= 2 {
for &pid in pids {
self.conflict_pids.insert(pid);
}
if !self.conflict_alerted.contains(*wt) {
self.conflict_alerted.insert(wt.to_string());
let project = sessions
.iter()
.find(|s| s.pid == pids[0])
.map(|s| s.display_name())
.unwrap_or("unknown");
self.status_msg =
format!("CONFLICT: {} sessions sharing {}", pids.len(), project);
fire_notification(&format!("{} sessions in {}", pids.len(), project));
if let Some(session) = sessions.iter().find(|s| s.pid == pids[0]) {
self.hooks.fire(HookEvent::ConflictDetected, session);
}
}
}
}
self.conflict_alerted.retain(|wt| {
wt_sessions
.get(wt.as_str())
.map(|pids| pids.len() >= 2)
.unwrap_or(false)
});
self.file_conflict_pids.clear();
self.file_conflicts.clear();
for session in &mut sessions {
session.has_file_conflict = false;
}
if self.file_conflicts_enabled {
let mut file_pids: HashMap<String, Vec<u32>> = HashMap::new();
for session in &sessions {
if session.status == SessionStatus::Finished {
continue;
}
for file in session.files_modified.keys() {
file_pids.entry(file.clone()).or_default().push(session.pid);
}
if let Some(ref pending) = session.pending_file_path {
file_pids
.entry(pending.clone())
.or_default()
.push(session.pid);
}
}
for pids in file_pids.values_mut() {
pids.sort_unstable();
pids.dedup();
}
for (file, pids) in &file_pids {
if pids.len() >= 2 {
for &pid in pids {
self.file_conflict_pids.insert(pid);
}
self.file_conflicts.insert(file.clone(), pids.clone());
for session in &mut sessions {
if let Some(ref pending) = session.pending_file_path {
if pending == file && pids.contains(&session.pid) {
session.has_file_conflict = true;
}
}
}
if !self.file_conflict_alerted.contains(file) {
self.file_conflict_alerted.insert(file.clone());
let names: Vec<&str> = pids
.iter()
.filter_map(|pid| {
sessions
.iter()
.find(|s| s.pid == *pid)
.map(|s| s.display_name())
})
.collect();
let short = file.rsplit('/').next().unwrap_or(file);
self.status_msg =
format!("FILE CONFLICT: {} edited by {}", short, names.join(", "));
fire_notification(&format!("File conflict: {short}"));
if let Some(session) = sessions.iter().find(|s| s.pid == pids[0]) {
self.hooks.fire(HookEvent::ConflictDetected, session);
}
}
}
}
self.file_conflict_alerted
.retain(|f| self.file_conflicts.contains_key(f));
}
self.prev_statuses = sessions.iter().map(|s| (s.pid, s.status)).collect();
self.sessions = sessions;
#[cfg(feature = "relay")]
{
for remote in &self.remote_sessions {
self.sessions.push(remote.clone());
}
}
self.normalize_selection();
if self.debug {
let total_elapsed = tick_start.elapsed();
self.debug_timings.record(
scan_elapsed.as_secs_f64() * 1000.0,
ps_elapsed.as_secs_f64() * 1000.0,
jsonl_elapsed.as_secs_f64() * 1000.0,
total_elapsed.as_secs_f64() * 1000.0,
);
}
}
fn apply_sort(&self, sessions: &mut [ClaudeSession]) {
match self.sort_column {
0 => sessions.sort_by(|a, b| {
a.status.sort_key().cmp(&b.status.sort_key()).then_with(|| {
if a.status == SessionStatus::NeedsInput {
let a_wait = self.wait_duration(a.pid).unwrap_or_default();
let b_wait = self.wait_duration(b.pid).unwrap_or_default();
b_wait.cmp(&a_wait)
} else {
b.elapsed.cmp(&a.elapsed)
}
})
}),
1 => sessions.sort_by(|a, b| {
b.context_percent()
.partial_cmp(&a.context_percent())
.unwrap_or(std::cmp::Ordering::Equal)
}),
2 => sessions.sort_by(|a, b| {
b.cost_usd
.partial_cmp(&a.cost_usd)
.unwrap_or(std::cmp::Ordering::Equal)
}),
3 => sessions.sort_by(|a, b| {
b.burn_rate_per_hr
.partial_cmp(&a.burn_rate_per_hr)
.unwrap_or(std::cmp::Ordering::Equal)
}),
4 => sessions.sort_by_key(|s| std::cmp::Reverse(s.elapsed)),
_ => {}
}
}
pub fn cycle_sort(&mut self) {
self.sort_column = (self.sort_column + 1) % SORT_COLUMNS.len();
self.status_msg = format!("Sort: {}", SORT_COLUMNS[self.sort_column]);
let mut sessions = std::mem::take(&mut self.sessions);
self.apply_sort(&mut sessions);
self.sessions = sessions;
}
fn refresh_demo(&mut self) {
self.demo_tick += 1;
let mut sessions = crate::demo::generate_sessions(self.demo_tick);
if self.show_skills {
let phase = self.demo_tick % 14;
match phase {
1..=5 => {
self.skills_tab = SkillsTab::Skills;
if !self.skills.is_empty() {
self.skills_selected =
((phase as usize - 1) % self.skills.len()).min(self.skills.len() - 1);
}
}
6 => {
self.skills_tab = SkillsTab::Hive;
self.skills_status_msg = Some("Hive: 2 peers connected".into());
}
7..=11 => {
self.skills_tab = SkillsTab::Hive;
}
_ => {
self.skills_tab = SkillsTab::Skills;
self.skills_status_msg = None;
}
}
}
let now_instant = std::time::Instant::now();
for session in &sessions {
if session.status == SessionStatus::NeedsInput {
self.needs_input_since
.entry(session.pid)
.or_insert(now_instant);
} else {
self.needs_input_since.remove(&session.pid);
}
}
self.conflict_pids.clear();
let mut wt_sessions: HashMap<&str, Vec<u32>> = HashMap::new();
for session in &sessions {
if session.status != SessionStatus::Finished {
let key = session.worktree_id.as_deref().unwrap_or(&session.cwd);
wt_sessions.entry(key).or_default().push(session.pid);
}
}
for pids in wt_sessions.values() {
if pids.len() >= 2 {
for &pid in pids {
self.conflict_pids.insert(pid);
}
}
}
if let Some(event) = crate::demo::demo_event(self.demo_tick) {
self.status_msg = event.message.clone();
match event.kind {
crate::demo::EventKind::RuleAction => {
self.last_rule_action = Some(event.message);
}
crate::demo::EventKind::BrainSuggestion | crate::demo::EventKind::BrainOverride => {
}
crate::demo::EventKind::Route | crate::demo::EventKind::HealthAlert => {}
crate::demo::EventKind::HiveSync | crate::demo::EventKind::HiveInfluence => {}
}
}
#[cfg(feature = "relay")]
{
self.relay_peers = crate::demo::demo_peers(self.demo_tick);
if self.demo_tick % 32 == 14 && !self.show_peers_panel {
self.show_peers_panel = true;
}
self.remote_sessions.clear();
if self.demo_tick % 32 >= 14 {
let remote_json = serde_json::json!({
"pid": 99001, "project": "backend",
"status": "Processing", "cost_usd": 1.4,
"elapsed_secs": 320, "context_pct": 42.0,
});
if let Some(s) = ClaudeSession::from_remote_json("ci-runner-9d1e", &remote_json) {
self.remote_sessions.push(s);
}
}
if self.demo_tick % 32 >= 28 {
let remote_json = serde_json::json!({
"pid": 99002, "project": "frontend",
"status": "Needs Input", "cost_usd": 0.32,
"elapsed_secs": 150,
});
if let Some(s) = ClaudeSession::from_remote_json("alice-mbp-f3a1", &remote_json) {
self.remote_sessions.push(s);
}
}
}
if let Some(ref mut engine) = self.brain_engine {
engine.pending.clear();
let phase = self.demo_tick % 32;
if (9..=12).contains(&phase) {
if let Some(s) = sessions
.iter()
.find(|s| s.status == SessionStatus::NeedsInput)
{
engine.pending.insert(
s.pid,
crate::brain::client::BrainSuggestion {
action: crate::rules::RuleAction::Approve,
message: s.pending_tool_input.clone(),
reasoning: "Safe build command, no side effects".into(),
confidence: 0.92,
suggested_at: 0,
},
);
}
}
if (14..=16).contains(&phase) {
if let Some(s) = sessions
.iter()
.find(|s| s.status == SessionStatus::NeedsInput)
{
engine.pending.insert(
s.pid,
crate::brain::client::BrainSuggestion {
action: crate::rules::RuleAction::Deny,
message: s.pending_tool_input.clone(),
reasoning: "Destructive operation, needs manual review".into(),
confidence: 0.87,
suggested_at: 0,
},
);
}
}
}
let highlight = self
.demo_highlight
.get_or_insert_with(crate::demo::DemoHighlightState::new);
for session in &mut sessions {
let path = highlight.ensure_jsonl(session.pid).clone();
session.jsonl_path = Some(path);
}
let recording_pids: Vec<u32> = self.session_recordings.keys().copied().collect();
let mut finished_pids: Vec<u32> = Vec::new();
for pid in recording_pids {
if !highlight.drip_feed(pid) {
finished_pids.push(pid);
}
}
for pid in finished_pids {
if let Some(path) = self.session_recordings.remove(&pid) {
self.status_msg = format!("Recording complete → {path}");
}
}
for session in &mut sessions {
session.decay_score =
crate::health::compute_decay_score(session, &self.health_thresholds);
}
self.sessions = sessions;
self.normalize_selection();
}
pub fn tick(&mut self) {
self.status_msg.clear();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
for session in &mut self.sessions {
let elapsed_ms = now_ms.saturating_sub(session.started_at);
session.elapsed = std::time::Duration::from_millis(elapsed_ms);
}
self.refresh();
self.run_auto_actions();
self.check_idle_mode();
self.weekly_summary_tick += 1;
if self.weekly_summary_tick >= 15 {
self.weekly_summary_tick = 0;
self.weekly_summary = crate::history::weekly_summary();
self.check_aggregate_budgets();
}
#[cfg(feature = "coord")]
{
self.coord_tick += 1;
if self.coord_tick >= 3 {
self.coord_tick = 0;
self.coord_refresh();
}
}
}
pub fn wait_duration(&self, pid: u32) -> Option<std::time::Duration> {
self.needs_input_since
.get(&pid)
.map(|since| since.elapsed())
}
pub fn format_wait_time(&self, pid: u32) -> Option<String> {
let dur = self.wait_duration(pid)?;
let secs = dur.as_secs();
if secs < 60 {
Some(format!("{secs}s"))
} else {
Some(format!("{}m {}s", secs / 60, secs % 60))
}
}
#[cfg(feature = "coord")]
pub fn coord_refresh(&mut self) {
let conn = match crate::coord::store::open() {
Ok(c) => c,
Err(_) => return,
};
let _ = crate::coord::store::expire_stale_leases(&conn);
self.coord_leases =
crate::coord::store::list_leases(&conn, Some(crate::coord::types::LeaseStatus::Active))
.unwrap_or_default();
self.coord_handoffs = crate::coord::store::list_pending_handoffs(&conn).unwrap_or_default();
self.coord_lease_sessions = self
.coord_leases
.iter()
.map(|l| l.owner_session_id.clone())
.collect();
self.coord_handoff_sessions = self
.coord_handoffs
.iter()
.flat_map(|h| {
let mut ids = vec![h.from_session_id.clone()];
if let Some(ref to) = h.to_session_id {
ids.push(to.clone());
}
ids
})
.collect();
let _ = crate::coord::store::expire_stale_interrupts(&conn);
self.coord_pending_interrupts = crate::coord::store::list_interrupts(
&conn,
Some(crate::coord::types::InterruptState::Pending),
)
.unwrap_or_default();
self.coord_interrupt_targets = self
.coord_pending_interrupts
.iter()
.map(|i| i.target_session_id.clone())
.collect();
}
#[cfg(feature = "coord")]
pub fn session_has_lease(&self, session_id: &str) -> bool {
self.coord_lease_sessions.contains(session_id)
}
#[cfg(feature = "coord")]
pub fn session_has_handoff(&self, session_id: &str) -> bool {
self.coord_handoff_sessions.contains(session_id)
}
#[cfg(feature = "coord")]
pub fn session_has_interrupt(&self, session_id: &str) -> bool {
self.coord_interrupt_targets.contains(session_id)
}
pub fn budget_eta(&self) -> Option<(f64, f64, String, u8)> {
let live_cost: f64 = self.sessions.iter().map(|s| s.cost_usd).sum();
let total_burn: f64 = self.sessions.iter().map(|s| s.burn_rate_per_hr).sum();
let (spent, limit) = if let Some(daily) = self.daily_limit {
(self.weekly_summary.today_cost_usd + live_cost, daily)
} else if let Some(budget) = self.budget_usd {
if let Some(session) = self.sessions.iter().max_by(|a, b| {
(a.cost_usd / budget)
.partial_cmp(&(b.cost_usd / budget))
.unwrap_or(std::cmp::Ordering::Equal)
}) {
(session.cost_usd, budget)
} else {
return None;
}
} else {
return None;
};
let remaining = limit - spent;
if remaining <= 0.0 {
return Some((spent, limit, "exceeded".into(), 2));
}
if total_burn < 0.01 {
return Some((spent, limit, "safe".into(), 0));
}
let hours_left = remaining / total_burn;
let mins_left = (hours_left * 60.0) as u64;
let eta_str = if mins_left >= 120 {
format!("{}h {}m", mins_left / 60, mins_left % 60)
} else {
format!("{}m", mins_left)
};
let urgency = if mins_left <= 30 {
2
} else if mins_left <= 120 {
1
} else {
0
};
Some((spent, limit, eta_str, urgency))
}
fn check_aggregate_budgets(&mut self) {
let ws = &self.weekly_summary;
let live_cost: f64 = self.sessions.iter().map(|s| s.cost_usd).sum();
if let Some(daily_limit) = self.daily_limit {
let today_total = ws.today_cost_usd + live_cost;
let pct = today_total / daily_limit * 100.0;
if pct >= 80.0 && !self.daily_alert_fired {
self.daily_alert_fired = true;
self.status_msg = format!(
"DAILY BUDGET: ${:.2}/${:.2} ({:.0}%)",
today_total, daily_limit, pct
);
fire_notification(&format!("Daily budget at {:.0}%", pct));
let mut dummy = create_aggregate_session(today_total, daily_limit, "daily");
self.hooks.fire(HookEvent::BudgetWarning, &dummy);
if pct >= 100.0 {
dummy.cost_usd = today_total;
self.hooks.fire(HookEvent::BudgetExceeded, &dummy);
}
}
}
if let Some(weekly_limit) = self.weekly_limit {
let week_total = ws.cost_usd + live_cost;
let pct = week_total / weekly_limit * 100.0;
if pct >= 80.0 && !self.weekly_alert_fired {
self.weekly_alert_fired = true;
self.status_msg = format!(
"WEEKLY BUDGET: ${:.2}/${:.2} ({:.0}%)",
week_total, weekly_limit, pct
);
fire_notification(&format!("Weekly budget at {:.0}%", pct));
let mut dummy = create_aggregate_session(week_total, weekly_limit, "weekly");
self.hooks.fire(HookEvent::BudgetWarning, &dummy);
if pct >= 100.0 {
dummy.cost_usd = week_total;
self.hooks.fire(HookEvent::BudgetExceeded, &dummy);
}
}
}
}
fn check_idle_mode(&mut self) {
if !self.idle_config.enabled {
return;
}
let idle_threshold = std::time::Duration::from_secs(self.idle_config.after_idle_mins * 60);
let was_idle = self.idle_mode_active;
self.idle_mode_active = self.last_user_interaction.elapsed() > idle_threshold;
if self.idle_mode_active && !was_idle {
crate::logger::log("IDLE", "Entering idle mode");
}
}
#[allow(dead_code)]
pub fn is_idle(&self) -> bool {
self.idle_mode_active
}
fn run_auto_actions(&mut self) {
if self.demo_mode {
return;
}
let legacy_pids: Vec<u32> = self
.sessions
.iter()
.filter(|s| s.status == SessionStatus::NeedsInput && self.auto_approve.contains(&s.pid))
.map(|s| s.pid)
.collect();
for pid in legacy_pids {
if let Some(session) = self.sessions.iter().find(|s| s.pid == pid) {
crate::brain::decisions::log_observation(
session.pid,
session.display_name(),
session.pending_tool_name.as_deref(),
session.pending_tool_input.as_deref(),
"user_approve",
Some(session),
);
match terminals::approve_session(session) {
Ok(()) => self.status_msg = format!("Auto-approved {}", session.display_name()),
Err(e) => self.status_msg = format!("Auto-approve error: {e}"),
}
}
}
if self.auto_deny_file_conflicts {
let conflict_candidates: Vec<(u32, String, String)> = self
.sessions
.iter()
.filter(|s| {
s.status == SessionStatus::NeedsInput
&& s.has_file_conflict
&& s.pending_file_path.is_some()
})
.filter_map(|s| {
let file = s.pending_file_path.as_ref()?;
let other_pids = self.file_conflicts.get(file)?;
let other_name = other_pids
.iter()
.filter(|&&p| p != s.pid)
.find_map(|pid| {
self.sessions
.iter()
.find(|o| o.pid == *pid)
.map(|o| format!("{} (PID {})", o.display_name(), o.pid))
})
.unwrap_or_else(|| "another session".into());
Some((s.pid, file.clone(), other_name))
})
.collect();
for (pid, file, other) in conflict_candidates {
if let Some(last) = self.auto_actions_fired.get(&pid) {
if last.elapsed().as_secs() < 5 {
continue;
}
}
if let Some(session) = self.sessions.iter().find(|s| s.pid == pid) {
crate::brain::decisions::log_observation(
session.pid,
session.display_name(),
session.pending_tool_name.as_deref(),
session.pending_tool_input.as_deref(),
"conflict_deny",
Some(session),
);
let short = file.rsplit('/').next().unwrap_or(&file);
let msg = format!("File {short} is being edited by {other}");
match terminals::send_input(session, &msg) {
Ok(()) => {
let status = format!(
"File conflict: denied {} edit to {short}",
session.display_name()
);
crate::logger::log("CONFLICT", &status);
self.status_msg = status;
}
Err(e) => {
self.status_msg = format!("File conflict deny error: {e}");
}
}
self.auto_actions_fired
.insert(pid, std::time::Instant::now());
}
}
}
if !self.rules.is_empty() {
let candidates: Vec<u32> = self
.sessions
.iter()
.filter(|s| {
matches!(
s.status,
SessionStatus::NeedsInput | SessionStatus::WaitingInput
)
})
.filter(|s| !self.auto_approve.contains(&s.pid)) .map(|s| s.pid)
.collect();
for pid in candidates {
if let Some(last) = self.auto_actions_fired.get(&pid) {
if last.elapsed().as_secs() < 3 {
continue;
}
}
let session = match self.sessions.iter().find(|s| s.pid == pid) {
Some(s) => s,
None => continue,
};
let result = crate::rules::evaluate(&self.rules, session);
let Some(rule_match) = result else {
continue;
};
let obs_action = format!("rule_{}", rule_match.action.label());
crate::brain::decisions::log_observation(
session.pid,
session.display_name(),
session.pending_tool_name.as_deref(),
session.pending_tool_input.as_deref(),
&obs_action,
Some(session),
);
let msg = crate::rules::execute(&rule_match, session);
match msg {
Ok(status) => {
crate::logger::log("AUTO", &status);
self.last_rule_action = Some(status.clone());
self.status_msg = status;
}
Err(e) => {
self.status_msg = format!("Rule error: {e}");
}
}
self.auto_actions_fired
.insert(pid, std::time::Instant::now());
}
}
if let Some(ref mut engine) = self.brain_engine {
let deny_rules: Vec<_> = self
.rules
.iter()
.filter(|r| r.action == crate::rules::RuleAction::Deny)
.cloned()
.collect();
let actions = engine.tick(&self.sessions, &deny_rules);
for (_pid, msg) in actions {
crate::logger::log("BRAIN", &msg);
self.status_msg = msg;
}
engine.cleanup(&self.sessions);
let deliveries = crate::brain::mailbox::deliver_pending(&self.sessions);
for (_pid, msg) in deliveries {
crate::logger::log("MAILBOX", &msg);
self.status_msg = msg;
}
}
#[cfg(feature = "coord")]
{
if let Ok(conn) = crate::coord::store::open() {
let deliveries =
crate::coord::interrupt_bus::deliver_pending(&conn, &self.sessions);
for (_intr_id, msg) in deliveries {
crate::logger::log("INTERRUPT", &msg);
self.status_msg = msg;
}
}
}
}
pub fn handle_auto_approve(&mut self) {
let Some(session) = self.selected_session() else {
return;
};
if session.is_remote() {
self.status_msg = "Remote session \u{2014} action not available".into();
return;
}
let pid = session.pid;
let name = session.display_name().to_string();
if self.pending_auto_approve == Some(pid) {
if self.auto_approve.contains(&pid) {
self.auto_approve.remove(&pid);
self.status_msg = format!("Auto-approve OFF for {name}");
} else {
self.auto_approve.insert(pid);
self.status_msg = format!("Auto-approve ON for {name}");
}
self.pending_auto_approve = None;
} else {
self.pending_auto_approve = Some(pid);
let action = if self.auto_approve.contains(&pid) {
"disable"
} else {
"enable"
};
self.status_msg = format!("Press a again to {action} auto-approve for {name}");
}
}
pub fn cancel_pending_auto_approve(&mut self) {
self.pending_auto_approve = None;
}
pub fn next(&mut self) {
let len = self.visible_session_count();
if len == 0 {
return;
}
let i = match self.table_state.selected() {
Some(i) if i >= len - 1 => 0,
Some(i) => i + 1,
None => 0,
};
self.table_state.select(Some(i));
}
pub fn previous(&mut self) {
let len = self.visible_session_count();
if len == 0 {
return;
}
let i = match self.table_state.selected() {
Some(0) => len - 1,
Some(i) => i - 1,
None => 0,
};
self.table_state.select(Some(i));
}
pub fn selected_session(&self) -> Option<&ClaudeSession> {
let visible = self.visible_session_indices();
let selected = self.table_state.selected()?;
let session_idx = *visible.get(selected)?;
self.sessions.get(session_idx)
}
pub fn handle_kill(&mut self) {
let Some(session) = self.selected_session() else {
return;
};
if session.is_remote() {
self.status_msg = "Remote session \u{2014} action not available".into();
return;
}
let pid = session.pid;
let name = session.display_name().to_string();
if self.pending_kill == Some(pid) {
match kill_process(pid) {
Ok(()) => {
self.status_msg = format!("Killed {name} (PID {pid})");
self.auto_approve.remove(&pid);
self.refresh();
}
Err(e) => self.status_msg = format!("Kill failed: {e}"),
}
self.pending_kill = None;
} else {
self.pending_kill = Some(pid);
self.status_msg = format!("Kill {name} (PID {pid})? Press d again to confirm");
}
}
pub fn cancel_pending_kill(&mut self) {
if self.pending_kill.is_some() {
self.pending_kill = None;
self.status_msg = "Kill cancelled".into();
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
self.last_user_interaction = std::time::Instant::now();
if self.idle_mode_active {
self.idle_mode_active = false;
if !self.idle_report.is_empty() {
let report = self.idle_report.join("; ");
self.status_msg = format!("Idle report: {report}");
self.idle_report.clear();
}
self.idle_tasks_launched.clear();
}
if self.show_help {
self.show_help = false;
return true;
}
if self.launch_mode {
self.handle_launch_key(key);
return true;
}
if self.search_mode {
self.handle_search_key(key);
return true;
}
if self.input_mode {
self.handle_input_key(key);
return true;
}
if self.show_skills {
self.handle_skills_key(key);
return true;
}
if self.pending_override_reason.is_some() {
match key.code {
KeyCode::Char('1') => {
self.handle_brain_accept_with_reason(Some("always_safe"));
}
KeyCode::Char('2') => {
self.handle_brain_accept_with_reason(Some("one_time_exception"));
}
KeyCode::Char('3') => {
self.handle_brain_accept_with_reason(Some("brain_is_wrong"));
}
KeyCode::Esc => {
self.pending_override_reason = None;
self.status_msg = "Override cancelled".into();
}
_ => {}
}
return true;
}
self.handle_normal_key(key);
!self.should_quit
}
fn handle_input_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
if let Some(pid) = self.input_target_pid {
if let Some(session) = self.sessions.iter().find(|s| s.pid == pid) {
crate::brain::decisions::log_observation(
session.pid,
session.display_name(),
session.pending_tool_name.as_deref(),
session.pending_tool_input.as_deref(),
"user_input",
Some(session),
);
let text = format!("{}\n", self.input_buffer);
match terminals::send_input(session, &text) {
Ok(()) => {
self.status_msg = format!("Sent to {}", session.display_name())
}
Err(e) => self.status_msg = format!("Error: {e}"),
}
}
}
self.input_mode = false;
self.input_buffer.clear();
self.input_target_pid = None;
}
KeyCode::Esc => {
self.input_mode = false;
self.input_buffer.clear();
self.input_target_pid = None;
self.status_msg = "Input cancelled".into();
}
KeyCode::Backspace => {
self.input_buffer.pop();
}
KeyCode::Char(c) => {
self.input_buffer.push(c);
}
_ => {}
}
}
fn handle_search_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
self.search_query = self.search_buffer.trim().to_string();
self.search_mode = false;
self.normalize_selection();
if self.search_query.is_empty() {
self.status_msg = "Search cleared".into();
} else {
self.status_msg = format!("Search: {}", self.search_query);
}
}
KeyCode::Esc => {
self.search_mode = false;
self.search_buffer.clear();
self.status_msg = "Search cancelled".into();
}
KeyCode::Backspace => {
self.search_buffer.pop();
}
KeyCode::Char(c) => {
self.search_buffer.push(c);
}
_ => {}
}
}
pub fn open_skills_overlay(&mut self) {
self.refresh_skills();
self.refresh_hive_view();
self.skills_selected = 0;
self.skills_status_msg = None;
self.hive_join_input_mode = false;
self.hive_join_buffer.clear();
self.show_skills = true;
}
pub fn refresh_skills(&mut self) {
let cwd = std::env::current_dir().ok();
self.skills = crate::skills::discover(cwd.as_deref());
self.shared_skill_keys = load_shared_skill_keys();
if self.skills_selected >= self.skills.len() {
self.skills_selected = self.skills.len().saturating_sub(1);
}
}
pub fn refresh_hive_view(&mut self) {
let snapshot = load_hive_view_snapshot();
self.hive_identity = snapshot.identity;
self.hive_known_peers = snapshot.peers;
}
fn handle_skills_key(&mut self, key: KeyEvent) {
if self.hive_join_input_mode {
self.handle_hive_join_input(key);
return;
}
match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('K'), _) | (KeyCode::Char('q'), _) => {
self.show_skills = false;
self.skills_status_msg = None;
return;
}
(KeyCode::Tab, _) | (KeyCode::BackTab, _) => {
self.skills_tab = self.skills_tab.toggle();
self.skills_status_msg = None;
return;
}
_ => {}
}
match self.skills_tab {
SkillsTab::Skills => self.handle_skills_tab_key(key),
SkillsTab::Hive => self.handle_hive_tab_key(key),
}
}
fn handle_skills_tab_key(&mut self, key: KeyEvent) {
match (key.code, key.modifiers) {
(KeyCode::Char('j'), _) | (KeyCode::Down, _)
if !self.skills.is_empty() && self.skills_selected + 1 < self.skills.len() =>
{
self.skills_selected += 1;
}
(KeyCode::Char('k'), _) | (KeyCode::Up, _) if self.skills_selected > 0 => {
self.skills_selected -= 1;
}
(KeyCode::Char('r'), _) => {
self.refresh_skills();
self.skills_status_msg = Some(format!("Rescanned: {} skills", self.skills.len()));
}
(KeyCode::Char('s'), _) => {
self.share_selected_skill();
}
_ => {}
}
}
fn handle_hive_tab_key(&mut self, key: KeyEvent) {
match (key.code, key.modifiers) {
(KeyCode::Char('h'), _) => {
self.start_hive_listener();
self.refresh_hive_view();
}
(KeyCode::Char('i'), _) => {
self.generate_hive_invite();
}
(KeyCode::Char('J'), _) => {
self.hive_join_input_mode = true;
self.hive_join_buffer.clear();
self.skills_status_msg =
Some("Paste invite (relay code, link, or word phrase); Enter to join".into());
}
(KeyCode::Char('r'), _) => {
self.refresh_hive_view();
self.skills_status_msg =
Some(format!("Known peers: {}", self.hive_known_peers.len()));
}
_ => {}
}
}
fn handle_hive_join_input(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
let code = self.hive_join_buffer.trim().to_string();
self.hive_join_input_mode = false;
if code.is_empty() {
self.skills_status_msg = Some("Join cancelled (empty)".into());
return;
}
match spawn_relay_join(&code) {
Ok(()) => {
self.skills_status_msg = Some(format!(
"Join started (claudectl relay join {} detached)",
short_id(&code)
));
}
Err(e) => {
self.skills_status_msg = Some(format!("Join failed: {e}"));
}
}
self.hive_join_buffer.clear();
}
KeyCode::Esc => {
self.hive_join_input_mode = false;
self.hive_join_buffer.clear();
self.skills_status_msg = Some("Join cancelled".into());
}
KeyCode::Backspace => {
self.hive_join_buffer.pop();
}
KeyCode::Char(c) if self.hive_join_buffer.len() < 256 => {
self.hive_join_buffer.push(c);
}
_ => {}
}
}
fn generate_hive_invite(&mut self) {
match generate_invite_via_cli() {
Ok(invite) => {
self.skills_status_msg = Some(format!("Invite: {}", invite.relay_code));
self.hive_last_invite = Some(invite);
}
Err(e) => {
self.skills_status_msg = Some(format!("Invite failed: {e}"));
}
}
}
fn share_selected_skill(&mut self) {
let Some(skill) = self.skills.get(self.skills_selected).cloned() else {
self.skills_status_msg = Some("No skill selected".into());
return;
};
if !cfg!(feature = "hive") {
self.skills_status_msg = Some("hive feature disabled in this build".into());
return;
}
if !skill.within_share_limit() {
self.skills_status_msg = Some("Skill exceeds 32kb share limit".into());
return;
}
if self.shared_skill_keys.contains(&skill.semantic_key()) {
self.skills_status_msg = Some("Already shared".into());
return;
}
match share_skill_to_hive(&skill) {
Ok(unit_id) => {
self.shared_skill_keys.insert(skill.semantic_key());
self.skills_status_msg = Some(format!(
"Shared '{}' → unit {}",
skill.name,
short_id(&unit_id)
));
}
Err(e) => {
self.skills_status_msg = Some(format!("Share failed: {e}"));
}
}
}
fn start_hive_listener(&mut self) {
if !cfg!(feature = "relay") {
self.skills_status_msg =
Some("relay feature not built — rebuild with --features relay,hive".into());
return;
}
if self.hive_listener_running {
self.skills_status_msg = Some("Hive listener already running".into());
return;
}
match spawn_relay_serve() {
Ok(()) => {
self.hive_listener_running = true;
self.skills_status_msg =
Some("Hive listener started (claudectl relay serve detached)".into());
}
Err(e) => {
self.skills_status_msg = Some(format!("Start failed: {e}"));
}
}
}
fn handle_normal_key(&mut self, key: KeyEvent) {
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Esc, _) => {
self.should_quit = true;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.should_quit = true;
}
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.next();
}
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.previous();
}
(KeyCode::Char('r'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.refresh();
}
(KeyCode::Char('R'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.toggle_session_recording();
}
(KeyCode::Char('d'), _) | (KeyCode::Char('x'), _) => {
self.cancel_pending_auto_approve();
self.handle_kill();
}
(KeyCode::Char('y'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.handle_approve();
}
(KeyCode::Char('b'), KeyModifiers::CONTROL) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.toggle_brain_gate();
}
(KeyCode::Char('b'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.handle_brain_accept();
}
(KeyCode::Char('B'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.handle_brain_reject();
}
(KeyCode::Char('i'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.enter_input_mode();
}
(KeyCode::Char('c'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.handle_compact();
}
(KeyCode::Char('?'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.show_help = !self.show_help;
}
(KeyCode::Char('K'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.open_skills_overlay();
}
(KeyCode::Char('s'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.cycle_sort();
}
(KeyCode::Char('f'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.cycle_status_filter();
}
(KeyCode::Char('v'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.cycle_focus_filter();
}
(KeyCode::Char('z'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.clear_filters();
}
(KeyCode::Char('/'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.enter_search_mode();
}
(KeyCode::Char('a'), _) => {
self.cancel_pending_kill();
self.handle_auto_approve();
}
(KeyCode::Char('n'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.enter_launch_mode();
}
(KeyCode::Char('g'), _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.grouped_view = !self.grouped_view;
self.status_msg = if self.grouped_view {
"Grouped by project".into()
} else {
"Flat view".into()
};
}
(KeyCode::Enter, _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.detail_panel = !self.detail_panel;
}
(KeyCode::Tab, _) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.handle_switch_terminal();
}
#[cfg(feature = "relay")]
(KeyCode::Char('p'), KeyModifiers::NONE) => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
self.show_peers_panel = !self.show_peers_panel;
self.status_msg = if self.show_peers_panel {
"Peers panel enabled".into()
} else {
"Peers panel disabled".into()
};
}
_ => {
self.cancel_pending_kill();
self.cancel_pending_auto_approve();
}
}
}
fn handle_launch_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.submit_launch_form();
}
KeyCode::Enter => {
if self.launch_form.is_last_field() {
self.submit_launch_form();
} else {
self.launch_form.advance();
self.status_msg = self.launch_form.status_hint();
}
}
KeyCode::Tab | KeyCode::Down => {
self.launch_form.advance();
self.status_msg = self.launch_form.status_hint();
}
KeyCode::BackTab | KeyCode::Up => {
self.launch_form.retreat();
self.status_msg = self.launch_form.status_hint();
}
KeyCode::Esc => {
self.launch_mode = false;
self.launch_form = LaunchForm::default();
self.status_msg = "Launch cancelled".into();
}
KeyCode::Backspace => {
self.launch_form.active_buffer_mut().pop();
}
KeyCode::Char(c) => {
self.launch_form.active_buffer_mut().push(c);
}
_ => {}
}
}
fn enter_launch_mode(&mut self) {
self.launch_mode = true;
self.launch_form = LaunchForm::default();
self.status_msg = self.launch_form.status_hint();
}
fn submit_launch_form(&mut self) {
let request = match self.launch_form.request() {
Ok(request) => request,
Err(err) => {
self.launch_form.field = LaunchField::Cwd;
self.status_msg = format!("Launch failed: {err}");
return;
}
};
match launch::launch(&request) {
Ok(target) => {
self.launch_mode = false;
self.launch_form = LaunchForm::default();
self.status_msg = format!(
"Launched session in {target} at {}{}",
request.cwd_path.display(),
request.option_summary()
);
}
Err(err) => {
self.status_msg = format!("Launch failed: {err}");
}
}
}
fn enter_search_mode(&mut self) {
self.search_mode = true;
self.search_buffer = self.search_query.clone();
}
pub fn clear_filters(&mut self) {
self.status_filter = StatusFilter::All;
self.focus_filter = FocusFilter::All;
self.search_query.clear();
self.search_buffer.clear();
self.search_mode = false;
self.normalize_selection();
self.status_msg = "Filters cleared".into();
}
pub fn cycle_status_filter(&mut self) {
self.status_filter = self.status_filter.next();
self.normalize_selection();
self.status_msg = format!("Status filter: {}", self.status_filter.label());
}
pub fn cycle_focus_filter(&mut self) {
self.focus_filter = self.focus_filter.next();
self.normalize_selection();
self.status_msg = format!("Focus filter: {}", self.focus_filter.label());
}
pub fn has_active_filters(&self) -> bool {
self.status_filter != StatusFilter::All
|| self.focus_filter != FocusFilter::All
|| !self.search_query.trim().is_empty()
}
pub fn filter_summary(&self) -> String {
let mut parts = Vec::new();
if self.status_filter != StatusFilter::All {
parts.push(format!("status={}", self.status_filter.label()));
}
if self.focus_filter != FocusFilter::All {
parts.push(format!("focus={}", self.focus_filter.label()));
}
if !self.search_query.trim().is_empty() {
parts.push(format!("search=\"{}\"", self.search_query));
}
if parts.is_empty() {
"filters: none".to_string()
} else {
format!("filters: {}", parts.join(" | "))
}
}
pub fn visible_session_indices(&self) -> Vec<usize> {
self.sessions
.iter()
.enumerate()
.filter_map(|(idx, session)| self.matches_filters(session).then_some(idx))
.collect()
}
pub fn visible_sessions(&self) -> Vec<&ClaudeSession> {
self.visible_session_indices()
.into_iter()
.filter_map(|idx| self.sessions.get(idx))
.collect()
}
pub fn visible_session_count(&self) -> usize {
self.visible_session_indices().len()
}
fn normalize_selection(&mut self) {
let len = self.visible_session_count();
if len == 0 {
self.table_state.select(None);
} else if self.table_state.selected().is_none() {
self.table_state.select(Some(0));
} else if let Some(sel) = self.table_state.selected() {
if sel >= len {
self.table_state.select(Some(len - 1));
}
}
}
fn matches_filters(&self, session: &ClaudeSession) -> bool {
self.status_filter.matches(session.status)
&& self.matches_focus_filter(session)
&& self.matches_search_query(session)
}
fn matches_focus_filter(&self, session: &ClaudeSession) -> bool {
let over_budget = self
.budget_usd
.map(|budget| session.has_usage_metrics() && session.cost_usd >= budget)
.unwrap_or(false);
let high_context = session.has_usage_metrics()
&& session.context_percent() >= self.context_warn_threshold as f64;
let unknown_telemetry = !session.has_usage_metrics();
let conflict = self.conflict_pids.contains(&session.pid);
match self.focus_filter {
FocusFilter::All => true,
FocusFilter::Attention => {
session.status == SessionStatus::NeedsInput
|| over_budget
|| high_context
|| unknown_telemetry
|| conflict
}
FocusFilter::OverBudget => over_budget,
FocusFilter::HighContext => high_context,
FocusFilter::UnknownTelemetry => unknown_telemetry,
FocusFilter::Conflict => conflict,
}
}
fn matches_search_query(&self, session: &ClaudeSession) -> bool {
let query = self.search_query.trim();
if query.is_empty() {
return true;
}
let query = query.to_ascii_lowercase();
let fields = [
session.display_name().to_string(),
session.project_name.clone(),
session.model.clone(),
session.cwd.clone(),
session.session_id.clone(),
];
fields
.iter()
.any(|field| field.to_ascii_lowercase().contains(&query))
}
fn handle_approve(&mut self) {
if let Some(session) = self.selected_session() {
if session.is_remote() {
self.status_msg = "Remote session \u{2014} action not available".into();
return;
}
if session.status == SessionStatus::NeedsInput {
crate::brain::decisions::log_observation(
session.pid,
session.display_name(),
session.pending_tool_name.as_deref(),
session.pending_tool_input.as_deref(),
"user_approve",
Some(session),
);
match terminals::approve_session(session) {
Ok(()) => self.status_msg = format!("Approved {}", session.display_name()),
Err(e) => self.status_msg = format!("Error: {e}"),
}
} else {
self.status_msg = "Session is not waiting for input".into();
}
}
}
fn handle_brain_accept(&mut self) {
self.handle_brain_accept_with_reason(None);
}
fn handle_brain_accept_with_reason(&mut self, override_reason: Option<&str>) {
let Some(session) = self.selected_session().cloned() else {
return;
};
if session.is_remote() {
self.status_msg = "Remote session \u{2014} action not available".into();
return;
}
let pid = session.pid;
let Some(ref mut engine) = self.brain_engine else {
self.status_msg = "Brain is not enabled".into();
return;
};
let suggestion = engine.pending.get(&pid).cloned();
if suggestion.is_none() {
self.status_msg = "No brain suggestion pending for this session".into();
return;
}
if let Some(ref sg) = suggestion {
if sg.action.label() == "deny" && override_reason.is_none() {
self.pending_override_reason = Some(pid);
self.status_msg =
"Override reason: [1] Always safe [2] One-time exception [3] Brain is wrong [Esc] Cancel"
.into();
return;
}
}
if let Some(msg) = engine.accept(pid, &session) {
if let Some(ref sg) = suggestion {
crate::brain::decisions::log_decision(
pid,
session.display_name(),
session.pending_tool_name.as_deref(),
session.pending_tool_input.as_deref(),
sg,
"accept",
Some(&session),
crate::brain::decisions::DecisionType::Session,
override_reason,
);
}
crate::logger::log("BRAIN", &format!("Accepted: {msg}"));
self.status_msg = msg;
}
self.pending_override_reason = None;
}
fn handle_brain_reject(&mut self) {
let Some(session) = self.selected_session().cloned() else {
return;
};
if session.is_remote() {
self.status_msg = "Remote session \u{2014} action not available".into();
return;
}
let pid = session.pid;
let Some(ref mut engine) = self.brain_engine else {
self.status_msg = "Brain is not enabled".into();
return;
};
if let Some(suggestion) = engine.reject(pid) {
crate::brain::decisions::log_decision(
pid,
session.display_name(),
session.pending_tool_name.as_deref(),
session.pending_tool_input.as_deref(),
&suggestion,
"reject",
Some(&session),
crate::brain::decisions::DecisionType::Session,
None,
);
let msg = format!(
"Rejected brain suggestion: {} ({})",
suggestion.action.label(),
suggestion.reasoning,
);
crate::logger::log("BRAIN", &msg);
self.status_msg = msg;
} else {
self.status_msg = "No brain suggestion pending for this session".into();
}
}
fn toggle_brain_gate(&mut self) {
let current = crate::brain::read_gate_mode();
let next = match current.as_str() {
"on" => "off",
"off" => "on",
"auto" => "off",
_ => "on",
};
let path = crate::brain::gate_mode_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if next == "on" {
let _ = std::fs::remove_file(&path);
} else {
let _ = std::fs::write(&path, next);
}
let description = match next {
"on" => "active — evaluating tool calls",
"off" => "disabled — normal permission flow",
_ => unreachable!(),
};
self.status_msg = format!("Brain: {description}");
crate::logger::log("BRAIN", &format!("Gate mode toggled: {current} → {next}"));
}
fn toggle_session_recording(&mut self) {
let info = self
.selected_session()
.map(|s| (s.pid, s.display_name().to_string(), s.jsonl_path.is_some()));
let Some((pid, name, has_jsonl)) = info else {
return;
};
if self.session_recordings.contains_key(&pid) {
let path = self.session_recordings.remove(&pid).unwrap_or_default();
self.status_msg = format!("Recording stopped → {path}");
return;
}
if !has_jsonl {
self.status_msg = "Cannot record — no JSONL file for this session".into();
return;
}
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let path = format!("{}-{}-{}.gif", name, pid, epoch);
self.session_recordings.insert(pid, path.clone());
self.status_msg = format!("Recording {name} → {path} (R to stop)");
}
fn handle_compact(&mut self) {
if let Some(session) = self.selected_session() {
if session.is_remote() {
self.status_msg = "Remote session \u{2014} action not available".into();
return;
}
match session.status {
SessionStatus::WaitingInput | SessionStatus::Idle => {
match terminals::send_input(session, "/compact\n") {
Ok(()) => {
self.status_msg = format!("Sent /compact to {}", session.display_name())
}
Err(e) => self.status_msg = format!("Compact error: {e}"),
}
}
SessionStatus::NeedsInput => {
self.status_msg =
"Cannot compact — session is waiting for permission approval".into();
}
SessionStatus::Processing => {
self.status_msg =
"Cannot compact — session is processing (wait until idle)".into();
}
SessionStatus::Unknown => {
self.status_msg =
"Cannot compact — transcript telemetry is unavailable for this session"
.into();
}
SessionStatus::Finished => {
self.status_msg = "Cannot compact — session has finished".into();
}
}
}
}
fn enter_input_mode(&mut self) {
if let Some(session) = self.selected_session() {
if session.is_remote() {
self.status_msg = "Remote session \u{2014} action not available".into();
return;
}
}
let info = self
.selected_session()
.map(|s| (s.pid, s.display_name().to_string()));
if let Some((pid, name)) = info {
self.input_mode = true;
self.input_buffer.clear();
self.input_target_pid = Some(pid);
self.status_msg = format!("Input to {name} (Enter to send, Esc to cancel): ");
}
}
fn handle_switch_terminal(&mut self) {
if let Some(session) = self.selected_session() {
if session.is_remote() {
self.status_msg = "Remote session \u{2014} action not available".into();
return;
}
match terminals::switch_to_terminal(session) {
Ok(()) => {
self.status_msg = format!("Switched to {}", session.display_name());
}
Err(e) => {
self.status_msg = format!("Error: {e}");
}
}
} else {
self.status_msg = "No session selected".into();
}
}
}
#[derive(Debug, Clone)]
pub struct ProjectGroup {
pub name: String,
pub session_count: usize,
pub active_count: usize,
pub total_cost: f64,
pub avg_context_pct: f64,
}
impl App {
pub fn project_groups(&self) -> Vec<ProjectGroup> {
let mut groups: HashMap<String, Vec<&ClaudeSession>> = HashMap::new();
for s in self.visible_sessions() {
groups.entry(s.project_name.clone()).or_default().push(s);
}
let mut result: Vec<ProjectGroup> = groups
.into_iter()
.map(|(name, sessions)| {
let active_count = sessions
.iter()
.filter(|s| {
matches!(
s.status,
SessionStatus::Processing | SessionStatus::NeedsInput
)
})
.count();
let total_cost: f64 = sessions.iter().map(|s| s.cost_usd).sum();
let avg_context_pct = if sessions.is_empty() {
0.0
} else {
sessions.iter().map(|s| s.context_percent()).sum::<f64>()
/ sessions.len() as f64
};
ProjectGroup {
name,
session_count: sessions.len(),
active_count,
total_cost,
avg_context_pct,
}
})
.collect();
result.sort_by(|a, b| {
b.total_cost
.partial_cmp(&a.total_cost)
.unwrap_or(std::cmp::Ordering::Equal)
});
result
}
}
fn load_shared_skill_keys() -> std::collections::HashSet<String> {
#[cfg(feature = "hive")]
{
let store = crate::hive::store::HiveStore::load();
let mut out = std::collections::HashSet::new();
for unit in store.all_units() {
if let crate::hive::KnowledgeContent::Skill { name, .. } = &unit.content {
out.insert(format!("skill:{}", name.to_lowercase().replace(' ', "-")));
}
}
out
}
#[cfg(not(feature = "hive"))]
{
std::collections::HashSet::new()
}
}
fn share_skill_to_hive(skill: &crate::skills::DiscoveredSkill) -> Result<String, String> {
#[cfg(feature = "hive")]
{
let path_str = skill.path.to_string_lossy().to_string();
crate::hive::cli::share_artifact_from_path("skill", &path_str, "universal")
.map(|(unit_id, _summary)| unit_id)
.map_err(|e| e.to_string())
}
#[cfg(not(feature = "hive"))]
{
let _ = skill;
Err("hive feature disabled".into())
}
}
#[cfg(feature = "relay")]
fn spawn_relay_serve() -> Result<(), String> {
use std::process::{Command, Stdio};
Command::new(std::env::current_exe().unwrap_or_else(|_| "claudectl".into()))
.args(["relay", "serve"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(feature = "relay"))]
fn spawn_relay_serve() -> Result<(), String> {
Err("relay feature not built".into())
}
#[cfg(feature = "relay")]
fn spawn_relay_join(code: &str) -> Result<(), String> {
use std::process::{Command, Stdio};
Command::new(std::env::current_exe().unwrap_or_else(|_| "claudectl".into()))
.args(["relay", "join", code])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(feature = "relay"))]
fn spawn_relay_join(_code: &str) -> Result<(), String> {
Err("relay feature not built".into())
}
pub struct HiveViewSnapshot {
pub identity: Option<String>,
pub peers: Vec<(String, Option<String>)>,
}
#[cfg(feature = "relay")]
fn load_hive_view_snapshot() -> HiveViewSnapshot {
let identity = Some(crate::relay::load_or_create_identity().as_str().to_string());
let peers = crate::relay::list_known_peers()
.into_iter()
.map(|id| {
let addr = crate::relay::load_peer_meta(&id).and_then(|v| {
v.get("addr")
.and_then(|a| a.as_str())
.map(|s| s.to_string())
});
(id, addr)
})
.collect();
HiveViewSnapshot { identity, peers }
}
#[cfg(not(feature = "relay"))]
fn load_hive_view_snapshot() -> HiveViewSnapshot {
HiveViewSnapshot {
identity: None,
peers: Vec::new(),
}
}
#[cfg(feature = "relay")]
fn generate_invite_via_cli() -> Result<HiveInvite, String> {
use std::process::Command;
let bin = std::env::current_exe().map_err(|e| e.to_string())?;
let output = Command::new(bin)
.args(["--json", "relay", "invite", "--words"])
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|e| e.to_string())?;
let relay_code = parsed
.get("relay_code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let invite_link = parsed
.get("invite_link")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let word_phrase = parsed
.get("word_phrase")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if relay_code.is_empty() {
return Err("invite payload missing relay_code".into());
}
Ok(HiveInvite {
relay_code,
invite_link,
word_phrase,
})
}
#[cfg(not(feature = "relay"))]
fn generate_invite_via_cli() -> Result<HiveInvite, String> {
Err("relay feature not built".into())
}
fn short_id(id: &str) -> String {
if id.len() <= 12 {
id.to_string()
} else {
format!("{}…", &id[..11])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{RawSession, TelemetryStatus};
fn make_session(
pid: u32,
project: &str,
model: &str,
status: SessionStatus,
cost_usd: f64,
context_pct: f64,
telemetry_available: bool,
) -> ClaudeSession {
let raw = RawSession {
pid,
session_id: format!("session-{pid}"),
cwd: format!("/tmp/{project}"),
started_at: 0,
};
let mut session = ClaudeSession::from_raw(raw);
session.project_name = project.to_string();
session.model = model.to_string();
session.status = status;
session.cost_usd = cost_usd;
session.context_max = 100;
session.context_tokens = context_pct as u64;
session.telemetry_status = if telemetry_available {
TelemetryStatus::Available
} else {
TelemetryStatus::MissingTranscript
};
session.usage_metrics_available = telemetry_available;
session
}
fn make_test_app() -> App {
let mut app = App::new();
app.sessions = vec![
make_session(
11,
"blocked-api",
"sonnet-4.6",
SessionStatus::NeedsInput,
2.0,
40.0,
true,
),
make_session(
12,
"hot-cost",
"opus-4.6",
SessionStatus::Processing,
7.5,
30.0,
true,
),
make_session(
13,
"high-context",
"haiku",
SessionStatus::WaitingInput,
1.0,
90.0,
true,
),
make_session(
14,
"unknown-metrics",
"",
SessionStatus::Unknown,
0.0,
0.0,
false,
),
];
app.budget_usd = Some(5.0);
app.context_warn_threshold = 75;
app.conflict_pids.insert(13);
app.normalize_selection();
app
}
#[test]
fn status_filter_returns_only_matching_sessions() {
let mut app = make_test_app();
app.status_filter = StatusFilter::NeedsInput;
let visible: Vec<u32> = app.visible_sessions().iter().map(|s| s.pid).collect();
assert_eq!(visible, vec![11]);
}
#[test]
fn focus_filter_attention_matches_high_signal_sessions() {
let mut app = make_test_app();
app.focus_filter = FocusFilter::Attention;
let visible: Vec<u32> = app.visible_sessions().iter().map(|s| s.pid).collect();
assert_eq!(visible, vec![11, 12, 13, 14]);
}
#[test]
fn search_query_matches_project_and_model() {
let mut app = make_test_app();
app.search_query = "sonnet".into();
let visible: Vec<u32> = app.visible_sessions().iter().map(|s| s.pid).collect();
assert_eq!(visible, vec![11]);
app.search_query = "unknown-metrics".into();
let visible: Vec<u32> = app.visible_sessions().iter().map(|s| s.pid).collect();
assert_eq!(visible, vec![14]);
}
#[test]
fn normalize_selection_clamps_to_filtered_session_count() {
let mut app = make_test_app();
app.table_state.select(Some(3));
app.status_filter = StatusFilter::NeedsInput;
app.normalize_selection();
assert_eq!(app.table_state.selected(), Some(0));
assert_eq!(app.selected_session().map(|s| s.pid), Some(11));
}
#[test]
fn launch_wizard_starts_with_cli_defaults() {
let mut app = App::new();
app.enter_launch_mode();
assert!(app.launch_mode);
assert_eq!(app.launch_form.field, LaunchField::Cwd);
assert_eq!(app.launch_form.cwd, ".");
assert!(app.launch_form.prompt.is_empty());
assert!(app.launch_form.resume.is_empty());
}
#[test]
fn launch_wizard_moves_between_fields() {
let mut app = App::new();
app.enter_launch_mode();
app.handle_launch_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.launch_form.field, LaunchField::Prompt);
app.handle_launch_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.launch_form.field, LaunchField::Resume);
app.handle_launch_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.launch_form.field, LaunchField::Prompt);
}
#[test]
fn invalid_launch_keeps_wizard_open_and_reports_error() {
let mut app = App::new();
app.enter_launch_mode();
app.launch_form.cwd = "/tmp/claudectl-this-path-should-not-exist".into();
app.launch_form.field = LaunchField::Resume;
app.submit_launch_form();
assert!(app.launch_mode);
assert_eq!(app.launch_form.field, LaunchField::Cwd);
assert!(
app.status_msg
.starts_with("Launch failed: Directory not found:")
);
}
}