Claude Code のステータスラインを自作する|プラン・effort・応答時間・使用量ゲージを1行に

AIコーディング

Claude Code のフッターに出るステータスライン(ステータスバー)を、Node.js スクリプトで自作した記録。契約プラン・effort・直近応答のAPI処理時間・公式の使用量ゲージまで、1本のスクリプトに詰め込むまでの実装方法と設計判断をまとめる。

実装は Claude Code 自身に要望を出しながら作らせるスタイルで進めた(AIに計測器を作らせて、AIを計測する構図)。もともとは Opus 4.8 の effort レベル別の消費を実測する企画のために整備した計測器で、実測編は別記事として公開予定。

最終的な見た目はこうなる:

自作ステータスラインの実際の表示:モデル・プラン・effort・応答時間・使用量ゲージが1行に並ぶ
セグメント意味データ源
🧭 navigatorエージェント設定トランスクリプト JSONL の agent-setting 行(筆者環境の独自行)
⚡ Opus 4.8モデル名stdin model.display_name
💎 Max契約プラン~/.claude/.credentials.jsonsubscriptionType
🧠 xHighreasoning effortstdin effort.level(無ければ settings.json
⏱ 12.4s直近応答の API 処理時間stdin cost.total_api_duration_ms の差分
📊 …使用35%公式の使用量上限stdin rate_limits/usage と同じ値)

なお、この仕組みは ~/.claude/settings.json に紐づくClaude Code 本体の機能なので、ターミナル環境を選ばない。VS Code・Cursor・JetBrains など IDE の統合ターミナルで Claude Code を起動しても、Windows Terminal 単体でも、同じステータスラインがそのまま表示される(設定は一度だけでよい)。

面倒なら:Claude Code に作らせる(プロンプト付き)

この先は実装の中身を解説していくが、「結果だけ欲しい」なら Claude Code に直接作らせる手がある。実際このスクリプト自体、筆者が Claude Code に要望を出しながら作らせたものだ。叩き台プロンプト:

Claude Code のステータスラインを自作したい。依存パッケージなしの Node.js スクリプト
(~/.claude/statusline/usage-statusline.js)を作成し、settings.json への登録までやって。

要件:
1. 表示セグメント(左から): モデル名 / 契約プラン / effortレベル /
   直近応答のAPI処理時間 / 公式の使用量ゲージ(5時間枠・7日間枠)
2. データ源:
   - モデル名・effort・使用量は stdin のJSON(model.display_name / effort.level / rate_limits)
   - 契約プランは ~/.claude/.credentials.json の claudeAiOauth.subscriptionType のみ読む
     (アクセストークンには絶対に触れない)
   - 応答時間は cost.total_api_duration_ms(セッション累積)の差分をターンごとに計算。
     baseline は session_id 別の state ファイルに保存し、消失時の安全策も入れる
   - ターン境界はトランスクリプトの「実ユーザー発話」で検出
     (tool_result / isSidechain / isMeta は除外)
3. 使用量はバッテリー式ゲージ(█░10セル)+使用%+リセットまでの時間。
   色は使用量に応じて 緑→黄→赤
4. データが無いセグメントは省略表示(無料プランや応答前でも壊れない)
5. ANSIカラー使用。最後に echo でJSONをstdinに流すテストもやって見せて

注意:出力の作りは保証できない。LLMの出力は毎回変わるため、生成されるコードは本記事のものと同一にはならない。動作は後述のテスト方法(stdinにJSONを流す)で必ず確認すること。一発で完成形を狙うより、出てきたものに要望を重ねて育てるのが現実的だ(筆者もこの形になるまで2セッションかけて段階的に直した)。


1. 前提:ステータスラインの仕組み

Claude Code のステータスラインは「外部コマンドを実行し、その標準出力をフッターに表示する」というシンプルな仕組み。

1.1 settings.json で登録する

~/.claude/settings.json

{
  "statusLine": {
    "type": "command",
    "command": "node C:/Users/<you>/.claude/statusline/usage-statusline.js",
    "padding": 0
  }
}
  • type"command" 固定。
  • command は実行されるシェルコマンド。Node スクリプトなら node <path>
  • padding は左右の余白(文字数)。
  • refreshInterval(秒)を入れると定期再実行になるが、未指定だとイベント時のみ実行(後述)。

1.2 stdin で JSON が渡る

コマンド実行時、Claude Code がセッションの状態を JSON で stdin に流し込む。スクリプトはこれを読んで好きに整形し、結果を stdout に書くだけ。ANSI エスケープ(色)も使える。

1.3 いつ再実行されるか(重要)

ステータスラインはイベント駆動で、主に次のタイミングで再実行される:

  • 新しいメッセージ(ユーザー送信/アシスタント応答)
  • /compact 完了
  • パーミッションモード変更
  • vim モード切替

設計上の含意:毎秒チップチップ動くタイマーが欲しい場合は refreshInterval: 1 が要るが、本スクリプトは毎回トランスクリプト全文を読むため、長いセッションでは毎秒読み込みが重くなる。そのため後述の「⏱ 応答時間」はイベント時更新(応答完了後に確定値を表示)で実装した。

1.4 stdin JSON スキーマ(主要フィールド)

実装で使ったもの+知っておくと便利なものを抜粋(バージョンにより増減あり):

{
  "session_id": "xxxxxxxx-…",
  "transcript_path": "C:\\Users\\…\\<session>.jsonl",
  "cwd": "…",
  "model":   { "id": "claude-opus-4-8", "display_name": "Opus 4.8" },
  "workspace": { "current_dir": "…", "project_dir": "…" },
  "version": "2.1.x",
  "output_style": { "name": "default" },
  "cost": {
    "total_cost_usd": 0.0123,
    "total_duration_ms": 45000,        // セッション総経過(実時間)
    "total_api_duration_ms": 2300,     // セッション累積の API 処理時間 ← ⏱ で使用
    "total_lines_added": 156,
    "total_lines_removed": 23
  },
  "context_window": {
    "context_window_size": 200000,
    "used_percentage": 8,
    "remaining_percentage": 92
  },
  "effort": { "level": "high" },       // low|medium|high|xhigh|max(モデル非対応時は欠落)
  "rate_limits": {                     // Pro/Max のみ・初回 API 応答後に出現
    "five_hour":  { "used_percentage": 23.5, "resets_at": 1738425600 },
    "seven_day":  { "used_percentage": 41.2, "resets_at": 1738857600 }
  }
}

ハマりどころとして特筆すべき欠落・制約:

  • 契約プラン(Max/Pro)は stdin に無い。.credentials.json から読む(詳しくは後述の「5. 契約プラン」)。
  • rate_limitsPro/Max 加入者で、かつセッション内で最初の API 応答が返った後にしか入らない。無料プランや応答前は丸ごと欠落。
  • effortモデルが reasoning effort をサポートする場合のみ
  • cost.total_api_duration_msセッション累積であって 1 ターン分ではない(後述の「7. API 処理時間」で差分計算する)。

2. スクリプト全体構成

usage-statusline.js は素の Node.js(依存パッケージなし)。処理は番号付きセクションに分けてある:

1. stdin(Claude Code コンテキスト)をパース
2. config.json を読み込み
3. トランスクリプトを 1 パスで走査(agentSetting・ターン境界の timestamp)
4. 📊 公式使用量セグメント(ゲージ+使用%+リセット)
5. 💎 契約プラン(.credentials.json)
6. 🧠 effort(stdin → settings.json フォールバック)
7. ⏱ 直近応答の API 処理時間(累積の差分+state ファイル)
8. 整形して emit

冒頭の定型:

'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');

// ANSI colors
const RESET='\x1b[0m', DIM='\x1b[2m', RED='\x1b[31m', YELLOW='\x1b[33m',
      GREEN='\x1b[32m', CYAN='\x1b[36m', MAGENTA='\x1b[35m', BLUE='\x1b[34m',
      BOLD='\x1b[1m', WHITE='\x1b[37m';

// 1. stdin を読む(fd 0 を直接読む)
let ctx = {};
try {
  const raw = fs.readFileSync(0, 'utf8');
  if (raw.trim()) ctx = JSON.parse(raw);
} catch (_) { /* stdin 無し or 不正 → 空コンテキスト */ }

fs.readFileSync(0, 'utf8') で fd=0(stdin)を同期読み。ステータスラインは「実行 → 出力 → 終了」の使い捨てプロセスなので、同期 I/O で十分かつ簡潔。


3. 🧭 agentSetting — トランスクリプトを読む

セッションのトランスクリプト(transcript_path で渡される JSONL)の中の agent-setting 行を拾って表示している。これは Claude Code 標準のフィールドではなく、筆者の環境のセットアップが書き込んでいる独自行である点に注意(標準環境では出ないので - になる。不要ならこのセグメントごと削ってよい)。

let agentSetting = '-';
// (後述のターン時間計算で使う変数も同じループで集める)
let lastUserPromptMs = 0, lastAssistantMs = 0, userPromptCount = 0;

if (transcriptPath && fs.existsSync(transcriptPath)) {
  const lines = fs.readFileSync(transcriptPath, 'utf8').split('\n');
  for (const line of lines) {
    if (!line.trim()) continue;
    try {
      const obj = JSON.parse(line);
      if (obj.type === 'agent-setting' && obj.agentSetting) {
        agentSetting = obj.agentSetting;
      }
      // …ターン境界の収集(後述)…
    } catch (_) { /* 壊れた行はスキップ */ }
  }
}

ポイントはトランスクリプトの全読みは 1 回だけにして、agentSetting と後述のターン時刻を同じループで取ること。読み込みは重い処理なので二度読みしない。


4. ⚡ モデル名

これは stdin から直接。

const modelDisplay =
  (ctx.model && (ctx.model.display_name || ctx.model.id)) || 'unknown';

5. 💎 契約プラン — .credentials.json から読む

stdin にプラン情報が無いので、OAuth 認証情報ファイルから読む。読むのは subscriptionType というプラン名だけで、アクセストークンには一切触れない。

function readSubscriptionType() {
  try {
    const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
    const j = JSON.parse(fs.readFileSync(credPath, 'utf8'));
    const t = j && j.claudeAiOauth && j.claudeAiOauth.subscriptionType;
    return typeof t === 'string' ? t : '';
  } catch (_) { return ''; }
}

const PLAN_LABEL = { max:'Max', pro:'Pro', team:'Team', enterprise:'Enterprise', free:'Free' };
const subType = readSubscriptionType();
const planName = PLAN_LABEL[subType]
  || (subType ? subType.charAt(0).toUpperCase() + subType.slice(1) : '');
const planSeg = planName ? `${YELLOW}${BOLD}💎 ${planName}${RESET}` : null;

.credentials.json の構造(抜粋・トークン値は伏せる):

{
  "claudeAiOauth": {
    "accessToken": "…",         // ← 触らない
    "subscriptionType": "max",  // ← これだけ使う
    "rateLimitTier": "…"
  }
}

ハードコードしないので、プラン変更が自動で反映される。


6. 🧠 effort — stdin → settings.json フォールバック

effort.level はセッション中の /effort 変更もリアルタイムに反映するライブ値。モデルが effort 非対応で stdin に無い場合は、設定ファイルの既定値にフォールバックする。

function readEffortFallback() {
  try {
    const sp = path.join(os.homedir(), '.claude', 'settings.json');
    const j = JSON.parse(fs.readFileSync(sp, 'utf8'));
    return typeof j.effortLevel === 'string' ? j.effortLevel : '';
  } catch (_) { return ''; }
}

const EFFORT_LABEL = { low:'Low', medium:'Med', high:'High', xhigh:'xHigh', max:'Max' };
let effortLevel = (ctx.effort && typeof ctx.effort.level === 'string') ? ctx.effort.level : '';
if (!effortLevel) effortLevel = readEffortFallback();
const effortName = effortLevel ? (EFFORT_LABEL[effortLevel] || effortLevel) : '';
const effortSeg = effortName ? `${BLUE}🧠 ${effortName}${RESET}` : null;

7. ⏱ 直近応答の API 処理時間 — 累積値の差分

これが一番テクニカル。「応答 1 回ごとの処理時間を出したい。次の応答が始まったら前回をクリア」という要件。

7.1 なぜ単純には取れないか

  • トランスクリプトの各アシスタント応答には per-request の処理時間フィールドが無い(あるのは timestamp とトークン usage だけ)。
  • stdin の cost.total_api_duration_msセッション累積。そのままでは「1 ターン分」にならない。

7.2 解法:累積値の「差分」を取る

ターン開始時点の累積値を baseline として保存し、現在の累積 − baseline を当該ターンの処理時間とする。baseline は session_id ごとの小さな state ファイルに記録(複数セッション併用でも混ざらない)。

ターン境界は「実ユーザー発話のタイムスタンプが変わったか」で検出する。そのため「3. agentSetting」のループで次も集めておく:

function isRealUserPrompt(obj) {
  // role:user でも tool_result は「本物の発話」ではない。sidechain/meta も除外。
  if (obj.type !== 'user' || obj.isSidechain || obj.isMeta) return false;
  const c = obj.message && obj.message.content;
  if (typeof c === 'string') return true;                       // 素のテキスト発話
  if (Array.isArray(c)) return !c.some(p => p && p.type === 'tool_result');
  return false;
}
// ループ内:
//   if (isRealUserPrompt(obj)) { lastUserPromptMs = ts; userPromptCount++; }
//   else if (obj.type === 'assistant' && !obj.isSidechain) { lastAssistantMs = ts; }

学び:トランスクリプトの「ユーザー発話」判定 ツール結果(tool_result)も role: "user" として記録される。サブエージェントの発話は isSidechain: true、システム注入は isMeta。これらを除外しないと、ターン境界を誤検出する。

差分計算と state 管理:

const sessionId = ctx.session_id || 'default';
const cumApiMs = (ctx.cost && typeof ctx.cost.total_api_duration_ms === 'number')
  ? ctx.cost.total_api_duration_ms : null;
const statePath = path.join(os.homedir(), '.claude', 'statusline', 'turn-timer-state.json');

let turnSeg = null;
if (lastUserPromptMs && cumApiMs != null) {
  let state = {};
  try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')) || {}; } catch (_) {}

  let s = state[sessionId];
  if (!s || s.promptMs !== lastUserPromptMs) {
    // 新ターン。baseline(ターン開始時点の累積 API 時間)を決める:
    let baseline;
    if (s) baseline = s.lastSeenApiMs;          // 既知セッションのターン境界 → 直前に観測した累積
    else if (userPromptCount <= 1) baseline = 0; // セッション真の初回ターン
    else baseline = cumApiMs;                    // state 消失 → 今ターンを 0 から測り直す(累積丸ごと表示を防ぐ)
    s = { promptMs: lastUserPromptMs, baselineApiMs: baseline, lastSeenApiMs: cumApiMs };
  } else {
    s.lastSeenApiMs = cumApiMs;                  // 同一ターン内の再描画 → 観測値だけ更新
  }
  const turnApiMs = Math.max(0, cumApiMs - s.baselineApiMs);

  state[sessionId] = s;
  if (Object.keys(state).length > 30) state = { [sessionId]: s }; // ファイル肥大防止
  try { fs.writeFileSync(statePath, JSON.stringify(state)); } catch (_) {}

  turnSeg = (lastAssistantMs > lastUserPromptMs)
    ? `${WHITE}⏱ ${fmtDur(turnApiMs)}${RESET}`   // 応答済み → 処理時間を表示
    : `${DIM}⏱ …${RESET}`;                        // プロンプト送信直後 → まだ未計測
}

7.3 state 消失時の安全策

state ファイルが消える/壊れると、未知セッションは baseline=0 になり「セッション累積を 1 ターンに丸ごと表示」してしまう。これを防ぐため、トランスクリプトの実発話数で「真の初回ターン」か「途中の state 消失」かを判定する:

  • 発話数 ≤ 1 → 真の初回 → baseline = 0(その初回ターンを正しく全部計測)
  • 発話数 > 1 なのに state 無し → 消失 → baseline = 現在の累積(今ターンを 0 から測り直し、次ターンで完全復帰)

7.4 時間整形

API 処理時間は短いことが多いので、1 秒未満は小数表示:

function fmtDur(ms) {
  if (!isFinite(ms) || ms < 0) return '';
  const s = Math.round(ms / 1000);
  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
  if (h > 0) return `${h}h${String(m).padStart(2,'0')}m`;
  if (m > 0) return `${m}m${String(sec).padStart(2,'0')}s`;
  if (s === 0 && ms > 0) return `${(ms/1000).toFixed(1)}s`;
  return `${sec}s`;
}
// 4200→"4s" / 45000→"45s" / 91376→"1m31s" / 605000→"10m05s" / 3725000→"1h02m"

設計判断:実時間か、API 処理時間か 最初は「プロンプト送信〜応答完了」の実時間(トランスクリプトの timestamp 差分)で実装した。だがこれだとツール実行の待ちや、対話式確認でユーザーが回答するまでの時間まで含んでしまう。「Claude が実際に処理していた時間」を出すため、cost.total_api_duration_ms ベースに切り替えた(差分方式)。


8. 📊 公式使用量ゲージ — rate_limits

/usage と同じ公式データ。5 時間ローリングと 7 日間の 2 ウィンドウを、使った量ベースのゲージで表示する。

// しきい値(残量基準)で色を決める
function remColor(rem) {
  if (rem < config.criticalPct) return RED;   // 既定 10
  if (rem < config.warnPct)     return YELLOW; // 既定 30
  return GREEN;
}
// ゲージ:使った分だけ塗る(10 セル)。使うほど埋まる。
function gauge(pct, color) {
  const filled = Math.max(0, Math.min(10, Math.round(pct / 10)));
  return `${color}${'█'.repeat(filled)}${RESET}${DIM}${'░'.repeat(10 - filled)}${RESET}`;
}
// リセットまでの時間を最大単位 1 つで("3h後" / "5d後")。epoch 秒だが ms も許容。
function fmtReset(resetsAt) {
  if (typeof resetsAt !== 'number' || !isFinite(resetsAt)) return '';
  const resetMs = resetsAt > 1e12 ? resetsAt : resetsAt * 1000;
  const diff = resetMs - Date.now();
  if (diff <= 0) return 'まもなく';
  const min = Math.floor(diff / 60000);
  const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
  if (d > 0) return `${d}d後`;
  if (h > 0) return `${h}h後`;
  return `${m}m後`;
}

function buildWindow(label, rl) {
  if (!rl || typeof rl.used_percentage !== 'number') return null;
  const used = Math.max(0, Math.min(100, rl.used_percentage));
  const c = remColor(100 - used);          // 使うほど黄→赤
  const reset = fmtReset(rl.resets_at);
  let s = `${label}${DIM}[${RESET}${gauge(used, c)}${DIM}]${RESET}${c}使用${used.toFixed(0)}%${RESET}`;
  if (reset) s += ` ${DIM}${reset}${RESET}`;
  return s;
}

let usageSeg;
if (rateLimits) {
  const parts = [
    buildWindow('5時間', rateLimits.five_hour),
    buildWindow('7日間', rateLimits.seven_day),
  ].filter(Boolean);
  usageSeg = parts.length ? `📊 ` + parts.join(` ${DIM}·${RESET} `) : `📊 ${DIM}使用量 —${RESET}`;
} else {
  usageSeg = `📊 ${DIM}使用量 —${RESET}`;   // データ未取得(無料/応答前)
}

9. 整形して出力

各セグメントを で連結。null のセグメント(データ無しで省略)は filter(Boolean) で落とす。

const sep = `${DIM}│${RESET}`;
const out = [
  `${CYAN}🧭 ${agentSetting}${RESET}`,
  `${MAGENTA}⚡ ${modelDisplay}${RESET}`,
  planSeg,
  effortSeg,
  turnSeg,
  usageSeg,
].filter(Boolean).join(` ${sep} `);

process.stdout.write(out);

10. 設定ファイル

~/.claude/statusline/config.json

{
  "warnPct": 30,     // 残量がこれ未満で黄(=使用 70% 以上)
  "criticalPct": 10  // 残量がこれ未満で赤(=使用 90% 以上)
}

歴史的な名残:旧版は「5 時間・100 万トークン上限」を独自推定する方式で、tokenBudget / windowHours キーを使っていた。公式の rate_limits に移行した今は不要なので、config.json に残っていたら掃除してよい。


11. テスト方法

外部サービスも実セッションも要らない。JSON を stdin に流すだけで動作確認できる。

# 通常ケース
echo '{"model":{"display_name":"Opus 4.8"},"effort":{"level":"xhigh"},
"cost":{"total_api_duration_ms":12400},"session_id":"T1",
"rate_limits":{"five_hour":{"used_percentage":35,"resets_at":4102444800},
"seven_day":{"used_percentage":8,"resets_at":4102444800}}}' \
  | node ~/.claude/statusline/usage-statusline.js

# データ欠落ケース(無料/応答前)→ プラン以外が素直に省略されるか
echo '{"model":{"display_name":"Opus 4.8"}}' | node ~/.claude/statusline/usage-statusline.js

# 構文チェック
node --check ~/.claude/statusline/usage-statusline.js

色を外して中身を見たい時は ANSI を除去:

… | node usage-statusline.js | sed -E 's/\x1b\[[0-9;]*m//g'

12. Windows のハマりどころ

  • Git Bash のパス変換/c/Users/… 形式をコマンド引数として native exe(node.exe)に渡すと、自動で C:/Users/… に変換される。だが JSON 文字列の中に書いたパスは変換されない。テストで transcript_path/c/... を埋めると node が解決できず無反応になる(実運用では Claude Code が正しい Windows パスを渡すので問題なし)。
  • settings.jsoncommand のパスはフォワードスラッシュ推奨(C:/Users/...)。バックスラッシュはエスケープ扱いされうる。
  • PowerShell スクリプトで書く場合は powershell -NoProfile -File C:/…/statusline.ps1。Git Bash があれば .sh でも可。

13. 設計判断・学びのまとめ(記事の核)

開発は 2 セッションにわたった。要望に答える形で段階的に育てた:

  1. 独自トークン推定 → 公式 rate_limits へ。 最初は「5h・100 万トークン上限」の自前推定だったが、Claude Code が /usage と同じ公式データを stdin で渡していると分かり、実値に置き換え。推定値と公式値が食い違う混乱の元も解消。
  2. 数字 → ゲージバー化。 「残 65%」だけでは直感的でないため、バッテリー式ゲージ+ラベル明確化(5時間/7日間)+リセット時刻のインライン表示に。
  3. 「残量」→「使用量」へ反転。 ユーザーの「使った量で」に合わせ、数値・ゲージの塗り・色をすべて使用量基準に。
  4. 契約プラン・effort・応答時間を追加。 プランは stdin に無いので .credentials.json から、effort は stdin(+設定フォールバック)、応答時間は累積 API 時間の差分で。
  5. 🧭 の正体。 トランスクリプト先頭の agent-setting 行(筆者環境の独自行。標準では出ない)。

技術的な再利用ポイント:

  • stdin に無い情報は設定ファイルや認証情報ファイルから読む(トークンには触れない)。
  • 累積値しか無いものは state ファイルで差分を取り、session_id キーで分離、消失時の安全策も入れる。
  • トランスクリプト解析では tool_result / isSidechain / isMeta を除外して「本物のユーザー発話」を判定。
  • ステータスラインはイベント駆動・使い捨てプロセス。重い処理(全文読み)は 1 回に、毎秒更新は慎重に。
  • セグメントは null 返却 → filter(Boolean) でデータ欠落時に自動で消す。

付録 A:usage-statusline.js 全文

実ファイル:C:\Users\<you>\.claude\statusline\usage-statusline.js 上の各セクションのコードを 1 本にまとめたもの。最新の全文はこのファイルを直接参照。

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');

// ANSI colors
const RESET='\x1b[0m', DIM='\x1b[2m', RED='\x1b[31m', YELLOW='\x1b[33m',
      GREEN='\x1b[32m', CYAN='\x1b[36m', MAGENTA='\x1b[35m', BLUE='\x1b[34m',
      BOLD='\x1b[1m', WHITE='\x1b[37m';

// 1. stdin
let ctx = {};
try { const raw = fs.readFileSync(0, 'utf8'); if (raw.trim()) ctx = JSON.parse(raw); }
catch (_) {}
const transcriptPath = ctx.transcript_path || '';
const modelDisplay = (ctx.model && (ctx.model.display_name || ctx.model.id)) || 'unknown';
const rateLimits = (ctx.rate_limits && typeof ctx.rate_limits === 'object') ? ctx.rate_limits : null;
const now = Date.now();

// 2. config
const configPath = path.join(os.homedir(), '.claude', 'statusline', 'config.json');
const config = { warnPct: 30, criticalPct: 10 };
try { Object.assign(config, JSON.parse(fs.readFileSync(configPath, 'utf8'))); } catch (_) {}

// 3. transcript(agentSetting+ターン境界)
let agentSetting = '-', lastUserPromptMs = 0, lastAssistantMs = 0, userPromptCount = 0;
function isRealUserPrompt(obj) {
  if (obj.type !== 'user' || obj.isSidechain || obj.isMeta) return false;
  const c = obj.message && obj.message.content;
  if (typeof c === 'string') return true;
  if (Array.isArray(c)) return !c.some(p => p && p.type === 'tool_result');
  return false;
}
if (transcriptPath && fs.existsSync(transcriptPath)) {
  try {
    for (const line of fs.readFileSync(transcriptPath, 'utf8').split('\n')) {
      if (!line.trim()) continue;
      try {
        const obj = JSON.parse(line);
        if (obj.type === 'agent-setting' && obj.agentSetting) agentSetting = obj.agentSetting;
        const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN;
        if (!isFinite(ts)) continue;
        if (isRealUserPrompt(obj)) { lastUserPromptMs = ts; userPromptCount++; }
        else if (obj.type === 'assistant' && !obj.isSidechain) lastAssistantMs = ts;
      } catch (_) {}
    }
  } catch (_) {}
}

// 4. 使用量ゲージ
function remColor(rem) { if (rem < config.criticalPct) return RED; if (rem < config.warnPct) return YELLOW; return GREEN; }
function gauge(pct, color) {
  const filled = Math.max(0, Math.min(10, Math.round(pct / 10)));
  return `${color}${'█'.repeat(filled)}${RESET}${DIM}${'░'.repeat(10 - filled)}${RESET}`;
}
function fmtReset(resetsAt) {
  if (typeof resetsAt !== 'number' || !isFinite(resetsAt)) return '';
  const resetMs = resetsAt > 1e12 ? resetsAt : resetsAt * 1000;
  const diff = resetMs - now;
  if (diff <= 0) return 'まもなく';
  const min = Math.floor(diff / 60000);
  const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
  if (d > 0) return `${d}d後`; if (h > 0) return `${h}h後`; return `${m}m後`;
}
function buildWindow(label, rl) {
  if (!rl || typeof rl.used_percentage !== 'number') return null;
  const used = Math.max(0, Math.min(100, rl.used_percentage));
  const c = remColor(100 - used);
  const reset = fmtReset(rl.resets_at);
  let s = `${label}${DIM}[${RESET}${gauge(used, c)}${DIM}]${RESET}${c}使用${used.toFixed(0)}%${RESET}`;
  if (reset) s += ` ${DIM}${reset}${RESET}`;
  return s;
}
let usageSeg;
if (rateLimits) {
  const parts = [buildWindow('5時間', rateLimits.five_hour), buildWindow('7日間', rateLimits.seven_day)].filter(Boolean);
  usageSeg = parts.length ? `📊 ` + parts.join(` ${DIM}·${RESET} `) : `📊 ${DIM}使用量 —${RESET}`;
} else { usageSeg = `📊 ${DIM}使用量 —${RESET}`; }

// 5. プラン
function readSubscriptionType() {
  try {
    const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', '.credentials.json'), 'utf8'));
    const t = j && j.claudeAiOauth && j.claudeAiOauth.subscriptionType;
    return typeof t === 'string' ? t : '';
  } catch (_) { return ''; }
}
const PLAN_LABEL = { max:'Max', pro:'Pro', team:'Team', enterprise:'Enterprise', free:'Free' };
const subType = readSubscriptionType();
const planName = PLAN_LABEL[subType] || (subType ? subType.charAt(0).toUpperCase() + subType.slice(1) : '');
const planSeg = planName ? `${YELLOW}${BOLD}💎 ${planName}${RESET}` : null;

// 6. effort
function readEffortFallback() {
  try {
    const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf8'));
    return typeof j.effortLevel === 'string' ? j.effortLevel : '';
  } catch (_) { return ''; }
}
const EFFORT_LABEL = { low:'Low', medium:'Med', high:'High', xhigh:'xHigh', max:'Max' };
let effortLevel = (ctx.effort && typeof ctx.effort.level === 'string') ? ctx.effort.level : '';
if (!effortLevel) effortLevel = readEffortFallback();
const effortName = effortLevel ? (EFFORT_LABEL[effortLevel] || effortLevel) : '';
const effortSeg = effortName ? `${BLUE}🧠 ${effortName}${RESET}` : null;

// 7. ターン API 処理時間
function fmtDur(ms) {
  if (!isFinite(ms) || ms < 0) return '';
  const s = Math.round(ms / 1000);
  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
  if (h > 0) return `${h}h${String(m).padStart(2,'0')}m`;
  if (m > 0) return `${m}m${String(sec).padStart(2,'0')}s`;
  if (s === 0 && ms > 0) return `${(ms/1000).toFixed(1)}s`;
  return `${sec}s`;
}
const sessionId = ctx.session_id || 'default';
const cumApiMs = (ctx.cost && typeof ctx.cost.total_api_duration_ms === 'number') ? ctx.cost.total_api_duration_ms : null;
const statePath = path.join(os.homedir(), '.claude', 'statusline', 'turn-timer-state.json');
let turnSeg = null;
if (lastUserPromptMs && cumApiMs != null) {
  let state = {};
  try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')) || {}; } catch (_) {}
  let s = state[sessionId];
  if (!s || s.promptMs !== lastUserPromptMs) {
    let baseline;
    if (s) baseline = s.lastSeenApiMs;
    else if (userPromptCount <= 1) baseline = 0;
    else baseline = cumApiMs;
    s = { promptMs: lastUserPromptMs, baselineApiMs: baseline, lastSeenApiMs: cumApiMs };
  } else { s.lastSeenApiMs = cumApiMs; }
  const turnApiMs = Math.max(0, cumApiMs - s.baselineApiMs);
  state[sessionId] = s;
  if (Object.keys(state).length > 30) state = { [sessionId]: s };
  try { fs.writeFileSync(statePath, JSON.stringify(state)); } catch (_) {}
  turnSeg = (lastAssistantMs > lastUserPromptMs)
    ? `${WHITE}⏱ ${fmtDur(turnApiMs)}${RESET}`
    : `${DIM}⏱ …${RESET}`;
}

// 8. emit
const sep = `${DIM}│${RESET}`;
const out = [
  `${CYAN}🧭 ${agentSetting}${RESET}`,
  `${MAGENTA}⚡ ${modelDisplay}${RESET}`,
  planSeg, effortSeg, turnSeg, usageSeg,
].filter(Boolean).join(` ${sep} `);
process.stdout.write(out);

付録 B:関連ファイル

ファイル役割
~/.claude/settings.jsonstatusLine 登録・effortLevel 既定値
~/.claude/statusline/usage-statusline.js本体
~/.claude/statusline/config.jsonしきい値(warnPct / criticalPct
~/.claude/statusline/turn-timer-state.json⏱ 用の session_id 別 baseline(自動生成)
~/.claude/.credentials.jsonプラン名 subscriptionType の読み取り元(読み取りのみ)
<project>/<session>.jsonlトランスクリプト(agentSetting・ターン境界の取得元)

*本記事のコードは自由に流用可(MIT相当)。実装は Claude Code との対話で段階的に作り上げたものです。


あわせて読みたい

関連: Claude Sonnet 5とOpus 4.8を同じ実装タスクで比較した記事

コメント

タイトルとURLをコピーしました