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
136 changes: 28 additions & 108 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const log = require('electron-log');
// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');
const { fetchAndTransformUsage } = require('./claude-auth');
const { runStatsCommand, refreshStatsCache } = require('./stats-refresh');
const { readHead } = require('./jsonl-scan');
const codexAuth = require('./codex-auth');

Expand Down Expand Up @@ -553,118 +554,37 @@ ipcMain.handle('get-stats', () => {
}
});

// --- IPC: refresh-stats (run /stats + /usage via PTY) ---
// --- IPC: refresh-stats ---
let statsRefreshInFlight = null;
ipcMain.handle('refresh-stats', async () => {
// For stats, use the configured shell profile
const globalSettings = getSetting('global') || {};
const statsProfileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile;
const statsShellProfile = resolveShell(statsProfileId);
const statsShell = statsShellProfile.path;
const statsShellExtraArgs = statsShellProfile.args || [];
const ptyEnv = {
...cleanPtyEnv,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'iTerm.app',
TERM_PROGRAM_VERSION: '3.6.6',
FORCE_COLOR: '3',
ITERM_SESSION_ID: '1',
};

// Helper: spawn claude with args, collect output, auto-accept trust, kill when idle
// waitFor: optional regex tested against stripped output — finish only when matched
function runClaude(args, { timeoutMs = 15000, waitFor = null } = {}) {
return new Promise((resolve) => {
let output = '';
let settled = false;
let trustAccepted = false;
// Track idle: ✳ in OSC title means Claude is idle and waiting for input
let sawActivity = false;

const finish = () => {
if (settled) return;
settled = true;
try { p.kill(); } catch {}
resolve(output);
};

const claudeCmd = `claude ${args}`;
const p = pty.spawn(statsShell, shellArgs(statsShell, claudeCmd, statsShellExtraArgs), {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: os.homedir(),
env: ptyEnv,
});

const strip = (s) => s
.replace(/\x1b\[[^@-~]*[@-~]/g, '')
.replace(/\x1b\][^\x07]*\x07/g, '')
.replace(/\x1b[^[\]].?/g, '');

p.onData((data) => {
output += data;

// Auto-accept trust directory prompt (Enter selects "1. Yes")
if (!trustAccepted) {
if (/trust\s*this\s*folder/i.test(strip(output))) {
trustAccepted = true;
try { p.write('\r'); } catch {}
return;
}
}

// If waitFor is set, finish when that pattern appears in stripped output
if (waitFor) {
if (waitFor.test(strip(output))) {
finish();
}
return;
}
if (!harnessEnabled(DEFAULT_HARNESS)) return { stats: null, usage: {} };
if (statsRefreshInFlight) return statsRefreshInFlight;

// Default: detect busy→idle transition via OSC title containing ✳
if (!sawActivity) {
const oscTitle = data.match(/\x1b\]0;([^\x07\x1b]*)/);
if (oscTitle) {
const first = oscTitle[1].charAt(0);
if (first.charCodeAt(0) >= 0x2800 && first.charCodeAt(0) <= 0x28FF) {
sawActivity = true;
}
}
} else if (data.includes('\u2733')) {
finish();
}
statsRefreshInFlight = (async () => {
const usagePromise = fetchAndTransformUsage().catch(() => ({}));
const result = await refreshStatsCache(STATS_CACHE_PATH, () => {
const globalSettings = getSetting('global') || {};
const profile = resolveShell(globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile);
return runStatsCommand({
spawn: (...args) => pty.spawn(...args),
shell: profile.path,
args: shellArgs(profile.path, 'claude "/stats"', profile.args || []),
options: {
name: 'xterm-256color', cols: 120, rows: 40, cwd: os.homedir(),
env: {
...cleanPtyEnv,
TERM: 'xterm-256color', COLORTERM: 'truecolor',
TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6',
FORCE_COLOR: '3', ITERM_SESSION_ID: '1',
},
},
});

p.onExit(() => finish());
setTimeout(finish, timeoutMs);
});
}

// A switched-off CLI is not spawned and not queried — the PTY run below is
// the most expensive thing in the app to do for a CLI the user has hidden.
if (!harnessEnabled(DEFAULT_HARNESS)) return { stats: null, usage: {} };

try {
// Run /stats via PTY (for heatmap/chart data) and fetch usage via API in parallel
const [, usage] = await Promise.all([
runClaude('"/stats"', { waitFor: /streak/i, timeoutMs: 10000 }),
fetchAndTransformUsage().catch(() => ({})),
]);

// Read refreshed stats cache
let stats = null;
try {
if (fs.existsSync(STATS_CACHE_PATH)) {
stats = JSON.parse(fs.readFileSync(STATS_CACHE_PATH, 'utf8'));
}
} catch {}

return { stats, usage: usage || {} };
} catch (err) {
log.error('Error refreshing stats:', err);
return { stats: null, usage: {} };
}
if (result.statsError) log.warn('Error refreshing stats:', result.statsError);
return { ...result, usage: await usagePromise || {} };
})();
try { return await statsRefreshInFlight; }
finally { statsRefreshInFlight = null; }
});

// --- IPC: get-usage (lightweight, API-only, no PTY) ---
Expand Down
18 changes: 14 additions & 4 deletions public/stats-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,23 @@ async function loadStats() {
spinner.innerHTML = `<div class="stats-spinner-icon"></div><span>Updating stats\u2026</span>`;
statsViewerBody.appendChild(spinner);

// Refresh stats cache via PTY (/stats + /usage)
let stats, usage;
// Refresh the stats cache via PTY and fetch rate limits via API.
let stats, usage, statsError;
// Codex usage is a plain API read, so it runs alongside the Claude refresh
// (which spawns a PTY) rather than after it.
const codexPromise = (window.api.getCodexUsage?.() ?? Promise.resolve({})).catch(() => ({}));

try {
const result = await window.api.refreshStats();
stats = result?.stats;
statsError = result?.statsError;
usage = result?.usage || {};
cachedUsage = usage;
} catch {
// Fallback to cached stats
stats = await window.api.getStats();
stats = await window.api.getStats().catch(() => null);
usage = cachedUsage || {};
statsError = 'Could not refresh stats.';
}

let codexUsage = {};
Expand All @@ -36,10 +38,18 @@ async function loadStats() {

statsViewerBody.innerHTML = '';

if (statsError) {
const notice = document.createElement('div');
notice.className = 'stats-refresh-error';
notice.setAttribute('role', 'status');
notice.textContent = `Stats refresh failed. ${statsError}${stats ? ' Showing cached data.' : ''}`;
statsViewerBody.appendChild(notice);
}

// Codex counts here too — a Codex-only user has no Claude stats cache, and
// bailing on that alone would hide their rate limits entirely.
if (!stats && !Object.keys(usage).length && !Object.keys(codexUsage || {}).length) {
statsViewerBody.innerHTML = '<div class="plans-empty">No stats data found. Run some sessions first.</div>';
if (!statsError) statsViewerBody.innerHTML = '<div class="plans-empty">No stats data found. Run some sessions first.</div>';
return;
}

Expand Down
10 changes: 10 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2038,6 +2038,16 @@ body { display: flex; flex-direction: column; }
font-size: 11px;
}

.stats-refresh-error {
font-size: 12px;
color: #d6ad59;
background: rgba(255,200,50,0.06);
border: 1px solid rgba(255,200,50,0.18);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 20px;
}

/* Stats spinner */
.stats-spinner {
display: flex;
Expand Down
118 changes: 118 additions & 0 deletions stats-refresh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const fs = require('node:fs');

function stripTerminal(output) {
return output
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
// Ink positions words using absolute columns instead of literal spaces.
.replace(/\x1b\[[0-?]*[ -/]*([@-~])/g, (_, command) => /[GHf]/.test(command) ? ' ' : '')
.replace(/\x1b[^[\]].?/g, '');
}

function selectedTrustOption(output) {
const matches = [...output.matchAll(/[❯›>]\s*(?:\d+\.\s*)?(Yes|No)\b/gi)];
return matches.at(-1)?.[1].toLowerCase();
}

// Keep navigation and confirmation in separate writes. Wait for Claude to
// render the selected Yes before pressing Enter, including on older CLIs that
// already select Yes. Never confirm an unknown selection.
function runStatsCommand({ spawn, shell, args, options, timeoutMs = 10000 }) {
return new Promise((resolve) => {
let p, timeout, inputTimer;
let output = '';
let settled = false;
let trustPrompt = false;
let moved = false;
let confirmed = false;
const subscriptions = [];

function finish(error = null) {
if (settled) return;
settled = true;
clearTimeout(timeout);
clearTimeout(inputTimer);
for (const subscription of subscriptions) subscription?.dispose();
try { p?.kill(); } catch {}
resolve({ error });
}

function sendAfterRender(key, delay) {
inputTimer = setTimeout(() => {
inputTimer = null;
if (settled) return;
// Recheck in case another render changed the selection while waiting.
const selected = selectedTrustOption(stripTerminal(output));
if (key === '\r' ? selected !== 'yes' : selected !== 'no') return;
if (key === '\r') confirmed = true;
else moved = true;
output = '';
try { p.write(key); }
catch { finish('Could not confirm the folder trust prompt.'); }
}, delay);
}

try {
p = spawn(shell, args, options);
timeout = setTimeout(() => finish('Stats refresh timed out.'), timeoutMs);
subscriptions.push(p.onData((data) => {
if (settled) return;
output = (output + data).slice(-65536);
const text = stripTerminal(output);
if (!confirmed) {
if (/trust[^\r\n]*folder/i.test(text)) trustPrompt = true;
if (trustPrompt) {
const selected = selectedTrustOption(text);
if (!inputTimer) {
if (selected === 'no' && !moved) sendAfterRender('\x1b[B', 1000);
else if (selected === 'yes') sendAfterRender('\r', moved ? 100 : 1000);
}
return;
}
}
if (/streak/i.test(text)) finish();
}));
subscriptions.push(p.onExit(() => finish('Claude exited before stats finished.')));
} catch {
finish('Could not start Claude to refresh stats.');
}
});
}

function readStatsCache(cachePath) {
try {
const stats = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
if (!stats || typeof stats !== 'object' || Array.isArray(stats)) return null;
return { stats, mtimeMs: fs.statSync(cachePath).mtimeMs };
} catch {
return null;
}
}

async function refreshStatsCache(cachePath, run, now = () => new Date()) {
const before = readStatsCache(cachePath);
let error;
try { ({ error } = await run()); }
catch { error = 'Could not refresh stats.'; }
const after = readStatsCache(cachePath);

if (!error) {
if (!after) {
error = 'Stats refresh finished without a readable cache.';
} else {
// Claude caches completed UTC days; today's activity is computed in its
// UI. An unchanged cache through yesterday is normal on repeat refreshes.
const yesterday = new Date(now());
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const lastDate = after.stats.lastComputedDate;
const current = typeof lastDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(lastDate)
&& lastDate >= yesterday.toISOString().slice(0, 10);
const advanced = !before || after.mtimeMs > before.mtimeMs
|| (typeof lastDate === 'string' && lastDate > before.stats.lastComputedDate);
if (!current && !advanced) error = 'Stats cache did not advance after refreshing.';
}
}

return { stats: after?.stats || before?.stats || null, statsError: error || null };
}

module.exports = { runStatsCommand, refreshStatsCache };
Loading
Loading