diff --git a/src/_live-monitor.ts b/src/_live-monitor.ts index 2f793586..e9249a3e 100644 --- a/src/_live-monitor.ts +++ b/src/_live-monitor.ts @@ -12,6 +12,7 @@ import type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts'; import type { CostMode, SortOrder } from './_types.ts'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { Result } from '@praha/byethrow'; import { glob } from 'tinyglobby'; import { CLAUDE_PROJECTS_DIR_NAME, USAGE_DATA_GLOB_PATTERN } from './_consts.ts'; import { identifySessionBlocks } from './_session-blocks.ts'; @@ -23,6 +24,7 @@ import { sortFilesByTimestamp, usageDataSchema, } from './data-loader.ts'; +import { logger } from './logger.ts'; import { PricingFetcher } from './pricing-fetcher.ts'; /** @@ -93,11 +95,22 @@ export class LiveMonitor implements Disposable { for (const file of sortedFiles) { const content = await readFile(file, 'utf-8') - .catch(() => { + .catch((error) => { + // Handle file access errors gracefully with specific logging for ENOENT + const isEnoent = error instanceof Error && + (error.message.includes('ENOENT') || error.message.includes('no such file or directory')); + + if (isEnoent) { + // For ENOENT errors (likely due to cloud sync), use debug level + logger.debug(`File temporarily unavailable (likely syncing): ${path.basename(file)}`); + } else { + // For other read errors, use debug level but with more detail + logger.debug(`Failed to read usage file ${path.basename(file)}:`, error.message); + } + // Skip files that can't be read return ''; }); - const lines = content .trim() .split('\n') diff --git a/src/_live-rendering.ts b/src/_live-rendering.ts index 855bfbf0..7fcea947 100644 --- a/src/_live-rendering.ts +++ b/src/_live-rendering.ts @@ -149,8 +149,8 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock // Session details (indented) const col1 = `${pc.gray('Started:')} ${startTime}`; - const col2 = `${pc.gray('Elapsed:')} ${prettyMs(elapsed * 60 * 1000, { compact: true })}`; - const col3 = `${pc.gray('Remaining:')} ${prettyMs(remaining * 60 * 1000, { compact: true })} (${endTime})`; + const col2 = `${pc.gray('Elapsed:')} ${Math.floor(elapsed / 60)}h ${Math.floor(elapsed % 60)}m`; + const col3 = `${pc.gray('Remaining:')} ${Math.floor(remaining / 60)}h ${Math.floor(remaining % 60)}m (${endTime})`; // Calculate actual visible lengths without ANSI codes const col1Visible = stringWidth(col1); const col2Visible = stringWidth(col2); @@ -370,7 +370,8 @@ export function renderCompactLiveDisplay( // Session info const sessionPercent = (elapsed / (elapsed + remaining)) * 100; - terminal.write(`Session: ${sessionPercent.toFixed(1)}% (${Math.floor(elapsed / 60)}h ${Math.floor(elapsed % 60)}m)\n`); + terminal.write(`Session: ${sessionPercent.toFixed(1)}% (${Math.floor(elapsed / 60)}h ${Math.floor(elapsed % 60)}m elapsed)\n`); + terminal.write(`Remaining: ${Math.floor(remaining / 60)}h ${Math.floor(remaining % 60)}m\n`); // Token usage if (config.tokenLimit != null && config.tokenLimit > 0) { diff --git a/src/commands/_blocks.live.ts b/src/commands/_blocks.live.ts index e09d8485..38cb5670 100644 --- a/src/commands/_blocks.live.ts +++ b/src/commands/_blocks.live.ts @@ -65,8 +65,39 @@ export async function startLiveMonitoring(config: LiveMonitoringConfig): Promise continue; } - // Get latest data - const activeBlock = await monitor.getActiveBlock(); + // Get latest data with error handling + const blockResult = await Result.try({ + try: async () => monitor.getActiveBlock(), + catch: (error) => error, + })(); + + if (Result.isFailure(blockResult)) { + const error = blockResult.error; + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if this is a file synchronization related error (ENOENT) + const isSyncError = errorMessage.includes('ENOENT') || errorMessage.includes('no such file or directory'); + + if (isSyncError) { + // For sync-related errors, show a friendlier message and continue monitoring + const friendlyMessage = 'File temporarily unavailable (likely due to cloud sync)'; + terminal.startBuffering(); + terminal.clearScreen(); + terminal.write(pc.yellow(`Warning: ${friendlyMessage}\n`)); + terminal.write(pc.dim('Waiting for file sync to complete...\n')); + terminal.flush(); + logger.warn(`File sync issue detected: ${errorMessage}`); + + // Continue monitoring after a delay + await delayWithAbort(config.refreshInterval, abortController.signal); + continue; + } else { + // For other errors, re-throw to be handled by outer try-catch + throw error; + } + } + + const activeBlock = blockResult.value; monitor.clearCache(); // TODO: debug LiveMonitor.getActiveBlock() efficiency if (activeBlock == null) { @@ -91,13 +122,14 @@ export async function startLiveMonitoring(config: LiveMonitoringConfig): Promise return; // Normal graceful shutdown } - // Handle and display errors + // Handle non-sync errors that caused the monitoring loop to exit const errorMessage = error instanceof Error ? error.message : String(error); terminal.startBuffering(); terminal.clearScreen(); terminal.write(pc.red(`Error: ${errorMessage}\n`)); terminal.flush(); logger.error(`Live monitoring error: ${errorMessage}`); + await delayWithAbort(config.refreshInterval, abortController.signal).catch(() => {}); } } diff --git a/src/data-loader.ts b/src/data-loader.ts index d1ac53f4..3631e41e 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -520,9 +520,21 @@ export async function getEarliestTimestamp(filePath: string): Promise