Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 103 additions & 6 deletions crates/tokscale-cli/src/tui/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
//! This module provides disk-based caching for TUI data to enable instant UI display
//! while fresh data loads in the background (matching TypeScript implementation behavior).

use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fs::{self, File};
use std::io::{BufReader, BufWriter};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};
use tokscale_core::GroupBy;
use tokscale_core::{sessions, GroupBy};

use crate::ClientFilter;

Expand All @@ -21,7 +21,7 @@ use super::data::{

/// Cache staleness threshold: 5 minutes (matches TS implementation)
const CACHE_STALE_THRESHOLD_MS: u64 = 5 * 60 * 1000;
const CACHE_SCHEMA_VERSION: u32 = 6;
const CACHE_SCHEMA_VERSION: u32 = 7;

/// Get the cache directory path
/// Uses `~/.cache/tokscale/` to match TypeScript implementation for cache sharing
Expand Down Expand Up @@ -586,7 +586,7 @@ impl TryFrom<CachedUsageData> for UsageData {

Ok(Self {
models: u.models.into_iter().map(|m| m.into()).collect(),
agents: u.agents.into_iter().map(|a| a.into()).collect(),
agents: normalize_cached_agents(u.agents),
daily: daily?,
hourly: hourly?,
graph: graph.transpose()?,
Expand All @@ -600,6 +600,58 @@ impl TryFrom<CachedUsageData> for UsageData {
}
}

fn normalize_cached_agents(agents: Vec<CachedAgentUsage>) -> Vec<AgentUsage> {
let mut merged: BTreeMap<String, AgentUsage> = BTreeMap::new();
let mut clients_by_agent: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();

for cached in agents {
let normalized_agent = normalize_cached_agent_name(&cached.agent, &cached.clients);
let entry = merged
.entry(normalized_agent.clone())
.or_insert_with(|| AgentUsage {
agent: normalized_agent.clone(),
clients: String::new(),
tokens: TokenBreakdown::default(),
cost: 0.0,
message_count: 0,
});

let tokens: TokenBreakdown = cached.tokens.into();
entry.tokens.input = entry.tokens.input.saturating_add(tokens.input);
entry.tokens.output = entry.tokens.output.saturating_add(tokens.output);
entry.tokens.cache_read = entry.tokens.cache_read.saturating_add(tokens.cache_read);
entry.tokens.cache_write = entry.tokens.cache_write.saturating_add(tokens.cache_write);
entry.tokens.reasoning = entry.tokens.reasoning.saturating_add(tokens.reasoning);
entry.cost += cached.cost;
entry.message_count = entry.message_count.saturating_add(cached.message_count);

let client_set = clients_by_agent.entry(normalized_agent).or_default();
for client in cached
.clients
.split(", ")
.filter(|client| !client.is_empty())
{
client_set.insert(client.to_string());
}
}

let mut agents = merged.into_values().collect::<Vec<_>>();
for agent in &mut agents {
if let Some(clients) = clients_by_agent.get(&agent.agent) {
agent.clients = clients.iter().cloned().collect::<Vec<_>>().join(", ");
}
}
agents
}

fn normalize_cached_agent_name(agent: &str, clients: &str) -> String {
if clients.split(", ").any(|client| client == "opencode") {
sessions::normalize_opencode_agent_name(agent)
} else {
sessions::normalize_agent_name(agent)
}
}

/// Result of loading the TUI cache — combines staleness check with data loading
/// to avoid double file I/O (previously is_cache_stale + load_cached_data both parsed the file).
pub enum CacheResult {
Expand Down Expand Up @@ -846,6 +898,51 @@ mod tests {
set
}

fn cached_agent(agent: &str, clients: &str, total_seed: u64) -> CachedAgentUsage {
CachedAgentUsage {
agent: agent.to_string(),
clients: clients.to_string(),
tokens: CachedTokenBreakdown {
input: total_seed,
output: 1,
cache_read: 2,
cache_write: 3,
reasoning: 4,
},
cost: total_seed as f64,
message_count: 1,
}
}

#[test]
fn test_normalize_cached_agents_merges_opencode_display_variants() {
let agents = normalize_cached_agents(vec![
cached_agent("Sisyphus", "opencode", 10),
cached_agent("\u{200B} Sisyphus - Ultraworker", "opencode", 20),
cached_agent(
"\u{200B}\u{200B}\u{200B} Prometheus Plan Builder",
"opencode",
30,
),
]);

assert_eq!(agents.len(), 2);
let sisyphus = agents
.iter()
.find(|agent| agent.agent == "Sisyphus")
.unwrap();
assert_eq!(sisyphus.clients, "opencode");
assert_eq!(sisyphus.message_count, 2);
assert_eq!(sisyphus.tokens.input, 30);
assert!((sisyphus.cost - 30.0).abs() < f64::EPSILON);

let prometheus = agents
.iter()
.find(|agent| agent.agent == "Prometheus")
.unwrap();
assert_eq!(prometheus.message_count, 1);
}

// ── check_client_match ──────────────────────────────────────────

#[test]
Expand Down Expand Up @@ -1127,7 +1224,7 @@ mod tests {
fs::write(
&cache_path,
r#"{
"schemaVersion": 6,
"schemaVersion": 7,
"timestamp": 9999999999999,
"enabledClients": ["claude", "cursor"],
"includeSynthetic": false,
Expand Down Expand Up @@ -1407,7 +1504,7 @@ mod tests {
fs::write(
&legacy_path,
r#"{
"schemaVersion": 6,
"schemaVersion": 7,
"timestamp": 9999999999999,
"enabledClients": ["claude"],
"includeSynthetic": false,
Expand Down
144 changes: 131 additions & 13 deletions crates/tokscale-core/src/sessions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,17 @@ const fn default_message_count() -> i32 {
}

pub fn normalize_agent_name(agent: &str) -> String {
let trimmed = agent.trim();
let cleaned = strip_zero_width_chars(agent);
let trimmed = cleaned.trim();
let stripped = strip_agent_prefix(trimmed);
let agent_lower = stripped.to_lowercase();
let canonical = canonicalize_agent_name(stripped);
let agent_lower = canonical.to_lowercase();

if agent_lower.contains("plan") {
if agent_lower.contains("omo") || agent_lower.contains("sisyphus") {
return "Planner-Sisyphus".to_string();
}
return titlecase_agent(stripped);
return titlecase_agent(&canonical);
}

if agent_lower == "omo" || agent_lower == "sisyphus" {
Expand All @@ -74,30 +76,52 @@ pub fn normalize_agent_name(agent: &str) -> String {
return "Atlas".to_string();
}

titlecase_agent(stripped)
titlecase_agent(&canonical)
}

pub fn normalize_opencode_agent_name(agent: &str) -> String {
let trimmed = agent.trim();
let cleaned = strip_zero_width_chars(agent);
let trimmed = cleaned.trim();
let stripped = strip_agent_prefix(trimmed);
let agent_lower = stripped.to_lowercase();
let canonical = canonicalize_agent_name(stripped);
let agent_lower = canonical.to_lowercase();

if let Some(normalized) = normalize_oh_my_opencode_agent_name(&agent_lower) {
return normalized;
}

normalize_agent_name(stripped)
normalize_agent_name(&canonical)
}

fn normalize_oh_my_opencode_agent_name(agent_lower: &str) -> Option<String> {
let normalized = match agent_lower {
"sisyphus (ultraworker)" | "sisyphus" => "Sisyphus",
// Parenthesized format and dash format
"sisyphus (ultraworker)"
| "sisyphus - ultraworker"
| "sisyphus ultraworker"
| "sisyphus" => "Sisyphus",
"hephaestus (deep agent)"
| "hephaestus - deep agent"
| "hephaestus deep agent"
| "hephaestus" => "Hephaestus",
"prometheus (plan builder)"
| "prometheus - plan builder"
| "prometheus plan builder"
| "prometheus (planner)"
| "prometheus" => "Prometheus",
"atlas (plan executor)" | "atlas - plan executor" | "atlas plan executor" | "atlas" => {
"Atlas"
}
"metis (plan consultant)"
| "metis - plan consultant"
| "metis plan consultant"
| "metis" => "Metis",
"momus (plan critic)"
| "momus - plan critic"
| "momus plan critic"
| "momus (plan reviewer)"
| "momus" => "Momus",
"orchestrator-sisyphus" => "Atlas",
"hephaestus (deep agent)" | "hephaestus" => "Hephaestus",
"prometheus (plan builder)" | "prometheus (planner)" | "prometheus" => "Prometheus",
"atlas (plan executor)" | "atlas" => "Atlas",
"metis (plan consultant)" | "metis" => "Metis",
"momus (plan critic)" | "momus (plan reviewer)" | "momus" => "Momus",
"sisyphus-junior" => "Sisyphus-Junior",
"planner-sisyphus" => "Planner-Sisyphus",
_ => return None,
Expand All @@ -106,6 +130,18 @@ fn normalize_oh_my_opencode_agent_name(agent_lower: &str) -> Option<String> {
Some(normalized.to_string())
}

/// Strip zero-width Unicode characters that oh-my-openagent uses as
/// invisible sort-order prefixes (U+200B ZERO WIDTH SPACE, U+200C ZERO
/// WIDTH NON-JOINER, U+200D ZERO WIDTH JOINER, U+FEFF BOM/ZWNBSP).
fn strip_zero_width_chars(s: &str) -> String {
if !s.contains(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}']) {
return s.to_string();
}
s.chars()
.filter(|c| !matches!(c, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'))
.collect()
}

fn strip_agent_prefix(name: &str) -> &str {
for prefix in &["astrape:", "oh-my-claudecode:", "oh-my-codex:"] {
if name
Expand All @@ -118,6 +154,10 @@ fn strip_agent_prefix(name: &str) -> &str {
name
}

fn canonicalize_agent_name(name: &str) -> String {
name.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn titlecase_word(word: &str) -> String {
match word.to_lowercase().as_str() {
"ui" => "UI".to_string(),
Expand All @@ -141,6 +181,7 @@ fn titlecase_agent(name: &str) -> String {
return String::new();
}
name.split('-')
.flat_map(|part| part.split_whitespace())
.map(titlecase_word)
.collect::<Vec<_>>()
.join(" ")
Expand Down Expand Up @@ -518,5 +559,82 @@ mod tests {
normalize_opencode_agent_name("oh-my-claudecode:executor"),
"Executor"
);

// New dash format (oh-my-openagent current)
assert_eq!(
normalize_opencode_agent_name("Sisyphus - Ultraworker"),
"Sisyphus"
);
assert_eq!(
normalize_opencode_agent_name("Hephaestus - Deep Agent"),
"Hephaestus"
);
assert_eq!(
normalize_opencode_agent_name("Prometheus - Plan Builder"),
"Prometheus"
);
assert_eq!(
normalize_opencode_agent_name("Atlas - Plan Executor"),
"Atlas"
);
assert_eq!(
normalize_opencode_agent_name("Metis - Plan Consultant"),
"Metis"
);
assert_eq!(
normalize_opencode_agent_name("Momus - Plan Critic"),
"Momus"
);

// ZWSP-prefixed names (oh-my-openagent sort-order prefixes)
assert_eq!(
normalize_opencode_agent_name("\u{200B}Sisyphus - Ultraworker"),
"Sisyphus"
);
assert_eq!(
normalize_opencode_agent_name("\u{200B}\u{200B}\u{200B}Prometheus - Plan Builder"),
"Prometheus"
);
assert_eq!(
normalize_opencode_agent_name("\u{200B}\u{200B}\u{200B}\u{200B}Atlas - Plan Executor"),
"Atlas"
);
assert_eq!(
normalize_opencode_agent_name("\u{FEFF}Momus - Plan Critic"),
"Momus"
);
assert_eq!(
normalize_opencode_agent_name("\u{200B}sisyphus-junior"),
"Sisyphus-Junior"
);
assert_eq!(
normalize_opencode_agent_name("\u{200B}sisyphus"),
"Sisyphus"
);
assert_eq!(
normalize_opencode_agent_name("\u{200B} Sisyphus - Ultraworker "),
"Sisyphus"
);
assert_eq!(
normalize_opencode_agent_name("\u{200B}\u{200B}\u{200B} Prometheus Plan Builder"),
"Prometheus"
);
}

#[test]
fn test_strip_zero_width_chars() {
assert_eq!(strip_zero_width_chars("hello"), "hello");
assert_eq!(strip_zero_width_chars("\u{200B}hello"), "hello");
assert_eq!(
strip_zero_width_chars("\u{200B}\u{200B}\u{200B}hello"),
"hello"
);
assert_eq!(strip_zero_width_chars("\u{FEFF}hello"), "hello");
assert_eq!(strip_zero_width_chars("\u{200C}hello\u{200D}"), "hello");
assert_eq!(strip_zero_width_chars(""), "");
assert_eq!(
strip_zero_width_chars("no special chars"),
"no special chars"
);
}
}
Loading