statuslin.es

Todo Ticker

node

Shows the task Claude is working on right now, read from the session todo list, beside context and five-hour usage bars.

MIT licensed · original source

Preview

Clean repo
app │ Opus 4.8 │ context: ██░░░░░░░░ 22%Fixing the null check in the parser
New session
app │ Opus 4.8 │ context: ░░░░░░░░░░ 0%
Dirty branch
app │ Sonnet 4.6 │ context: ████░░░░░░ 48%Wiring up the OAuth callback route
Near-full
app │ Opus 4.8 │ context: 💀 █████████░ 91%Refactoring the session store
1M context
app │ Fable 5 │ context: ██████░░░░ 64%Fixing the null check in the parser
Post-compact
app │ Haiku 4.5 │ context: ░░░░░░░░░░ 0%Fixing the null check in the parser
Worktree
feature │ Opus 4.8 │ context: ███░░░░░░░ 37%Building the feature flag toggle
Non-git
scratch │ Opus 4.8 │ context: ██░░░░░░░░ 22%Fixing the null check in the parser

Source

#!/usr/bin/env node
// Claude Code Enhanced Statusline
// Shows: directory | model | context usage | API usage (5-hour limit) | current task
// Auto-detects API key vs subscription usage
// https://github.com/TahaSabir0/claude-statusline

const fs = require('fs');
const path = require('path');
const os = require('os');
const https = require('https');
const { execSync } = require('child_process');

const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;

// Cache configuration
const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
const CACHE_TTL_MS = 30000; // Cache valid for 30 seconds

// ANSI color codes
const colors = {
  reset: '\x1b[0m',
  dim: '\x1b[2m',
  bold: '\x1b[1m',
  green: '\x1b[32m',
  yellow: '\x1b[33m',
  orange: '\x1b[38;5;208m',
  red: '\x1b[31m',
  blink: '\x1b[5m'
};

function getUsageColor(percentage) {
  if (percentage < 50) return colors.green;
  if (percentage < 75) return colors.yellow;
  if (percentage < 90) return colors.orange;
  return colors.red;
}

function getContextBar(remaining) {
  const effectiveRemaining = remaining ?? 100;
  const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));

  const filled = Math.floor(used / 10);
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);

  let coloredBar;
  if (used < 50) {
    coloredBar = `${colors.green}${bar} ${used}%${colors.reset}`;
  } else if (used < 65) {
    coloredBar = `${colors.yellow}${bar} ${used}%${colors.reset}`;
  } else if (used < 80) {
    coloredBar = `${colors.orange}${bar} ${used}%${colors.reset}`;
  } else {
    coloredBar = `${colors.blink}${colors.red}\u{1F480} ${bar} ${used}%${colors.reset}`;
  }

  return coloredBar;
}

// Read cached usage data
function getCachedUsage() {
  try {
    if (!fs.existsSync(USAGE_CACHE_FILE)) return null;

    const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
    const age = Date.now() - cache.timestamp;

    // Return cached data if fresh enough
    if (age < CACHE_TTL_MS) {
      return cache.data;
    }

    return null;
  } catch (e) {
    return null;
  }
}

// Write usage data to cache (shared across all sessions)
function setCachedUsage(data) {
  try {
    if (!fs.existsSync(CACHE_DIR)) {
      fs.mkdirSync(CACHE_DIR, { recursive: true });
    }

    const cache = {
      timestamp: Date.now(),
      data: data
    };

    fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(cache), 'utf8');
  } catch (e) {
    // Silently fail
  }
}

function getCredentials() {
  // Try file first (legacy / Linux / Windows)
  const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
  if (fs.existsSync(credsPath)) {
    try {
      return JSON.parse(fs.readFileSync(credsPath, 'utf8'));
    } catch (e) {}
  }

  // Fallback: macOS keychain
  if (os.platform() === 'darwin') {
    try {
      const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf8', timeout: 1000 });
      return JSON.parse(raw.trim());
    } catch (e) {}
  }

  return null;
}

function getApiUsage(callback) {
  try {
    // Read credentials (file or macOS keychain)
    const creds = getCredentials();
    if (!creds) {
      return callback(null);
    }

    const accessToken = creds.claudeAiOauth?.accessToken;

    if (!accessToken) {
      return callback(null);
    }

    // Adaptive timeout: if cache exists, be faster (1200ms); if not, be patient (1500ms)
    // API typically takes ~850ms, so 1200ms gives reasonable headroom
    const hasCache = fs.existsSync(USAGE_CACHE_FILE);
    const timeout = hasCache ? 1200 : 1500;

    // Make API call with adaptive timeout
    const req = https.request({
      hostname: 'api.anthropic.com',
      path: '/api/oauth/usage',
      method: 'GET',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
        'anthropic-beta': 'oauth-2025-04-20'
      },
      timeout: timeout
    }, (res) => {
      let data = '';

      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try {
          const usage = JSON.parse(data);

          // Get 5-hour session usage
          if (usage.five_hour) {
            const percentage = Math.round(usage.five_hour.utilization);
            const resetsAt = usage.five_hour.resets_at;

            // Parse reset time
            let timeStr = '';
            if (resetsAt) {
              const resetDate = new Date(resetsAt);
              const now = new Date();
              const diffMs = resetDate - now;
              const diffMins = Math.floor(diffMs / 60000);
              const hours = Math.floor(diffMins / 60);
              const mins = diffMins % 60;

              if (hours > 0) {
                timeStr = `${hours}h${mins}m`;
              } else {
                timeStr = `${mins}m`;
              }
            }

            // Build bar
            const barWidth = 10;
            const filledWidth = Math.round((percentage / 100) * barWidth);
            const filled = '\u2588'.repeat(filledWidth);
            const empty = '\u2591'.repeat(barWidth - filledWidth);
            const color = getUsageColor(percentage);

            const bar = `${color}${filled}${empty} ${percentage}%${colors.reset}${colors.dim} (${timeStr})${colors.reset}`;

            // Cache the result for other sessions
            setCachedUsage(bar);

            callback(bar);
          } else {
            callback(null);
          }
        } catch (e) {
          callback(null);
        }
      });
    });

    req.on('error', () => callback(null));
    req.on('timeout', () => {
      req.destroy();
      callback(null);
    });

    req.end();
  } catch (e) {
    callback(null);
  }
}

// Get usage with cache fallback
function getUsageWithCache(callback) {
  // First, try to get fresh data from API
  getApiUsage((freshData) => {
    if (freshData) {
      // Got fresh data, use it
      callback(freshData);
    } else {
      // API failed or timed out, try cache
      const cachedData = getCachedUsage();
      callback(cachedData);
    }
  });
}

function getCurrentTask(sessionId) {
  if (!sessionId) return '';

  const homeDir = os.homedir();
  const todosDir = path.join(homeDir, '.claude', 'todos');

  if (!fs.existsSync(todosDir)) return '';

  try {
    const files = fs.readdirSync(todosDir)
      .filter(f => f.startsWith(sessionId) && f.includes('-agent-') && f.endsWith('.json'))
      .map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
      .sort((a, b) => b.mtime - a.mtime);

    if (files.length > 0) {
      const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
      const inProgress = todos.find(t => t.status === 'in_progress');
      if (inProgress) return inProgress.activeForm || '';
    }
  } catch (e) {}

  return '';
}

// Main
function outputStatus(data, usageBar) {
  try {
    const model = data?.model?.display_name || 'Claude';
    const dir = data?.workspace?.current_dir || process.cwd();
    const dirname = path.basename(dir);
    const sessionId = data?.session_id || '';
    const remaining = data?.context_window?.remaining_percentage;

    const contextBar = getContextBar(remaining);
    const task = getCurrentTask(sessionId);
    const parts = [];
    parts.push(dirname);
    parts.push(model);
    parts.push(`context: ${contextBar}`);

    if (usageBar) {
      parts.push(`usage: ${usageBar}`);
    }

    if (task) parts.push(`${colors.dim}${task}${colors.reset}`);
    process.stdout.write(parts.join(' \u2502 '));
  } catch (e) {
    process.stdout.write('Status unavailable');
  }
}

function outputFallback(usageBar) {
  const contextBar = getContextBar(undefined);
  const parts = ['~', 'Claude', `context: ${contextBar}`];
  if (usageBar) parts.push(`usage: ${usageBar}`);
  process.stdout.write(parts.join(' \u2502 '));
}

// Wrapper that skips usage fetch for API key users
function getUsage(callback) {
  if (IS_API_KEY) {
    callback(null);
  } else {
    getUsageWithCache(callback);
  }
}

// Process with timeout
if (process.stdin.isTTY) {
  getUsage((usageBar) => {
    outputFallback(usageBar);
    process.exit(0);
  });
} else {
  let input = '';
  let timeoutReached = false;

  const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);

  const timeout = setTimeout(() => {
    timeoutReached = true;
    getUsage((usageBar) => {
      if (input.length > 0) {
        try {
          const data = JSON.parse(input);
          outputStatus(data, usageBar);
        } catch (e) {
          outputFallback(usageBar);
        }
      } else {
        outputFallback(usageBar);
      }
      process.exit(0);
    });
  }, overallTimeout);

  process.stdin.setEncoding('utf8');
  process.stdin.on('data', chunk => input += chunk);
  process.stdin.on('end', () => {
    if (timeoutReached) return;
    clearTimeout(timeout);

    getUsage((usageBar) => {
      try {
        const data = JSON.parse(input);
        outputStatus(data, usageBar);
      } catch (e) {
        outputFallback(usageBar);
      }
      process.exit(0);
    });
  });
}

What it shows

  • The basename of the current working directory
  • The active model's display name
  • Context window usage as a ten-segment bar with a percentage
  • Five-hour usage limit as a ten-segment bar with a percentage and time until reset, fetched from Anthropic's OAuth usage API (subscription accounts only)
  • The task Claude is currently working on, read from the session's in-progress todo item in ~/.claude/todos

Requirements

  • Node.js runtime; the script uses only built-in modules (fs, path, os, https, child_process)
  • Network access to api.anthropic.com for the five-hour usage bar
  • A Claude OAuth access token, read from ~/.claude/.credentials.json or, on macOS, from the keychain via the security command
  • A terminal font that renders Unicode block characters and the skull emoji used at high context usage

Behavior notes

  • The context bar changes color with usage: green below 50%, yellow to 65%, orange to 80%, then blinking red with a skull emoji at 80% and above (91% scenario)
  • When the context percentage is null or missing, the bar shows 0% in green, as in the brand-new and post-compact scenarios
  • The task segment appears only when the session's todo file has an in-progress item; it is absent in the brand-new session scenario
  • The usage segment appears only when the OAuth usage fetch or its 30-second cache succeeds; it is absent from every preview, and it is skipped entirely when ANTHROPIC_API_KEY is set
  • The script ignores the rate_limits field sent on stdin; even the hot-limits scenario shows no usage bar because usage comes only from the live API call
  • Usage results are cached to ~/.claude/cache/usage-cache.json for 30 seconds and shared across sessions, with shorter API timeouts when a cache file exists
  • Vim mode, PR info, effort level, output style, and git branch fields in the input have no effect on the output across scenarios
  • If stdin is missing or unparseable, the script falls back to printing ~ and Claude with an empty context bar

More status lines

app · main · Opus-4.8 · effort high · ctx 22% · 5h 26% ↻2h6m · 7d 7% ↻2d0h
Opus 4.8 | main | ██░░░░░░░░ 22.0% | 44k / 200k | 02:07 26% | Wed 7%
Opus 4.8 main app 44k/200k $0.41
app ⏱ 10m +128 -34 ░░░░░░░ 22% $0.41 ◆ Opus
󰚩 Opus 4.8󰍛 ▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 22%󰄉 $0.41 󰝰 app󰘬 main
Opus 4.8 high ctx 22% app (main) PR #1287 $0.41 · 10m +128 -34 Session 74% left Resets in 2h 7m Weekly 93% left Resets in 2d 1h ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions

New to Claude Code status lines? Read the setup guide.