diff --git a/main.js b/main.js index 0a0fa002..26d79232 100644 --- a/main.js +++ b/main.js @@ -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'); @@ -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) --- diff --git a/public/stats-view.js b/public/stats-view.js index f8ebf377..941338fa 100644 --- a/public/stats-view.js +++ b/public/stats-view.js @@ -13,8 +13,8 @@ async function loadStats() { spinner.innerHTML = `
Updating stats\u2026`; 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(() => ({})); @@ -22,12 +22,14 @@ async function loadStats() { 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 = {}; @@ -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 = '
No stats data found. Run some sessions first.
'; + if (!statsError) statsViewerBody.innerHTML = '
No stats data found. Run some sessions first.
'; return; } diff --git a/public/style.css b/public/style.css index c2a9e5df..c0e25f62 100644 --- a/public/style.css +++ b/public/style.css @@ -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; diff --git a/stats-refresh.js b/stats-refresh.js new file mode 100644 index 00000000..29d4563c --- /dev/null +++ b/stats-refresh.js @@ -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 }; diff --git a/test/stats-refresh.test.js b/test/stats-refresh.test.js new file mode 100644 index 00000000..bf292880 --- /dev/null +++ b/test/stats-refresh.test.js @@ -0,0 +1,180 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { runStatsCommand, refreshStatsCache } = require('../stats-refresh'); + +function ptyFixture(t, { writeError = false, timeoutMs = 10000 } = {}) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let onData, onExit, killed = 0; + const writes = []; + const result = runStatsCommand({ + spawn: () => ({ + write(value) { + if (writeError) throw new Error('closed'); + writes.push(value); + }, + kill() { killed++; }, + onData(fn) { onData = fn; return { dispose() {} }; }, + onExit(fn) { onExit = fn; return { dispose() {} }; }, + }), + shell: '/bin/sh', args: ['-c', 'claude "/stats"'], options: {}, timeoutMs, + }); + return { result, writes, data: value => onData(value), exit: code => onExit({ exitCode: code }), kills: () => killed }; +} + +// Includes the absolute-column escape sequences emitted by Claude 2.1.263. +const noSelected = '\x1b[2GAccessing workspace:\r\n/fake-home\r\n' + + '\x1b[2G❯\x1b[4GNo,\x1b[8Gexit\r\n' + + '\x1b[4GYes,\x1b[9GI\x1b[11Gtrust\x1b[17Gthis\x1b[22Gfolder\r\n'; +const yesSelected = '\x1b[1D\x1b[4B\r\x1b[1C\x1b[4A No, exit\r' + + '\x1b[1C\x1b[1B\x1b[38;2;153;204;255m❯\x1b[4GYes, I trust this folder\x1b[39m\r\n'; + +test('moves Down from No and only confirms after a fragmented Yes redraw', async t => { + const p = ptyFixture(t); + for (const byte of noSelected) p.data(byte); + t.mock.timers.tick(1000); + assert.deepEqual(p.writes, ['\x1b[B']); + // Old accumulated output or unrelated chunks must not trigger Enter. + p.data('\x1b[?2026h'); + t.mock.timers.tick(1000); + assert.deepEqual(p.writes, ['\x1b[B']); + for (const byte of yesSelected) p.data(byte); + t.mock.timers.tick(100); + assert.deepEqual(p.writes, ['\x1b[B', '\r']); + p.data('Current\x1b[43Gstreak: 2 days'); + assert.deepEqual(await p.result, { error: null }); + t.mock.timers.tick(20000); + assert.equal(p.kills(), 1); + assert.equal(p.writes.length, 2); +}); + +test('preserves older numbered prompts that already highlight Yes', async t => { + const p = ptyFixture(t); + p.data('Do you trust this folder?\n❯ 1. Yes, proceed\n 2. No, exit\n'); + t.mock.timers.tick(1000); + assert.deepEqual(p.writes, ['\r']); + p.data('Longest streak: 5 days'); + assert.equal((await p.result).error, null); +}); + +test('an already trusted directory requires no keyboard input', async t => { + const p = ptyFixture(t); + p.data('Loading stats...\nCurrent streak: 2 days'); + assert.equal((await p.result).error, null); + assert.deepEqual(p.writes, []); +}); + +test('does not accept a trust prompt whose selected option is unknown', async t => { + const p = ptyFixture(t); + p.data('Do you trust this folder?\nNo, exit\nYes, I trust this folder\n'); + t.mock.timers.tick(10000); + assert.match((await p.result).error, /timed out/); + assert.deepEqual(p.writes, []); +}); + +test('times out instead of confirming when Down never produces a Yes selection', async t => { + const p = ptyFixture(t); + p.data(noSelected); + t.mock.timers.tick(1000); + p.data(noSelected); + t.mock.timers.tick(9000); + assert.match((await p.result).error, /timed out/); + assert.deepEqual(p.writes, ['\x1b[B']); +}); + +for (const exitCode of [0, 1, 127]) { + test(`exit ${exitCode} before stats output fails and cancels pending trust input`, async t => { + const p = ptyFixture(t); + p.data(noSelected); + p.exit(exitCode); + assert.match((await p.result).error, /exited/); + t.mock.timers.tick(20000); + p.data(yesSelected); + assert.deepEqual(p.writes, []); + assert.equal(p.kills(), 1); + }); +} + +test('reports a failed PTY write and stops the process', async t => { + const p = ptyFixture(t, { writeError: true }); + p.data(noSelected); + t.mock.timers.tick(1000); + assert.match((await p.result).error, /trust prompt/); + assert.equal(p.kills(), 1); +}); + +test('reports a failed spawn', async () => { + const result = await runStatsCommand({ spawn() { throw new Error('ENOENT'); } }); + assert.match(result.error, /Could not start/); +}); + +function cacheFixture(t, date = '2026-08-20') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-stats-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const file = path.join(dir, 'stats-cache.json'); + function write(lastComputedDate) { + fs.writeFileSync(file, JSON.stringify({ lastComputedDate, totalSessions: 1 })); + } + if (date) write(date); + return { file, write, now: () => new Date('2026-09-07T04:00:00Z') }; +} + +test('reports a completed command that leaves an old cache unchanged', async t => { + const c = cacheFixture(t); + const result = await refreshStatsCache(c.file, async () => ({ error: null }), c.now); + assert.match(result.statsError, /did not advance/); + assert.equal(result.stats.lastComputedDate, '2026-08-20'); +}); + +test('accepts a cache date advancing even when the filesystem timestamp is unchanged', async t => { + const c = cacheFixture(t); + fs.utimesSync(c.file, 1000, 1000); + const result = await refreshStatsCache(c.file, async () => { + c.write('2026-09-06'); + fs.utimesSync(c.file, 1000, 1000); + return { error: null }; + }, c.now); + assert.equal(result.statsError, null); + assert.equal(result.stats.lastComputedDate, '2026-09-06'); +}); + +test('an unchanged cache through yesterday is normal on a repeat refresh', async t => { + const c = cacheFixture(t, '2026-09-06'); + const result = await refreshStatsCache(c.file, async () => ({ error: null }), c.now); + assert.equal(result.statsError, null); +}); + +test('a current cache does not hide a failed command', async t => { + const c = cacheFixture(t, '2026-09-06'); + const result = await refreshStatsCache(c.file, async () => ({ error: 'Stats refresh timed out.' }), c.now); + assert.match(result.statsError, /timed out/); + assert.equal(result.stats.lastComputedDate, '2026-09-06'); +}); + +test('keeps the last readable cache when the refreshed file is invalid', async t => { + const c = cacheFixture(t); + const result = await refreshStatsCache(c.file, async () => { + fs.writeFileSync(c.file, '{'); + return { error: null }; + }, c.now); + assert.match(result.statsError, /readable cache/); + assert.equal(result.stats.lastComputedDate, '2026-08-20'); +}); + +test('reports a missing cache for a first-time user without discarding the error', async t => { + const c = cacheFixture(t, null); + const result = await refreshStatsCache(c.file, async () => ({ error: 'Claude exited before stats finished.' }), c.now); + assert.match(result.statsError, /exited/); + assert.equal(result.stats, null); +}); + +test('a first successful cache creation succeeds', async t => { + const c = cacheFixture(t, null); + const result = await refreshStatsCache(c.file, async () => { + c.write('2026-09-06'); + return { error: null }; + }, c.now); + assert.equal(result.statsError, null); +}); diff --git a/test/stats-view.test.js b/test/stats-view.test.js new file mode 100644 index 00000000..def5fdde --- /dev/null +++ b/test/stats-view.test.js @@ -0,0 +1,77 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../public/stats-view.js'), 'utf8'); + +async function render({ stats = null, statsError = null, usage = {}, codexUsage = {}, reject = false, rejectCache = false } = {}) { + const body = { + children: [], html: '', + set innerHTML(value) { this.html = value; this.children = []; }, + get innerHTML() { return this.html; }, + appendChild(child) { this.children.push(child); }, + }; + const rendered = { charts: [], usage: [] }; + const context = { + document: { createElement: () => ({ setAttribute(name, value) { this[name] = value; } }) }, + statsViewerBody: body, + escapeHtml: value => value, + window: { api: { + refreshStats: async () => { + if (reject) throw new Error('IPC unavailable'); + return { stats, statsError, usage }; + }, + getStats: async () => { + if (rejectCache) throw new Error('cache unavailable'); + return stats; + }, + getCodexUsage: async () => codexUsage, + } }, + }; + vm.runInNewContext(source, context); + context.buildHeatmap = () => {}; + context.buildDailyBarChart = value => rendered.charts.push(value); + context.buildStatsSummary = () => {}; + context.buildUsageSection = (value, { runtime }) => rendered.usage.push({ runtime, value }); + await context.loadStats(); + return { body, rendered, error: body.children.find(child => child.className === 'stats-refresh-error') }; +} + +test('failed refresh keeps cached charts and both independent usage panels visible', async () => { + const stats = { lastComputedDate: '2026-08-20', dailyActivity: [] }; + const { rendered, error } = await render({ + stats, statsError: 'Stats cache did not advance after refreshing.', + usage: { session: 12 }, codexUsage: { limits: [{ percent: 20 }] }, + }); + assert.equal(rendered.charts[0], stats); + assert.deepEqual(rendered.usage.map(panel => panel.runtime), ['claude', 'codex']); + assert.match(error.textContent, /Stats refresh failed.*did not advance.*Showing cached data/); + assert.equal(error.role, 'status'); +}); + +test('first-time refresh failure displays its error instead of the no-sessions empty state', async () => { + const { body, error } = await render({ statsError: 'Claude exited before stats finished.' }); + assert.match(error.textContent, /Claude exited/); + assert.doesNotMatch(error.textContent, /Showing cached/); + assert.doesNotMatch(body.innerHTML, /Run some sessions first/); +}); + +test('successful repeat refresh displays no error', async () => { + const { error, rendered } = await render({ stats: { lastComputedDate: '2026-09-06' } }); + assert.equal(error, undefined); + assert.equal(rendered.charts.length, 1); +}); + +test('IPC rejection displays a failure while falling back to cached charts', async () => { + const { error, rendered } = await render({ stats: { lastComputedDate: '2026-08-20' }, reject: true }); + assert.match(error.textContent, /Could not refresh stats.*Showing cached data/); + assert.equal(rendered.charts.length, 1); +}); + +test('a failed cache fallback still clears the spinner and displays the error', async () => { + const { body, error } = await render({ reject: true, rejectCache: true }); + assert.match(error.textContent, /Could not refresh stats/); + assert.equal(body.children.some(child => child.className === 'stats-spinner'), false); +});