pub(crate) mod draw;
pub(crate) mod input;
pub(crate) mod past;
pub(crate) mod switch;
pub(crate) use draw::render;
pub(crate) use input::{handle_event, InputOutcome};
use agent_engine::daemon::pins::PinEntry;
use agent_engine::session::{SessionId, SessionLifecycle, SessionMeta};
use chrono::{DateTime, Utc};
use std::path::PathBuf;
use std::time::Duration;
pub(crate) const POLL_EVERY: Duration = Duration::from_secs(1);
const POLL_TIMEOUT: Duration = Duration::from_secs(3);
pub(crate) const CLOSE_ATTACHED: bool = true;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Tab {
Live,
Past,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum LiveSource {
Loading,
Daemon,
InProcess,
DaemonDown(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum RowState {
Streaming,
Waiting,
Idle,
Transient,
Parked,
}
impl RowState {
pub(crate) fn of(m: &SessionMeta) -> Self {
match m.lifecycle {
SessionLifecycle::Parked => Self::Parked,
SessionLifecycle::Parking | SessionLifecycle::Ending => Self::Transient,
SessionLifecycle::Live if m.awaiting_input > 0 => Self::Waiting,
SessionLifecycle::Live if m.busy => Self::Streaming,
SessionLifecycle::Live => Self::Idle,
}
}
pub(crate) fn is_parked(self) -> bool {
self == Self::Parked
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ConfirmClose {
pub(crate) id: String,
pub(crate) label: String,
pub(crate) clients: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Notice {
pub(crate) text: String,
pub(crate) error: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SessionsOp {
Pin { id: String, on: bool },
ClearPin { id: String },
PinPast { journal_id: String, cwd: Option<std::path::PathBuf> },
Close { id: String, label: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SwitchTarget {
Attach { id: String },
Continue { journal_id: String, cwd: Option<std::path::PathBuf> },
}
#[derive(Debug)]
pub(crate) enum SessionsMsg {
Live { gen: u64, result: Result<Vec<SessionMeta>, String> },
Pins { gen: u64, pins: Vec<PinEntry> },
Past { gen: u64, msg: past::PastMsg },
OpDone { gen: u64, op: SessionsOp, result: Result<String, String> },
}
#[derive(Clone, Debug)]
pub(crate) struct SessionsState {
pub(crate) tab: Tab,
pub(crate) gen: u64,
pub(crate) own_id: String,
pub(crate) source: LiveSource,
pub(crate) live: Vec<SessionMeta>,
pub(crate) pins: Vec<PinEntry>,
pub(crate) offline: Vec<PinEntry>,
pub(crate) selected: Option<String>,
pub(crate) confirm: Option<ConfirmClose>,
pub(crate) notice: Option<Notice>,
pub(crate) pending: Option<SessionsOp>,
pub(crate) pending_switch: Option<SwitchTarget>,
pub(crate) past: past::PastState,
pub(crate) now: DateTime<Utc>,
pub(crate) home: Option<PathBuf>,
}
impl SessionsState {
pub(crate) fn new(gen: u64, own: SessionMeta, daemon: bool) -> Self {
let own_id = own.id.0.clone();
let mut st = Self {
tab: Tab::Live,
gen,
own_id: own_id.clone(),
source: if daemon { LiveSource::Loading } else { LiveSource::InProcess },
live: Vec::new(),
pins: Vec::new(),
offline: Vec::new(),
selected: Some(own_id),
confirm: None,
notice: None,
pending: None,
pending_switch: None,
past: past::PastState::default(),
now: Utc::now(),
home: std::env::var_os("HOME").map(PathBuf::from),
};
st.set_live(vec![own]);
st
}
pub(crate) fn set_live(&mut self, mut rows: Vec<SessionMeta>) {
rows.sort_by(|a, b| {
RowState::of(a)
.cmp(&RowState::of(b))
.then_with(|| b.created_at.cmp(&a.created_at))
.then_with(|| a.id.0.cmp(&b.id.0))
});
self.live = rows;
let live_ids: std::collections::HashSet<String> = self
.live
.iter()
.flat_map(|m| [m.id.0.clone(), m.journal_id.clone()])
.filter(|id| !id.is_empty())
.collect();
self.past.set_exclude(live_ids.clone());
self.offline = self.pins.iter().filter(|e| !live_ids.contains(&e.journal_id)).cloned().collect();
let still = self.selected.as_ref().is_some_and(|id| self.row_ids().any(|r| r == id));
if !still {
self.selected = self
.live
.iter()
.find(|m| m.id.0 == self.own_id)
.or_else(|| self.live.first())
.map(|m| m.id.0.clone());
}
if let Some(c) = &self.confirm {
if !self.live.iter().any(|m| m.id.0 == c.id) {
self.confirm = None; }
}
}
pub(crate) fn set_pins(&mut self, pins: Vec<PinEntry>) {
self.past.set_pinned(pins.iter().map(|e| e.journal_id.clone()).collect());
self.pins = pins;
let rows = std::mem::take(&mut self.live);
self.set_live(rows);
}
fn row_ids(&self) -> impl Iterator<Item = &String> {
self.live.iter().map(|m| &m.id.0).chain(self.offline.iter().map(|e| &e.journal_id))
}
pub(crate) fn selected_index(&self) -> Option<usize> {
let id = self.selected.as_ref()?;
self.live.iter().position(|m| &m.id.0 == id)
}
pub(crate) fn selected_meta(&self) -> Option<&SessionMeta> {
self.selected_index().map(|i| &self.live[i])
}
pub(crate) fn selected_pin(&self) -> Option<&PinEntry> {
let id = self.selected.as_ref()?;
self.offline.iter().find(|e| &e.journal_id == id)
}
pub(crate) fn is_own(&self, m: &SessionMeta) -> bool {
m.id.0 == self.own_id
}
pub(crate) fn move_selection(&mut self, delta: isize) {
let ids: Vec<String> = self.row_ids().cloned().collect();
if ids.is_empty() {
return;
}
let cur = self.selected.as_ref().and_then(|s| ids.iter().position(|r| r == s)).unwrap_or(0) as isize;
let next = cur.saturating_add(delta).clamp(0, ids.len() as isize - 1) as usize;
self.selected = Some(ids[next].clone());
}
pub(crate) fn counts(&self) -> (usize, usize, usize, usize) {
let mut c = (0, 0, 0, 0);
for m in &self.live {
match RowState::of(m) {
RowState::Streaming => c.0 += 1,
RowState::Waiting => c.1 += 1,
RowState::Idle | RowState::Transient => c.2 += 1,
RowState::Parked => c.3 += 1,
}
}
c
}
pub(crate) fn apply(&mut self, msg: SessionsMsg, own_fallback: impl FnOnce() -> SessionMeta) -> bool {
match msg {
SessionsMsg::Live { gen, result } => {
if gen != self.gen {
return false;
}
self.now = Utc::now();
let own = own_fallback();
match result {
Ok(mut rows) => {
if let Some(m) = rows.iter_mut().find(|m| m.id.0 == self.own_id) {
m.busy |= own.busy;
}
self.source = LiveSource::Daemon;
self.set_live(rows);
}
Err(e) => {
self.source = LiveSource::DaemonDown(e);
self.set_live(vec![own]);
}
}
}
SessionsMsg::Pins { gen, pins } => {
if gen != self.gen {
return false;
}
if pins == self.pins {
return false;
}
self.set_pins(pins);
}
SessionsMsg::Past { gen, msg } => {
if gen != self.gen {
return false;
}
return self.past.apply(msg);
}
SessionsMsg::OpDone { gen, op, result } => {
if gen != self.gen {
return false;
}
self.pending = None;
self.notice = Some(op_notice(&op, result));
}
}
true
}
}
pub(crate) const PINNED_NOTICE: &str = "pinned — it stays loaded and comes back after restarts";
fn op_notice(op: &SessionsOp, result: Result<String, String>) -> Notice {
match (op, result) {
(SessionsOp::Pin { on: true, .. } | SessionsOp::PinPast { .. }, Ok(_)) => {
Notice { text: PINNED_NOTICE.into(), error: false }
}
(SessionsOp::Pin { on: false, .. }, Ok(_)) => {
Notice { text: "unpinned — it parks when idle and won't come back after a restart".into(), error: false }
}
(SessionsOp::ClearPin { .. }, Ok(_)) => Notice { text: "unpinned — it won't come back".into(), error: false },
(SessionsOp::Pin { .. } | SessionsOp::ClearPin { .. } | SessionsOp::PinPast { .. }, Err(e)) => {
Notice { text: format!("pin failed: {e}"), error: true }
}
(SessionsOp::Close { label, .. }, Ok(_)) => Notice { text: format!("closed {label}"), error: false },
(SessionsOp::Close { label, .. }, Err(e)) => Notice { text: format!("{label} not closed: {}", e.trim_start_matches("not closed: ")), error: true },
}
}
pub(crate) fn short_dur(secs: i64) -> String {
let s = secs.max(0);
if s < 60 {
format!("{s}s")
} else if s < 3600 {
format!("{}m", s / 60)
} else if s < 86_400 {
format!("{}h", s / 3600)
} else {
format!("{}d", s / 86_400)
}
}
pub(crate) fn state_text(m: &SessionMeta, now: DateTime<Utc>) -> String {
let since = m
.state_since
.map(|t| format!(" {}", short_dur((now - t).num_seconds())))
.unwrap_or_default();
let until = |due: Option<DateTime<Utc>>, verb: &str| {
due.map(|d| {
let left = (d - now).num_seconds();
if left <= 0 {
format!(" · {verb} now")
} else {
format!(" · {verb} in {}", short_dur(left))
}
})
.unwrap_or_default()
};
match RowState::of(m) {
RowState::Streaming => format!("streaming{since}"),
RowState::Waiting => "waiting on you".to_string(),
RowState::Idle if m.pinned => format!("idle{since}"),
RowState::Idle => format!("idle{since}{}", until(m.park_due, "parks")),
RowState::Transient => match m.lifecycle {
SessionLifecycle::Ending => "ending…".to_string(),
_ => "parking…".to_string(),
},
RowState::Parked => format!("parked{since}{}", until(m.evict_due, "evicts")),
}
}
pub(crate) fn clients_text(m: &SessionMeta, own: bool) -> String {
match (own, m.clients) {
(true, 0 | 1) => "you".to_string(),
(true, n) => format!("you +{}", n - 1),
(false, 0) => "\u{2014}".to_string(),
(false, 1) => "1 client".to_string(),
(false, n) => format!("{n} clients"),
}
}
pub(crate) fn short_model(model: &str) -> String {
let tail = model.rsplit('/').next().unwrap_or(model);
tail.strip_prefix("claude-").unwrap_or(tail).to_string()
}
pub(crate) fn short_cwd(cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> String {
let Some(cwd) = cwd else { return String::new() };
if let Some(home) = home {
if cwd == home {
return "~/".to_string();
}
if let Ok(rest) = cwd.strip_prefix(home) {
return format!("~/{}", rest.display());
}
}
cwd.display().to_string()
}
pub(crate) fn short_id(id: &str) -> &str {
let mut start = id.len().saturating_sub(4);
while !id.is_char_boundary(start) {
start -= 1;
}
&id[start..]
}
pub(crate) fn label(m: &SessionMeta) -> String {
match m.name.as_deref().filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
None => format!("\u{2026}{}", short_id(&m.id.0)),
}
}
pub(crate) fn cost_text(usd: f64) -> String {
if usd <= 0.0 {
String::new()
} else if usd < 0.005 {
"<$0.01".to_string()
} else if usd >= 100.0 {
format!("${usd:.0}")
} else {
format!("${usd:.2}")
}
}
pub(crate) fn own_row(app: &super::app::App, link: &super::session_link::SessionLink) -> SessionMeta {
own_row_from(link.transport().meta(), app)
}
pub(crate) fn own_row_from(meta: &SessionMeta, app: &super::app::App) -> SessionMeta {
let mut m = meta.clone();
m.lifecycle = SessionLifecycle::Live;
m.busy = app.streaming || app.compacting;
m.clients = m.clients.max(1);
if m.name.is_none() {
m.name = app.session.name.clone();
}
m
}
pub(crate) fn open(app: &mut super::app::App, link: &super::session_link::SessionLink) {
let socket = link.transport().socket_path().map(|p| p.to_path_buf());
let own = own_row(app, link);
open_with(app, own, socket);
}
pub(crate) fn open_with(app: &mut super::app::App, own: SessionMeta, socket: Option<PathBuf>) {
if app.sessions.is_some() {
return;
}
app.sessions_gen = app.sessions_gen.wrapping_add(1);
let gen = app.sessions_gen;
app.sessions = Some(SessionsState::new(gen, own, socket.is_some()));
app.modal_stack.push(super::focus::PaneId::Sessions);
#[cfg(debug_assertions)]
super::focus::debug_assert_stack_sync(app);
if tokio::runtime::Handle::try_current().is_err() {
return;
}
let tx = app.sessions_tx.clone();
if let Some(st) = app.sessions.as_mut() {
let paths = agent_core::session_catalog::CatalogPaths::resolve();
st.past.paths = Some(paths.clone());
if let Some(cat) = past::tasks::last_catalog() {
st.past.set_catalog(cat);
}
past::tasks::spawn_load(gen, tx.clone(), paths, st.past.stop.clone());
}
if let Some(path) = socket {
app.sessions_poller = Some(AbortOnDrop(tokio::spawn(poll_loop(path, gen, tx))));
}
}
async fn poll_loop(path: PathBuf, gen: u64, tx: tokio::sync::mpsc::UnboundedSender<SessionsMsg>) {
use agent_engine::session::socket_transport::SocketTransport;
loop {
let result = match tokio::time::timeout(POLL_TIMEOUT, SocketTransport::sessions(&path)).await {
Ok(Ok(rows)) => Ok(rows),
Ok(Err(e)) => Err(format!("daemon unreachable: {e}")),
Err(_) => Err("daemon did not answer".to_string()),
};
if tx.send(SessionsMsg::Live { gen, result }).is_err() {
return;
}
let pins = agent_engine::daemon::pins::PinBook::for_active_profile().load();
if tx.send(SessionsMsg::Pins { gen, pins }).is_err() {
return;
}
tokio::time::sleep(POLL_EVERY).await;
}
}
pub(crate) fn spawn_op(app: &mut super::app::App, socket: Option<PathBuf>, op: SessionsOp) {
use agent_engine::session::socket_transport::SocketTransport;
let Some(st) = app.sessions.as_mut() else { return };
let gen = st.gen;
let Some(path) = socket else {
st.pending = None;
st.notice = Some(Notice {
text: "no daemon — pin and close need `synaps --attach`".into(),
error: true,
});
return;
};
st.pending = Some(op.clone());
st.notice = None;
let tx = app.sessions_tx.clone();
tokio::spawn(async move {
let result = match &op {
SessionsOp::Pin { id, on } => SocketTransport::pin(&path, &SessionId(id.clone()), *on)
.await
.map(|on| if on { "pinned".into() } else { "unpinned".into() })
.map_err(|e| e.to_string()),
SessionsOp::ClearPin { id } => SocketTransport::pin(&path, &SessionId(id.clone()), false)
.await
.map(|_| "unpinned".into())
.map_err(|e| e.to_string()),
SessionsOp::PinPast { journal_id, cwd } => switch::pin_past(&path, journal_id, cwd.clone()).await,
SessionsOp::Close { id, .. } => {
SocketTransport::close(&path, &SessionId(id.clone()), false, CLOSE_ATTACHED)
.await
.map_err(|e| match e {
agent_engine::session::transport::TransportError::Refused(why) => why,
other => other.to_string(),
})
}
};
let _ = tx.send(SessionsMsg::OpDone { gen, op, result });
});
}
pub(crate) fn close(app: &mut super::app::App) {
if let Some(st) = app.sessions.as_mut() {
st.past.shutdown(); }
app.sessions = None;
app.sessions_poller = None;
app.modal_stack.remove(super::focus::PaneId::Sessions);
}
pub(crate) struct AbortOnDrop(pub(crate) tokio::task::JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
#[cfg(test)]
mod tests;