Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions demo-modes.mjs
Original file line number Diff line number Diff line change
@@ -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
*/

Expand Down Expand Up @@ -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)
);
}
Expand Down Expand Up @@ -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");
11 changes: 11 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
try {
Expand Down Expand Up @@ -45,6 +46,14 @@ async function main(): Promise<void> {
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));
Expand Down
68 changes: 68 additions & 0 deletions src/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
43 changes: 39 additions & 4 deletions src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
124 changes: 124 additions & 0 deletions src/utils/history.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading