Adding block timer widget, miscellaneous UI improvements

This commit is contained in:
Matthew Breedlove committed 2025-08-18 02:40:34 -04:00
1 parent f19c24a39d
commit d1ba0c0be5
33 files changed
+600 -163

No files matched your search

+31 -1
View File
@@ -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`
---
+1
View File
@@ -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",
},
+2 -1
View File
@@ -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"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+12 -1
View File
@@ -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
};
+6 -2
View File
@@ -219,8 +219,10 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ 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<ColorMenuProps> = ({ 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);
+50 -19
View File
@@ -249,8 +249,8 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ widgets, onUpdate, onB
)}
</Box>
{moveMode ? (
<Text dimColor>↑↓ to move item, ESC or Enter to exit move mode</Text>
<Box flexDirection='column' marginBottom={1}>
<Text dimColor>↑↓ to move item, ESC or Enter to exit move mode</Text>
</Box>
) : (
<Box flexDirection='column'>
<Text dimColor>{helpText}</Text>
@@ -404,17 +410,21 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{widgets.length === 0 ? (
<Text dimColor>No items. Press 'a' to add one.</Text>
) : (
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 (
<Box key={widget.id}>
<Text>
return (
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}
</Text>
</Box>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}
{` ${index + 1}. ${displayText || getWidgetDisplay(widget)}`}
{`${index + 1}. ${displayText || getWidgetDisplay(widget)}`}
</Text>
{modifierText && (
<Text dimColor>
@@ -425,10 +435,31 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{widget.rawValue && <Text dimColor> (raw value)</Text>}
{widget.merge === true && <Text dimColor> (merged→)</Text>}
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
</Box>
);
})}
{/* Display description for selected widget */}
{currentWidget && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>
{(() => {
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';
}
}
})()}
</Text>
</Box>
);
})
)}
</>
)}
</Box>
</Box>
+3
View File
@@ -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)
+1 -1
View File
@@ -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({
+4 -1
View File
@@ -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 {
+323
View File
@@ -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<string | null> {
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<TokenMetrics> {
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;
}
+20
View File
@@ -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<string, unknown> = { ...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;
}
}
-136
View File
@@ -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<string | null> {
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<TokenMetrics> {
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,
+1
View File
@@ -19,6 +19,7 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
['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()],
+121
View File
@@ -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; }
}
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+8
View File
@@ -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]';
+1
View File
@@ -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 {
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+1
View File
@@ -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() };
+2 -1
View File
@@ -13,4 +13,5 @@ export { SessionClockWidget } from './SessionClock';
export { TerminalWidthWidget } from './TerminalWidth';
export { VersionWidget } from './Version';
export { CustomTextWidget } from './CustomText';
export { CustomCommandWidget } from './CustomCommand';
export { CustomCommandWidget } from './CustomCommand';
export { BlockTimerWidget } from './BlockTimer';