Building a Custom Status Line for Claude Code

Building a Custom Status Line for Claude Code

Claude Code's default status line tells you almost nothing. Model name, maybe a context indicator. That's it. You're flying blind on the things that actually matter mid-session: which git branch you're on, whether you've got uncommitted changes, how close you are to blowing the context window, and what task the agent thinks it's working on.

I've been running a custom status line for a few weeks now. The difference is hard to explain until you've had it and then lost it. Imagine driving with the instrument panel taped over, then someone rips the tape off. You didn't realise how much you were guessing.

Here's what mine looks like:

Custom Claude Code status line

Two lines. Top line: project directory, model and CLI version, context usage bar with token counts. Bottom line: git branch, working tree status, current task. Colour-coded with a Tokyo Night Moon palette because I stare at a terminal all day and the aesthetics matter.

Why bother?

The context bar is what sold me. Claude Code doesn't make it obvious how much context you've burned through. You're happily coding, the model is sharp, and then responses get vague and it starts forgetting things you said ten minutes ago. That's context rot, and by the time you notice it from the output quality, you're already deep in it. Having a percentage bar with token counts right there means I can see it coming. Below 63% remaining the bar turns yellow. Below 19%, orange. Below 5%, it blinks with a skull emoji. I've learned to start a fresh session before I hit orange. That single change has made my sessions noticeably better.

The git state pill has been the other win. I work across branches constantly. Before this, I'd run git status every few minutes out of paranoia. Now I can see "2 staged, 1 untracked" without breaking focus. Yellow when there are changes, green when the tree is clean. It removes a whole category of "wait, did I commit that?" moments.

If you use Claude Code's task system (or GSD, which I'll get to), the status line also shows what the agent is working on. When you've got parallel agents running, being able to glance down and see "Running database migrations" without scrolling through output is surprisingly handy.

The GSD connection

Credit where it's due. The Get Shit Done CLI, a context engineering system for Claude Code, ships with its own status line. It shows GSD-specific information: phase progress, agent status, that kind of thing. I was already using GSD and liked having that ambient information, but I wanted something that worked outside of GSD too. Across all my Claude Code sessions, with information that's universally useful.

So I took the concept and built a standalone version. Same idea, different data.

Setting it up

Claude Code supports custom status lines through settings.json. You point it at a script that receives session data on stdin and writes ANSI-formatted output to stdout. The configuration is one line:

{
  "statusLine": {
    "type": "command",
    "command": "node \"$HOME/.claude/hooks/statusline.js\""
  }
}

You can set this up by running /statusline inside Claude Code, or by editing ~/.claude/settings.json directly.

Building it piece by piece

Instead of dumping the full script and saying "good luck", here's how I'd build it from scratch. Each section below is a prompt you could give Claude Code to build up the status line incrementally.

Step 1: The skeleton

Create a Node.js statusline script at ~/.claude/hooks/statusline.js. It should read JSON from stdin (Claude Code passes session data this way), parse it, and write ANSI-formatted output to stdout. Set up true-color ANSI helpers for foreground fg(r,g,b), background bg(r,g,b), reset, and bold. Use a Tokyo Night Moon colour palette with these RGB values: blue [130,170,255], cyan [134,225,252], green [195,232,141], magenta [252,167,234], yellow [255,199,119], orange [255,150,108], red [255,117,127], dark [30,32,48]. Create a pill helper that renders text as a coloured badge with background and foreground colours. The output should be two lines separated by a dim horizontal rule.

This gives you the foundation. Everything else plugs into it.

Step 2: Project and model info (Line 1)

Add the first line of the statusline. Show three pills: (1) the current directory name with a 📁 icon on a blue background, (2) the model display name plus Claude Code CLI version on a magenta background (get the version by running claude --version), and (3) a context usage bar. For the context bar, calculate usage from data.context_window.remaining_percentage, scale it so 80% consumed maps to 100% displayed (context degrades before hitting the actual limit). Render a bar using ▰ for filled and ▱ for empty (10 segments), coloured green below 63%, yellow below 81%, orange below 95%, red with blinking skull emoji at 95%+. Show token counts in parentheses like (52k/200k).

Step 3: Git information (Line 2)

Add a second line showing git information. Get the current branch name using git rev-parse --abbrev-ref HEAD and display it in a green pill with a 🌿 icon. Parse git status --porcelain to count staged, modified, and untracked files. Show the counts as a summary like "2 staged, 1 modified" in a yellow pill (or "clean" in green if there are no changes). Use the workspace directory from the session data for the git commands.

Step 4: Current task display

Add current task information to line 2. Read task files from ~/.claude/tasks/{sessionId}/ where sessionId comes from the session data. Look for any task with status: "in_progress" and display its activeForm text in a cyan pill with a 📋 icon. This integrates with Claude Code's built-in task tracking.

Step 5: Wire it up

Update my Claude Code settings.json to use this statusline script. Add a statusLine entry pointing to the script with type "command".

The full script

Here's the complete statusline.js. Drop it in ~/.claude/hooks/ and point your settings at it.

#!/usr/bin/env node
// Claude Code Statusline — Tokyo Night Moon
// Line 1: 📁 dir │ model (version) │ context bar (used/total)
// Line 2: 🌿 branch │ git status │ current task

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

let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
  try {
    const data = JSON.parse(input);

    // --- ANSI true-color helpers ---
    const fg = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
    const bg = (r, g, b) => `\x1b[48;2;${r};${g};${b}m`;
    const R = '\x1b[0m\x1b[39m\x1b[49m';
    const B = '\x1b[1m';

    // Tokyo Night Moon palette
    const c = {
      blue:    [130, 170, 255],
      cyan:    [134, 225, 252],
      green:   [195, 232, 141],
      magenta: [252, 167, 234],
      yellow:  [255, 199, 119],
      orange:  [255, 150, 108],
      red:     [255, 117, 127],
      dark:    [30, 32, 48],
    };

    const pill = (bgc, fgc, text) =>
      `${bg(...bgc)}${fg(...fgc)}${B} ${text} ${R}`;

    // --- Data ---
    const model = data.model?.display_name || 'Claude';
    const dir = data.workspace?.current_dir || process.cwd();
    const dirname = path.basename(dir);
    const session = data.session_id || '';
    const remaining = data.context_window?.remaining_percentage;

    // Claude Code version
    let version = '';
    try {
      const raw = execSync('claude --version 2>/dev/null', {
        timeout: 1500,
      }).toString().trim();
      const m = raw.match(/(\d+\.\d+\.\d+)/);
      if (m) version = `v${m[1]}`;
    } catch (e) {}

    // Git info
    let branch = '';
    let statusSummary = '';
    try {
      branch = execSync(
        `git -C "${dir}" rev-parse --abbrev-ref HEAD 2>/dev/null`,
        { timeout: 1500 }
      ).toString().trim();

      const porcelain = execSync(
        `git -C "${dir}" status --porcelain 2>/dev/null`,
        { timeout: 1500 }
      ).toString().trim();

      if (porcelain) {
        const lines = porcelain.split('\n').filter(Boolean);
        let staged = 0, unstaged = 0, untracked = 0;
        for (const line of lines) {
          const x = line[0], y = line[1];
          if (x === '?' && y === '?') { untracked++; continue; }
          if (x !== ' ' && x !== '?') staged++;
          if (y !== ' ' && y !== '?') unstaged++;
        }
        const parts = [];
        if (staged) parts.push(`${staged} staged`);
        if (unstaged) parts.push(`${unstaged} modified`);
        if (untracked) parts.push(`${untracked} untracked`);
        statusSummary = parts.join(', ') || `${lines.length} changes`;
      } else {
        statusSummary = 'clean';
      }
    } catch (e) {}

    // Context bar + numbers (scaled to 80% ceiling)
    let ctxBar = '';
    let ctxNums = '';
    if (remaining != null) {
      const rem = Math.round(remaining);
      const rawUsed = Math.max(0, Math.min(100, 100 - rem));
      const used = Math.min(100, Math.round((rawUsed / 80) * 100));

      const filled = Math.floor(used / 10);
      const bar = '▰'.repeat(filled) + '▱'.repeat(10 - filled);

      let color;
      if (used < 63) color = c.green;
      else if (used < 81) color = c.yellow;
      else if (used < 95) color = c.orange;
      else color = c.red;

      const blink = used >= 95 ? '\x1b[5m' : '';
      const skull = used >= 95 ? '💀 ' : '';
      ctxBar = `${blink}${fg(...color)}${skull}${bar} ${used}%${R}`;

      const totalTokens = data.context_window?.total_tokens || 200000;
      const usedTokens = Math.round(totalTokens * (rawUsed / 100));
      const fmtK = (n) => n >= 1000
        ? `${Math.round(n / 1000)}k`
        : `${n}`;
      ctxNums = `${fg(...color)}(${fmtK(usedTokens)}/${fmtK(totalTokens)})${R}`;
    }

    // Current task from ~/.claude/tasks/{sessionId}/*.json
    let task = '';
    const homeDir = os.homedir();
    const tasksDir = path.join(homeDir, '.claude', 'tasks', session);
    if (session && fs.existsSync(tasksDir)) {
      try {
        const files = fs.readdirSync(tasksDir)
          .filter(f => f.endsWith('.json'))
          .map(f => path.join(tasksDir, f));
        for (const file of files) {
          const t = JSON.parse(fs.readFileSync(file, 'utf8'));
          if (t.status === 'in_progress' && t.activeForm) {
            task = t.activeForm;
            break;
          }
        }
      } catch (e) {}
    }

    // --- BUILD OUTPUT ---
    // Line 1: dir | model (version) | context bar (tokens)
    const line1 = [
      pill(c.blue, c.dark, `📁 ${dirname}`),
      pill(c.magenta, c.dark,
        `${model}${version ? ` (${version})` : ''}`),
      ctxBar ? `${ctxBar} ${ctxNums}` : '',
    ].filter(Boolean).join(' ');

    // Line 2: branch | status | task
    const line2Parts = [];
    if (branch)
      line2Parts.push(pill(c.green, c.dark, `🌿 ${branch}`));
    if (statusSummary) {
      const col = statusSummary === 'clean' ? c.green : c.yellow;
      line2Parts.push(pill(col, c.dark, statusSummary));
    }
    if (task)
      line2Parts.push(pill(c.cyan, c.dark, `📋 ${task}`));

    let output = line1;
    if (line2Parts.length)
      output += `\n\x1b[2m${'─'.repeat(60)}${R}\n`
        + line2Parts.join(' ');

    process.stdout.write(
      output + `\n\x1b[2m${'─'.repeat(60)}${R}\n`
    );
  } catch (e) {
    // Silent fail — don't break statusline
  }
});

Adapting it

The colour palette is the easiest thing to change. Swap the RGB values in the c object to match whatever theme you run. Catppuccin, Dracula, Gruvbox, whatever.

You could add more data to either line. Uptime, cost estimate, tool call count. Claude Code passes a decent amount of session metadata through stdin. Log it once with console.error(JSON.stringify(data, null, 2)) redirected to a file and see what's there.

The 80% context ceiling is worth explaining. Context quality degrades before you hit the token limit. By the time you're at 80% raw usage, the model is already losing coherence. Scaling the display so that 80% raw shows as 100% means the bar reflects useful context, not theoretical capacity. When my bar hits 100%, it's time for a new session, not time to squeeze out another few thousand tokens.

One less thing to think about

I don't think about the status line anymore. It's just there. I glance down, I know what branch I'm on, whether I've committed, how much context I have left. Some sessions I barely look at it. Others, it's the thing that tells me to stop and start fresh before the model goes off the rails.

That's about as much as you can ask from a 250-line script.