diff --git a/README.md b/README.md index 3365be8..af35568 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,16 @@ ## πŸ†• Recent Updates +### v2.0.2 - Block Timer Widget + +![Block Timer](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/blockTimerSmall.png) + +- **⏱️ Block Timer** - Track your progress through 5-hour Claude Code blocks + - Displays time elapsed in current block as hours/minutes (e.g., "3hr 45m") + - Progress bar mode shows visual completion percentage + - Two progress bar styles: full width (32 chars) or compact (16 chars) + - Automatically detects block boundaries from transcript timestamps + ### v2.0.0 - Powerline Support & Enhanced Themes - **⚑ Powerline Mode** - Beautiful Powerline-style status lines with arrow separators and customizable caps - **🎨 Built-in Themes** - Multiple pre-configured themes that you can copy and customize @@ -55,7 +65,7 @@ ## ✨ Features -- **πŸ“Š Real-time Metrics** - Display model name, git branch, token usage, session duration, and more +- **πŸ“Š Real-time Metrics** - Display model name, git branch, token usage, session duration, block timer, and more - **🎨 Fully Customizable** - Choose what to display and customize colors for each element - **⚑ Powerline Support** - Beautiful Powerline-style rendering with arrow separators, caps, and custom fonts - **πŸ“ Multi-line Support** - Configure up to 3 independent status lines @@ -104,6 +114,7 @@ Once configured, ccstatusline automatically formats your Claude Code status line - **Git Branch** - Displays current git branch name - **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10") - **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m") +- **Block Timer** - Shows time elapsed in current 5-hour block or progress bar - **Version** - Shows Claude Code version - **Output Style** - Shows the currently set output style in Claude Code - **Tokens Input** - Shows input tokens used @@ -161,11 +172,30 @@ Configure global formatting preferences that apply to all status items: > ⚠️ **VSCode Users:** If colors appear incorrect in the VSCode integrated terminal, the "Terminal β€Ί Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors. +### ⏱️ Block Timer Widget + +The Block Timer widget helps you track your progress through Claude Code's 5-hour conversation blocks: + +![Block Timer](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/blockTimer.png) + +**Display Modes:** +- **Time Display** - Shows elapsed time as "3hr 45m" (default) +- **Progress Bar** - Full width 32-character progress bar with percentage +- **Progress Bar (Short)** - Compact 16-character progress bar with percentage + +**Features:** +- Automatically detects block boundaries from transcript timestamps +- Floors block start time to the hour for consistent tracking +- Shows "Block: 3hr 45m" in normal mode or just "3hr 45m" in raw value mode +- Progress bars show completion percentage (e.g., "[β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 73.9%") +- Toggle between modes with the **(p)** key in the items editor + ### πŸ”€ Raw Value Mode Some items support "raw value" mode which displays just the value without a label: - Normal: `Model: Claude 3.5 Sonnet` β†’ Raw: `Claude 3.5 Sonnet` - Normal: `Session: 2hr 15m` β†’ Raw: `2hr 15m` +- Normal: `Block: 3hr 45m` β†’ Raw: `3hr 45m` - Normal: `Ctx: 18.6k` β†’ Raw: `18.6k` --- diff --git a/bun.lock b/bun.lock index 3465052..00e9146 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "patch-package": "^8.0.0", "react": "^19.1.1", "react-devtools-core": "^6.1.5", + "tinyglobby": "^0.2.14", "typescript": "^5.9.2", "typescript-eslint": "^8.39.1", }, diff --git a/package.json b/package.json index 7af038b..32872e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.0.1", + "version": "2.0.2", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", @@ -37,6 +37,7 @@ "patch-package": "^8.0.0", "react": "^19.1.1", "react-devtools-core": "^6.1.5", + "tinyglobby": "^0.2.14", "typescript": "^5.9.2", "typescript-eslint": "^8.39.1" }, diff --git a/screenshots/blockTimer.png b/screenshots/blockTimer.png new file mode 100644 index 0000000..ccc2991 Binary files /dev/null and b/screenshots/blockTimer.png differ diff --git a/screenshots/blockTimerSmall.png b/screenshots/blockTimerSmall.png new file mode 100644 index 0000000..b97a2f0 Binary files /dev/null and b/screenshots/blockTimerSmall.png differ diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index b48c76b..2c87ea6 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -9,8 +9,11 @@ import { saveSettings } from './utils/config'; import { + getBlockMetrics, getSessionDuration, - getTokenMetrics, + getTokenMetrics +} from './utils/jsonl'; +import { renderStatusLine, type RenderContext, type StatusJSON @@ -62,6 +65,9 @@ async function renderMultipleLines(data: StatusJSON) { // Check if session clock is needed const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock')); + // Check if block timer is needed + const hasBlockTimer = lines.some(line => line.some(item => item.type === 'block-timer')); + let tokenMetrics = null; if (hasTokenItems && data.transcript_path) tokenMetrics = await getTokenMetrics(data.transcript_path); @@ -70,11 +76,16 @@ async function renderMultipleLines(data: StatusJSON) { if (hasSessionClock && data.transcript_path) sessionDuration = await getSessionDuration(data.transcript_path); + let blockMetrics = null; + if (hasBlockTimer && data.transcript_path) + blockMetrics = getBlockMetrics(data.transcript_path); + // Create render context const context: RenderContext = { data, tokenMetrics, sessionDuration, + blockMetrics, isPreview: false }; diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index b5fb036..778a00f 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -219,8 +219,10 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const newItems = widgets.map((widget) => { if (widget.id === selectedWidget.id) { // Remove color, backgroundColor, and bold properties - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { color, backgroundColor, bold, ...restWidget } = widget; + void color; // Intentionally unused + void backgroundColor; // Intentionally unused + void bold; // Intentionally unused return restWidget; } return widget; @@ -430,8 +432,10 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // Clear all colors from all widgets const newItems = widgets.map((widget) => { // Remove color, backgroundColor, and bold properties - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { color, backgroundColor, bold, ...restWidget } = widget; + void color; // Intentionally unused + void backgroundColor; // Intentionally unused + void bold; // Intentionally unused return restWidget; }); onUpdate(newItems); diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index aa5ae9d..61942bb 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -249,8 +249,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } if (nextMergeState === undefined) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { merge: _merge, ...rest } = currentWidget; + const { merge, ...rest } = currentWidget; + void merge; // Intentionally unused newWidgets[selectedIndex] = rest; } else { newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState }; @@ -270,11 +270,15 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const matchedKeybind = customKeybinds.find(kb => kb.key === input); if (matchedKeybind) { - // Handle toggle actions directly - if (matchedKeybind.action === 'toggle-preserve') { - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = { ...currentWidget, preserveColors: !currentWidget.preserveColors }; - onUpdate(newWidgets); + // Check if widget handles the action directly + if (widgetImpl.handleEditorAction) { + // Let the widget handle the action directly + const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); + if (updatedWidget) { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = updatedWidget; + onUpdate(newWidgets); + } } else if (widgetImpl.renderEditor) { // Set the action on the widget if it supports it if (widgetImpl.setEditorAction) { @@ -387,7 +391,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB )} {moveMode ? ( - ↑↓ to move item, ESC or Enter to exit move mode + + ↑↓ to move item, ESC or Enter to exit move mode + ) : ( {helpText} @@ -404,17 +410,21 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {widgets.length === 0 ? ( No items. Press 'a' to add one. ) : ( - widgets.map((widget, index) => { - const isSelected = index === selectedIndex; - const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null; - const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; + <> + {widgets.map((widget, index) => { + const isSelected = index === selectedIndex; + const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null; + const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; - return ( - - + return ( + + + + {isSelected ? (moveMode ? 'β—† ' : 'β–Ά ') : ' '} + + - {isSelected ? (moveMode ? 'β—† ' : 'β–Ά ') : ' '} - {` ${index + 1}. ${displayText || getWidgetDisplay(widget)}`} + {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} {modifierText && ( @@ -425,10 +435,31 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {widget.rawValue && (raw value)} {widget.merge === true && (mergedβ†’)} {widget.merge === 'no-padding' && (merged-no-padβ†’)} + + ); + })} + {/* Display description for selected widget */} + {currentWidget && ( + + + {(() => { + if (currentWidget.type === 'separator') { + return 'A separator character between status line items'; + } else if (currentWidget.type === 'flex-separator') { + return 'Expands to fill available terminal width'; + } else { + try { + const widgetImpl = getWidget(currentWidget.type); + return widgetImpl.getDescription(); + } catch { + return 'Widget description not available'; + } + } + })()} - ); - }) + )} + )} diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 59cc852..7d84431 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -1,3 +1,5 @@ +import type { BlockInfo } from '../utils/jsonl'; + import type { StatusJSON } from './StatusJSON'; import type { TokenMetrics } from './TokenMetrics'; @@ -5,6 +7,7 @@ export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; sessionDuration?: string | null; + blockMetrics?: BlockInfo | null; terminalWidth?: number | null; isPreview?: boolean; lineIndex?: number; // Index of the current line being rendered (for theme cycling) diff --git a/src/types/Settings.ts b/src/types/Settings.ts index 1b38c8b..05e2d45 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -6,7 +6,7 @@ import { PowerlineConfigSchema } from './PowerlineConfig'; import { WidgetItemSchema } from './Widget'; // Current version - bump this when making breaking changes to the schema -export const CURRENT_VERSION = 2; +export const CURRENT_VERSION = 3; // Schema for v1 settings (before version field was added) export const SettingsSchema_v1 = z.object({ diff --git a/src/types/Widget.ts b/src/types/Widget.ts index dac78b4..3f48093 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -17,7 +17,8 @@ export const WidgetItemSchema = z.object({ maxWidth: z.number().optional(), preserveColors: z.boolean().optional(), timeout: z.number().optional(), - merge: z.union([z.boolean(), z.literal('no-padding')]).optional() + merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), + metadata: z.record(z.string(), z.string()).optional() }); // Inferred types from Zod schemas @@ -31,6 +32,7 @@ export interface WidgetEditorDisplay { export interface Widget { getDefaultColor(): string; + getDescription(): string; getDisplayName(): string; getEditorDisplay(item: WidgetItem): WidgetEditorDisplay; render(item: WidgetItem, context: RenderContext, settings: Settings): string | null; @@ -39,6 +41,7 @@ export interface Widget { supportsRawValue(): boolean; supportsColors(item: WidgetItem): boolean; setEditorAction?(action: string): void; + handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null; } export interface WidgetEditorProps { diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts new file mode 100644 index 0000000..f1cae04 --- /dev/null +++ b/src/utils/jsonl.ts @@ -0,0 +1,323 @@ +import * as fs from 'fs'; +import path from 'node:path'; +import { globSync } from 'tinyglobby'; +import { promisify } from 'util'; + +import type { + TokenMetrics, + TranscriptLine +} from '../types'; + +// Ensure fs.promises compatibility for older Node versions +const readFile = promisify(fs.readFile); +const readFileSync = fs.readFileSync; +const statSync = fs.statSync; + +export interface BlockInfo { + startTime: Date; + lastActivity: Date; + isActive: boolean; +} + +export async function getSessionDuration(transcriptPath: string): Promise { + try { + if (!fs.existsSync(transcriptPath)) { + return null; + } + + const content = await readFile(transcriptPath, 'utf-8'); + const lines = content.trim().split('\n').filter((line: string) => line.trim()); + + if (lines.length === 0) { + return null; + } + + let firstTimestamp: Date | null = null; + let lastTimestamp: Date | null = null; + + // Find first valid timestamp + for (const line of lines) { + try { + const data = JSON.parse(line) as { timestamp?: string }; + if (data.timestamp) { + firstTimestamp = new Date(data.timestamp); + break; + } + } catch { + // Skip invalid lines + } + } + + // Find last valid timestamp (iterate backwards) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const data = JSON.parse(lines[i] ?? '') as { timestamp?: string }; + if (data.timestamp) { + lastTimestamp = new Date(data.timestamp); + break; + } + } catch { + // Skip invalid lines + } + } + + if (!firstTimestamp || !lastTimestamp) { + return null; + } + + // Calculate duration in milliseconds + const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime(); + + // Convert to minutes + const totalMinutes = Math.floor(durationMs / (1000 * 60)); + + if (totalMinutes < 1) { + return '<1m'; + } + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours === 0) { + return `${minutes}m`; + } else if (minutes === 0) { + return `${hours}hr`; + } else { + return `${hours}hr ${minutes}m`; + } + } catch { + return null; + } +} + +export async function getTokenMetrics(transcriptPath: string): Promise { + try { + // Use Node.js-compatible file reading + if (!fs.existsSync(transcriptPath)) { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; + } + + const content = await readFile(transcriptPath, 'utf-8'); + const lines = content.trim().split('\n'); + + let inputTokens = 0; + let outputTokens = 0; + let cachedTokens = 0; + let contextLength = 0; + + // Parse each line and sum up token usage for totals + let mostRecentMainChainEntry: TranscriptLine | null = null; + let mostRecentTimestamp: Date | null = null; + + for (const line of lines) { + try { + const data = JSON.parse(line) as TranscriptLine; + if (data.message?.usage) { + inputTokens += data.message.usage.input_tokens || 0; + outputTokens += data.message.usage.output_tokens || 0; + cachedTokens += data.message.usage.cache_read_input_tokens ?? 0; + cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0; + + // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) + if (data.isSidechain !== true && data.timestamp) { + const entryTime = new Date(data.timestamp); + if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) { + mostRecentTimestamp = entryTime; + mostRecentMainChainEntry = data; + } + } + } + } catch { + // Skip invalid JSON lines + } + } + + // Calculate context length from the most recent main chain message + if (mostRecentMainChainEntry?.message?.usage) { + const usage = mostRecentMainChainEntry.message.usage; + contextLength = (usage.input_tokens || 0) + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0); + } + + const totalTokens = inputTokens + outputTokens + cachedTokens; + + return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; + } catch { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; + } +} + +/** + * Gets block metrics for the current 5-hour block from JSONL files + */ +export function getBlockMetrics(transcriptPath: string | undefined): BlockInfo | null { + if (!transcriptPath || typeof transcriptPath !== 'string') { + return null; + } + + // Walk up the directory tree to find .claude folder + let currentPath = path.dirname(transcriptPath); + let claudePath: string | null = null; + + while (currentPath && currentPath !== path.dirname(currentPath)) { + const baseName = path.basename(currentPath); + if (baseName === '.claude') { + claudePath = currentPath; + break; + } + currentPath = path.dirname(currentPath); + } + + if (!claudePath) { + return null; + } + + try { + return findMostRecentBlockStartTime(claudePath); + } catch { + return null; + } +} + +/** + * Efficiently finds the most recent 5-hour block start time from JSONL files + * Uses file modification times as hints to avoid unnecessary reads + */ +function findMostRecentBlockStartTime( + rootDir: string, + sessionDurationHours = 5 +): BlockInfo | null { + const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; + const now = new Date(); + + // Step 1: Find all JSONL files with their modification times + const pattern = path.join(rootDir, 'projects', '**', '*.jsonl').replace(/\\/g, '/'); + const files = globSync([pattern]); + + if (files.length === 0) { + return null; + } + + // Step 2: Get file stats and sort by modification time (most recent first) + const filesWithStats = files.map((file) => { + const stats = statSync(file); + return { file, mtime: stats.mtime }; + }); + + filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + // Step 3: Collect recent timestamps to properly identify blocks + // We need to look back far enough to find the start of the current/recent block + const timestamps: Date[] = []; + // Look back 20 hours to find the current session + const cutoffTime = new Date(now.getTime() - 20 * 60 * 60 * 1000); + + for (const { file, mtime } of filesWithStats) { + // Skip files that are too old (optimization) + if (mtime.getTime() < cutoffTime.getTime()) { + break; + } + + // Get all timestamps from this file (not just the latest) + const fileTimestamps = getAllTimestampsFromFile(file); + timestamps.push(...fileTimestamps); + } + + if (timestamps.length === 0) { + return null; + } + + // Step 4: Sort timestamps and identify the most recent block + timestamps.sort((a, b) => b.getTime() - a.getTime()); // Most recent first + + // Find the most recent timestamp + const mostRecentTimestamp = timestamps[0]; + if (!mostRecentTimestamp) { + return null; + } + + // Now work backwards to find the start of this block + let blockStartTimestamp: Date = mostRecentTimestamp; + + for (let i = 1; i < timestamps.length; i++) { + const currentTimestamp = timestamps[i]; + const previousTimestamp = timestamps[i - 1]; + + if (!currentTimestamp || !previousTimestamp) { + continue; + } + + // Check if there's a gap larger than session duration + const gap = previousTimestamp.getTime() - currentTimestamp.getTime(); + + if (gap > sessionDurationMs) { + // We found a gap - the current block starts with previousTimestamp + break; + } + + // Check if we're still within the session duration from the most recent + const timeFromMostRecent = mostRecentTimestamp.getTime() - currentTimestamp.getTime(); + + if (timeFromMostRecent < sessionDurationMs) { + // This timestamp is still part of the current block + blockStartTimestamp = currentTimestamp; + } else { + // We've gone too far back + break; + } + } + + // Floor the block start to the hour + const blockStart = floorToHour(blockStartTimestamp); + const timeSinceActivity = now.getTime() - mostRecentTimestamp.getTime(); + const blockEnd = new Date(blockStart.getTime() + sessionDurationMs); + + // Check if block is still active + const isActive = timeSinceActivity < sessionDurationMs && now < blockEnd; + + return { + startTime: blockStart, + lastActivity: mostRecentTimestamp, + isActive + }; +} + +/** + * Gets all timestamps from a JSONL file + */ +function getAllTimestampsFromFile(filePath: string): Date[] { + const timestamps: Date[] = []; + try { + const content = readFileSync(filePath, 'utf-8'); + const lines = content.trim().split('\n').filter(line => line.length > 0); + + for (const line of lines) { + try { + const json = JSON.parse(line) as { timestamp?: string }; + if (json.timestamp && typeof json.timestamp === 'string') { + const date = new Date(json.timestamp); + if (!Number.isNaN(date.getTime())) { + timestamps.push(date); + } + } + } catch { + // Skip invalid JSON lines + continue; + } + } + + return timestamps; + } catch { + return []; + } +} + +/** + * Floors a timestamp to the beginning of the hour (matching existing logic) + */ +function floorToHour(timestamp: Date): Date { + const floored = new Date(timestamp); + floored.setUTCMinutes(0, 0, 0); + return floored; +} \ No newline at end of file diff --git a/src/utils/migrations.ts b/src/utils/migrations.ts index e5425bc..35dbc5b 100644 --- a/src/utils/migrations.ts +++ b/src/utils/migrations.ts @@ -89,6 +89,26 @@ export const migrations: Migration[] = [ remaining: 12 }; + return migrated; + } + }, + { + fromVersion: 2, + toVersion: 3, + description: 'Migrate from v2 to v3', + migrate: (data) => { + // Copy all existing data to v3 + const migrated: Record = { ...data }; + + // Update version to 3 + migrated.version = 3; + + // Add update message for v3 migration + migrated.updatemessage = { + message: 'ccstatusline updated to v2.0.2, 5hr block timer widget added', + remaining: 12 + }; + return migrated; } } diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 5d99c04..3076ccf 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -1,14 +1,10 @@ import chalk from 'chalk'; -import * as fs from 'fs'; -import { promisify } from 'util'; // ANSI escape sequence for stripping color codes const ANSI_REGEX = new RegExp(`\\x1b\\[[0-9;]*m`, 'g'); import type { RenderContext, - TokenMetrics, - TranscriptLine, WidgetItem } from '../types'; import { getColorLevelString } from '../types/ColorLevel'; @@ -28,9 +24,6 @@ export type { StatusJSON } from '../types/StatusJSON'; export type { TokenMetrics, TokenUsage, TranscriptLine } from '../types/TokenMetrics'; export type { RenderContext } from '../types/RenderContext'; -// Ensure fs.promises compatibility for older Node versions -const readFile = promisify(fs.readFile); - // Color functions moved to colors.ts // Re-exported for backward compatibility export { applyColors } from './colors'; @@ -47,135 +40,6 @@ export function formatTokens(count: number): string { return count.toString(); } -export async function getSessionDuration(transcriptPath: string): Promise { - try { - if (!fs.existsSync(transcriptPath)) { - return null; - } - - const content = await readFile(transcriptPath, 'utf-8'); - const lines = content.trim().split('\n').filter((line: string) => line.trim()); - - if (lines.length === 0) { - return null; - } - - let firstTimestamp: Date | null = null; - let lastTimestamp: Date | null = null; - - // Find first valid timestamp - for (const line of lines) { - try { - const data = JSON.parse(line) as { timestamp?: string }; - if (data.timestamp) { - firstTimestamp = new Date(data.timestamp); - break; - } - } catch { - // Skip invalid lines - } - } - - // Find last valid timestamp (iterate backwards) - for (let i = lines.length - 1; i >= 0; i--) { - try { - const data = JSON.parse(lines[i] ?? '') as { timestamp?: string }; - if (data.timestamp) { - lastTimestamp = new Date(data.timestamp); - break; - } - } catch { - // Skip invalid lines - } - } - - if (!firstTimestamp || !lastTimestamp) { - return null; - } - - // Calculate duration in milliseconds - const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime(); - - // Convert to minutes - const totalMinutes = Math.floor(durationMs / (1000 * 60)); - - if (totalMinutes < 1) { - return '<1m'; - } - - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - - if (hours === 0) { - return `${minutes}m`; - } else if (minutes === 0) { - return `${hours}hr`; - } else { - return `${hours}hr ${minutes}m`; - } - } catch { - return null; - } -} - -export async function getTokenMetrics(transcriptPath: string): Promise { - try { - // Use Node.js-compatible file reading - if (!fs.existsSync(transcriptPath)) { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; - } - - const content = await readFile(transcriptPath, 'utf-8'); - const lines = content.trim().split('\n'); - - let inputTokens = 0; - let outputTokens = 0; - let cachedTokens = 0; - let contextLength = 0; - - // Parse each line and sum up token usage for totals - let mostRecentMainChainEntry: TranscriptLine | null = null; - let mostRecentTimestamp: Date | null = null; - - for (const line of lines) { - try { - const data = JSON.parse(line) as TranscriptLine; - if (data.message?.usage) { - inputTokens += data.message.usage.input_tokens || 0; - outputTokens += data.message.usage.output_tokens || 0; - cachedTokens += data.message.usage.cache_read_input_tokens ?? 0; - cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0; - - // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) - if (data.isSidechain !== true && data.timestamp) { - const entryTime = new Date(data.timestamp); - if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) { - mostRecentTimestamp = entryTime; - mostRecentMainChainEntry = data; - } - } - } - } catch { - // Skip invalid JSON lines - } - } - - // Calculate context length from the most recent main chain message - if (mostRecentMainChainEntry?.message?.usage) { - const usage = mostRecentMainChainEntry.message.usage; - contextLength = (usage.input_tokens || 0) - + (usage.cache_read_input_tokens ?? 0) - + (usage.cache_creation_input_tokens ?? 0); - } - - const totalTokens = inputTokens + outputTokens + cachedTokens; - - return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; - } catch { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; - } -} - function renderPowerlineStatusLine( widgets: WidgetItem[], settings: Settings, diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 280b49c..a21163f 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -19,6 +19,7 @@ const widgetRegistry = new Map([ ['context-percentage', new widgets.ContextPercentageWidget()], ['context-percentage-usable', new widgets.ContextPercentageUsableWidget()], ['session-clock', new widgets.SessionClockWidget()], + ['block-timer', new widgets.BlockTimerWidget()], ['terminal-width', new widgets.TerminalWidthWidget()], ['version', new widgets.VersionWidget()], ['custom-text', new widgets.CustomTextWidget()], diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts new file mode 100644 index 0000000..fc41666 --- /dev/null +++ b/src/widgets/BlockTimer.ts @@ -0,0 +1,121 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +type DisplayMode = 'time' | 'progress' | 'progress-short'; + +export class BlockTimerWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows elapsed time since beginning of current 5hr block'; } + getDisplayName(): string { return 'Block Timer'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const mode = item.metadata?.display ?? 'time'; + const modifiers: string[] = []; + + if (mode === 'progress') { + modifiers.push('progress bar'); + } else if (mode === 'progress-short') { + modifiers.push('short bar'); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-progress') { + const currentMode = (item.metadata?.display ?? 'time') as DisplayMode; + let nextMode: DisplayMode; + + if (currentMode === 'time') { + nextMode = 'progress'; + } else if (currentMode === 'progress') { + nextMode = 'progress-short'; + } else { + nextMode = 'time'; + } + + return { + ...item, + metadata: { + ...item.metadata, + display: nextMode + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const displayMode = (item.metadata?.display ?? 'time') as DisplayMode; + + if (context.isPreview) { + if (displayMode === 'progress') { + return 'Block [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 73.9%'; + } else if (displayMode === 'progress-short') { + return 'Block [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 73.9%'; + } + return item.rawValue ? '3hr 45m' : 'Block: 3hr 45m'; + } + + // Check if we have block metrics in context + const blockInfo = context.blockMetrics; + if (!blockInfo) { + return null; + } + + try { + // Calculate elapsed time and progress + const now = new Date(); + const elapsedMs = now.getTime() - blockInfo.startTime.getTime(); + const sessionDurationMs = 5 * 60 * 60 * 1000; // 5 hours + const progress = Math.min(elapsedMs / sessionDurationMs, 1.0); + const percentage = (progress * 100).toFixed(1); + + if (displayMode === 'progress' || displayMode === 'progress-short') { + const barWidth = displayMode === 'progress' ? 32 : 16; + const filledWidth = Math.floor(progress * barWidth); + const emptyWidth = barWidth - filledWidth; + const progressBar = 'β–ˆ'.repeat(filledWidth) + 'β–‘'.repeat(emptyWidth); + + if (item.rawValue) { + return `[${progressBar}] ${percentage}%`; + } else { + return `Block [${progressBar}] ${percentage}%`; + } + } else { + // Time display mode + const elapsedHours = Math.floor(elapsedMs / (1000 * 60 * 60)); + const elapsedMinutes = Math.floor((elapsedMs % (1000 * 60 * 60)) / (1000 * 60)); + + let timeString: string; + if (elapsedMinutes === 0) { + timeString = `${elapsedHours}hr`; + } else { + timeString = `${elapsedHours}hr ${elapsedMinutes}m`; + } + + return item.rawValue ? timeString : `Block: ${timeString}`; + } + } catch { + return null; + } + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' } + ]; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 5a6f55a..3d60f77 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -9,6 +9,7 @@ import { formatTokens } from '../utils/renderer'; export class ContextLengthWidget implements Widget { getDefaultColor(): string { return 'brightBlack'; } + getDescription(): string { return 'Shows the current context window size in tokens'; } getDisplayName(): string { return 'Context Length'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index a0e1309..5bf6974 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -8,6 +8,7 @@ import type { export class ContextPercentageWidget implements Widget { getDefaultColor(): string { return 'blue'; } + getDescription(): string { return 'Shows percentage of context window used (of 200k tokens)'; } getDisplayName(): string { return 'Context %'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 624d4dd..37bd1c8 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -8,6 +8,7 @@ import type { export class ContextPercentageUsableWidget implements Widget { getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows percentage of usable context window used (of 160k tokens before auto-compact)'; } getDisplayName(): string { return 'Context % (usable)'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/CustomCommand.tsx b/src/widgets/CustomCommand.tsx index 3dc88e0..9026f7d 100644 --- a/src/widgets/CustomCommand.tsx +++ b/src/widgets/CustomCommand.tsx @@ -20,6 +20,7 @@ export class CustomCommandWidget implements Widget { private currentAction: string = 'edit-command'; getDefaultColor(): string { return 'white'; } + getDescription(): string { return 'Executes a custom shell command and displays output'; } getDisplayName(): string { return 'Custom Command'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { @@ -49,6 +50,13 @@ export class CustomCommandWidget implements Widget { this.currentAction = action; } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-preserve') { + return { ...item, preserveColors: !item.preserveColors }; + } + return null; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return item.commandPath ? `[cmd: ${item.commandPath.substring(0, 20)}${item.commandPath.length > 20 ? '...' : ''}]` : '[No command]'; diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index 207e5e1..224f730 100644 --- a/src/widgets/CustomText.tsx +++ b/src/widgets/CustomText.tsx @@ -17,6 +17,7 @@ import type { export class CustomTextWidget implements Widget { getDefaultColor(): string { return 'white'; } + getDescription(): string { return 'Displays user-defined custom text'; } getDisplayName(): string { return 'Custom Text'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index 6306c6f..aceeaa0 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -10,6 +10,7 @@ import type { export class GitBranchWidget implements Widget { getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the current git branch name'; } getDisplayName(): string { return 'Git Branch'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/GitChanges.ts b/src/widgets/GitChanges.ts index ff6a9ff..1234fb5 100644 --- a/src/widgets/GitChanges.ts +++ b/src/widgets/GitChanges.ts @@ -10,6 +10,7 @@ import type { export class GitChangesWidget implements Widget { getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows git changes count (+insertions, -deletions)'; } getDisplayName(): string { return 'Git Changes'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/Model.ts b/src/widgets/Model.ts index cfcbc7e..3de797d 100644 --- a/src/widgets/Model.ts +++ b/src/widgets/Model.ts @@ -8,6 +8,7 @@ import type { export class ModelWidget implements Widget { getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Displays the Claude model name (e.g., Claude 3.5 Sonnet)'; } getDisplayName(): string { return 'Model'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/OutputStyle.ts b/src/widgets/OutputStyle.ts index bb917bc..de41b9e 100644 --- a/src/widgets/OutputStyle.ts +++ b/src/widgets/OutputStyle.ts @@ -8,6 +8,7 @@ import type { export class OutputStyleWidget implements Widget { getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the current Claude Code output style'; } getDisplayName(): string { return 'Output Style'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/SessionClock.ts b/src/widgets/SessionClock.ts index 8d59045..87c8752 100644 --- a/src/widgets/SessionClock.ts +++ b/src/widgets/SessionClock.ts @@ -8,6 +8,7 @@ import type { export class SessionClockWidget implements Widget { getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows elapsed time since current session started'; } getDisplayName(): string { return 'Session Clock'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/TerminalWidth.ts b/src/widgets/TerminalWidth.ts index 1da5ba6..700776b 100644 --- a/src/widgets/TerminalWidth.ts +++ b/src/widgets/TerminalWidth.ts @@ -9,6 +9,7 @@ import { getTerminalWidth } from '../utils/renderer'; export class TerminalWidthWidget implements Widget { getDefaultColor(): string { return 'gray'; } + getDescription(): string { return 'Shows current terminal width in columns'; } getDisplayName(): string { return 'Terminal Width'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index 8fde9ac..ad4984d 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -9,6 +9,7 @@ import { formatTokens } from '../utils/renderer'; export class TokensCachedWidget implements Widget { getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows cached token count for the current session'; } getDisplayName(): string { return 'Tokens Cached'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index 2e2dd99..707aab7 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -9,6 +9,7 @@ import { formatTokens } from '../utils/renderer'; export class TokensInputWidget implements Widget { getDefaultColor(): string { return 'blue'; } + getDescription(): string { return 'Shows input token count for the current session'; } getDisplayName(): string { return 'Tokens Input'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index d5efaee..d5008c8 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -9,6 +9,7 @@ import { formatTokens } from '../utils/renderer'; export class TokensOutputWidget implements Widget { getDefaultColor(): string { return 'white'; } + getDescription(): string { return 'Shows output token count for the current session'; } getDisplayName(): string { return 'Tokens Output'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index dc84b8d..a1ccf36 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -9,6 +9,7 @@ import { formatTokens } from '../utils/renderer'; export class TokensTotalWidget implements Widget { getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows total token count (input + output + cache) for the current session'; } getDisplayName(): string { return 'Tokens Total'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/Version.ts b/src/widgets/Version.ts index 145b4d5..e46c991 100644 --- a/src/widgets/Version.ts +++ b/src/widgets/Version.ts @@ -8,6 +8,7 @@ import type { export class VersionWidget implements Widget { getDefaultColor(): string { return 'gray'; } + getDescription(): string { return 'Shows Claude Code CLI version number'; } getDisplayName(): string { return 'Version'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 59eab9a..fc73da1 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -13,4 +13,5 @@ export { SessionClockWidget } from './SessionClock'; export { TerminalWidthWidget } from './TerminalWidth'; export { VersionWidget } from './Version'; export { CustomTextWidget } from './CustomText'; -export { CustomCommandWidget } from './CustomCommand'; \ No newline at end of file +export { CustomCommandWidget } from './CustomCommand'; +export { BlockTimerWidget } from './BlockTimer'; \ No newline at end of file