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
12 changes: 8 additions & 4 deletions apps/desktop-tauri/src-tauri/src/auto_refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,6 @@ pub fn install(app: tauri::AppHandle) {
.unwrap_or(now);
if now >= scheduled_at {
let _ = crate::commands::do_refresh_providers_if_stale(&app).await;
// Treat a completed refresh as a weak activity signal while adaptive.
if adaptive {
note_coding_activity();
}
let next_interval =
resolve_refresh_interval(&Settings::load()).unwrap_or(interval);
schedule = Some((
Expand All @@ -72,6 +68,14 @@ pub fn install(app: tauri::AppHandle) {
}
}
}
// Sample coding-agent processes on each poll while Adaptive is on
// so delays can drop to the 5m coding-activity cap without waiting
// for a refresh tick.
if ADAPTIVE_ACTIVE.load(Ordering::Relaxed)
&& crate::coding_activity::coding_agent_process_active()
{
note_coding_activity();
}
tokio::time::sleep(AUTO_REFRESH_POLL_INTERVAL).await;
}
});
Expand Down
124 changes: 124 additions & 0 deletions apps/desktop-tauri/src-tauri/src/coding_activity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//! Best-effort coding-agent process detection for Adaptive refresh (Windows).
//!
//! Looks for well-known agent process names. Does not inspect command lines or
//! env (no secret leakage). Consent is the Settings Adaptive toggle itself.

/// Process base names (lowercase, without `.exe`) that count as coding activity.
const AGENT_PROCESS_NAMES: &[&str] = &[
"claude",
"codex",
"cursor",
"cursor-agent",
"gemini",
"opencode",
"aider",
"continue",
"windsurf",
"codeium",
"copilot",
];

/// Returns true when at least one known coding-agent process is running.
pub fn coding_agent_process_active() -> bool {
#[cfg(windows)]
{
windows_agent_running()
}
#[cfg(not(windows))]
{
false
}
}

#[cfg(windows)]
fn windows_agent_running() -> bool {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;

#[repr(C)]
struct ProcessEntry32W {
dw_size: u32,
cnt_usage: u32,
th32_process_id: u32,
th32_default_heap_id: usize,
th32_module_id: u32,
cnt_threads: u32,
th32_parent_process_id: u32,
pc_pri_class_base: i32,
dw_flags: u32,
sz_exe_file: [u16; 260],
}

const TH32CS_SNAPPROCESS: u32 = 0x0000_0002;
const INVALID_HANDLE_VALUE: *mut std::ffi::c_void = -1isize as *mut std::ffi::c_void;

#[link(name = "kernel32")]
unsafe extern "system" {
fn CreateToolhelp32Snapshot(
dw_flags: u32,
th32_process_id: u32,
) -> *mut std::ffi::c_void;
fn Process32FirstW(snapshot: *mut std::ffi::c_void, entry: *mut ProcessEntry32W) -> i32;
fn Process32NextW(snapshot: *mut std::ffi::c_void, entry: *mut ProcessEntry32W) -> i32;
fn CloseHandle(handle: *mut std::ffi::c_void) -> i32;
}

unsafe {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snap.is_null() || snap == INVALID_HANDLE_VALUE {
return false;
}
let mut entry = ProcessEntry32W {
dw_size: std::mem::size_of::<ProcessEntry32W>() as u32,
cnt_usage: 0,
th32_process_id: 0,
th32_default_heap_id: 0,
th32_module_id: 0,
cnt_threads: 0,
th32_parent_process_id: 0,
pc_pri_class_base: 0,
dw_flags: 0,
sz_exe_file: [0; 260],
};
let mut found = false;
if Process32FirstW(snap, &mut entry) != 0 {
loop {
let len = entry
.sz_exe_file
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.sz_exe_file.len());
let name = OsString::from_wide(&entry.sz_exe_file[..len])
.to_string_lossy()
.to_ascii_lowercase();
let stem = name.strip_suffix(".exe").unwrap_or(&name);
if AGENT_PROCESS_NAMES.iter().any(|n| *n == stem) {
found = true;
break;
}
if Process32NextW(snap, &mut entry) == 0 {
break;
}
}
}
let _ = CloseHandle(snap);
found
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn agent_name_list_is_nonempty() {
assert!(!AGENT_PROCESS_NAMES.is_empty());
assert!(AGENT_PROCESS_NAMES.contains(&"claude"));
assert!(AGENT_PROCESS_NAMES.contains(&"codex"));
}

#[test]
fn detection_does_not_panic() {
let _ = coding_agent_process_active();
}
}
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use std::time::Duration;

mod auto_refresh;
mod coding_activity;
mod commands;
mod events;
mod floatbar;
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ export const ALL_LOCALE_KEYS = [
"UsageSpendCol30d",
"UsageSpendColCurrency",
"UsageSpendColSource",
"UsageSpendShareFailed",
"UsageSpendShareEmpty",
"UsageSpendShare",
"AgentSessionsTitle",
"AgentSessionsEnableLabel",
"AgentSessionsEnableHelper",
Expand Down
128 changes: 125 additions & 3 deletions apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useLocale } from "../../../hooks/useLocale";
import { getUsageSpendSummary } from "../../../lib/tauri";
import type { UsageSpendSummary } from "../../../types/bridge";
Expand All @@ -17,11 +17,105 @@ function formatUsd(value: number | null | undefined, currency: string): string {
}
}

/** Sanitized share-card PNG (no account emails) — upstream #2112. */
function renderSharePng(summary: UsageSpendSummary, title: string): string {
const rows = summary.rows ?? [];
const pad = 24;
const rowH = 28;
const headerH = 48;
const colW = [160, 100, 100, 80, 160];
const width = pad * 2 + colW.reduce((a, b) => a + b, 0);
const height = pad * 2 + headerH + Math.max(1, rows.length) * rowH + 36;
const canvas = document.createElement("canvas");
canvas.width = width * 2;
canvas.height = height * 2;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
ctx.scale(2, 2);

// Background
ctx.fillStyle = "#0f1419";
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = "#243044";
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);

ctx.fillStyle = "#e7ecf3";
ctx.font = "600 16px system-ui,Segoe UI,sans-serif";
ctx.fillText(title, pad, pad + 18);

ctx.fillStyle = "#8b9bb4";
ctx.font = "12px system-ui,Segoe UI,sans-serif";
ctx.fillText("Win-CodexBar · local estimates · no account emails", pad, pad + 36);

const headers = ["Provider", "7 days", "30 days", "Currency", "Source"];
let x = pad;
const y0 = pad + headerH;
ctx.fillStyle = "#9fb0c8";
ctx.font = "600 12px system-ui,Segoe UI,sans-serif";
headers.forEach((h, i) => {
ctx.fillText(h, x, y0);
x += colW[i];
});

ctx.strokeStyle = "#243044";
ctx.beginPath();
ctx.moveTo(pad, y0 + 8);
ctx.lineTo(width - pad, y0 + 8);
ctx.stroke();

ctx.font = "13px system-ui,Segoe UI,sans-serif";
if (rows.length === 0) {
ctx.fillStyle = "#8b9bb4";
ctx.fillText("No spend data yet.", pad, y0 + rowH);
} else {
rows.forEach((row, idx) => {
const y = y0 + (idx + 1) * rowH;
const cells = [
row.displayName,
formatUsd(row.sevenDay, row.currency),
formatUsd(row.thirtyDay, row.currency),
row.currency || "USD",
row.source,
];
let cx = pad;
cells.forEach((cell, i) => {
ctx.fillStyle = i === 0 ? "#e7ecf3" : "#c5d0e0";
const text = String(cell);
const max = colW[i] - 8;
let draw = text;
if (ctx.measureText(draw).width > max) {
while (draw.length > 1 && ctx.measureText(`${draw}…`).width > max) {
draw = draw.slice(0, -1);
}
draw = `${draw}…`;
}
ctx.fillText(draw, cx, y);
cx += colW[i];
});
});
}

return canvas.toDataURL("image/png");
}

function downloadDataUrl(dataUrl: string, filename: string) {
const a = document.createElement("a");
a.href = dataUrl;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
}

export default function UsageSpendTab(_props: TabProps) {
const { t } = useLocale();
const [summary, setSummary] = useState<UsageSpendSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [shareError, setShareError] = useState<string | null>(null);
const tableRef = useRef<HTMLTableElement | null>(null);

const load = useCallback(() => {
setLoading(true);
Expand All @@ -41,14 +135,33 @@ export default function UsageSpendTab(_props: TabProps) {
load();
}, [load]);

const onShare = useCallback(() => {
setShareError(null);
if (!summary) {
setShareError(t("UsageSpendShareEmpty"));
return;
}
try {
const dataUrl = renderSharePng(summary, t("UsageSpendTitle"));
if (!dataUrl) {
setShareError(t("UsageSpendShareFailed"));
return;
}
const stamp = new Date().toISOString().slice(0, 10);
downloadDataUrl(dataUrl, `codexbar-usage-spend-${stamp}.png`);
} catch {
setShareError(t("UsageSpendShareFailed"));
}
}, [summary, t]);

return (
<section className="settings-section">
<h3 className="settings-section__title settings-section__title--bold">
{t("UsageSpendTitle")}
</h3>
<p className="settings-section__caption">{t("UsageSpendCaption")}</p>

<div className="settings-section__group" style={{ marginBottom: 12 }}>
<div className="settings-section__group" style={{ marginBottom: 12, display: "flex", gap: 8 }}>
<button
type="button"
className="credential-btn credential-btn--secondary"
Expand All @@ -57,12 +170,21 @@ export default function UsageSpendTab(_props: TabProps) {
>
{loading ? t("UsageSpendLoading") : t("UsageSpendRefresh")}
</button>
<button
type="button"
className="credential-btn credential-btn--secondary"
disabled={loading || !summary}
onClick={onShare}
>
{t("UsageSpendShare")}
</button>
</div>

{error && <p className="settings-section__error">{error}</p>}
{shareError && <p className="settings-section__error">{shareError}</p>}

{!error && (
<table className="usage-spend-table">
<table className="usage-spend-table" ref={tableRef}>
<thead>
<tr>
<th>{t("UsageSpendColProvider")}</th>
Expand Down
Loading
Loading