use std::path::PathBuf;
use std::time::SystemTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentSource {
Claude,
Codex,
Ecs,
AnthropicManaged,
}
impl AgentSource {
pub fn label(self) -> &'static str {
match self {
AgentSource::Claude => "claude",
AgentSource::Codex => "codex",
AgentSource::Ecs => "ecs",
AgentSource::AnthropicManaged => "managed",
}
}
pub fn exe_name(self) -> &'static str {
match self {
AgentSource::Claude => "claude",
AgentSource::Codex => "codex",
AgentSource::Ecs | AgentSource::AnthropicManaged => "",
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum ClaudeAgentsAction {
YankSessionId,
YankCwd,
OpenTranscript,
KillPrompt,
ResumeSession,
ExportMarkdown,
}
#[derive(Debug, Clone)]
pub struct AgentRow {
pub source: AgentSource,
pub transcript_path: PathBuf,
pub session_id: String,
pub workspace: String,
pub cwd: Option<String>,
pub git_branch: Option<String>,
pub model: Option<String>,
pub last_activity: Option<SystemTime>,
pub tokens: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_create_tokens: u64,
pub cache_read_tokens: u64,
pub cost_usd: f64,
pub event_count: usize,
pub last_user_msg: Option<String>,
pub last_assistant_msg: Option<String>,
pub pid: Option<u32>,
pub state: AgentState,
pub current_tool: Option<String>,
pub todos: Vec<TodoEntry>,
pub recent_bash: Vec<String>,
pub recent_files: Vec<RecentFile>,
pub recent_subagents: Vec<String>,
pub pending_tool_uses: usize,
pub tokens_per_min: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentState {
Streaming,
Idle,
Ended,
ToolCall,
}
impl AgentRow {
pub fn state_badge(&self) -> String {
if matches!(self.state, AgentState::ToolCall)
&& let Some(name) = &self.current_tool
{
let short: String = name.chars().take(8).collect();
return format!("▸ {short}");
}
self.state.badge().to_string()
}
}
impl AgentState {
pub fn badge(self) -> &'static str {
match self {
AgentState::Streaming => "● live",
AgentState::Idle => "○ idle",
AgentState::Ended => "· ended",
AgentState::ToolCall => "▸ tool",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailView {
Summary,
Todos,
Files,
Bash,
Subagents,
}
impl DetailView {
pub fn label(self) -> &'static str {
match self {
DetailView::Summary => "Summary",
DetailView::Todos => "Todos",
DetailView::Files => "Files",
DetailView::Bash => "Bash",
DetailView::Subagents => "Agents",
}
}
pub fn cycle(self) -> Self {
match self {
DetailView::Summary => DetailView::Todos,
DetailView::Todos => DetailView::Files,
DetailView::Files => DetailView::Bash,
DetailView::Bash => DetailView::Subagents,
DetailView::Subagents => DetailView::Summary,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AgeFilter {
Today,
#[default]
Week,
Month,
All,
}
impl AgeFilter {
pub fn label(&self) -> &'static str {
match self {
AgeFilter::Today => "Today",
AgeFilter::Week => "7d",
AgeFilter::Month => "30d",
AgeFilter::All => "All",
}
}
pub fn max_age_secs(&self) -> Option<u64> {
match self {
AgeFilter::Today => Some(24 * 3600),
AgeFilter::Week => Some(7 * 24 * 3600),
AgeFilter::Month => Some(30 * 24 * 3600),
AgeFilter::All => None,
}
}
pub fn cycle(&self) -> Self {
match self {
AgeFilter::Today => AgeFilter::Week,
AgeFilter::Week => AgeFilter::Month,
AgeFilter::Month => AgeFilter::All,
AgeFilter::All => AgeFilter::Today,
}
}
}
pub struct ClaudeAgentsPane {
pub rows: Vec<AgentRow>,
pub selected: usize,
pub pending_g: bool,
pub built_at: SystemTime,
pub query: String,
pub filter_mode: bool,
pub detail: DetailView,
pub paused: bool,
pub paused_by_user: bool,
pub state_filter: Option<AgentState>,
pub source_filter: Option<AgentSource>,
pub workspace_only: bool,
pub age_filter: AgeFilter,
pub anchor_workspace: PathBuf,
pub show_help: bool,
pub detail_scroll: usize,
pub last_live_tail: SystemTime,
pub prior_state_snapshot: std::collections::HashMap<String, (AgentState, usize)>,
pub token_samples:
std::collections::HashMap<String, std::collections::VecDeque<(SystemTime, u64)>>,
pub kill_escalation: std::collections::HashMap<u32, SystemTime>,
pub lifetime_cache: std::collections::HashMap<String, LifetimeTotals>,
pub multi_selected: std::collections::HashSet<String>,
pub group_by: GroupBy,
pub sort_by: SortBy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortBy {
StateActivity,
TokensDesc,
CostDesc,
ActivityDesc,
}
impl SortBy {
pub fn label(self) -> &'static str {
match self {
SortBy::StateActivity => "state",
SortBy::TokensDesc => "tokens↓",
SortBy::CostDesc => "cost↓",
SortBy::ActivityDesc => "recent",
}
}
pub fn cycle(self) -> Self {
match self {
SortBy::StateActivity => SortBy::TokensDesc,
SortBy::TokensDesc => SortBy::CostDesc,
SortBy::CostDesc => SortBy::ActivityDesc,
SortBy::ActivityDesc => SortBy::StateActivity,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroupBy {
Source,
Workspace,
}
impl GroupBy {
pub fn label(self) -> &'static str {
match self {
GroupBy::Source => "source",
GroupBy::Workspace => "workspace",
}
}
pub fn cycle(self) -> Self {
match self {
GroupBy::Source => GroupBy::Workspace,
GroupBy::Workspace => GroupBy::Source,
}
}
}
impl ClaudeAgentsPane {
pub fn build() -> Self {
Self::build_anchored(PathBuf::new())
}
pub fn build_anchored(anchor: PathBuf) -> Self {
let rows = prefetch_rows();
Self::build_anchored_from_rows(anchor, rows)
}
pub fn build_anchored_from_rows(anchor: PathBuf, rows: Vec<AgentRow>) -> Self {
let mut pane = ClaudeAgentsPane::empty_with_rows(rows);
pane.anchor_workspace = anchor;
pane.merge_lifetime_totals();
pane.recompute_token_rates();
pane
}
fn empty_with_rows(rows: Vec<AgentRow>) -> Self {
ClaudeAgentsPane {
rows,
selected: 0,
pending_g: false,
built_at: SystemTime::now(),
query: String::new(),
filter_mode: false,
detail: DetailView::Summary,
paused: false,
paused_by_user: false,
state_filter: None,
source_filter: None,
workspace_only: false,
age_filter: AgeFilter::default(),
anchor_workspace: PathBuf::new(),
sort_by: SortBy::StateActivity,
show_help: false,
detail_scroll: 0,
last_live_tail: SystemTime::now(),
prior_state_snapshot: std::collections::HashMap::new(),
token_samples: std::collections::HashMap::new(),
kill_escalation: std::collections::HashMap::new(),
lifetime_cache: std::collections::HashMap::new(),
multi_selected: std::collections::HashSet::new(),
group_by: GroupBy::Source,
}
}
pub fn merge_lifetime_totals(&mut self) {
for row in &mut self.rows {
if row.source != AgentSource::Claude {
continue;
}
let path = &row.transcript_path;
if !path.is_file() {
continue;
}
let cur_size = match std::fs::metadata(path) {
Ok(m) => m.len(),
Err(_) => continue,
};
let totals = self
.lifetime_cache
.entry(row.session_id.clone())
.or_default();
if cur_size > totals.last_seen_bytes {
let delta = read_byte_range(path, totals.last_seen_bytes, cur_size);
if let Some(text) = delta {
let mut model = row.model.clone();
for line in text.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
continue;
}
let msg = v.get("message");
if let Some(m) = msg.and_then(|m| m.get("model")).and_then(|m| m.as_str()) {
model = Some(m.to_string());
}
let usage = match msg.and_then(|m| m.get("usage")) {
Some(u) => u,
None => continue,
};
let i = usage
.get("input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let o = usage
.get("output_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cc = usage
.get("cache_creation_input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cr = usage
.get("cache_read_input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
totals.tokens = totals.tokens.saturating_add(i + o);
totals.input_tokens = totals.input_tokens.saturating_add(i);
totals.output_tokens = totals.output_tokens.saturating_add(o);
totals.cache_create_tokens = totals.cache_create_tokens.saturating_add(cc);
totals.cache_read_tokens = totals.cache_read_tokens.saturating_add(cr);
}
if let Some(m) = model {
let extra_cost = estimate_cost(
&m,
totals.input_tokens,
totals.output_tokens,
totals.cache_create_tokens,
totals.cache_read_tokens,
);
totals.cost_usd = extra_cost;
}
}
totals.last_seen_bytes = cur_size;
}
if totals.tokens > row.tokens {
row.tokens = totals.tokens;
row.input_tokens = totals.input_tokens;
row.output_tokens = totals.output_tokens;
row.cache_create_tokens = totals.cache_create_tokens;
row.cache_read_tokens = totals.cache_read_tokens;
}
if totals.cost_usd > row.cost_usd {
row.cost_usd = totals.cost_usd;
}
}
}
pub fn recompute_token_rates(&mut self) {
const RING: usize = 5;
let now = SystemTime::now();
for row in &mut self.rows {
let entry = self
.token_samples
.entry(row.session_id.clone())
.or_default();
entry.push_back((now, row.tokens));
while entry.len() > RING {
entry.pop_front();
}
if !matches!(row.state, AgentState::Streaming | AgentState::ToolCall) {
row.tokens_per_min = None;
continue;
}
if entry.len() < 2 {
row.tokens_per_min = None;
continue;
}
let (oldest_ts, oldest_tokens) = entry[0];
let dt = now
.duration_since(oldest_ts)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let dtok = row.tokens.saturating_sub(oldest_tokens);
if dt > 0.5 && dtok > 0 {
row.tokens_per_min = Some((dtok as f64) * 60.0 / dt);
} else {
row.tokens_per_min = None;
}
}
let live_sids: std::collections::HashSet<String> =
self.rows.iter().map(|r| r.session_id.clone()).collect();
self.token_samples.retain(|sid, _| live_sids.contains(sid));
}
pub fn live_tail_selected(&mut self) -> bool {
let Some(vi) = self.visible_indices().get(self.selected).copied() else {
return false;
};
let Some(row) = self.rows.get(vi) else {
return false;
};
if !matches!(row.state, AgentState::Streaming | AgentState::ToolCall) {
return false;
}
if row.source != AgentSource::Claude {
return false; }
let path = row.transcript_path.clone();
if !path.is_file() {
return false;
}
let stats = parse_tail(&path);
let cost = stats
.model
.as_deref()
.map(|m| {
estimate_cost(
m,
stats.input_tokens,
stats.output_tokens,
stats.cache_create_tokens,
stats.cache_read_tokens,
)
})
.unwrap_or(0.0);
let mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
let lifetime = self.lifetime_cache.get(&self.rows[vi].session_id).cloned();
if let Some(row) = self.rows.get_mut(vi) {
if let Some(m) = stats.model.clone() {
row.model = Some(m);
}
if let Some(c) = stats.cwd.clone() {
row.cwd = Some(c);
}
if let Some(b) = stats.git_branch.clone() {
row.git_branch = Some(b);
}
let lt = lifetime.as_ref();
row.tokens = lt
.map(|l| l.tokens.max(stats.tokens))
.unwrap_or(stats.tokens);
row.input_tokens = lt
.map(|l| l.input_tokens.max(stats.input_tokens))
.unwrap_or(stats.input_tokens);
row.output_tokens = lt
.map(|l| l.output_tokens.max(stats.output_tokens))
.unwrap_or(stats.output_tokens);
row.cache_create_tokens = lt
.map(|l| l.cache_create_tokens.max(stats.cache_create_tokens))
.unwrap_or(stats.cache_create_tokens);
row.cache_read_tokens = lt
.map(|l| l.cache_read_tokens.max(stats.cache_read_tokens))
.unwrap_or(stats.cache_read_tokens);
row.cost_usd = lt.map(|l| l.cost_usd.max(cost)).unwrap_or(cost);
row.event_count = stats.event_count;
row.last_user_msg = stats.last_user_msg;
row.last_assistant_msg = stats.last_assistant_msg;
row.current_tool = stats.last_tool_name;
row.todos = stats.todos;
row.recent_bash = stats.recent_bash;
row.recent_files = stats.recent_files;
row.recent_subagents = stats.recent_subagents;
row.pending_tool_uses = stats.pending_tool_uses;
if mtime.is_some() {
row.last_activity = mtime;
}
}
self.recompute_token_rates();
self.last_live_tail = SystemTime::now();
true
}
pub fn compute_transitions(&mut self) -> Vec<String> {
let mut messages: Vec<String> = Vec::new();
let was_empty = self.prior_state_snapshot.is_empty();
let mut new_snapshot: std::collections::HashMap<String, (AgentState, usize)> =
std::collections::HashMap::new();
for row in &self.rows {
let sid_short: String = row.session_id.chars().take(8).collect();
new_snapshot.insert(row.session_id.clone(), (row.state, row.pending_tool_uses));
if was_empty {
continue;
}
let prev = self.prior_state_snapshot.get(&row.session_id);
match prev {
None => {
if matches!(row.state, AgentState::Streaming | AgentState::ToolCall) {
messages.push(format!(
"{} new {} session ({})",
row.source.label(),
row.state.badge(),
sid_short
));
}
}
Some(&(prev_state, prev_pending)) => {
if prev_state != row.state {
messages.push(format!(
"{} {} → {} ({})",
row.source.label(),
prev_state.badge(),
row.state.badge(),
sid_short
));
}
if row.pending_tool_uses > prev_pending {
messages.push(format!(
"{} ⚠ pending tool ({})",
row.source.label(),
sid_short
));
}
}
}
}
self.prior_state_snapshot = new_snapshot;
messages
}
pub fn refresh_in_place(&mut self) {
let prior_sid = self.selected_row().map(|r| r.session_id.clone());
let claude_pids = scan_running_pids(AgentSource::Claude);
let codex_pids = scan_running_pids(AgentSource::Codex);
let max_age = self.age_filter.max_age_secs();
let mut rows = collect_rows_with_max_age(&claude_pids, max_age);
rows.extend(collect_codex_rows_with_max_age(&codex_pids, max_age));
rows.sort_by(|a, b| {
state_rank(a.state)
.cmp(&state_rank(b.state))
.then_with(|| b.last_activity.cmp(&a.last_activity))
});
self.rows = rows;
self.built_at = SystemTime::now();
let live_sids: std::collections::HashSet<String> =
self.rows.iter().map(|r| r.session_id.clone()).collect();
self.multi_selected.retain(|sid| live_sids.contains(sid));
self.merge_lifetime_totals();
self.recompute_token_rates();
let new_visible = self.visible_indices();
let resolved = prior_sid.and_then(|sid| {
new_visible
.iter()
.position(|&i| self.rows.get(i).map(|r| &r.session_id) == Some(&sid))
});
self.selected = resolved.unwrap_or_default();
if !new_visible.is_empty() {
self.selected = self.selected.min(new_visible.len() - 1);
}
}
pub fn aggregate(&self) -> Aggregate {
let mut a = Aggregate::default();
for r in &self.rows {
match r.state {
AgentState::Streaming => a.streaming += 1,
AgentState::ToolCall => a.tool_calls += 1,
AgentState::Idle => a.idle += 1,
AgentState::Ended => a.ended += 1,
}
a.total_tokens = a.total_tokens.saturating_add(r.tokens);
a.pending_confirms = a
.pending_confirms
.saturating_add(r.pending_tool_uses as u64);
a.total_cost_usd += r.cost_usd;
}
a
}
pub fn tab_title(&self) -> String {
let live = self
.rows
.iter()
.filter(|r| matches!(r.state, AgentState::Streaming | AgentState::ToolCall))
.count();
let total = self.rows.len();
if live > 0 {
format!("claude agents ({live} live / {total})")
} else {
format!("claude agents ({total})")
}
}
pub fn visible_indices(&self) -> Vec<usize> {
let q = self.query.to_lowercase();
let mut idx: Vec<usize> = self
.rows
.iter()
.enumerate()
.filter(|(_, r)| {
if let Some(sf) = self.state_filter
&& r.state != sf
{
return false;
}
if let Some(src) = self.source_filter
&& r.source != src
{
return false;
}
if self.workspace_only && !self.anchor_workspace.as_os_str().is_empty() {
let cwd_ok = r
.cwd
.as_deref()
.map(|c| std::path::Path::new(c).starts_with(&self.anchor_workspace))
.unwrap_or(false);
if !cwd_ok {
return false;
}
}
if let Some(max_age) = self.age_filter.max_age_secs()
&& let Some(la) = r.last_activity
{
let age = std::time::SystemTime::now()
.duration_since(la)
.map(|d| d.as_secs())
.unwrap_or(0);
if age > max_age {
return false;
}
}
if q.is_empty() {
return true;
}
let hay = format!(
"{} {} {} {} {} {}",
r.workspace,
r.session_id,
r.model.as_deref().unwrap_or(""),
r.last_user_msg.as_deref().unwrap_or(""),
r.last_assistant_msg.as_deref().unwrap_or(""),
r.git_branch.as_deref().unwrap_or(""),
);
hay.to_lowercase().contains(&q)
})
.map(|(i, _)| i)
.collect();
match self.sort_by {
SortBy::StateActivity => {}
SortBy::TokensDesc => {
idx.sort_by(|&a, &b| self.rows[b].tokens.cmp(&self.rows[a].tokens));
}
SortBy::CostDesc => {
idx.sort_by(|&a, &b| {
self.rows[b]
.cost_usd
.partial_cmp(&self.rows[a].cost_usd)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
SortBy::ActivityDesc => {
idx.sort_by(|&a, &b| self.rows[b].last_activity.cmp(&self.rows[a].last_activity));
}
}
idx
}
pub fn selected_row(&self) -> Option<&AgentRow> {
let vis = self.visible_indices();
vis.get(self.selected).and_then(|&i| self.rows.get(i))
}
pub fn move_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
self.detail_scroll = 0;
}
pub fn move_down(&mut self) {
let n = self.visible_indices().len();
if self.selected + 1 < n {
self.selected += 1;
self.detail_scroll = 0;
}
}
pub fn cycle_detail(&mut self) {
self.detail = self.detail.cycle();
self.detail_scroll = 0;
}
pub fn cycle_group_by(&mut self) {
self.group_by = self.group_by.cycle();
}
pub fn cycle_sort(&mut self) {
let prior_sid = self
.visible_indices()
.get(self.selected)
.and_then(|i| self.rows.get(*i).map(|r| r.session_id.clone()));
self.sort_by = self.sort_by.cycle();
if let Some(sid) = prior_sid {
let new_vi = self
.visible_indices()
.iter()
.position(|i| self.rows.get(*i).map(|r| &r.session_id) == Some(&sid));
self.selected = new_vi.unwrap_or(0);
} else {
self.selected = 0;
}
}
pub fn clear_multi_selected(&mut self) {
self.multi_selected.clear();
}
pub fn clear_filters(&mut self) {
self.query.clear();
self.filter_mode = false;
self.state_filter = None;
self.source_filter = None;
self.workspace_only = false;
self.age_filter = AgeFilter::default();
self.selected = 0;
}
pub fn any_filter_active(&self) -> bool {
!self.query.is_empty()
|| self.state_filter.is_some()
|| self.source_filter.is_some()
|| self.workspace_only
|| self.age_filter != AgeFilter::default()
}
pub fn toggle_multi_selected(&mut self) -> usize {
if let Some(sid) = self.selected_row().map(|r| r.session_id.clone()) {
if self.multi_selected.contains(&sid) {
self.multi_selected.remove(&sid);
} else {
self.multi_selected.insert(sid);
}
}
self.multi_selected.len()
}
pub fn multi_selected_pids(&self) -> Vec<(String, u32)> {
self.rows
.iter()
.filter(|r| self.multi_selected.contains(&r.session_id))
.filter_map(|r| r.pid.map(|p| (r.session_id.clone(), p)))
.collect()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Aggregate {
pub streaming: usize,
pub tool_calls: usize,
pub idle: usize,
pub ended: usize,
pub total_tokens: u64,
pub pending_confirms: u64,
pub total_cost_usd: f64,
}
fn home_projects_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".claude/projects"))
}
#[allow(dead_code)]
pub fn preview_last_messages(session_id: &str, workspace: &std::path::Path) -> Option<String> {
let root = home_projects_dir()?;
let encoded = workspace
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "-");
let candidate = root.join(&encoded).join(format!("{session_id}.jsonl"));
let bytes = std::fs::metadata(&candidate).ok().map(|m| m.len())?;
let start = bytes.saturating_sub(32 * 1024);
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(&candidate).ok()?;
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::with_capacity(32 * 1024);
f.read_to_end(&mut buf).ok()?;
let text = String::from_utf8_lossy(&buf);
let mut last_user: Option<String> = None;
let mut last_asst: Option<String> = None;
for line in text.lines().rev() {
if !line.starts_with('{') {
continue;
}
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let role = v
.get("message")
.and_then(|m| m.get("role"))
.and_then(|r| r.as_str());
let content = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.or_else(|| {
v.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|x| x.get("text"))
.and_then(|t| t.as_str())
});
let Some(c) = content else { continue };
let snippet: String = c.trim().chars().take(80).collect();
match role {
Some("user") if last_user.is_none() => last_user = Some(snippet),
Some("assistant") if last_asst.is_none() => last_asst = Some(snippet),
_ => {}
}
if last_user.is_some() && last_asst.is_some() {
break;
}
}
let mut parts = Vec::new();
if let Some(u) = last_user {
parts.push(format!("you: {u}"));
}
if let Some(a) = last_asst {
parts.push(format!("claude: {a}"));
}
if parts.is_empty() {
None
} else {
Some(parts.join(" · "))
}
}
pub fn transcript_summary_lines(session_id: &str, workspace: &std::path::Path) -> Vec<String> {
let Some(root) = home_projects_dir() else {
return Vec::new();
};
let encoded = workspace
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "-");
let candidate = root.join(&encoded).join(format!("{session_id}.jsonl"));
let Ok(meta) = std::fs::metadata(&candidate) else {
return Vec::new();
};
let bytes = meta.len();
let start = bytes.saturating_sub(32 * 1024);
use std::io::{Read, Seek, SeekFrom};
let Ok(mut f) = std::fs::File::open(&candidate) else {
return Vec::new();
};
if f.seek(SeekFrom::Start(start)).is_err() {
return Vec::new();
}
let mut buf = Vec::with_capacity(32 * 1024);
if f.read_to_end(&mut buf).is_err() {
return Vec::new();
}
let text = String::from_utf8_lossy(&buf);
let mut last_user: Option<String> = None;
let mut last_asst: Option<String> = None;
for line in text.lines().rev() {
if !line.starts_with('{') {
continue;
}
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let role = v
.get("message")
.and_then(|m| m.get("role"))
.and_then(|r| r.as_str());
let content = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.or_else(|| {
v.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| {
a.iter().find_map(|x| {
let is_text = x
.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "text")
.unwrap_or(true);
if !is_text {
return None;
}
x.get("text").and_then(|t| t.as_str())
})
})
});
let Some(c) = content else { continue };
let flat: String = c.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.is_empty() {
continue;
}
let snippet: String = flat.chars().take(120).collect();
match role {
Some("user") if last_user.is_none() => last_user = Some(snippet),
Some("assistant") if last_asst.is_none() => last_asst = Some(snippet),
_ => {}
}
if last_user.is_some() && last_asst.is_some() {
break;
}
}
let mut out = Vec::new();
if let Some(u) = last_user {
out.push(format!("you: {u}"));
}
if let Some(a) = last_asst {
out.push(format!("claude: {a}"));
}
out
}
fn decode_workspace_label(encoded: &str) -> String {
encoded
.trim_start_matches('-')
.rsplit('-')
.next()
.unwrap_or(encoded)
.to_string()
}
pub fn prefetch_rows() -> Vec<AgentRow> {
let claude_pids = scan_running_pids(AgentSource::Claude);
let codex_pids = scan_running_pids(AgentSource::Codex);
let mut rows = collect_rows(&claude_pids);
rows.extend(collect_codex_rows(&codex_pids));
rows.sort_by(|a, b| {
state_rank(a.state)
.cmp(&state_rank(b.state))
.then_with(|| b.last_activity.cmp(&a.last_activity))
});
rows
}
fn collect_rows(pids: &[(String, u32, String)]) -> Vec<AgentRow> {
collect_rows_with_max_age(pids, Some(7 * 24 * 3600))
}
fn collect_rows_with_max_age(
pids: &[(String, u32, String)],
max_age_secs: Option<u64>,
) -> Vec<AgentRow> {
let Some(root) = home_projects_dir() else {
return Vec::new();
};
let mut rows: Vec<AgentRow> = Vec::new();
let Ok(rd) = std::fs::read_dir(&root) else {
return rows;
};
for entry in rd.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let encoded = dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let workspace = decode_workspace_label(&encoded);
let Ok(files) = std::fs::read_dir(&dir) else {
continue;
};
for f in files.flatten() {
let p = f.path();
let Some(name) = p.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some(session_id) = name.strip_suffix(".jsonl") else {
continue;
};
let Ok(meta) = f.metadata() else { continue };
let mtime = meta.modified().ok();
if let Some(cap) = max_age_secs
&& let Some(t) = mtime
&& let Ok(age) = SystemTime::now().duration_since(t)
&& age.as_secs() > cap
{
continue;
}
let stats = parse_tail(&p);
let pid = pids
.iter()
.find_map(|(sid, pid, _)| (sid == session_id).then_some(*pid));
let state = derive_state(pid.is_some(), mtime, &stats);
let workspace = stats
.cwd
.as_deref()
.and_then(|c| std::path::Path::new(c).file_name())
.and_then(|s| s.to_str())
.map(String::from)
.unwrap_or_else(|| workspace.clone());
let current_tool = stats.last_tool_name.clone();
let cost = stats
.model
.as_deref()
.map(|m| {
estimate_cost(
m,
stats.input_tokens,
stats.output_tokens,
stats.cache_create_tokens,
stats.cache_read_tokens,
)
})
.unwrap_or(0.0);
rows.push(AgentRow {
source: AgentSource::Claude,
transcript_path: p.clone(),
session_id: session_id.to_string(),
workspace: workspace.clone(),
cwd: stats.cwd,
git_branch: stats.git_branch,
model: stats.model,
last_activity: mtime,
tokens: stats.tokens,
input_tokens: stats.input_tokens,
output_tokens: stats.output_tokens,
cache_create_tokens: stats.cache_create_tokens,
cache_read_tokens: stats.cache_read_tokens,
cost_usd: cost,
event_count: stats.event_count,
last_user_msg: stats.last_user_msg,
last_assistant_msg: stats.last_assistant_msg,
pid,
state,
current_tool,
todos: stats.todos,
recent_bash: stats.recent_bash,
recent_files: stats.recent_files,
recent_subagents: stats.recent_subagents,
pending_tool_uses: stats.pending_tool_uses,
tokens_per_min: None,
});
}
}
rows.sort_by(|a, b| {
let aw = state_rank(a.state);
let bw = state_rank(b.state);
aw.cmp(&bw)
.then_with(|| b.last_activity.cmp(&a.last_activity))
});
rows
}
fn collect_codex_rows(pids: &[(String, u32, String)]) -> Vec<AgentRow> {
collect_codex_rows_with_max_age(pids, Some(7 * 24 * 3600))
}
fn collect_codex_rows_with_max_age(
pids: &[(String, u32, String)],
max_age_secs: Option<u64>,
) -> Vec<AgentRow> {
let mut rows: Vec<AgentRow> = Vec::new();
let sessions_dir = std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex/sessions"));
let pid_cwds: Vec<(u32, Option<String>)> = pids
.iter()
.map(|(_, pid, _)| (*pid, read_pid_cwd(*pid)))
.collect();
let mut claimed_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
if let Some(dir) = sessions_dir.as_deref() {
for p in walk_codex_sessions(dir) {
let name = match p.file_name().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let stem = match name.strip_suffix(".jsonl") {
Some(s) => s,
None => continue,
};
let session_id = stem
.rsplit('-')
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("-");
if session_id.len() != 36 || session_id.matches('-').count() != 4 {
continue;
}
let Ok(meta) = std::fs::metadata(&p) else {
continue;
};
let mtime = meta.modified().ok();
if let Some(cap) = max_age_secs
&& let Some(t) = mtime
&& let Ok(age) = SystemTime::now().duration_since(t)
&& age.as_secs() > cap
{
continue;
}
let stats = parse_codex_tail(&p);
let pid = pids
.iter()
.find_map(|(sid, pid, _)| (sid == &session_id).then_some(*pid))
.or_else(|| {
stats.cwd.as_ref().and_then(|disk_cwd| {
pid_cwds.iter().find_map(|(p_pid, p_cwd)| {
if claimed_pids.contains(p_pid) {
return None;
}
(p_cwd.as_deref() == Some(disk_cwd.as_str())).then_some(*p_pid)
})
})
});
if let Some(p) = pid {
claimed_pids.insert(p);
}
let state = if pid.is_some() {
if stats.last_was_tool_call {
AgentState::ToolCall
} else {
let fresh = mtime
.and_then(|t| SystemTime::now().duration_since(t).ok())
.is_some_and(|d| d.as_secs() < 60);
if fresh {
AgentState::Streaming
} else {
AgentState::Idle
}
}
} else {
AgentState::Ended
};
let workspace = stats
.cwd
.as_deref()
.and_then(|c| std::path::Path::new(c).file_name())
.and_then(|s| s.to_str())
.map(String::from)
.unwrap_or_else(|| "?".to_string());
let net_input = stats.input_tokens.saturating_sub(stats.cache_read_tokens);
let cost = stats
.model
.as_deref()
.map(|m| {
estimate_cost(
m,
net_input,
stats.output_tokens,
0,
stats.cache_read_tokens,
)
})
.unwrap_or(0.0);
rows.push(AgentRow {
source: AgentSource::Codex,
transcript_path: p.clone(),
session_id,
workspace,
cwd: stats.cwd,
git_branch: None,
model: stats.model,
last_activity: mtime,
tokens: stats.tokens,
input_tokens: stats.input_tokens,
output_tokens: stats.output_tokens,
cache_create_tokens: 0,
cache_read_tokens: stats.cache_read_tokens,
cost_usd: cost,
event_count: stats.event_count,
last_user_msg: stats.last_user_msg,
last_assistant_msg: stats.last_assistant_msg,
pid,
state,
current_tool: if stats.last_was_tool_call {
Some("exec".to_string())
} else {
None
},
todos: Vec::new(),
recent_bash: stats.recent_bash,
recent_files: Vec::new(),
recent_subagents: Vec::new(),
pending_tool_uses: stats.pending_tool_uses,
tokens_per_min: None,
});
}
}
for (_, pid, cmdline) in pids {
if claimed_pids.contains(pid) {
continue;
}
let cwd = read_pid_cwd(*pid);
let workspace = cwd
.as_deref()
.and_then(|c| std::path::Path::new(c).file_name())
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string();
rows.push(AgentRow {
source: AgentSource::Codex,
transcript_path: PathBuf::new(),
session_id: format!("pid-{pid}"),
workspace,
cwd,
git_branch: None,
model: None,
last_activity: Some(SystemTime::now()),
tokens: 0,
input_tokens: 0,
output_tokens: 0,
cache_create_tokens: 0,
cache_read_tokens: 0,
cost_usd: 0.0,
event_count: 0,
last_user_msg: None,
last_assistant_msg: Some(format!("(running) {}", truncate(cmdline, 160))),
pid: Some(*pid),
state: AgentState::Streaming,
current_tool: None,
todos: Vec::new(),
recent_bash: Vec::new(),
recent_files: Vec::new(),
recent_subagents: Vec::new(),
pending_tool_uses: 0,
tokens_per_min: None,
});
}
rows
}
fn walk_codex_sessions(root: &std::path::Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let Ok(years) = std::fs::read_dir(root) else {
return out;
};
for y in years.flatten() {
let Ok(months) = std::fs::read_dir(y.path()) else {
continue;
};
for m in months.flatten() {
let Ok(days) = std::fs::read_dir(m.path()) else {
continue;
};
for d in days.flatten() {
let Ok(files) = std::fs::read_dir(d.path()) else {
continue;
};
for f in files.flatten() {
let p = f.path();
if p.extension().is_some_and(|e| e == "jsonl") {
out.push(p);
}
}
}
}
}
out
}
#[derive(Default)]
struct CodexTailStats {
cwd: Option<String>,
model: Option<String>,
tokens: u64,
input_tokens: u64,
output_tokens: u64,
cache_read_tokens: u64,
event_count: usize,
last_user_msg: Option<String>,
last_assistant_msg: Option<String>,
recent_bash: Vec<String>,
pending_tool_uses: usize,
last_was_tool_call: bool,
}
fn parse_codex_tail(path: &std::path::Path) -> CodexTailStats {
let mut stats = CodexTailStats::default();
let Ok(text) = read_tail(path, 256 * 1024) else {
return stats;
};
let mut pending_calls: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut last_assistant_was_tool = false;
for line in text.lines() {
stats.event_count += 1;
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
let payload = v.get("payload");
match ty {
"session_meta" => {
if let Some(c) = payload.and_then(|p| p.get("cwd")).and_then(|c| c.as_str()) {
stats.cwd = Some(c.to_string());
}
}
"turn_context" => {
if let Some(c) = payload.and_then(|p| p.get("cwd")).and_then(|c| c.as_str()) {
stats.cwd = Some(c.to_string());
}
if let Some(m) = payload
.and_then(|p| p.get("model"))
.and_then(|m| m.as_str())
{
stats.model = Some(m.to_string());
}
}
"response_item" => {
let inner_type = payload
.and_then(|p| p.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
match inner_type {
"function_call" => {
let call_id = payload
.and_then(|p| p.get("call_id"))
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
if !call_id.is_empty() {
pending_calls.insert(call_id);
}
last_assistant_was_tool = true;
let args_str = payload
.and_then(|p| p.get("arguments"))
.and_then(|a| a.as_str())
.unwrap_or("");
if let Ok(args) = serde_json::from_str::<serde_json::Value>(args_str)
&& let Some(cmd) = args.get("cmd").and_then(|c| c.as_str())
{
stats.recent_bash.insert(0, truncate(cmd, 96));
stats.recent_bash.truncate(10);
}
continue;
}
"function_call_output" => {
if let Some(call_id) = payload
.and_then(|p| p.get("call_id"))
.and_then(|c| c.as_str())
{
pending_calls.remove(call_id);
}
continue;
}
"message" => {} _ => continue,
}
let role = payload
.and_then(|p| p.get("role"))
.and_then(|r| r.as_str())
.unwrap_or("");
let content = payload
.and_then(|p| p.get("content"))
.and_then(|c| c.as_array());
let text = content.and_then(|arr| {
arr.iter().find_map(|b| {
let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or("");
if bt == "input_text" || bt == "output_text" {
b.get("text").and_then(|t| t.as_str()).map(String::from)
} else {
None
}
})
});
let Some(text) = text else { continue };
let text = text.trim();
if text.starts_with("<environment_context>")
|| text.starts_with("<permissions instructions>")
|| text.starts_with("<task_complete>")
|| text.is_empty()
{
continue;
}
match role {
"user" => stats.last_user_msg = Some(truncate(text, 200)),
"assistant" => {
stats.last_assistant_msg = Some(truncate(text, 200));
last_assistant_was_tool = false;
}
_ => {}
}
}
"event_msg" => {
let inner_type = payload
.and_then(|p| p.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
if inner_type == "token_count"
&& let Some(info) = payload.and_then(|p| p.get("info"))
{
let total = info.get("total_token_usage");
let i = total
.and_then(|t| t.get("input_tokens"))
.and_then(|n| n.as_u64())
.unwrap_or(0);
let o = total
.and_then(|t| t.get("output_tokens"))
.and_then(|n| n.as_u64())
.unwrap_or(0);
let ro = total
.and_then(|t| t.get("reasoning_output_tokens"))
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cached = total
.and_then(|t| t.get("cached_input_tokens"))
.and_then(|n| n.as_u64())
.unwrap_or(0);
stats.input_tokens = i;
stats.output_tokens = o.saturating_add(ro);
stats.cache_read_tokens = cached;
stats.tokens = i.saturating_add(o).saturating_add(ro);
}
}
_ => {}
}
}
stats.pending_tool_uses = pending_calls.len();
stats.last_was_tool_call = last_assistant_was_tool && stats.pending_tool_uses > 0;
stats
}
fn read_pid_cwd(pid: u32) -> Option<String> {
#[cfg(target_os = "linux")]
{
let p = std::fs::read_link(format!("/proc/{pid}/cwd")).ok()?;
Some(p.to_string_lossy().into_owned())
}
#[cfg(not(target_os = "linux"))]
{
let out = std::process::Command::new("lsof")
.args(["-p", &pid.to_string()])
.output()
.ok()?;
if !out.status.success() {
return None;
}
for line in String::from_utf8_lossy(&out.stdout).lines() {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.get(3).copied() == Some("cwd") {
return Some(cols[8..].join(" "));
}
}
None
}
}
pub fn state_rank(s: AgentState) -> u8 {
match s {
AgentState::Streaming => 0,
AgentState::ToolCall => 1,
AgentState::Idle => 2,
AgentState::Ended => 3,
}
}
fn derive_state(has_pid: bool, mtime: Option<SystemTime>, stats: &TailStats) -> AgentState {
if !has_pid {
return AgentState::Ended;
}
if stats.last_was_tool_call {
return AgentState::ToolCall;
}
let fresh = mtime
.and_then(|t| SystemTime::now().duration_since(t).ok())
.is_some_and(|d| d.as_secs() < 60);
if fresh {
AgentState::Streaming
} else {
AgentState::Idle
}
}
#[derive(Default)]
struct TailStats {
cwd: Option<String>,
git_branch: Option<String>,
model: Option<String>,
tokens: u64,
input_tokens: u64,
output_tokens: u64,
cache_create_tokens: u64,
cache_read_tokens: u64,
event_count: usize,
last_user_msg: Option<String>,
last_assistant_msg: Option<String>,
last_was_tool_call: bool,
last_tool_name: Option<String>,
todos: Vec<TodoEntry>,
recent_bash: Vec<String>,
recent_files: Vec<RecentFile>,
recent_subagents: Vec<String>,
pending_tool_uses: usize,
}
#[derive(Debug, Clone)]
pub struct TodoEntry {
pub content: String,
pub status: String,
}
#[derive(Debug, Clone)]
pub struct RecentFile {
pub tool: String,
pub path: String,
}
#[derive(Debug, Clone, Default)]
pub struct LifetimeTotals {
pub last_seen_bytes: u64,
pub tokens: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_create_tokens: u64,
pub cache_read_tokens: u64,
pub cost_usd: f64,
}
fn parse_tail(path: &std::path::Path) -> TailStats {
parse_stats(path, Some(256 * 1024))
}
fn parse_full(path: &std::path::Path) -> TailStats {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if size <= 10 * 1024 * 1024 {
parse_stats(path, None)
} else {
parse_stats(path, Some(10 * 1024 * 1024))
}
}
fn parse_stats(path: &std::path::Path, cap: Option<usize>) -> TailStats {
let mut stats = TailStats::default();
let read_result = match cap {
Some(c) => read_tail(path, c),
None => std::fs::read_to_string(path),
};
let Ok(text) = read_result else {
return stats;
};
let lines: Vec<&str> = text.lines().collect();
let mut seen_assistant_text = false;
let mut seen_user_msg = false;
let mut last_assistant_was_tool = false;
let mut pending: std::collections::HashSet<String> = std::collections::HashSet::new();
for line in &lines {
stats.event_count += 1;
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if let Some(cwd) = v.get("cwd").and_then(|c| c.as_str()) {
stats.cwd = Some(cwd.to_string());
}
if let Some(b) = v.get("gitBranch").and_then(|c| c.as_str()) {
stats.git_branch = Some(b.to_string());
}
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
"assistant" => {
last_assistant_was_tool = false;
let msg = v.get("message");
if let Some(model) = msg.and_then(|m| m.get("model")).and_then(|m| m.as_str()) {
stats.model = Some(model.to_string());
}
if let Some(usage) = msg.and_then(|m| m.get("usage")) {
let i = usage
.get("input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let o = usage
.get("output_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cc = usage
.get("cache_creation_input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cr = usage
.get("cache_read_input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
stats.tokens = stats.tokens.saturating_add(i + o);
stats.input_tokens = stats.input_tokens.saturating_add(i);
stats.output_tokens = stats.output_tokens.saturating_add(o);
stats.cache_create_tokens = stats.cache_create_tokens.saturating_add(cc);
stats.cache_read_tokens = stats.cache_read_tokens.saturating_add(cr);
}
if let Some(content) = msg
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
let mut text_acc: Option<String> = None;
let mut tool_name: Option<String> = None;
for block in content {
let bt = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match bt {
"text" if text_acc.is_none() => {
text_acc = block
.get("text")
.and_then(|t| t.as_str())
.map(|s| truncate(s, 200));
}
"tool_use" => {
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("?")
.to_string();
let id = block
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let input = block.get("input");
if !id.is_empty() {
pending.insert(id);
}
if tool_name.is_none() {
tool_name = Some(name.clone());
}
match name.as_str() {
"TaskCreate" | "TodoWrite" => {
if let Some(arr) = input
.and_then(|i| i.get("todos"))
.and_then(|t| t.as_array())
{
stats.todos = arr
.iter()
.filter_map(|t| {
let content = t
.get("content")
.or_else(|| t.get("activeForm"))
.and_then(|c| c.as_str())?;
let status = t
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("pending");
Some(TodoEntry {
content: content.to_string(),
status: status.to_string(),
})
})
.collect();
}
}
"Bash" => {
if let Some(cmd) = input
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
{
stats.recent_bash.insert(0, truncate(cmd, 96));
stats.recent_bash.truncate(10);
}
}
"Edit" | "Write" | "NotebookEdit" => {
if let Some(p) = input
.and_then(|i| i.get("file_path"))
.and_then(|f| f.as_str())
{
let entry = RecentFile {
tool: name.clone(),
path: p.to_string(),
};
if !stats.recent_files.iter().any(|e| {
e.tool == entry.tool && e.path == entry.path
}) {
stats.recent_files.insert(0, entry);
stats.recent_files.truncate(10);
}
}
}
"Agent" => {
let sub_type = input
.and_then(|i| i.get("subagent_type"))
.and_then(|s| s.as_str())
.unwrap_or("?");
let desc = input
.and_then(|i| i.get("description"))
.and_then(|s| s.as_str())
.unwrap_or("");
stats
.recent_subagents
.insert(0, format!("{sub_type}: {desc}"));
stats.recent_subagents.truncate(5);
}
_ => {}
}
}
_ => {}
}
}
if let Some(t) = text_acc {
stats.last_assistant_msg = Some(t);
seen_assistant_text = true;
} else if let Some(n) = tool_name {
stats.last_assistant_msg = Some(format!("⚙ {n}"));
last_assistant_was_tool = true;
stats.last_tool_name = Some(n);
seen_assistant_text = true;
}
}
}
"user" => {
let content = v.get("message").and_then(|m| m.get("content"));
if let Some(serde_json::Value::Array(arr)) = content {
for b in arr {
if b.get("type").and_then(|t| t.as_str()) == Some("tool_result")
&& let Some(id) = b.get("tool_use_id").and_then(|i| i.as_str())
{
pending.remove(id);
}
}
}
let text = match content {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|b| {
if b.get("type").and_then(|t| t.as_str()) == Some("text") {
b.get("text").and_then(|t| t.as_str()).map(String::from)
} else {
None
}
})
.next(),
_ => None,
};
if let Some(t) = text {
let t = t.trim();
if !t.is_empty()
&& !t.starts_with("<system-reminder>")
&& !t.starts_with("<command-")
&& !t.starts_with("Caveat:")
{
stats.last_user_msg = Some(truncate(t, 200));
seen_user_msg = true;
}
}
}
_ => {}
}
let _ = seen_assistant_text;
let _ = seen_user_msg;
}
stats.last_was_tool_call = last_assistant_was_tool;
stats.pending_tool_uses = pending.len();
stats
}
fn read_byte_range(path: &std::path::Path, start: u64, end: u64) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
if end <= start {
return Some(String::new());
}
let mut f = std::fs::File::open(path).ok()?;
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::with_capacity((end - start) as usize);
f.take(end - start).read_to_end(&mut buf).ok()?;
let s = String::from_utf8_lossy(&buf).into_owned();
if start > 0
&& let Some(nl) = s.find('\n')
{
return Some(s[nl + 1..].to_string());
}
Some(s)
}
fn read_tail(path: &std::path::Path, cap: usize) -> std::io::Result<String> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path)?;
let len = f.metadata()?.len();
let start = len.saturating_sub(cap as u64);
f.seek(SeekFrom::Start(start))?;
let mut buf = Vec::with_capacity(cap);
f.take(cap as u64).read_to_end(&mut buf)?;
let s = String::from_utf8_lossy(&buf).into_owned();
if start > 0
&& let Some(nl) = s.find('\n')
{
return Ok(s[nl + 1..].to_string());
}
Ok(s)
}
fn truncate(s: &str, max: usize) -> String {
let mut collapsed = String::with_capacity(s.len());
let mut last_was_space = false;
for c in s.trim().chars() {
if c.is_whitespace() {
if !last_was_space {
collapsed.push(' ');
}
last_was_space = true;
} else {
collapsed.push(c);
last_was_space = false;
}
}
if collapsed.chars().count() <= max {
collapsed
} else {
let cut: String = collapsed.chars().take(max).collect();
format!("{cut}…")
}
}
#[derive(Debug, Clone)]
pub struct SearchHit {
pub transcript_path: PathBuf,
pub workspace: String,
pub session_id: String,
pub snippet: String,
pub role: SearchRole,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchRole {
User,
Assistant,
ToolBash,
ToolEdit,
}
impl SearchRole {
pub fn glyph(self) -> &'static str {
match self {
SearchRole::User => "user",
SearchRole::Assistant => "asst",
SearchRole::ToolBash => "bash",
SearchRole::ToolEdit => "edit",
}
}
}
pub fn search_all_transcripts(query: &str) -> Vec<SearchHit> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return Vec::new();
}
let Some(root) = home_projects_dir() else {
return Vec::new();
};
let Ok(dirs) = std::fs::read_dir(&root) else {
return Vec::new();
};
let mut files: Vec<(PathBuf, SystemTime, String)> = Vec::new();
for d in dirs.flatten() {
let dir = d.path();
if !dir.is_dir() {
continue;
}
let workspace = dir
.file_name()
.and_then(|s| s.to_str())
.map(decode_workspace_label)
.unwrap_or_else(|| "?".to_string());
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for f in rd.flatten() {
let p = f.path();
if p.extension().is_none_or(|e| e != "jsonl") {
continue;
}
let mtime = f
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
files.push((p, mtime, workspace.clone()));
}
}
files.sort_by_key(|b| std::cmp::Reverse(b.1));
let mut hits: Vec<SearchHit> = Vec::new();
const HIT_CAP: usize = 200;
'outer: for (path, _mtime, workspace) in files {
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string();
let Ok(f) = std::fs::File::open(&path) else {
continue;
};
use std::io::BufRead;
let reader = std::io::BufReader::new(f);
for line in reader.lines().map_while(Result::ok) {
if !line.to_lowercase().contains(&q) {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
let extracted = match ty {
"user" => extract_user_snippet(&v, &q),
"assistant" => extract_assistant_snippet(&v, &q),
_ => None,
};
if let Some((role, snippet)) = extracted {
hits.push(SearchHit {
transcript_path: path.clone(),
workspace: workspace.clone(),
session_id: session_id.clone(),
snippet,
role,
});
if hits.len() >= HIT_CAP {
break 'outer;
}
}
}
}
hits
}
fn extract_user_snippet(v: &serde_json::Value, q: &str) -> Option<(SearchRole, String)> {
let content = v.get("message").and_then(|m| m.get("content"))?;
let text = match content {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|b| {
if b.get("type").and_then(|t| t.as_str()) == Some("text") {
b.get("text").and_then(|t| t.as_str()).map(String::from)
} else {
None
}
})
.next()?,
_ => return None,
};
if !text.to_lowercase().contains(q) {
return None;
}
if text.starts_with("<system-reminder>")
|| text.starts_with("<command-")
|| text.starts_with("Caveat:")
{
return None;
}
Some((SearchRole::User, truncate(&text, 160)))
}
fn extract_assistant_snippet(v: &serde_json::Value, q: &str) -> Option<(SearchRole, String)> {
let content = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())?;
for block in content {
let bt = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match bt {
"text" => {
if let Some(s) = block.get("text").and_then(|t| t.as_str())
&& s.to_lowercase().contains(q)
{
return Some((SearchRole::Assistant, truncate(s, 160)));
}
}
"tool_use" => {
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = block.get("input");
match name {
"Bash" => {
if let Some(cmd) = input
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
&& cmd.to_lowercase().contains(q)
{
return Some((SearchRole::ToolBash, truncate(cmd, 160)));
}
}
"Edit" | "Write" | "Read" => {
if let Some(p) = input
.and_then(|i| i.get("file_path"))
.and_then(|f| f.as_str())
&& p.to_lowercase().contains(q)
{
return Some((SearchRole::ToolEdit, format!("{name} {p}")));
}
}
_ => {}
}
}
_ => {}
}
}
None
}
pub fn export_transcript_as_markdown(row: &AgentRow) -> Result<(String, String), String> {
let sid_short: String = row.session_id.chars().take(8).collect();
let ws_safe: String = row
.workspace
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect();
let ws_safe = if ws_safe.is_empty() {
"workspace".to_string()
} else {
ws_safe
};
let stem = format!("{}-{ws_safe}-{sid_short}", utc_stamp());
if row.source == AgentSource::Codex {
let mut out = String::new();
out.push_str(&format!(
"# Codex session {sid_short}\n\n_workspace: {} · pid: {} · state: {} · model: {}_\n\n",
row.workspace,
row.pid
.map(|p| p.to_string())
.unwrap_or_else(|| "—".to_string()),
row.state.badge(),
row.model.as_deref().unwrap_or("?"),
));
if let Some(cwd) = &row.cwd {
out.push_str(&format!("**cwd**: `{cwd}`\n\n"));
}
let path = row.transcript_path.clone();
if path.is_file()
&& let Ok(f) = std::fs::File::open(&path)
{
use std::io::BufRead;
let reader = std::io::BufReader::new(f);
for line in reader.lines().map_while(Result::ok) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let payload = v.get("payload").unwrap_or(&v);
let ty = payload.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
"user_message" => {
if let Some(c) = payload.get("content").and_then(|c| c.as_str()) {
out.push_str(&format!("## User\n\n{c}\n\n"));
}
}
"assistant_message" => {
if let Some(c) = payload.get("content").and_then(|c| c.as_str()) {
out.push_str(&format!("## Codex\n\n{c}\n\n"));
}
}
"function_call" => {
let name = payload.get("name").and_then(|s| s.as_str()).unwrap_or("?");
let args = payload
.get("arguments")
.and_then(|s| s.as_str())
.unwrap_or("");
out.push_str(&format!("### tool: `{name}`\n\n```\n{args}\n```\n\n"));
}
_ => {}
}
}
}
return Ok((stem, out));
}
let path = row.transcript_path.clone();
if !path.is_file() {
return Err("no transcript on disk".to_string());
}
let f = std::fs::File::open(&path).map_err(|e| format!("open: {e}"))?;
use std::io::BufRead;
let reader = std::io::BufReader::new(f);
let mut out = String::new();
out.push_str(&format!(
"# Claude session {sid_short}\n\n_workspace: {} · branch: {} · model: {}_\n\n",
row.workspace,
row.git_branch.as_deref().unwrap_or("?"),
row.model.as_deref().unwrap_or("?"),
));
for line in reader.lines().map_while(Result::ok) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
"user" => {
if let Some(txt) = extract_user_message_text(&v) {
if txt.starts_with("<system-reminder>") {
continue;
}
out.push_str("## 👤 User\n\n");
out.push_str(&txt);
out.push_str("\n\n");
}
}
"assistant" => {
if let Some(content) = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
let mut header_written = false;
for block in content {
let bt = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match bt {
"text" => {
if !header_written {
out.push_str("## 🤖 Assistant\n\n");
header_written = true;
}
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
out.push_str(t);
out.push_str("\n\n");
}
}
"tool_use" => {
let name =
block.get("name").and_then(|n| n.as_str()).unwrap_or("?");
let input = block.get("input").unwrap_or(&serde_json::Value::Null);
if !header_written {
out.push_str("## 🤖 Assistant\n\n");
header_written = true;
}
out.push_str(&format!("_⚙ tool: {name}_\n\n```json\n"));
if let Ok(s) = serde_json::to_string_pretty(input) {
let trimmed: String = s.chars().take(2000).collect();
out.push_str(&trimmed);
}
out.push_str("\n```\n\n");
}
_ => {}
}
}
}
}
_ => {}
}
}
Ok((stem, out))
}
fn utc_stamp() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days_since_epoch = (secs / 86400) as i64;
let time_of_day = secs % 86400;
let h = time_of_day / 3600;
let m = (time_of_day % 3600) / 60;
let s = time_of_day % 60;
let z = days_since_epoch + 719468;
let era = z.div_euclid(146097);
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = (yoe as i64) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m_civil = if mp < 10 { mp + 3 } else { mp - 9 };
let y_civil = if m_civil <= 2 { y + 1 } else { y };
format!(
"{:04}{:02}{:02}-{:02}{:02}{:02}",
y_civil, m_civil, d, h, m, s
)
}
fn extract_user_message_text(v: &serde_json::Value) -> Option<String> {
let content = v.get("message").and_then(|m| m.get("content"))?;
match content {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|b| {
if b.get("type").and_then(|t| t.as_str()) == Some("text") {
b.get("text").and_then(|t| t.as_str()).map(String::from)
} else {
None
}
})
.next(),
_ => None,
}
}
#[derive(Debug, Clone, Default)]
pub struct SpendToday {
pub claude_sessions: usize,
pub codex_sessions: usize,
pub total_tokens: u64,
pub total_cost_usd: f64,
pub per_workspace: Vec<(String, u64, f64)>,
}
#[allow(dead_code)]
pub fn spend_today() -> SpendToday {
spend_today_with_abort(None)
}
pub fn spend_today_with_abort(
abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> SpendToday {
let aborted = || {
abort
.as_ref()
.is_some_and(|a| a.load(std::sync::atomic::Ordering::Relaxed))
};
let mut s = SpendToday::default();
let mut by_workspace: std::collections::BTreeMap<String, (u64, f64)> =
std::collections::BTreeMap::new();
let cutoff = SystemTime::now()
.checked_sub(std::time::Duration::from_secs(24 * 3600))
.unwrap_or(SystemTime::UNIX_EPOCH);
if let Some(root) = home_projects_dir()
&& let Ok(dirs) = std::fs::read_dir(&root)
{
for d in dirs.flatten() {
if aborted() {
return s;
}
let p = d.path();
let workspace = p
.file_name()
.and_then(|s| s.to_str())
.map(decode_workspace_label)
.unwrap_or_else(|| "?".to_string());
let Ok(rd) = std::fs::read_dir(&p) else {
continue;
};
for f in rd.flatten() {
if aborted() {
return s;
}
let fp = f.path();
if fp.extension().is_none_or(|e| e != "jsonl") {
continue;
}
let Ok(meta) = f.metadata() else { continue };
let Ok(mt) = meta.modified() else { continue };
if mt < cutoff {
continue;
}
let stats = parse_full(&fp);
let cost = stats
.model
.as_deref()
.map(|m| {
estimate_cost(
m,
stats.input_tokens,
stats.output_tokens,
stats.cache_create_tokens,
stats.cache_read_tokens,
)
})
.unwrap_or(0.0);
s.claude_sessions += 1;
s.total_tokens += stats.tokens;
s.total_cost_usd += cost;
let bucket = by_workspace.entry(workspace.clone()).or_default();
bucket.0 += stats.tokens;
bucket.1 += cost;
}
}
}
if let Some(root) = std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex/sessions")) {
for fp in walk_codex_sessions(&root) {
if aborted() {
return s;
}
let Ok(meta) = std::fs::metadata(&fp) else {
continue;
};
let Ok(mt) = meta.modified() else { continue };
if mt < cutoff {
continue;
}
let stats = parse_codex_tail(&fp);
let net_input = stats.input_tokens.saturating_sub(stats.cache_read_tokens);
let cost = stats
.model
.as_deref()
.map(|m| {
estimate_cost(
m,
net_input,
stats.output_tokens,
0,
stats.cache_read_tokens,
)
})
.unwrap_or(0.0);
let workspace = stats
.cwd
.as_deref()
.and_then(|c| std::path::Path::new(c).file_name())
.and_then(|s| s.to_str())
.map(String::from)
.unwrap_or_else(|| "?".to_string());
s.codex_sessions += 1;
s.total_tokens += stats.tokens;
s.total_cost_usd += cost;
let bucket = by_workspace.entry(workspace).or_default();
bucket.0 += stats.tokens;
bucket.1 += cost;
}
}
let mut per_ws: Vec<(String, u64, f64)> = by_workspace
.into_iter()
.map(|(k, (tok, cost))| (k, tok, cost))
.collect();
per_ws.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
s.per_workspace = per_ws;
s
}
fn price_per_mt(model: &str) -> (f64, f64, f64, f64) {
let trimmed = model
.rsplit_once('-')
.map(|(stem, tail)| {
if tail.chars().all(|c| c.is_ascii_digit()) && tail.len() >= 6 {
stem
} else {
model
}
})
.unwrap_or(model);
match trimmed {
"claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" => (15.0, 75.0, 18.75, 1.50),
"claude-sonnet-4-6" | "claude-sonnet-4-5" => (3.0, 15.0, 3.75, 0.30),
"claude-haiku-4-5" | "claude-haiku-4-4" => (1.0, 5.0, 1.25, 0.10),
"gpt-5" | "gpt-5.5" => (5.0, 30.0, 0.0, 0.50),
"gpt-5-mini" | "gpt-5.5-mini" => (0.50, 2.0, 0.0, 0.05),
"gpt-4o" => (2.50, 10.0, 0.0, 1.25),
"gpt-4o-mini" => (0.15, 0.60, 0.0, 0.075),
_ => (0.0, 0.0, 0.0, 0.0),
}
}
fn estimate_cost(model: &str, input: u64, output: u64, cache_create: u64, cache_read: u64) -> f64 {
let (in_pmt, out_pmt, cw_pmt, cr_pmt) = price_per_mt(model);
let f = |n: u64| n as f64 / 1_000_000.0;
f(input) * in_pmt + f(output) * out_pmt + f(cache_create) * cw_pmt + f(cache_read) * cr_pmt
}
fn scan_running_pids(source: AgentSource) -> Vec<(String, u32, String)> {
let exe = source.exe_name();
let out = std::process::Command::new("ps")
.args(["-axo", "pid=,command="])
.output();
let Ok(o) = out else {
return Vec::new();
};
if !o.status.success() {
return Vec::new();
}
let text = String::from_utf8_lossy(&o.stdout);
let mut found: Vec<(String, u32, String)> = Vec::new();
for raw_line in text.lines() {
let line = raw_line.trim_start();
let mut parts = line.splitn(2, ' ');
let Some(pid_str) = parts.next() else {
continue;
};
let Some(cmdline) = parts.next() else {
continue;
};
let cmdline = cmdline.trim_start();
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
if !cmdline.contains(exe) {
continue;
}
let first = cmdline.split_whitespace().next().unwrap_or("");
let basename = std::path::Path::new(first)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if basename != exe && !first.ends_with(&format!("/{exe}")) {
continue;
}
let sid = parse_session_id_arg(cmdline).unwrap_or_default();
found.push((sid, pid, cmdline.to_string()));
}
found
}
fn parse_session_id_arg(cmdline: &str) -> Option<String> {
let mut tokens = cmdline.split_whitespace();
while let Some(t) = tokens.next() {
if (t == "--session-id" || t == "--resume")
&& let Some(v) = tokens.next()
{
if v.len() == 36 && v.matches('-').count() == 4 {
return Some(v.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn parse_session_id_finds_uuid_after_flag() {
let id = parse_session_id_arg(
"/usr/bin/claude --session-id 12345678-1234-1234-1234-1234567890ab --foo bar",
)
.unwrap();
assert_eq!(id.len(), 36);
}
#[test]
fn parse_session_id_finds_resume_flag() {
let id =
parse_session_id_arg("claude --resume aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
assert!(id.contains("aaaa"));
}
#[test]
fn parse_session_id_returns_none_when_absent() {
assert!(parse_session_id_arg("claude --help").is_none());
}
#[test]
fn workspace_decoder_takes_last_segment() {
assert_eq!(decode_workspace_label("-Users-chris-Projects-mnml"), "mnml");
assert_eq!(decode_workspace_label("-tmp-foo"), "foo");
}
#[test]
fn tail_parses_assistant_event_with_tokens() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("session.jsonl");
let mut f = std::fs::File::create(&p).unwrap();
let line = serde_json::json!({
"type": "assistant",
"timestamp": "2026-06-20T12:00:00Z",
"cwd": "/Users/x/Projects/mnml",
"gitBranch": "main",
"message": {
"model": "claude-opus-4-7",
"content": [{"type":"text","text":"Hello back"}],
"usage": {"input_tokens": 50, "output_tokens": 10}
}
});
writeln!(f, "{line}").unwrap();
let stats = parse_tail(&p);
assert_eq!(stats.model.as_deref(), Some("claude-opus-4-7"));
assert_eq!(stats.tokens, 60);
assert_eq!(stats.cwd.as_deref(), Some("/Users/x/Projects/mnml"));
assert_eq!(stats.git_branch.as_deref(), Some("main"));
assert_eq!(stats.last_assistant_msg.as_deref(), Some("Hello back"));
}
#[test]
fn tail_marks_last_assistant_as_tool_when_tool_use_only() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("session.jsonl");
let mut f = std::fs::File::create(&p).unwrap();
let line = serde_json::json!({
"type": "assistant",
"message": {
"model": "claude-opus-4-7",
"content": [{"type":"tool_use","name":"Bash","input":{"command":"ls"}}],
"usage": {"input_tokens": 5, "output_tokens": 2}
}
});
writeln!(f, "{line}").unwrap();
let stats = parse_tail(&p);
assert!(stats.last_was_tool_call);
assert_eq!(stats.last_assistant_msg.as_deref(), Some("⚙ Bash"));
}
#[test]
fn parse_codex_tail_extracts_user_assistant_tokens_and_model() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("rollout-2026-06-20T23-25-18-abc.jsonl");
let mut f = std::fs::File::create(&p).unwrap();
use std::io::Write;
writeln!(
f,
r#"{{"timestamp":"2026-06-21T03:25:20.758Z","type":"session_meta","payload":{{"id":"x","cwd":"/Users/chrismclennan","cli_version":"0.141.0"}}}}"#
)
.unwrap();
writeln!(
f,
r#"{{"timestamp":"2026-06-21T03:25:20.763Z","type":"turn_context","payload":{{"model":"gpt-5.5","cwd":"/Users/chrismclennan"}}}}"#
)
.unwrap();
writeln!(
f,
r#"{{"timestamp":"2026-06-21T03:25:21.0Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"hello"}}]}}}}"#
)
.unwrap();
writeln!(
f,
r#"{{"timestamp":"2026-06-21T03:25:22.0Z","type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"Hello back"}}]}}}}"#
)
.unwrap();
writeln!(
f,
r#"{{"timestamp":"2026-06-21T03:25:22.145Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":12229,"cached_input_tokens":9600,"output_tokens":15,"reasoning_output_tokens":0,"total_tokens":12244}}}}}}}}"#
)
.unwrap();
let stats = parse_codex_tail(&p);
assert_eq!(stats.cwd.as_deref(), Some("/Users/chrismclennan"));
assert_eq!(stats.model.as_deref(), Some("gpt-5.5"));
assert_eq!(stats.last_user_msg.as_deref(), Some("hello"));
assert_eq!(stats.last_assistant_msg.as_deref(), Some("Hello back"));
assert_eq!(stats.input_tokens, 12229);
assert_eq!(stats.output_tokens, 15);
assert_eq!(stats.cache_read_tokens, 9600);
assert_eq!(stats.tokens, 12244);
}
#[test]
fn parse_codex_tail_extracts_function_call_as_bash_and_tracks_pending() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("rollout-x.jsonl");
let mut f = std::fs::File::create(&p).unwrap();
use std::io::Write;
writeln!(
f,
r#"{{"timestamp":"t","type":"response_item","payload":{{"type":"function_call","name":"exec_command","arguments":"{{\"cmd\":\"cat /tmp/foo\"}}","call_id":"call_A"}}}}"#
)
.unwrap();
writeln!(
f,
r#"{{"timestamp":"t","type":"response_item","payload":{{"type":"function_call_output","call_id":"call_A","output":"hello\n"}}}}"#
)
.unwrap();
let stats = parse_codex_tail(&p);
assert_eq!(stats.recent_bash, vec!["cat /tmp/foo".to_string()]);
assert_eq!(stats.pending_tool_uses, 0);
assert!(!stats.last_was_tool_call);
}
#[test]
fn parse_codex_tail_marks_pending_when_function_call_has_no_output() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("rollout-x.jsonl");
let mut f = std::fs::File::create(&p).unwrap();
use std::io::Write;
writeln!(
f,
r#"{{"timestamp":"t","type":"response_item","payload":{{"type":"function_call","name":"exec_command","arguments":"{{\"cmd\":\"sleep 5\"}}","call_id":"call_B"}}}}"#
)
.unwrap();
let stats = parse_codex_tail(&p);
assert_eq!(stats.pending_tool_uses, 1);
assert!(stats.last_was_tool_call);
assert_eq!(stats.recent_bash, vec!["sleep 5".to_string()]);
}
#[test]
fn parse_codex_tail_filters_environment_context_blocks() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("rollout-x.jsonl");
let mut f = std::fs::File::create(&p).unwrap();
use std::io::Write;
writeln!(
f,
r#"{{"timestamp":"x","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"<environment_context>...</environment_context>"}}]}}}}"#
)
.unwrap();
writeln!(
f,
r#"{{"timestamp":"x","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"real message"}}]}}}}"#
)
.unwrap();
let stats = parse_codex_tail(&p);
assert_eq!(stats.last_user_msg.as_deref(), Some("real message"));
}
#[test]
fn openai_cost_does_not_double_count_cached_portion() {
let net = 12229u64.saturating_sub(9600);
let cost = estimate_cost("gpt-5.5", net, 0, 0, 9600);
assert!((cost - 0.01795).abs() < 0.0001, "got {cost}");
}
#[test]
fn gpt_5_5_pricing_is_in_table() {
let (i, o, _, cr) = price_per_mt("gpt-5.5");
assert!(i > 0.0 && o > 0.0 && cr > 0.0);
assert!(o > i, "output should cost more than input");
}
#[test]
fn read_byte_range_skips_partial_first_line_when_offset_nonzero() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("rolling.jsonl");
std::fs::write(
&p,
b"line one is long here\n{\"k\":\"v\"}\n{\"second\":true}\n",
)
.unwrap();
let s = read_byte_range(&p, 5, 50).unwrap();
assert!(!s.contains("line one"));
assert!(s.contains("\"k\":\"v\""));
}
#[test]
fn read_byte_range_returns_empty_when_no_new_bytes() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("rolling.jsonl");
std::fs::write(&p, b"hello").unwrap();
let s = read_byte_range(&p, 5, 5).unwrap();
assert_eq!(s, "");
}
#[test]
fn utc_stamp_has_yyyymmdd_hhmmss_shape() {
let s = utc_stamp();
assert_eq!(s.len(), 15);
assert_eq!(s.as_bytes()[8], b'-');
assert!(s.starts_with("20"));
for (i, c) in s.char_indices() {
if i == 8 {
continue;
}
assert!(c.is_ascii_digit(), "non-digit at pos {i}: {c}");
}
}
#[test]
fn export_markdown_walks_user_and_assistant_blocks() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("session.jsonl");
let mut f = std::fs::File::create(&p).unwrap();
use std::io::Write;
writeln!(
f,
r#"{{"type":"user","message":{{"role":"user","content":"hello there"}}}}"#
)
.unwrap();
writeln!(
f,
r#"{{"type":"assistant","message":{{"model":"x","content":[{{"type":"text","text":"hi back"}}]}}}}"#
)
.unwrap();
writeln!(
f,
r#"{{"type":"assistant","message":{{"model":"x","content":[{{"type":"tool_use","name":"Bash","input":{{"command":"ls"}}}}]}}}}"#
)
.unwrap();
let row = AgentRow {
source: AgentSource::Claude,
transcript_path: p.clone(),
session_id: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb".to_string(),
workspace: "x".to_string(),
cwd: Some("/Users/x".to_string()),
git_branch: Some("main".to_string()),
model: Some("claude-opus-4-7".to_string()),
last_activity: None,
tokens: 0,
input_tokens: 0,
output_tokens: 0,
cache_create_tokens: 0,
cache_read_tokens: 0,
cost_usd: 0.0,
event_count: 0,
last_user_msg: None,
last_assistant_msg: None,
pid: None,
state: AgentState::Ended,
current_tool: None,
todos: Vec::new(),
recent_bash: Vec::new(),
recent_files: Vec::new(),
recent_subagents: Vec::new(),
pending_tool_uses: 0,
tokens_per_min: None,
};
let (_stem, md) = export_transcript_as_markdown(&row).unwrap();
assert!(md.contains("hello there"));
assert!(md.contains("hi back"));
assert!(md.contains("Bash"));
assert!(md.contains("ls"));
assert!(md.contains("# Claude session"));
}
#[test]
fn search_finds_substring_across_transcripts() {
let v: serde_json::Value = serde_json::from_str(
r#"{"type":"user","message":{"role":"user","content":"please run cargo build for me"}}"#,
)
.unwrap();
let s = extract_user_snippet(&v, "cargo");
assert!(s.is_some());
assert_eq!(s.unwrap().0, SearchRole::User);
}
#[test]
fn search_skips_system_reminder_user_messages() {
let v: serde_json::Value = serde_json::from_str(
r#"{"type":"user","message":{"role":"user","content":"<system-reminder>cargo info</system-reminder>"}}"#,
)
.unwrap();
let s = extract_user_snippet(&v, "cargo");
assert!(s.is_none());
}
#[test]
fn search_extracts_bash_command_from_assistant_tool_use() {
let v: serde_json::Value = serde_json::from_str(
r#"{"type":"assistant","message":{"model":"x","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo build --release"}}]}}"#,
)
.unwrap();
let s = extract_assistant_snippet(&v, "cargo build");
assert!(s.is_some());
let (role, snippet) = s.unwrap();
assert_eq!(role, SearchRole::ToolBash);
assert!(snippet.contains("cargo build"));
}
#[test]
fn pricing_table_handles_known_models_and_falls_back_to_zero() {
let (i, o, _, _) = price_per_mt("claude-opus-4-7");
assert!(i > 0.0 && o > 0.0);
let (i2, o2, _, _) = price_per_mt("claude-haiku-4-5-20251001");
assert!(i2 > 0.0 && o2 > 0.0);
let (i3, o3, _, _) = price_per_mt("gpt-5-turbo");
assert_eq!(i3, 0.0);
assert_eq!(o3, 0.0);
}
#[test]
fn cost_computes_dollars_per_million_tokens() {
let c = estimate_cost("claude-opus-4-7", 1_000_000, 0, 0, 0);
assert!((c - 15.0).abs() < 0.001);
let c2 = estimate_cost("claude-opus-4-7", 0, 1_000_000, 0, 0);
assert!((c2 - 75.0).abs() < 0.001);
}
#[test]
fn agent_source_labels_are_unique() {
assert_eq!(AgentSource::Claude.label(), "claude");
assert_eq!(AgentSource::Codex.label(), "codex");
assert_eq!(AgentSource::Claude.exe_name(), "claude");
assert_eq!(AgentSource::Codex.exe_name(), "codex");
}
#[test]
fn cycle_sort_preserves_focused_session_id() {
let make = |sid: &str, ws: &str, tokens: u64| AgentRow {
source: AgentSource::Claude,
transcript_path: PathBuf::from(format!("/tmp/{sid}.jsonl")),
session_id: sid.to_string(),
workspace: ws.to_string(),
cwd: None,
git_branch: None,
model: None,
last_activity: None,
tokens,
input_tokens: 0,
output_tokens: 0,
cache_create_tokens: 0,
cache_read_tokens: 0,
cost_usd: 0.0,
event_count: 0,
last_user_msg: None,
last_assistant_msg: None,
pid: None,
state: AgentState::Idle,
current_tool: None,
todos: Vec::new(),
recent_bash: Vec::new(),
recent_files: Vec::new(),
recent_subagents: Vec::new(),
pending_tool_uses: 0,
tokens_per_min: None,
};
let rows = vec![
make("aaa", "ws1", 100),
make("bbb", "ws2", 50),
make("ccc", "ws3", 200),
];
let mut p = ClaudeAgentsPane::empty_with_rows(rows);
p.selected = 1;
let before = p.visible_indices()[p.selected];
let before_sid = p.rows[before].session_id.clone();
assert_eq!(before_sid, "bbb");
p.cycle_sort();
let after = p.visible_indices()[p.selected];
assert_eq!(
p.rows[after].session_id, "bbb",
"cycle_sort should re-locate the focused session_id in the new order"
);
}
#[test]
fn tail_filters_system_reminder_user_messages() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("session.jsonl");
let mut f = std::fs::File::create(&p).unwrap();
let real_msg = serde_json::json!({
"type": "user",
"message": {"role": "user", "content": "Hello there"}
});
let reminder = serde_json::json!({
"type": "user",
"message": {"role": "user", "content": "<system-reminder>noise</system-reminder>"}
});
writeln!(f, "{real_msg}").unwrap();
writeln!(f, "{reminder}").unwrap();
let stats = parse_tail(&p);
assert_eq!(stats.last_user_msg.as_deref(), Some("Hello there"));
}
}