Files
KimiCodeSwitch/src-tauri/src/dashboard.rs
T
KimiSwitch Dev 32de98bff5 feat: v0.3.0 re-release - add dashboard & sessions, hide pi option
- Usage dashboard: KPIs, model split, daily bars, year heatmap, recent requests
- Sessions management: list/preview/archive/delete with streaming preview
- Double-click daily bar opens per-model breakdown modal
- Paginated recent requests (30/page)
- Hide pi option from nav (kept code, just removed from UI)
- Timezone fixes: today/day_key/heatmap now use local calendar day
- Streaming preview with 20MB byte cap + 500-char frontend clip
- README attribution to kimicode-dashboard (MIT, JochenYang)
2026-07-28 09:16:21 +08:00

1343 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use chrono::{DateTime, Datelike, Local, TimeZone};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use walkdir::WalkDir;
// ---------------------------------------------------------------------------
// Types – match the Node API JSON shape exactly
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PathsResult {
pub current: String,
pub valid: bool,
pub candidates: Vec<PathCandidate>,
pub env: EnvInfo,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PathCandidate {
pub path: String,
pub valid: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EnvInfo {
#[serde(rename = "KIMI_CODE_HOME")]
pub kimi_code_home: Option<String>,
#[serde(rename = "KIMI_MODEL_NAME")]
pub kimi_model_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PriceRow {
pub id: String,
pub cache_hit: f64,
pub input: f64,
pub output: f64,
pub context: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PricesResult {
pub prices: Vec<PriceRow>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SummaryResult {
pub home: String,
pub valid: bool,
pub scanned_at: u64,
pub meta: ScanMeta,
pub model_map: ModelMapInfo,
pub range: String,
pub stats: RangeStats,
pub heatmap: HeatmapData,
pub all_models: Vec<AllModelRow>,
pub all_model_count: usize,
pub range_totals: HashMap<String, TotalsRow>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ScanMeta {
pub files_scanned: usize,
pub lines_seen: usize,
pub record_count: usize,
pub home: String,
pub sessions_root: String,
pub errors: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelMapInfo {
pub default_model: Option<String>,
pub env_model: Option<EnvModelInfo>,
pub alias_count: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EnvModelInfo {
pub name: String,
pub provider: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RangeStats {
pub range: String,
pub totals: TotalsRow,
pub daily: Vec<DailyRow>,
pub models: Vec<ModelRow>,
pub recent: Vec<RecentRow>,
pub recent_total: usize,
pub recent_limit: usize,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TotalsRow {
pub requests: usize,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
pub total_tokens: u64,
pub cache_hit_rate: f64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DailyRow {
pub date: String,
pub requests: usize,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
pub total_tokens: u64,
pub cache_hit_rate: f64,
/// Per-model token breakdown for stacked-bar rendering
pub by_model: HashMap<String, u64>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelRow {
pub model: String,
pub model_display: String,
pub model_resolved: String,
pub price_id: String,
pub cost_estimated: bool,
pub requests: usize,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
pub total_tokens: u64,
pub cache_hit_rate: f64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RecentRow {
pub time: u64,
pub model: String,
pub model_display: String,
pub model_resolved: String,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub total_tokens: u64,
pub cost_usd: f64,
pub cost_estimated: bool,
pub price_id: String,
pub from_env: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AllModelRow {
pub model: String,
pub model_display: String,
pub requests: usize,
pub total_tokens: u64,
pub cost_usd: f64,
pub cost_estimated: bool,
pub cache_hit_rate: f64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct HeatmapData {
pub weeks: usize,
pub start: String,
pub end: String,
pub max_tokens: u64,
pub cells: Vec<HeatmapCell>,
pub month_labels: Vec<MonthLabel>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct HeatmapCell {
pub date: String,
pub dow: usize,
pub week_index: usize,
pub requests: usize,
pub total_tokens: u64,
pub cost_usd: f64,
pub cache_hit_rate: f64,
pub level: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MonthLabel {
pub week_index: usize,
pub label: String,
}
// Sessions types
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SessionsResult {
pub home: String,
pub archive_root: String,
pub workspaces: Vec<WorkspaceRow>,
pub sessions: Vec<SessionRow>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceRow {
pub id: String,
pub name: String,
pub root: Option<String>,
pub created_at: Option<String>,
pub last_opened_at: Option<String>,
pub active_count: usize,
pub archived_count: usize,
pub empty: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SessionRow {
pub id: String,
pub workspace_id: String,
pub status: String,
pub title: Option<String>,
pub work_dir: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub bytes: u64,
pub files: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ActionResponse {
pub ok: bool,
pub workspace_id: String,
pub session_id: String,
pub status: Option<String>,
pub path: Option<String>,
pub deleted: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PreviewResult {
pub workspace_id: String,
pub session_id: String,
pub status: String,
pub title: Option<String>,
pub work_dir: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub message_count: usize,
pub truncated: bool,
pub messages: Vec<PreviewMessage>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PreviewMessage {
pub role: String,
pub time: Option<u64>,
pub text: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceDeleteBody {
pub workspace_id: String,
pub confirm: Option<bool>,
pub force: Option<bool>,
}
// ---------------------------------------------------------------------------
// App state
// ---------------------------------------------------------------------------
pub struct AppState {
pub scan_cache: Mutex<ScanCache>,
}
#[derive(Clone)]
pub struct ScanCache {
pub home: String,
pub scanned_at: u64,
pub records: Vec<UsageRecord>,
pub meta: ScanMeta,
pub model_map: ModelMapInfo,
}
#[derive(Debug, Clone)]
pub struct UsageRecord {
pub time: u64,
pub model: String,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
pub cost_estimated: bool,
pub price_id: String,
pub model_resolved: String,
pub model_display: String,
pub provider: Option<String>,
pub from_env: bool,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn resolve_kimi_home(override_path: Option<String>) -> PathBuf {
if let Some(p) = override_path {
if !p.trim().is_empty() {
return PathBuf::from(p);
}
}
if let Ok(env_home) = std::env::var("KIMI_CODE_HOME") {
if !env_home.trim().is_empty() {
return PathBuf::from(env_home);
}
}
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".kimi-code")
}
fn is_kimi_home(dir: &Path) -> bool {
if !dir.exists() || !dir.is_dir() {
return false;
}
dir.join("config.toml").exists() || dir.join("sessions").exists()
}
fn sessions_root(home: &Path) -> PathBuf {
home.join("sessions")
}
fn workspace_re() -> Regex {
Regex::new(r"^wd_[A-Za-z0-9._-]+$").unwrap()
}
fn session_re() -> Regex {
Regex::new(r"^session_[0-9a-fA-F-]{8,}$").unwrap()
}
fn safe_read_dir(path: &Path) -> Vec<fs::DirEntry> {
let mut entries = Vec::new();
if let Ok(rd) = fs::read_dir(path) {
for e in rd.flatten() {
entries.push(e);
}
}
entries
}
fn file_size_approx(path: &Path) -> (u64, usize) {
let mut total_bytes = 0u64;
let mut total_files = 0usize;
for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
if let Ok(meta) = entry.metadata() {
total_bytes += meta.len();
total_files += 1;
}
}
}
(total_bytes, total_files)
}
fn day_key(ts_ms: u64) -> String {
let secs = if ts_ms > 1e12 as u64 { ts_ms / 1000 } else { ts_ms };
// Bucket by LOCAL calendar date (not UTC) so records around midnight stay on the correct day.
let local = Local
.timestamp_opt(secs as i64, 0)
.single()
.unwrap_or_else(|| DateTime::from_timestamp(secs as i64, 0).unwrap_or_default().with_timezone(&Local));
format!("{:04}-{:02}-{:02}", local.year(), local.month(), local.day())
}
// ---------------------------------------------------------------------------
// Pricing
// ---------------------------------------------------------------------------
fn list_prices() -> Vec<PriceRow> {
vec![
PriceRow { id: "kimi-k3".into(), cache_hit: 0.30, input: 3.00, output: 15.00, context: 1_048_576 },
PriceRow { id: "kimi-k2.7-code".into(), cache_hit: 0.19, input: 0.95, output: 4.00, context: 262_144 },
PriceRow { id: "kimi-k2.6".into(), cache_hit: 0.16, input: 0.95, output: 4.00, context: 262_144 },
PriceRow { id: "kimi-k2.5".into(), cache_hit: 0.10, input: 0.60, output: 3.00, context: 262_144 },
PriceRow { id: "kimi-k2-turbo".into(), cache_hit: 0.15, input: 1.15, output: 8.00, context: 262_144 },
PriceRow { id: "kimi-k2".into(), cache_hit: 0.15, input: 0.60, output: 2.50, context: 262_144 },
]
}
fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
let bare = match model_name.rsplit_once('/') {
Some((_, b)) => b,
None => model_name,
};
let bare_l = bare.to_ascii_lowercase();
for p in &list_prices() {
let id_l = p.id.to_ascii_lowercase();
if bare_l == id_l || bare_l.contains(&id_l) || id_l.contains(&bare_l) {
return (p.id.clone(), p.cache_hit, p.input, p.output, false);
}
}
("kimi-k2.6".into(), 0.16, 0.95, 4.00, true)
}
fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create: u64, model: &str) -> (f64, bool) {
let (_price_id, cache_hit, input_price, output_price, est) = match_price(model);
let cost = (input_other as f64 / 1e6) * input_price
+ (cache_read as f64 / 1e6) * cache_hit
+ (cache_create as f64 / 1e6) * input_price
+ (output as f64 / 1e6) * output_price;
(cost, est)
}
// ---------------------------------------------------------------------------
// Scanner
// ---------------------------------------------------------------------------
fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
let root = sessions_root(home);
let mut records = Vec::new();
let mut files_scanned = 0;
let mut lines_seen = 0;
let errors = Vec::new();
if !root.exists() {
return (records, ScanMeta {
files_scanned: 0, lines_seen: 0, record_count: 0,
home: home.to_string_lossy().to_string(),
sessions_root: root.to_string_lossy().to_string(),
errors: vec!["sessions directory not found".into()],
});
}
for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) {
if entry.file_name() != "wire.jsonl" { continue; }
if !entry.file_type().is_file() { continue; }
// Skip blob/task directories
let p = entry.path();
if p.to_string_lossy().contains("blobs") || p.to_string_lossy().contains("tasks") {
continue;
}
files_scanned += 1;
let content = match fs::read_to_string(entry.path()) {
Ok(c) => c,
Err(_) => continue,
};
for line in content.lines() {
lines_seen += 1;
if !line.contains("\"usage.record\"") { continue; }
let obj: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; }
let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn");
if scope != "turn" { continue; }
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
let usage = &obj["usage"];
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0);
// Resolve model display
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &model_raw);
let (pid, _ch, _ip, _op, _) = match_price(&model_raw);
records.push(UsageRecord {
time,
model: model_raw.clone(),
model_resolved: bare.to_string(),
model_display: model_raw.clone(),
provider: model_raw.rsplit_once('/').map(|x| x.0.to_string()),
from_env: model_raw == "__kimi_env_model__",
input_other,
output,
input_cache_read: cache_read,
input_cache_creation: cache_create,
cost_usd: cost,
cost_estimated: est,
price_id: pid,
});
}
}
records.sort_by(|a, b| b.time.cmp(&a.time));
let count = records.len();
(records, ScanMeta {
files_scanned, lines_seen, record_count: count,
home: home.to_string_lossy().to_string(),
sessions_root: root.to_string_lossy().to_string(),
errors: errors.into_iter().take(20).collect(),
})
}
// ---------------------------------------------------------------------------
// Aggregate
// ---------------------------------------------------------------------------
fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
let filtered: Vec<&UsageRecord> = filter_by_range(records, range, now_ms);
let range_label = range.to_string();
if filtered.is_empty() {
return RangeStats {
range: range_label,
totals: TotalsRow::default(),
daily: vec![],
models: vec![],
recent: vec![],
recent_total: 0,
recent_limit: 500,
};
}
let mut totals = TotalsRow::default();
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, u64>)> = HashMap::new();
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
for r in &filtered {
totals.add(r);
let dk = day_key(r.time);
let (day_totals, day_models) = by_day.entry(dk.clone()).or_default();
day_totals.add(r);
*day_models.entry(r.model.clone()).or_insert(0) +=
r.input_other + r.output + r.input_cache_read + r.input_cache_creation;
let mk = r.model.clone();
let entry = by_model.entry(mk.clone()).or_insert_with(|| {
let m = ModelRow {
model: mk.clone(),
model_display: r.model_display.clone(),
model_resolved: r.model_resolved.clone(),
price_id: r.price_id.clone(),
cost_estimated: r.cost_estimated,
requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
};
(m, TotalsRow::default())
});
entry.1.add(r);
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
}
let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation;
totals.cache_hit_rate = if total_input > 0 { totals.input_cache_read as f64 / total_input as f64 } else { 0.0 };
let mut daily: Vec<DailyRow> = by_day.into_iter()
.map(|(date, (t, by_model))| {
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
DailyRow {
date, requests: t.requests,
input_other: t.input_other, output: t.output,
input_cache_read: t.input_cache_read, input_cache_creation: t.input_cache_creation,
cost_usd: t.cost_usd, total_tokens: t.total_tokens, cache_hit_rate: ch,
by_model,
}
})
.collect();
daily.sort_by(|a, b| a.date.cmp(&b.date));
let mut models: Vec<ModelRow> = by_model.into_iter().map(|(_, (mut m, t))| {
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
m.requests = t.requests;
m.input_other = t.input_other; m.output = t.output;
m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation;
m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens;
m
}).collect();
models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
let recent_total = filtered.len();
let recent_limit = 500;
let recent: Vec<RecentRow> = filtered.iter().take(recent_limit).map(|r| RecentRow {
time: r.time, model: r.model.clone(), model_display: r.model_display.clone(),
model_resolved: r.model_resolved.clone(),
input_other: r.input_other, output: r.output,
input_cache_read: r.input_cache_read, input_cache_creation: r.input_cache_creation,
total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation,
cost_usd: r.cost_usd, cost_estimated: r.cost_estimated,
price_id: r.price_id.clone(), from_env: r.from_env,
}).collect();
RangeStats {
range: range_label,
totals,
daily,
models,
recent,
recent_total,
recent_limit,
}
}
impl TotalsRow {
fn default() -> Self {
TotalsRow {
requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
}
}
fn add(&mut self, r: &UsageRecord) {
self.requests += 1;
self.input_other += r.input_other;
self.output += r.output;
self.input_cache_read += r.input_cache_read;
self.input_cache_creation += r.input_cache_creation;
self.cost_usd += r.cost_usd;
self.total_tokens = self.input_other + self.output + self.input_cache_read + self.input_cache_creation;
}
}
fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> {
if range == "all" { return records.iter().collect(); }
let start = range_start(range, now_ms);
records.iter().filter(|r| r.time >= start).collect()
}
fn range_start(range: &str, now_ms: u64) -> u64 {
match range {
"today" => {
// LOCAL midnight today — not UTC midnight. Without this, users east of UTC see
// "today" begin at the wrong hour (e.g. UTC+8 users get cutoff at local 08:00).
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
let now_local = now_utc.with_timezone(&Local);
let today_date = now_local.date_naive();
let today_start = today_date.and_hms_opt(0, 0, 0).unwrap_or_default();
Local
.from_local_datetime(&today_start)
.single()
.map(|dt| dt.timestamp() as u64 * 1000)
.unwrap_or_else(|| today_start.and_utc().timestamp() as u64 * 1000)
}
"7d" => now_ms - 7 * 24 * 3600 * 1000,
"30d" => now_ms - 30 * 24 * 3600 * 1000,
_ => now_ms - 30 * 24 * 3600 * 1000,
}
}
fn build_heatmap(records: &[UsageRecord], now_ms: u64) -> HeatmapData {
let weeks = 53;
// Use LOCAL today for the heatmap's right edge so the current day's cell lights up correctly.
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
let now_local = now_utc.with_timezone(&Local);
let end_date = now_local.date_naive();
let start_date = end_date - chrono::Duration::days(weeks * 7 - 1);
let start_date = start_date - chrono::Duration::days(start_date.weekday().num_days_from_sunday() as i64);
let mut by_day: HashMap<String, TotalsRow> = HashMap::new();
for r in records {
let dk = day_key(r.time);
let t = by_day.entry(dk).or_default();
t.add(r);
}
let mut cursor = start_date;
let mut cells = Vec::new();
let mut max_tokens = 0u64;
while cursor <= end_date {
let key = format!("{:04}-{:02}-{:02}", cursor.year(), cursor.month(), cursor.day());
let t = by_day.get(&key).cloned().unwrap_or_else(TotalsRow::default);
let tok = t.total_tokens;
if tok > max_tokens { max_tokens = tok; }
let dow = cursor.weekday().num_days_from_sunday() as usize;
let week_idx = ((cursor - start_date).num_days() / 7) as usize;
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
cells.push(HeatmapCell {
date: key, dow, week_index: week_idx,
requests: t.requests, total_tokens: tok, cost_usd: t.cost_usd,
cache_hit_rate: ch, level: 0,
});
cursor += chrono::Duration::days(1);
if cells.len() > (weeks * 7 + 7) as usize { break; }
}
for c in &mut cells {
c.level = if max_tokens == 0 || c.total_tokens == 0 {
0
} else {
let r = c.total_tokens as f64 / max_tokens as f64;
if r > 0.75 { 4 } else if r > 0.5 { 3 } else if r > 0.25 { 2 } else { 1 }
};
}
let mut month_labels = Vec::new();
let mut last_month = String::new();
for c in &cells {
let m = c.date[..7].to_string();
if m != last_month {
month_labels.push(MonthLabel { week_index: c.week_index, label: c.date[5..7].to_string() });
last_month = m;
}
}
HeatmapData {
weeks: weeks as usize,
start: start_date.format("%Y-%m-%d").to_string(),
end: end_date.format("%Y-%m-%d").to_string(),
max_tokens,
cells,
month_labels,
}
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
fn list_workspace_dirs(home: &Path) -> Vec<String> {
let root = sessions_root(home);
if !root.exists() { return vec![]; }
let mut out = Vec::new();
let re = workspace_re();
for entry in safe_read_dir(&root) {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let name = entry.file_name().to_string_lossy().to_string();
if name == ".kcd-archive" { continue; }
if !re.is_match(&name) { continue; }
out.push(name);
}
out.sort();
out
}
fn read_state_safe(session_dir: &Path) -> (Option<String>, Option<String>, Option<String>, Option<String>) {
let sp = session_dir.join("state.json");
if !sp.exists() { return (None, None, None, None); }
if let Ok(content) = fs::read_to_string(&sp) {
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&content) {
let title = obj.get("title").and_then(|v| v.as_str().map(|s| s.to_string()));
let work_dir = obj.get("workDir").and_then(|v| v.as_str().map(|s| s.to_string()));
let created_at = obj.get("createdAt").and_then(|v| v.as_str().map(|s| s.to_string()));
let updated_at = obj.get("updatedAt").and_then(|v| v.as_str().map(|s| s.to_string()));
return (title, work_dir, created_at, updated_at);
}
}
(None, None, None, None)
}
fn humanize_workspace(id: &str) -> String {
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
if let Some(caps) = re.captures(id) {
caps.get(1).map(|m| m.as_str().to_string()).unwrap_or_else(|| id.to_string())
} else {
id.to_string()
}
}
fn list_sessions_in_dir(dir: &Path, workspace_id: &str, status: &str) -> Vec<SessionRow> {
let mut sessions = Vec::new();
if !dir.exists() { return sessions; }
let re = session_re();
for entry in safe_read_dir(dir) {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let name = entry.file_name().to_string_lossy().to_string();
if !re.is_match(&name) { continue; }
let (title, work_dir, created_at, updated_at) = read_state_safe(&entry.path());
let (bytes, files) = file_size_approx(&entry.path());
let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok())
.map(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)).flatten();
sessions.push(SessionRow {
id: name, workspace_id: workspace_id.to_string(), status: status.to_string(),
title, work_dir, created_at,
updated_at: updated_at.or_else(|| mtime.map(|m| {
let secs = if m > 1e12 as u64 { m / 1000 } else { m };
let dt = DateTime::from_timestamp(secs as i64, 0).unwrap_or_default().naive_utc();
dt.to_string()
})),
bytes, files,
});
}
sessions
}
fn list_sessions_cmd(home: &Path, status: &str, workspace_filter: Option<String>) -> SessionsResult {
let root = sessions_root(home);
let archive_root = root.join(".kcd-archive");
let mut ws_ids: Vec<String> = list_workspace_dirs(home);
// Also include archive-only workspaces
if archive_root.exists() {
for entry in safe_read_dir(&archive_root) {
let name = entry.file_name().to_string_lossy().to_string();
if workspace_re().is_match(&name) && !ws_ids.contains(&name) {
ws_ids.push(name);
}
}
}
ws_ids.sort();
// Load workspaces.json
let wp = home.join("workspaces.json");
let mut ws_meta: HashMap<String, serde_json::Value> = HashMap::new();
if let Ok(content) = fs::read_to_string(&wp) {
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(ws) = obj.get("workspaces").and_then(|v| v.as_object()) {
for (k, v) in ws {
ws_meta.insert(k.clone(), v.clone());
}
}
}
}
let mut workspaces = Vec::new();
let mut all_sessions = Vec::new();
for wid in &ws_ids {
if let Some(ref filter) = workspace_filter {
if wid != filter { continue; }
}
let meta = ws_meta.get(wid);
let name = meta.and_then(|m| m.get("name").and_then(|v| v.as_str()))
.map(|s| s.to_string()).unwrap_or_else(|| humanize_workspace(wid));
let root_path = meta.and_then(|m| m.get("root").and_then(|v| v.as_str()).map(|s| s.to_string()));
let active_dir = root.join(wid);
let arch_dir = archive_root.join(wid);
let active_all = list_sessions_in_dir(&active_dir, wid, "active");
let arch_all = list_sessions_in_dir(&arch_dir, wid, "archived");
let active_list = if status == "archived" { vec![] } else { active_all.clone() };
let arch_list = if status == "active" { vec![] } else { arch_all.clone() };
let empty = active_all.is_empty() && arch_all.is_empty();
workspaces.push(WorkspaceRow {
id: wid.clone(), name, root: root_path,
created_at: None, last_opened_at: None,
active_count: active_all.len(), archived_count: arch_all.len(), empty,
});
all_sessions.extend(active_list);
all_sessions.extend(arch_list);
}
all_sessions.sort_by(|a, b| {
let ta = a.updated_at.as_deref().or(a.created_at.as_deref()).unwrap_or("").to_string();
let tb = b.updated_at.as_deref().or(b.created_at.as_deref()).unwrap_or("").to_string();
tb.cmp(&ta)
});
SessionsResult {
home: home.to_string_lossy().to_string(),
archive_root: ".kcd-archive".into(),
workspaces,
sessions: all_sessions,
}
}
fn assert_safe_path(home: &Path, workspace_id: &str, session_id: &str) -> Result<PathBuf, String> {
if !workspace_re().is_match(workspace_id) {
return Err("invalid workspace id".into());
}
if !session_re().is_match(session_id) {
return Err("invalid session id".into());
}
let root = sessions_root(home).canonicalize().unwrap_or_else(|_| sessions_root(home));
let candidate = root.join(workspace_id).join(session_id).canonicalize().unwrap_or_else(|_| root.join(workspace_id).join(session_id));
if !candidate.starts_with(&root) {
return Err("path escape blocked".into());
}
Ok(candidate)
}
fn archive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> {
let src = assert_safe_path(home, workspace_id, session_id)?;
let dest = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
if !src.exists() { return Err("session not found".into()); }
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?;
}
fs::rename(&src, &dest).or_else(|_| {
// cross-device fallback
fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok();
fs::remove_dir_all(&src).ok();
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
scrub_session_index(home, session_id);
Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("archived".into()),
path: Some(dest.to_string_lossy().to_string()), deleted: None,
})
}
fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> {
let src = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
let dest = assert_safe_path(home, workspace_id, session_id)?;
if !src.exists() { return Err("archived session not found".into()); }
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?;
}
fs::rename(&src, &dest).or_else(|_| {
fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok();
fs::remove_dir_all(&src).ok();
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("active".into()),
path: Some(dest.to_string_lossy().to_string()), deleted: None,
})
}
fn delete_session_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result<ActionResponse, String> {
let active = assert_safe_path(home, workspace_id, session_id)?;
let archived = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
let target = match status_hint {
Some("archived") if archived.exists() => archived,
Some("active") if active.exists() => active,
_ => {
if active.exists() { active } else if archived.exists() { archived }
else { return Err("session not found".into()); }
}
};
fs::remove_dir_all(&target).map_err(|e| format!("delete: {}", e))?;
scrub_session_index(home, session_id);
Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: None,
path: Some(target.to_string_lossy().to_string()), deleted: Some(true),
})
}
fn scrub_session_index(home: &Path, session_id: &str) {
let ip = home.join("session_index.jsonl");
if !ip.exists() { return; }
if let Ok(content) = fs::read_to_string(&ip) {
let lines: Vec<&str> = content.lines().filter(|l| {
let sid = format!("\"sessionId\":\"{}\"", session_id);
let sid2 = format!("\"sessionId\": \"{}\"", session_id);
let path_match = format!("/{}\"", session_id);
!l.contains(&sid) && !l.contains(&sid2) && !l.contains(&path_match)
}).collect();
if lines.len() < content.lines().count() {
let _ = fs::write(&ip, lines.join("\n") + "\n");
}
}
}
fn delete_workspace_cmd(home: &Path, workspace_id: &str, _confirm: bool, _force: bool) -> Result<ActionResponse, String> {
if !workspace_re().is_match(workspace_id) {
return Err("invalid workspace id".into());
}
let root = sessions_root(home);
let active_dir = root.join(workspace_id);
let arch_dir = root.join(".kcd-archive").join(workspace_id);
if active_dir.exists() {
let active_list = list_sessions_in_dir(&active_dir, workspace_id, "active");
if !active_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); }
}
if arch_dir.exists() {
let arch_list = list_sessions_in_dir(&arch_dir, workspace_id, "archived");
if !arch_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); }
}
if active_dir.exists() { fs::remove_dir_all(&active_dir).ok(); }
if arch_dir.exists() { fs::remove_dir_all(&arch_dir).ok(); }
// Update workspaces.json
let wp = home.join("workspaces.json");
if let Ok(content) = fs::read_to_string(&wp) {
if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(ws) = obj.get_mut("workspaces").and_then(|v| v.as_object_mut()) {
ws.remove(workspace_id);
}
let deleted = obj.get_mut("deleted_workspace_ids")
.and_then(|v| v.as_array_mut());
if let Some(arr) = deleted {
if !arr.iter().any(|v| v.as_str() == Some(workspace_id)) {
arr.push(serde_json::Value::String(workspace_id.to_string()));
}
}
let _ = fs::write(&wp, serde_json::to_string_pretty(&obj).unwrap() + "\n");
}
}
Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: String::new(), status: None, path: None, deleted: Some(true),
})
}
// ---------------------------------------------------------------------------
// Preview
// ---------------------------------------------------------------------------
fn clip_text(s: &str, max: usize) -> String {
// Only walk the first `max` bytes — earlier version filtered the whole string,
// which was fine for short text but blew up when previewing big turns.
let (end, trunc) = if s.len() <= max {
(s.len(), false)
} else {
let mut e = max;
while e < s.len() && !s.is_char_boundary(e) { e += 1; }
(e, true)
};
let head = &s[..end];
let cleaned: String = head.chars().filter(|&c| c != '\0').collect();
if trunc { format!("{}…", cleaned) } else { cleaned }
}
fn extract_text_parts(content: &serde_json::Value) -> String {
if let Some(s) = content.as_str() { return s.to_string(); }
if let Some(arr) = content.as_array() {
let mut parts = Vec::new();
for p in arr {
if let Some(obj) = p.as_object() {
if let Some(t) = obj.get("text").and_then(|v| v.as_str()) {
parts.push(t.to_string());
}
}
}
return parts.join("\n");
}
String::new()
}
fn push_user_msg(msgs: &mut Vec<PreviewMessage>, role: &str, text: &str, time: u64) {
let norm: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if norm.is_empty() { return; }
if let Some(last) = msgs.last() {
if last.role == role {
let last_norm: String = last.text.split_whitespace().collect::<Vec<_>>().join(" ");
if last_norm == norm { return; }
}
}
let text = if looks_like_secret(text) { "[redacted: possible secret content]".into() } else { clip_text(text, 2500) };
msgs.push(PreviewMessage { role: role.into(), time: Some(time), text });
}
fn flush_assistant_msg(msgs: &mut Vec<PreviewMessage>, bucket: &Option<(Vec<String>, u64)>) {
if let Some((texts, _)) = bucket {
let combined = texts.join("");
if !combined.trim().is_empty() {
let text = if looks_like_secret(&combined) { "[redacted: possible secret content]".into() } else { clip_text(&combined, 2500) };
if let Some(last) = msgs.last() {
if last.role == "assistant" {
let last_norm: String = last.text.split_whitespace().collect::<Vec<_>>().join(" ");
let this_norm: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if last_norm == this_norm { return; }
}
}
msgs.push(PreviewMessage { role: "assistant".into(), time: None, text });
}
}
}
fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result<PreviewResult, String> {
let root = sessions_root(home);
let active = assert_safe_path(home, workspace_id, session_id)?;
let archived = root.join(".kcd-archive").join(workspace_id).join(session_id);
let session_dir = match status_hint {
Some("archived") if archived.exists() => archived,
_ => if active.exists() { active } else if archived.exists() { archived }
else { return Err("session not found".into()); }
};
let (title, work_dir, created_at, updated_at) = read_state_safe(&session_dir);
let api_dir = session_dir.join("agents").join("main");
let mut wire_path = api_dir.join("wire.jsonl");
if !wire_path.exists() {
wire_path = session_dir.join("wire.jsonl");
}
if !wire_path.exists() {
// try first agent wire
let agents_dir = session_dir.join("agents");
if agents_dir.exists() {
for entry in safe_read_dir(&agents_dir) {
let w = entry.path().join("wire.jsonl");
if w.exists() { wire_path = w; break; }
}
}
}
let mut messages: Vec<PreviewMessage> = Vec::new();
let mut truncated = false;
let max_msgs = 80;
let _max_chars = 2500usize;
// Hard byte cap: refuse to stream more than this from a single wire.jsonl.
// Typical heavy sessions are 5–30 MB; anything larger risks OOM in the WebView.
const MAX_WIRE_BYTES: usize = 20 * 1024 * 1024; // 20 MB
if wire_path.exists() {
let file = match fs::File::open(&wire_path) {
Ok(f) => f,
Err(e) => return Err(format!("open wire: {e}")),
};
let reader = BufReader::new(file);
let mut bytes_read = 0usize;
let mut line_count = 0usize;
let mut current_assistant: Option<(Vec<String>, u64)> = None;
let mut current_step_key: Option<String> = None;
for line_res in reader.lines() {
let line = match line_res {
Ok(l) => l,
Err(_) => break,
};
bytes_read += line.len() + 1; // +1 for the stripped newline
line_count += 1;
if bytes_read > MAX_WIRE_BYTES { truncated = true; break; }
if messages.len() >= max_msgs { truncated = true; break; }
if !line.contains("\"context.append_message\"") && !line.contains("\"turn.steer\"")
&& !line.contains("\"turn.prompt\"") && !line.contains("\"content.part\"")
&& !line.contains("\"step.end\"") { continue; }
let obj: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v, Err(_) => continue,
};
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
if typ == "context.append_message" {
flush_assistant_msg(&mut messages, &current_assistant);
current_assistant = None;
current_step_key = None;
let msg = &obj["message"];
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("unknown");
if role == "tool" { continue; }
let raw = extract_text_parts(&msg["content"]);
if raw.trim().is_empty() { continue; }
push_user_msg(&mut messages, role, &raw, obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0));
continue;
}
if typ == "turn.steer" || typ == "turn.prompt" {
let input = &obj["input"];
let raw = if let Some(s) = input.as_str() { s.to_string() }
else if let Some(arr) = input.as_array() {
arr.iter().filter_map(|x| x.get("text").and_then(|v| v.as_str())).collect::<Vec<_>>().join("\n")
} else { String::new() };
if raw.trim().is_empty() { continue; }
push_user_msg(&mut messages, "user", &raw, obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0));
continue;
}
if typ == "context.append_loop_event" {
let ev = &obj["event"];
let ev_type = ev.get("type").and_then(|v| v.as_str()).unwrap_or("");
if ev_type == "content.part" {
let part = &ev["part"];
if part.get("type").and_then(|v| v.as_str()) == Some("text") {
let text = part.get("text").and_then(|v| v.as_str()).unwrap_or("");
if !text.is_empty() {
let step_key = ev.get("stepUuid").and_then(|v| v.as_str())
.map(|s| s.to_string()).unwrap_or_else(|| format!("{}:{}", ev["turnId"], ev["step"]));
// flush if step changed
if current_step_key.as_deref() != Some(&step_key) {
flush_assistant_msg(&mut messages, &current_assistant);
current_assistant = Some((Vec::new(), obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0)));
current_step_key = Some(step_key.clone());
} else if current_assistant.is_none() {
current_assistant = Some((Vec::new(), obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0)));
}
current_assistant.as_mut().unwrap().0.push(text.to_string());
}
}
continue;
}
if ev_type == "step.end" {
flush_assistant_msg(&mut messages, &current_assistant);
current_assistant = None;
current_step_key = None;
}
}
}
flush_assistant_msg(&mut messages, &current_assistant);
if line_count > 8000 { truncated = true; }
if messages.len() >= max_msgs { truncated = true; }
if bytes_read > MAX_WIRE_BYTES { truncated = true; }
}
Ok(PreviewResult {
workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(),
status: if session_dir.to_string_lossy().contains(".kcd-archive") { "archived".into() } else { "active".into() },
title, work_dir, created_at, updated_at,
message_count: messages.len(),
truncated,
messages,
})
}
fn looks_like_secret(s: &str) -> bool {
let re = regex::Regex::new(r"(?i)api[_-]?key|sk-[a-zA-Z0-9]{12,}|BEGIN (RSA |OPENSSH )?PRIVATE KEY").unwrap();
re.is_match(s)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn get_paths() -> PathsResult {
let home = resolve_kimi_home(None);
let valid = is_kimi_home(&home);
let mut candidates = Vec::new();
if let Ok(env_home) = std::env::var("KIMI_CODE_HOME") {
let p = PathBuf::from(&env_home);
candidates.push(PathCandidate { path: env_home, valid: is_kimi_home(&p) });
}
if let Some(hd) = dirs::home_dir() {
let p = hd.join(".kimi-code");
candidates.push(PathCandidate { path: p.to_string_lossy().to_string(), valid: is_kimi_home(&p) });
if let Ok(up) = std::env::var("USERPROFILE") {
let p2 = PathBuf::from(&up).join(".kimi-code");
if !candidates.iter().any(|c| c.path == p2.to_string_lossy()) {
candidates.push(PathCandidate { path: p2.to_string_lossy().to_string(), valid: is_kimi_home(&p2) });
}
}
}
PathsResult {
current: home.to_string_lossy().to_string(),
valid,
candidates,
env: EnvInfo {
kimi_code_home: std::env::var("KIMI_CODE_HOME").ok(),
kimi_model_name: std::env::var("KIMI_MODEL_NAME").ok(),
},
}
}
#[tauri::command]
pub fn get_prices() -> PricesResult {
PricesResult { prices: list_prices() }
}
#[tauri::command]
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
let _refresh = refresh.unwrap_or(false);
let home = resolve_kimi_home(home_override);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
let r = range.unwrap_or_else(|| "30d".into());
let (records, meta) = scan_usage(&home);
let stats = aggregate(&records, &r, now_ms);
let all_stats = aggregate(&records, "all", now_ms);
let heatmap = build_heatmap(&records, now_ms);
let all_models: Vec<AllModelRow> = all_stats.models.into_iter().map(|m| {
AllModelRow {
model: m.model, model_display: m.model_display, requests: m.requests,
total_tokens: m.total_tokens, cost_usd: m.cost_usd, cost_estimated: m.cost_estimated,
cache_hit_rate: m.cache_hit_rate,
}
}).collect();
let all_model_count = all_models.len();
let mut range_totals = HashMap::new();
for r_k in ["today", "7d", "30d", "all"] {
let s = aggregate(&records, r_k, now_ms);
range_totals.insert(r_k.to_string(), s.totals);
}
let default_model = None;
let env_model = std::env::var("KIMI_MODEL_NAME").ok().map(|name| EnvModelInfo {
name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(),
});
SummaryResult {
home: home.to_string_lossy().to_string(),
valid: is_kimi_home(&home),
scanned_at: now_ms,
meta,
model_map: ModelMapInfo { default_model, env_model, alias_count: 0 },
range: r,
stats,
heatmap,
all_models,
all_model_count,
range_totals,
}
}
#[tauri::command]
pub fn list_sessions(home_override: Option<String>, status: Option<String>, workspace: Option<String>) -> SessionsResult {
let home = resolve_kimi_home(home_override);
list_sessions_cmd(&home, &status.unwrap_or_else(|| "active".into()), workspace)
}
#[tauri::command]
pub fn archive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
let home = resolve_kimi_home(home_override);
archive_session_cmd(&home, &workspace_id, &session_id)
}
#[tauri::command]
pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
let home = resolve_kimi_home(home_override);
unarchive_session_cmd(&home, &workspace_id, &session_id)
}
#[tauri::command]
pub fn delete_session(home_override: Option<String>, workspace_id: String, session_id: String, status: Option<String>) -> Result<ActionResponse, String> {
let home = resolve_kimi_home(home_override);
delete_session_cmd(&home, &workspace_id, &session_id, status.as_deref())
}
#[tauri::command]
pub fn delete_workspace(home_override: Option<String>, workspace_id: String, confirm: bool, force: Option<bool>) -> Result<ActionResponse, String> {
if !confirm { return Err("confirm_required: Pass confirm:true to delete an empty workspace".into()); }
let home = resolve_kimi_home(home_override);
delete_workspace_cmd(&home, &workspace_id, confirm, force.unwrap_or(false))
}
#[tauri::command]
pub fn get_session_preview(home_override: Option<String>, workspace_id: String, session_id: String, status: Option<String>) -> Result<PreviewResult, String> {
let home = resolve_kimi_home(home_override);
get_session_preview_cmd(&home, &workspace_id, &session_id, status.as_deref())
}