0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の statusLine を自作する ― モデル / コンテキスト / レート制限を 2〜3 行で全部見せる

0
Last updated at Posted at 2026-04-06

TL;DR

スクリーンショット 2026-04-07 020043.png

  • Claude Code は settings.jsonstatusLine フィールドに登録した 任意のコマンド を stdin 経由で叩き、その標準出力を画面下に表示してくれる
  • 入力は JSON 1 行。cwd / model / context_window / rate_limits などが全部入っている
  • bash + jq だけで、ターミナル風プロンプト+モデル名+ctx 使用量+5h/7d レート制限 まで一望できる statusLine が組める
  • デバッグ用に生 JSON をダンプする env フラグを仕込んでおくと改造が一気に楽になる

この記事では、筆者が実際に使っている 250 行ほどの bash スクリプトを題材に、作り方とハマりどころを解説する。


1. statusLine とは

Claude Code (CLI) にはプロンプト下部に任意の 1〜数行を描画する statusLine 機能がある。ターミナルの PS1 と似た発想で、

  • 今どのモデルで動いているか
  • コンテキストウィンドウをどれくらい食っているか
  • 5 時間レート / 週次レートの残量
  • カレントディレクトリ / git ブランチ

あたりを常に視界に入れておくと、長時間の作業で「気づいたら Opus が 1M ctx の 80% を舐めていた」みたいな事故が減る。

設定は ~/.claude/settings.json にこう書くだけ。

{
  "statusLine": {
    "type": "command",
    "command": "bash ~/.claude/statusline-command.sh",
    "padding": 0
  }
}

type: "command" だと Claude Code はこのコマンドを起動し、stdin に JSON を流し込みstdout に書かれたテキストをそのまま描画する。ANSI エスケープもそのまま効く。


2. 入力 JSON の中身

まずは何が来るのかを知らないと作りようがない。以下を仕込んでおくと捗る。

# Debug dump: set CLAUDE_STATUSLINE_DEBUG=1 to capture raw JSON for inspection
if [ "${CLAUDE_STATUSLINE_DEBUG:-0}" = "1" ]; then
  echo "$input" > /tmp/claude-statusline-debug.json
fi

CLAUDE_STATUSLINE_DEBUG=1 claude で起動して /tmp/claude-statusline-debug.json を眺めれば、だいたい以下のようなキーが見える(抜粋)。

{
  "cwd": "/home/jfk/works/foo",
  "model": {
    "id": "claude-opus-4-6[1m]",
    "display_name": "Opus 4.6 (1M context)"
  },
  "context_window": {
    "context_window_size": 1000000,
    "used_percentage": 12.3,
    "current_usage": {
      "input_tokens": 1234,
      "output_tokens": 567,
      "cache_read_input_tokens": 8901
    }
  },
  "rate_limits": {
    "five_hour": { "used_percentage": 42.5, "resets_at": 1712500000 },
    "seven_day": { "used_percentage": 18.2, "resets_at": 1713000000 }
  }
}

⚠️ フィールドは Claude Code のバージョンによって変わる可能性がある。debug dump を一度覗いてから組むのが安全。


3. 設計方針

  • jq は 1 回だけ呼ぶ:statusLine は描画のたびに叩かれるので、jq を 10 連発するとカーソル移動がもたつく。欲しいフィールドを TSV で一括取得し、bash の read で変数に流し込む
  • display_name を尊重する:モデル表示名はサーバから降ってくる display_name をそのまま使うのが正。ただし空や 1 ワードしか無い場合のフォールバックとして、model.id から "Claude Opus 4.6 (1M)" を合成する経路も用意する
  • rate_limits は存在しないことがある:claude.ai サブスクライバ以外だと降ってこないので、欠損前提でガードする
  • cwd の省略:長いパスは ~/works/.../repo/sub のように途中を省略する
  • git 情報は --no-optional-locks:裏で別の git プロセスが走ってると statusLine がブロックされるので --no-optional-locks 必須

4. 実装を分解して読む

以下、冒頭で紹介したスクリプトを段階的に切り出す。

4.1 JSON を 1 回で全部バラす

input=$(cat)

IFS=$'\t' read -r cwd model_id model_display \
        ctx_window used_pct \
        cur_input cur_output cur_cache_r \
        five_pct five_rst week_pct week_rst \
  < <(echo "$input" | jq -r '[
      .cwd,
      (.model.id         // ""),
      (.model.display_name // ""),
      (.context_window.context_window_size // ""),
      (.context_window.used_percentage     // ""),
      (.context_window.current_usage.input_tokens              // ""),
      (.context_window.current_usage.output_tokens             // ""),
      (.context_window.current_usage.cache_read_input_tokens   // ""),
      (.rate_limits.five_hour.used_percentage  // ""),
      (.rate_limits.five_hour.resets_at        // ""),
      (.rate_limits.seven_day.used_percentage  // ""),
      (.rate_limits.seven_day.resets_at        // "")
    ] | @tsv')
  • // "" でフィールドが無い場合を空文字に正規化
  • 配列 → @tsv で タブ区切り → IFS=$'\t' read で一発分解

この書き方は、jq プロセス 1 つ・read 1 つ で済むので統計的に最速クラス。

4.2 モデル名の決定

サーバ側の display_name が正っぽい場合は尊重しつつ、空/1ワードしかない時だけ model_id から合成する:

# "claude-opus-4-6[1m]" → "Claude Opus 4.6 (1M)"
canonical_name=""
if [ -n "$model_id" ]; then
  ctx_variant=""
  if echo "$model_id" | grep -qiE '[\[_-]1m[\]_-]?$|1m$'; then
    ctx_variant=" (1M)"
  elif echo "$model_id" | grep -qiE '[\[_-]200k[\]_-]?$|200k$'; then
    ctx_variant=" (200K)"
  fi
  stripped=$(echo "$model_id" | sed 's/^claude-//i; s/[\[_-]\?[0-9]*[mk][\]]*$//i; s/-[0-9]\{8,\}$//')
  canonical_name=$(echo "$stripped" | awk '{
    n = split($0, parts, "-")
    out = ""
    for (i=1; i<=n; i++) {
      w = parts[i]
      if (w ~ /^[a-zA-Z]/) { w = toupper(substr(w,1,1)) substr(w,2) }
      if (w ~ /^[0-9]/ && out != "") { out = out "." w }
      else { out = (out == "") ? w : out " " w }
    }
    print out
  }')
  canonical_name="Claude ${canonical_name}${ctx_variant}"
fi

chosen_name="$model_display"
if [ -z "$chosen_name" ] || ! echo "$chosen_name" | grep -q ' '; then
  [ -n "$canonical_name" ] && chosen_name="$canonical_name"
fi
if ! echo "$chosen_name" | grep -qi '^claude '; then
  chosen_name="Claude ${chosen_name}"
fi

ポイントは "Opus 4.6" の "4.6" 部分を . で繋ぐこと。claude-opus-4-6 を愚直に - で split して capitalize すると "Opus 4 6" になって気持ち悪いので、数字が連続する場合だけ . で結合する小細工を入れている。

4.3 コンテキスト使用量

fmt_k() {
  local n=$1
  if [ "$n" -ge 1000 ]; then
    printf "%.0fk" "$(awk "BEGIN{printf \"%.1f\", $n/1000}")"
  else
    printf "%d" "$n"
  fi
}

ctx_part=""
sess_part=""
if [ -n "$used_pct" ] && [ -n "$ctx_window" ]; then
  used_tokens=$(awk "BEGIN{printf \"%.0f\", $ctx_window * $used_pct / 100}")
  used_k=$(fmt_k "$used_tokens")
  total_k=$(fmt_k "$ctx_window")
  pct_int=$(printf '%.0f' "$used_pct")
  ctx_part="📦 ctx:${used_k}/${total_k}(${pct_int}%)"

  cur_tokens=$(( ${cur_input:-0} + ${cur_output:-0} + ${cur_cache_r:-0} ))
  if [ "$cur_tokens" -gt 0 ]; then
    sess_part="💬 sess:$(fmt_k "$cur_tokens")"
  fi
fi
  • ctx累積(会話全体での使用量)
  • sess直近 1 ターン(今まさに投げた分)

この 2 つを分けて出すと「もう ctx 詰まってるのか、今回の入力が重いのか」が一発で分かる。

4.4 レート制限(5h / 7d)

fmt_reset_time() {  # "HH:MM JST"
  local epoch=$1
  [ -z "$epoch" ] && return
  TZ=Asia/Tokyo date -d "@$epoch" "+%H:%M JST" 2>/dev/null || \
  TZ=Asia/Tokyo date -r "$epoch" "+%H:%M JST" 2>/dev/null
}

fmt_reset_dow_time() {  # "月 HH:MM JST"
  local epoch=$1
  [ -z "$epoch" ] && return
  local raw_day
  raw_day=$(TZ=Asia/Tokyo date -d "@$epoch" "+%a" 2>/dev/null || \
            TZ=Asia/Tokyo date -r "$epoch" "+%a" 2>/dev/null)
  local raw_time
  raw_time=$(TZ=Asia/Tokyo date -d "@$epoch" "+%H:%M" 2>/dev/null || \
             TZ=Asia/Tokyo date -r "$epoch" "+%H:%M" 2>/dev/null)
  local jp_day
  case "$raw_day" in
    Mon) jp_day="月" ;; Tue) jp_day="火" ;; Wed) jp_day="水" ;;
    Thu) jp_day="木" ;; Fri) jp_day="金" ;; Sat) jp_day="土" ;;
    Sun) jp_day="日" ;;  *) jp_day="$raw_day" ;;
  esac
  printf "%s %s JST" "$jp_day" "$raw_time"
}

date -d (GNU) と date -r (BSD / macOS) の両方にフォールバックしている点がポイント。macOS でそのまま使える。

週次リセットは「木 15:00 JST」のように 曜日+時刻の方が脳に優しいので、英語の %a を日本語 1 文字にマップしている。

4.5 cwd とブランチ名の短縮

shorten_pwd() {
  local dir="$1"
  local home="$HOME"
  dir="${dir/#$home/\~}"
  if [ ${#dir} -gt 40 ]; then
    local parts
    IFS='/' read -ra parts <<< "$dir"
    local n=${#parts[@]}
    if [ $n -gt 4 ]; then
      local head="${parts[0]}/${parts[1]}"
      local tail="${parts[$((n-2))]}/${parts[$((n-1))]}"
      dir="${head}/.../${tail}"
    fi
  fi
  echo "$dir"
}

shorten_branch() {
  local b="$1"
  if [ ${#b} -gt 50 ]; then
    echo "${b:0:47}..."
  else
    echo "$b"
  fi
}

長大な feature ブランチ名(feature/really-long-name-that-nobody-reads...)は 47 文字で切って ... を足す。

4.6 描画

line1=$(printf "\033[01;32m%s@%s\033[00m%s" \
  "$(whoami)" "$(hostname -s)" "$short_branch")

line2_parts=("🤖 ${model_part}")
[ -n "$ctx_part" ]  && line2_parts+=("$ctx_part")
[ -n "$sess_part" ] && line2_parts+=("$sess_part")
line2=""
for p in "${line2_parts[@]}"; do
  if [ -z "$line2" ]; then line2="$p"
  else line2="${line2}  ${p}"
  fi
done

if [ -n "$rate_part" ]; then
  line3="${rate_part#  }"
  printf "%s\n%s\n%s" "$line1" "$line2" "$line3"
else
  printf "%s\n%s" "$line1" "$line2"
fi
  • 1 行目: user@host (branch) を太字グリーンで(.bashrc の PS1 風)
  • 2 行目: モデル / ctx / sess
  • 3 行目: 5h / 7d レート制限(ある場合のみ)

printf の末尾に \n を付けない(Claude Code 側が勝手に改行するため)のが地味なコツ。


5. 完成品(全文)

~/.claude/statusline-command.sh として保存し、chmod +x すれば OK。

#!/bin/bash
# Claude Code status line — derived from ~/.bashrc PS1
input=$(cat)

# Debug dump: set CLAUDE_STATUSLINE_DEBUG=1 to capture raw JSON for inspection
if [ "${CLAUDE_STATUSLINE_DEBUG:-0}" = "1" ]; then
  echo "$input" > /tmp/claude-statusline-debug.json
fi

# --- Parse all fields in one jq call ---
IFS=$'\t' read -r cwd model_id model_display \
        ctx_window used_pct \
        cur_input cur_output cur_cache_r \
        five_pct five_rst week_pct week_rst \
  < <(echo "$input" | jq -r '[
      .cwd,
      (.model.id         // ""),
      (.model.display_name // ""),
      (.context_window.context_window_size // ""),
      (.context_window.used_percentage     // ""),
      (.context_window.current_usage.input_tokens              // ""),
      (.context_window.current_usage.output_tokens             // ""),
      (.context_window.current_usage.cache_read_input_tokens   // ""),
      (.rate_limits.five_hour.used_percentage  // ""),
      (.rate_limits.five_hour.resets_at        // ""),
      (.rate_limits.seven_day.used_percentage  // ""),
      (.rate_limits.seven_day.resets_at        // "")
    ] | @tsv')

# --- Model display ---
canonical_name=""
if [ -n "$model_id" ]; then
  ctx_variant=""
  if echo "$model_id" | grep -qiE '[\[_-]1m[\]_-]?$|1m$'; then
    ctx_variant=" (1M)"
  elif echo "$model_id" | grep -qiE '[\[_-]200k[\]_-]?$|200k$'; then
    ctx_variant=" (200K)"
  elif echo "$model_id" | grep -qiE '[\[_-]128k[\]_-]?$|128k$'; then
    ctx_variant=" (128K)"
  fi
  stripped=$(echo "$model_id" | sed 's/^claude-//i; s/[\[_-]\?[0-9]*[mk][\]]*$//i; s/-[0-9]\{8,\}$//')
  canonical_name=$(echo "$stripped" | awk '{
    n = split($0, parts, "-")
    out = ""
    for (i=1; i<=n; i++) {
      w = parts[i]
      if (w ~ /^[a-zA-Z]/) { w = toupper(substr(w,1,1)) substr(w,2) }
      if (w ~ /^[0-9]/ && out != "") { out = out "." w }
      else { out = (out == "") ? w : out " " w }
    }
    print out
  }')
  canonical_name="Claude ${canonical_name}${ctx_variant}"
fi

chosen_name="$model_display"
if [ -z "$chosen_name" ] || ! echo "$chosen_name" | grep -q ' '; then
  [ -n "$canonical_name" ] && chosen_name="$canonical_name"
fi
if ! echo "$chosen_name" | grep -qi '^claude '; then
  chosen_name="Claude ${chosen_name}"
fi
model_part="${chosen_name}"

# --- Git branch (skip optional locks to avoid blocking) ---
git_branch=$(git -C "$cwd" --no-optional-locks branch --show-current 2>/dev/null)

# --- Context window ---
fmt_k() {
  local n=$1
  if [ "$n" -ge 1000 ]; then
    printf "%.0fk" "$(awk "BEGIN{printf \"%.1f\", $n/1000}")"
  else
    printf "%d" "$n"
  fi
}

ctx_part=""
sess_part=""
if [ -n "$used_pct" ] && [ -n "$ctx_window" ]; then
  used_tokens=$(awk "BEGIN{printf \"%.0f\", $ctx_window * $used_pct / 100}")
  used_k=$(fmt_k "$used_tokens")
  total_k=$(fmt_k "$ctx_window")
  pct_int=$(printf '%.0f' "$used_pct")
  ctx_part="📦 ctx:${used_k}/${total_k}(${pct_int}%)"

  cur_tokens=$(( ${cur_input:-0} + ${cur_output:-0} + ${cur_cache_r:-0} ))
  if [ "$cur_tokens" -gt 0 ]; then
    sess_part="💬 sess:$(fmt_k "$cur_tokens")"
  fi
fi

# --- Rate limits (only present for claude.ai subscribers) ---
fmt_reset_time() {
  local epoch=$1
  [ -z "$epoch" ] && return
  TZ=Asia/Tokyo date -d "@$epoch" "+%H:%M JST" 2>/dev/null || \
  TZ=Asia/Tokyo date -r "$epoch" "+%H:%M JST" 2>/dev/null
}

fmt_reset_dow_time() {
  local epoch=$1
  [ -z "$epoch" ] && return
  local raw_day raw_time
  raw_day=$(TZ=Asia/Tokyo date -d "@$epoch" "+%a" 2>/dev/null || \
            TZ=Asia/Tokyo date -r "$epoch" "+%a" 2>/dev/null)
  raw_time=$(TZ=Asia/Tokyo date -d "@$epoch" "+%H:%M" 2>/dev/null || \
             TZ=Asia/Tokyo date -r "$epoch" "+%H:%M" 2>/dev/null)
  local jp_day
  case "$raw_day" in
    Mon) jp_day="月" ;; Tue) jp_day="火" ;; Wed) jp_day="水" ;;
    Thu) jp_day="木" ;; Fri) jp_day="金" ;; Sat) jp_day="土" ;;
    Sun) jp_day="日" ;;  *) jp_day="$raw_day" ;;
  esac
  printf "%s %s JST" "$jp_day" "$raw_time"
}

rate_part=""
if [ -n "$five_pct" ] || [ -n "$week_pct" ]; then
  rate_items=()
  if [ -n "$five_pct" ]; then
    five_pct_int=$(printf '%.0f' "$five_pct")
    if [ "$five_pct_int" -gt 100 ] 2>/dev/null; then
      part="⚡ 5h:100%+"
    else
      part="⚡ 5h:${five_pct_int}%"
    fi
    rst=$(fmt_reset_time "$five_rst")
    [ -n "$rst" ] && part="${part}${rst}"
    rate_items+=("$part")
  fi
  if [ -n "$week_pct" ]; then
    week_pct_int=$(printf '%.0f' "$week_pct")
    if [ "$week_pct_int" -gt 100 ] 2>/dev/null; then
      part="📅 7d:100%+"
    else
      part="📅 7d:${week_pct_int}%"
    fi
    if [ -n "$week_rst" ] && [ "$week_rst" -gt 0 ] 2>/dev/null; then
      rst=$(fmt_reset_dow_time "$week_rst")
      [ -n "$rst" ] && part="${part}${rst}"
    fi
    rate_items+=("$part")
  fi
  joined=""
  for item in "${rate_items[@]}"; do
    if [ -z "$joined" ]; then joined="$item"
    else joined="${joined}  ${item}"
    fi
  done
  rate_part="  ${joined}"
fi

# --- Shorten branch ---
shorten_branch() {
  local b="$1"
  if [ ${#b} -gt 50 ]; then echo "${b:0:47}..."
  else echo "$b"
  fi
}
short_branch=""
[ -n "$git_branch" ] && short_branch=" ($(shorten_branch "$git_branch"))"

# --- Assemble ---
line1=$(printf "\033[01;32m%s@%s\033[00m%s" \
  "$(whoami)" "$(hostname -s)" "$short_branch")

line2_parts=("🤖 ${model_part}")
[ -n "$ctx_part" ]  && line2_parts+=("$ctx_part")
[ -n "$sess_part" ] && line2_parts+=("$sess_part")
line2=""
for p in "${line2_parts[@]}"; do
  if [ -z "$line2" ]; then line2="$p"
  else line2="${line2}  ${p}"
  fi
done

if [ -n "$rate_part" ]; then
  line3="${rate_part#  }"
  printf "%s\n%s\n%s" "$line1" "$line2" "$line3"
else
  printf "%s\n%s" "$line1" "$line2"
fi

そして ~/.claude/settings.json:

{
  "statusLine": {
    "type": "command",
    "command": "bash ~/.claude/statusline-command.sh",
    "padding": 0
  }
}

6. ハマりどころ

症状 原因 対策
statusLine が一瞬フリーズする git status 系が裏プロセスとロック競合 git --no-optional-locks を使う
macOS で時刻が出ない date -d は GNU 拡張 date -r にフォールバック
display_name が "Opus" だけで寂しい サーバが 1 ワードだけ返す時がある canonical_namemodel.id から合成
rate_limitsnull claude.ai サブスクライバ以外だと降ってこない 欠損前提で [ -n "$..." ] ガード
速度が重い jq を複数回呼んでいる 1 回の jq で配列 → TSV → read で分解
printf の末尾が崩れる \n を付けすぎ 最後の行末には \n を付けない

7. 生成プロンプト

  Claude Codeのstatus lineを以下の仕様で設定してください。~/.claude/statusline-command.sh
  にbashスクリプトを作成し、~/.claude/settings.json の statusLine をそのスクリプトを呼び出すように設定してください。

  以下の変数は環境に合わせて設定してください。
  {UserName}@{HostName}

  入力: Claude Codeはstdin経由でJSONを渡します。以下のフィールドを使います:
  - model.display_name — モデル名(例: "Claude Opus 4.6")
  - model.id — モデルID("[1m]" 判定用、1M contextかどうか)
  - workspace.current_dir または cwd — 現在のディレクトリ
  - session_id — セッションID(~/.claude/projects/ 以下のJSONLログ検索用)
  - transcript_path — 現在の会話のトランスクリプトパス(コンテキスト使用量算出用)

  出力: 2〜3行構成

  1行目(アイデンティティ): シェルのPS1を再現
  {UserName}@{HostName}:<cwd_basename in bold blue><git_branch in red>
  - $VIRTUAL_ENV がセットされていれば (venv_name)  をプレフィックス
  - 固定文字列 jfk@lenovo:
  - カレントディレクトリのbasenameを太字青 (\033[01;34m)
  - gitブランチを赤 (\033[31m) で (branch) 形式。JSON入力の cwd を基準に git -C <cwd> branch --show-current で取得
  - 末尾の $ は付けない

  2行目(モデル + コンテキスト + セッション):
  🤖 <model_display_name> (1M context)  📦 ctx:<used>k/<total>k(<pct>%)  💬 sess:<tokens>k
  - (1M context) はモデルIDに [1m] が含まれる場合のみ
  - ctx はトランスクリプトの最新メッセージから現在のコンテキスト使用量を算出。1Mコンテキストなら分母1000k、通常は200k
  - sess は同一セッション内で消費した累積トークン数(input + output + cache含む)をk単位で

  3行目(オプション、レート制限): ~/.claude/ 配下にレート制限情報があれば表示、なければ省略

  色の扱い: ANSIエスケープを使用。背景色は使わず、前景色のみ。

8. まとめ

  • stdin に JSON、stdout にテキスト という素直なプロトコルなので、シェル芸だけで実用的な statusLine が組める
  • CLAUDE_STATUSLINE_DEBUG=1 で生 JSON を /tmp に落とす仕込みは、API 変更の追従にも効く
  • モデル名の整形・レート制限の JST 表示・累積 ctx と今ターンの sess を分離、といった "ちゃんと見える化" が長時間セッションを救う

自分の PS1 を育てるのと同じ感覚で、Claude Code の statusLine も育てていこう。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?