From c84aeab2bba3760391cff990eaa50ebd01c09a1a Mon Sep 17 00:00:00 2001 From: Tyler Gray Date: Wed, 17 Dec 2025 18:35:29 -0500 Subject: [PATCH 1/2] Add sparkline history and reset time toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New features: - Reset time toggle: Show either time remaining ("3h20m") or absolute reset time ("2:30pm") - Config: `block.timeDisplay: "remaining" | "absolute"` - Config: `block.timeFormat: "12h" | "24h"` - Sparkline history: Visual usage trend using block characters (▁▂▃▄▅▆▇█) - Config: `block.showSparkline: boolean` (default false) - Config: `block.sparklineWidth: number` (default 8) - Stores usage history in ~/.claude/limitline-history.json - Shows last 24 hours of usage data Example output with sparkline: ◫ 29% ▁▂▃▄▅▆▇█ (3h) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/config/types.ts | 11 ++++ src/index.ts | 9 +++ src/renderer.test.ts | 68 +++++++++++++++++++++ src/renderer.ts | 43 +++++++++++-- src/utils/history.test.ts | 124 ++++++++++++++++++++++++++++++++++++++ src/utils/history.ts | 111 ++++++++++++++++++++++++++++++++++ 6 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 src/utils/history.test.ts create mode 100644 src/utils/history.ts diff --git a/src/config/types.ts b/src/config/types.ts index 90a1b0d..f5c1a87 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -8,8 +8,15 @@ export interface SimpleSegmentConfig { enabled: boolean; } +export type TimeDisplay = "remaining" | "absolute"; +export type TimeFormat = "12h" | "24h"; + export interface BlockSegmentConfig extends SegmentConfig { showTimeRemaining?: boolean; + timeDisplay?: TimeDisplay; // "remaining" (3h20m) or "absolute" (2:30pm), default "remaining" + timeFormat?: TimeFormat; // "12h" or "24h" for absolute time, default "12h" + showSparkline?: boolean; // Show usage history sparkline, default false + sparklineWidth?: number; // Number of sparkline characters, default 8 } export type WeeklyViewMode = "simple" | "smart"; @@ -71,6 +78,10 @@ export const DEFAULT_CONFIG: LimitlineConfig = { displayStyle: "text", barWidth: 10, showTimeRemaining: true, + timeDisplay: "remaining", + timeFormat: "12h", + showSparkline: false, + sparklineWidth: 8, }, weekly: { enabled: true, diff --git a/src/index.ts b/src/index.ts index b1bb484..5e4745e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { getEnvironmentInfo } from "./utils/environment.js"; import { readHookData } from "./utils/claude-hook.js"; import { getUsageTrend } from "./utils/oauth.js"; import { debug } from "./utils/logger.js"; +import { addSample } from "./utils/history.js"; async function main(): Promise { try { @@ -45,6 +46,14 @@ async function main(): Promise { debug("Block info:", JSON.stringify(blockInfo)); debug("Weekly info:", JSON.stringify(weeklyInfo)); + // Record usage sample for sparkline history + if (blockInfo?.percentUsed !== null || weeklyInfo?.percentUsed !== null) { + addSample( + blockInfo?.percentUsed ?? null, + weeklyInfo?.percentUsed ?? null + ); + } + // Get trend info for usage changes const trendInfo = config.showTrend ? getUsageTrend() : null; debug("Trend info:", JSON.stringify(trendInfo)); diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 9c54ed9..e930daf 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -164,6 +164,74 @@ describe("Renderer", () => { expect(output).toContain("45m"); }); + + it("shows absolute time in 12h format when configured", () => { + const config: LimitlineConfig = { + ...DEFAULT_CONFIG, + block: { + enabled: true, + showTimeRemaining: true, + timeDisplay: "absolute", + timeFormat: "12h", + }, + }; + const renderer = new Renderer(config); + // Create a reset time at 2:30 PM + const resetAt = new Date(); + resetAt.setHours(14, 30, 0, 0); + const blockInfo: BlockInfo = { + ...defaultBlockInfo, + resetAt, + }; + const output = renderer.render(blockInfo, defaultWeeklyInfo, defaultEnvInfo); + + expect(output).toContain("2:30pm"); + }); + + it("shows absolute time in 24h format when configured", () => { + const config: LimitlineConfig = { + ...DEFAULT_CONFIG, + block: { + enabled: true, + showTimeRemaining: true, + timeDisplay: "absolute", + timeFormat: "24h", + }, + }; + const renderer = new Renderer(config); + // Create a reset time at 2:30 PM + const resetAt = new Date(); + resetAt.setHours(14, 30, 0, 0); + const blockInfo: BlockInfo = { + ...defaultBlockInfo, + resetAt, + }; + const output = renderer.render(blockInfo, defaultWeeklyInfo, defaultEnvInfo); + + expect(output).toContain("14:30"); + }); + + it("shows AM correctly for morning times", () => { + const config: LimitlineConfig = { + ...DEFAULT_CONFIG, + block: { + enabled: true, + showTimeRemaining: true, + timeDisplay: "absolute", + timeFormat: "12h", + }, + }; + const renderer = new Renderer(config); + const resetAt = new Date(); + resetAt.setHours(9, 15, 0, 0); + const blockInfo: BlockInfo = { + ...defaultBlockInfo, + resetAt, + }; + const output = renderer.render(blockInfo, defaultWeeklyInfo, defaultEnvInfo); + + expect(output).toContain("9:15am"); + }); }); describe("trend arrows", () => { diff --git a/src/renderer.ts b/src/renderer.ts index b27bc04..9734bae 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -6,6 +6,7 @@ import { type WeeklyInfo } from "./segments/weekly.js"; import { type EnvironmentInfo } from "./utils/environment.js"; import { type TrendInfo } from "./utils/oauth.js"; import { getTerminalWidth } from "./utils/terminal.js"; +import { getBlockSparkline } from "./utils/history.js"; interface SymbolSet { block: string; @@ -100,6 +101,22 @@ export class Renderer { return `${minutes}m`; } + private formatAbsoluteTime(resetAt: Date, format: "12h" | "24h"): string { + const hours = resetAt.getHours(); + const minutes = resetAt.getMinutes(); + const paddedMinutes = minutes.toString().padStart(2, "0"); + + if (format === "24h") { + const paddedHours = hours.toString().padStart(2, "0"); + return `${paddedHours}:${paddedMinutes}`; + } + + // 12-hour format + const hour12 = hours % 12 || 12; + const ampm = hours < 12 ? "am" : "pm"; + return `${hour12}:${paddedMinutes}${ampm}`; + } + private getTrendSymbol(trend: "up" | "down" | "same" | null): string { if (!this.config.showTrend) return ""; if (trend === "up") return this.symbols.trendUp; @@ -253,10 +270,28 @@ export class Renderer { text = `${Math.round(percent)}%${trend}`; } - // Add time remaining if available and enabled (skip in compact mode) - if (showTime && ctx.blockInfo.timeRemaining !== null && !ctx.compact) { - const timeStr = this.formatTimeRemaining(ctx.blockInfo.timeRemaining, ctx.compact); - text += ` (${timeStr})`; + // Add sparkline if enabled (skip in compact mode) + const showSparkline = this.config.block?.showSparkline ?? false; + if (showSparkline && !ctx.compact) { + const sparklineWidth = this.config.block?.sparklineWidth ?? 8; + const sparkline = getBlockSparkline(sparklineWidth); + if (sparkline) { + text += ` ${sparkline}`; + } + } + + // Add time if available and enabled (skip in compact mode) + if (showTime && !ctx.compact) { + const timeDisplay = this.config.block?.timeDisplay ?? "remaining"; + const timeFormat = this.config.block?.timeFormat ?? "12h"; + + if (timeDisplay === "absolute" && ctx.blockInfo.resetAt) { + const timeStr = this.formatAbsoluteTime(ctx.blockInfo.resetAt, timeFormat); + text += ` (${timeStr})`; + } else if (ctx.blockInfo.timeRemaining !== null) { + const timeStr = this.formatTimeRemaining(ctx.blockInfo.timeRemaining, ctx.compact); + text += ` (${timeStr})`; + } } return { diff --git a/src/utils/history.test.ts b/src/utils/history.test.ts new file mode 100644 index 0000000..52a153f --- /dev/null +++ b/src/utils/history.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getSparkline, pruneOldSamples, type HistoryData } from "./history.js"; + +// Mock fs module +vi.mock("node:fs", () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + }, +})); + +describe("history utilities", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getSparkline", () => { + it("generates sparkline from samples", () => { + const samples = [0, 25, 50, 75, 100]; + const sparkline = getSparkline(samples, 5); + + expect(sparkline).toHaveLength(5); + // First char should be lowest (0%), last should be highest (100%) + expect(sparkline[0]).toBe("▁"); + expect(sparkline[4]).toBe("█"); + }); + + it("returns empty string for empty samples", () => { + const sparkline = getSparkline([], 5); + expect(sparkline).toBe(""); + }); + + it("returns empty string for all null samples", () => { + const sparkline = getSparkline([null, null, null], 5); + expect(sparkline).toBe(""); + }); + + it("filters out null values", () => { + const samples = [null, 50, null, 100]; + const sparkline = getSparkline(samples, 5); + + expect(sparkline).toHaveLength(2); + }); + + it("limits to specified width", () => { + const samples = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + const sparkline = getSparkline(samples, 5); + + // Should only take the last 5 samples + expect(sparkline).toHaveLength(5); + }); + + it("handles values at boundaries correctly", () => { + // Test edge cases: 0, 50, 100 + const samples = [0, 50, 100]; + const sparkline = getSparkline(samples, 3); + + expect(sparkline[0]).toBe("▁"); // 0% + expect(sparkline[1]).toBe("▄"); // 50% -> index 3 + expect(sparkline[2]).toBe("█"); // 100% -> index 7 + }); + + it("clamps values over 100", () => { + const samples = [150]; + const sparkline = getSparkline(samples, 1); + + expect(sparkline).toBe("█"); + }); + + it("clamps negative values to 0", () => { + const samples = [-10]; + const sparkline = getSparkline(samples, 1); + + expect(sparkline).toBe("▁"); + }); + }); + + describe("pruneOldSamples", () => { + it("removes samples older than 24 hours", () => { + const now = Date.now(); + const oldTimestamp = now - 25 * 60 * 60 * 1000; // 25 hours ago + const recentTimestamp = now - 1 * 60 * 60 * 1000; // 1 hour ago + + const data: HistoryData = { + samples: [ + { timestamp: oldTimestamp, blockPercent: 50, weeklyPercent: 30 }, + { timestamp: recentTimestamp, blockPercent: 60, weeklyPercent: 40 }, + ], + }; + + const pruned = pruneOldSamples(data); + + expect(pruned.samples).toHaveLength(1); + expect(pruned.samples[0].timestamp).toBe(recentTimestamp); + }); + + it("keeps all samples if none are old", () => { + const now = Date.now(); + const data: HistoryData = { + samples: [ + { timestamp: now - 1000, blockPercent: 50, weeklyPercent: 30 }, + { timestamp: now - 2000, blockPercent: 60, weeklyPercent: 40 }, + ], + }; + + const pruned = pruneOldSamples(data); + + expect(pruned.samples).toHaveLength(2); + }); + + it("handles empty samples array", () => { + const data: HistoryData = { samples: [] }; + const pruned = pruneOldSamples(data); + + expect(pruned.samples).toHaveLength(0); + }); + }); +}); diff --git a/src/utils/history.ts b/src/utils/history.ts new file mode 100644 index 0000000..5697580 --- /dev/null +++ b/src/utils/history.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { debug } from "./logger.js"; + +export interface UsageSample { + timestamp: number; // Unix ms + blockPercent: number | null; + weeklyPercent: number | null; +} + +export interface HistoryData { + samples: UsageSample[]; +} + +const SPARKLINE_CHARS = "▁▂▃▄▅▆▇█"; +const MAX_HISTORY_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours +const HISTORY_FILE = "limitline-history.json"; + +function getHistoryPath(): string { + return path.join(os.homedir(), ".claude", HISTORY_FILE); +} + +export function loadHistory(): HistoryData { + const historyPath = getHistoryPath(); + try { + if (fs.existsSync(historyPath)) { + const content = fs.readFileSync(historyPath, "utf-8"); + const data = JSON.parse(content) as HistoryData; + return data; + } + } catch (error) { + debug("Failed to load history:", error); + } + return { samples: [] }; +} + +export function saveHistory(data: HistoryData): void { + const historyPath = getHistoryPath(); + try { + // Ensure directory exists + const dir = path.dirname(historyPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(historyPath, JSON.stringify(data, null, 2)); + } catch (error) { + debug("Failed to save history:", error); + } +} + +export function pruneOldSamples(data: HistoryData): HistoryData { + const cutoff = Date.now() - MAX_HISTORY_AGE_MS; + return { + samples: data.samples.filter(s => s.timestamp > cutoff), + }; +} + +export function addSample( + blockPercent: number | null, + weeklyPercent: number | null +): void { + const history = loadHistory(); + + // Add new sample + history.samples.push({ + timestamp: Date.now(), + blockPercent, + weeklyPercent, + }); + + // Prune old samples and save + const pruned = pruneOldSamples(history); + saveHistory(pruned); +} + +export function getSparkline( + samples: (number | null)[], + width: number +): string { + // Filter out nulls and get the last 'width' samples + const validSamples = samples.filter((s): s is number => s !== null); + + if (validSamples.length === 0) { + return ""; + } + + // Take the last 'width' samples + const recentSamples = validSamples.slice(-width); + + // Map each value to a sparkline character (0-100 -> 0-7) + return recentSamples + .map(value => { + const clamped = Math.max(0, Math.min(100, value)); + const index = Math.floor((clamped / 100) * 7); + return SPARKLINE_CHARS[index]; + }) + .join(""); +} + +export function getBlockSparkline(width: number): string { + const history = loadHistory(); + const blockSamples = history.samples.map(s => s.blockPercent); + return getSparkline(blockSamples, width); +} + +export function getWeeklySparkline(width: number): string { + const history = loadHistory(); + const weeklySamples = history.samples.map(s => s.weeklyPercent); + return getSparkline(weeklySamples, width); +} From 2ef0614dc4b6737d05eea35322132648bf45931c Mon Sep 17 00:00:00 2001 From: Tyler Gray Date: Wed, 17 Dec 2025 18:37:42 -0500 Subject: [PATCH 2/2] Update demo to showcase sparkline and time toggle features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo-modes.mjs | 53 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/demo-modes.mjs b/demo-modes.mjs index 501843a..c04483f 100644 --- a/demo-modes.mjs +++ b/demo-modes.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Demo script to showcase the three weekly view modes + * Demo script to showcase weekly view modes and block segment features * Outputs raw ANSI-styled text to demonstrate the modes */ @@ -42,12 +42,12 @@ function segment(text, color, nextColor = null) { return out + ansi.reset; } -function renderStatusline(weeklyText, weeklyColor = colors.weekly) { +function renderStatusline(weeklyText, weeklyColor = colors.weekly, blockText = ` ${symbols.block} 29% (3h) `) { return ( segment(` claude-limitline `, colors.directory, colors.git) + segment(` ${symbols.branch} main `, colors.git, colors.model) + segment(` ${symbols.model} Opus 4.5 `, colors.model, colors.block) + - segment(` ${symbols.block} 29% (3h) `, colors.block, weeklyColor) + + segment(blockText, colors.block, weeklyColor) + segment(weeklyText, weeklyColor) ); } @@ -86,3 +86,50 @@ console.log("\n"); console.log("─".repeat(62)); console.log("Note: Model-specific limits (Opus/Sonnet) are only available"); console.log("on certain subscription tiers.\n"); + +// ============================================ +// Block Segment Features +// ============================================ + +console.log("\n┌─────────────────────────────────────────────────────────────┐"); +console.log("│ Block Segment Features │"); +console.log("└─────────────────────────────────────────────────────────────┘\n"); + +// Time remaining (default) +console.log('\x1b[1mTime Remaining\x1b[0m (default):'); +console.log('Shows time until block resets'); +console.log('Config: { "block": { "timeDisplay": "remaining" } }\n'); +console.log(renderStatusline(` ${symbols.weekly} 47% (wk 85%) `, colors.weekly, ` ${symbols.block} 29% (3h20m) `)); +console.log("\n"); + +// Absolute time - 12h format +console.log('\x1b[1mAbsolute Time (12-hour)\x1b[0m:'); +console.log('Shows exact reset time in 12-hour format'); +console.log('Config: { "block": { "timeDisplay": "absolute", "timeFormat": "12h" } }\n'); +console.log(renderStatusline(` ${symbols.weekly} 47% (wk 85%) `, colors.weekly, ` ${symbols.block} 29% (2:30pm) `)); +console.log("\n"); + +// Absolute time - 24h format +console.log('\x1b[1mAbsolute Time (24-hour)\x1b[0m:'); +console.log('Shows exact reset time in 24-hour format'); +console.log('Config: { "block": { "timeDisplay": "absolute", "timeFormat": "24h" } }\n'); +console.log(renderStatusline(` ${symbols.weekly} 47% (wk 85%) `, colors.weekly, ` ${symbols.block} 29% (14:30) `)); +console.log("\n"); + +// Sparkline +console.log('\x1b[1mSparkline History\x1b[0m:'); +console.log('Shows usage trend over last 24 hours'); +console.log('Config: { "block": { "showSparkline": true, "sparklineWidth": 8 } }\n'); +console.log(renderStatusline(` ${symbols.weekly} 47% (wk 85%) `, colors.weekly, ` ${symbols.block} 29% ▁▂▂▃▄▅▆▇ (3h) `)); +console.log("\n"); + +// Sparkline with absolute time +console.log('\x1b[1mSparkline + Absolute Time\x1b[0m:'); +console.log('Combine sparkline with absolute reset time'); +console.log('Config: { "block": { "showSparkline": true, "timeDisplay": "absolute" } }\n'); +console.log(renderStatusline(` ${symbols.weekly} 47% (wk 85%) `, colors.weekly, ` ${symbols.block} 29% ▁▂▂▃▄▅▆▇ (2:30pm) `)); +console.log("\n"); + +console.log("─".repeat(62)); +console.log("Sparkline characters: ▁▂▃▄▅▆▇█ (low to high usage)"); +console.log("History stored in: ~/.claude/limitline-history.json\n");