Skip to content
Open
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,25 @@ For pinned installs, launch the TUI with `npx -y ccstatusline@latest` or `bunx -

</details>

<details>
<summary><b>Usage Tracker (opt-in usage logging)</b></summary>

The Usage Tracker records your subscription rate limit usage over time so it can be analyzed later. It is **off by default**; enable it in the TUI under **📊 Usage Tracker**.

When enabled, every distinct rate limit observation is appended as one JSON line to:

```
$XDG_DATA_HOME/ccstatusline/usage-log.jsonl # or ~/.local/share/ccstatusline/usage-log.jsonl
```

Each record holds the rate limit percentages and reset timestamps exactly as Claude Code (or the Anthropic usage API) reported them, plus a hashed account id so records from different accounts can be told apart. **Tokens, credentials, prompts, and transcript contents are never written.** Heartbeat records mark stretches without new observations, so gaps in the data are unambiguous.

The log is size bound: once it passes the configured rotation size (default 5 MB), it is rotated to `usage-log.1.jsonl` and only that one previous file is kept.

> ⚠️ **API logging:** the tracker also logs the Anthropic usage API responses by default. If you have no usage widgets configured, that starts polling the usage API (~1 request every 3 minutes across all your sessions) where previously there were none. Turn **API Usage Logging** off to log only what Claude Code already sends.

</details>

## 🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.
Expand Down
36 changes: 31 additions & 5 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ import {
getPackageVersion,
getTerminalWidth
} from './utils/terminal';
import {
initUsageLog,
logStdinRateLimits
} from './utils/usage-log';
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';

function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
Expand Down Expand Up @@ -99,7 +103,18 @@ async function ensureWindowsUtf8CodePage() {
}
}

async function renderMultipleLines(data: StatusJSON) {
// Reads rate_limits off the raw, pre-zod stdin payload: StatusJSONSchema
// declares rate_limits as a strict object, so the parsed data has unknown
// buckets stripped - the usage log must keep them
function getRawRateLimits(rawInput: unknown): unknown {
if (typeof rawInput !== 'object' || rawInput === null) {
return undefined;
}

return (rawInput as Record<string, unknown>).rate_limits;
}

async function renderMultipleLines(data: StatusJSON, rawInput?: unknown) {
const settings = await loadSettings();
const configError = getConfigLoadError();

Expand All @@ -109,6 +124,15 @@ async function renderMultipleLines(data: StatusJSON) {
// Update color map after setting chalk level
updateColorMap();

// Usage Tracker: runs before prefetchUsageDataIfNeeded so a slow or failed
// API fetch cannot delay the stdin record and so the api-path hook inside
// fetchUsageData sees an initialized logger. Only call site of initUsageLog.
initUsageLog(settings.usageTracker, {
sessionId: data.session_id,
modelId: typeof data.model === 'string' ? data.model : data.model?.id
});
logStdinRateLimits(getRawRateLimits(rawInput));

// Get all lines to render
const lines = settings.lines;

Expand Down Expand Up @@ -136,7 +160,7 @@ async function renderMultipleLines(data: StatusJSON) {
sessionDuration = await getSessionDuration(data.transcript_path);
}

const usageData = await prefetchUsageDataIfNeeded(lines, data);
const usageData = await prefetchUsageDataIfNeeded(lines, data, { forceUsageFetch: settings.usageTracker.enabled && settings.usageTracker.logApiUsage });

let speedMetrics: SpeedMetrics | null = null;
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
Expand Down Expand Up @@ -328,14 +352,16 @@ async function main() {
const input = await readStdin();
if (input && input.trim() !== '') {
try {
// Parse and validate JSON in one step
const result = StatusJSONSchema.safeParse(JSON.parse(input));
// Keep the raw parse result: the usage log needs rate_limits
// before zod strips unknown buckets (see getRawRateLimits)
const rawInput: unknown = JSON.parse(input);
const result = StatusJSONSchema.safeParse(rawInput);
if (!result.success) {
console.error('Invalid status JSON format:', result.error.message);
process.exit(1);
}

await renderMultipleLines(result.data);
await renderMultipleLines(result.data, rawInput);
} catch (error) {
console.error('Error parsing JSON:', error);
process.exit(1);
Expand Down
25 changes: 25 additions & 0 deletions src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ import {
TerminalWidthMenu,
UninstallMenu,
UpdateCheckerMenu,
UsageTrackerMenu,
buildMainMenuItems,
getMainMenuInstallSelectionIndex,
getMainMenuSelectionIndex,
type InstallSelection,
type MainMenuOption,
type UninstallSelection,
Expand Down Expand Up @@ -127,6 +130,7 @@ type AppScreen = 'main'
| 'uninstallOptions'
| 'updates'
| 'refreshInterval'
| 'usageTracker'
| 'exportConfig'
| 'importConfig'
| 'importPreview';
Expand Down Expand Up @@ -1011,6 +1015,9 @@ export const App: React.FC = () => {
case 'configureStatusLine':
setScreen('refreshInterval');
break;
case 'usageTracker':
setScreen('usageTracker');
break;
case 'exportConfig':
setScreen('exportConfig');
break;
Expand Down Expand Up @@ -1395,6 +1402,24 @@ export const App: React.FC = () => {
}}
/>
)}
{screen === 'usageTracker' && (
<UsageTrackerMenu
settings={settings}
onUpdate={(updatedSettings) => {
setSettings(updatedSettings);
}}
onBack={() => {
setMenuSelections(prev => ({
...prev,
main: getMainMenuSelectionIndex(
buildMainMenuItems(isClaudeInstalled, hasChanges, effectiveInstallation),
'usageTracker'
)
}));
setScreen('main');
}}
/>
)}
{screen === 'powerline' && (
<PowerlineSetup
settings={settings}
Expand Down
13 changes: 8 additions & 5 deletions src/tui/__tests__/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ describe('Main menu structure', () => {
'terminalConfig',
'globalOverrides',
'configureStatusLine',
'usageTracker',
'-',
'exportConfig',
'importConfig',
Expand All @@ -214,6 +215,7 @@ describe('Main menu structure', () => {
'terminalConfig',
'globalOverrides',
'configureStatusLine',
'usageTracker',
'-',
'exportConfig',
'importConfig',
Expand All @@ -240,6 +242,7 @@ describe('Main menu structure', () => {
'terminalConfig',
'globalOverrides',
'configureStatusLine',
'usageTracker',
'-',
'exportConfig',
'importConfig',
Expand Down Expand Up @@ -274,14 +277,14 @@ describe('Main menu structure', () => {
sublabel: '(install first)'
}));
expect(buildManageInstallationItems()[0]).toEqual(expect.objectContaining({ label: '🔄 Check for Updates' }));
expect(getMainMenuInstallSelectionIndex(false)).toBe(7);
expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(8);
expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(8);
expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(8);
expect(getMainMenuInstallSelectionIndex(false)).toBe(8);
expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(9);
expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(9);
expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(9);
expect(getMainMenuSelectionIndex(
buildMainMenuItems(true, false, pinnedInstallation),
'manageInstallation'
)).toBe(8);
)).toBe(9);
});
});

Expand Down
6 changes: 6 additions & 0 deletions src/tui/components/MainMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type MainMenuOption = 'lines'
| 'manageInstallation'
| 'checkUpdates'
| 'configureStatusLine'
| 'usageTracker'
| 'exportConfig'
| 'importConfig'
| 'starGithub'
Expand Down Expand Up @@ -122,6 +123,11 @@ export function buildMainMenuItems(
value: 'configureStatusLine',
description: 'Configure Claude Code status line settings like refresh interval'
},
{
label: '📊 Usage Tracker',
value: 'usageTracker',
description: 'Record rate limit usage to a local log file for later analysis'
},
'-',
{
label: '📤 Export Config',
Expand Down
Loading