PROMARI JOURNAL

Claude Code の足もとに、18分類の計器盤を。ステータスラインを作りました

作成: / 公開: / 内容更新:

執筆:tamito0201 / 掲載・運営:プロマリ

Claude Code の入力欄の下に、レート制限・コンテキスト・料金・作業の密度まで18分類の指標を並べるステータスラインを作りました。数字の出どころ、率を倍率と残り時間へ翻訳する計算、端末に黙って切られない並べ方、点滅の作り方、導入と検証まで、実画面の数字を検算しながら図20点で読み解きます。

PASS IT ON

ひとつの発見を、次の会話へ。

画面に、正しく並べる

ここまでで、表示する数字はそろいました。このページでは、それを画面の上に、欠けず、ずれず、重ならずに並べる方法を見ていきます。ステータスラインの文字は、ふつうのターミナルと同じく、セルと呼ばれるマス目に1文字ずつ置かれていきます。このマス目の扱いには、エラーを出さずに表示を壊す落とし穴が、いくつも隠れていました。

右端は、黙って切られる

最初に踏んだのは、行の右端が消える問題です。作り始めのころ、行が長くなると、右端のチップがいつの間にか表示されなくなっていました。エラーの文字は出ません。警告もありません。ただ、端末の幅を超えた部分が、何事もなかったように描かれないのです。画面が壊れているのに、何も知らせてくれない。これがいちばん厄介なところでした。

図4.1-1 右端は、黙って切られる
図4.1-1 右端は、黙って切られる。実測。推測した幅に合わせて詰めると、切れるか余るかのどちらかです。幅は、渡される値を読んで決めます。図4.1-1 右端は、黙って切られる。実測。推測した幅に合わせて詰めると、切れるか余るかのどちらかです。幅は、渡される値を読んで決めます。

最初は、行の長さを手で調整しようとしました。95マスで組んだら右が切れたので、78マスまで縮めます。すると今度は、右側が大きく余って、詰められるはずのチップが次の行に回ってしまいます。端末の幅は、ウィンドウの大きさや文字の大きさで変わります。幅を推測して決めている限り、切れるか余るかの往復は終わりません。

この往復を止めたのは、幅を推測するのをやめて、測ることでした。Claude Code は、ステータスラインのコマンドを呼ぶとき、COLUMNSという環境変数で端末の幅を渡していました。私の環境では87です。この値を読むようにした瞬間、行の長さを調整するという作業そのものが無くなりました。

Pythonstatusline.py(端末の幅を測る)
def term_width():
    """端末幅を実測する。COLUMNS 環境変数 → /dev/tty の ioctl → 既定100。
    (tput cols はステータスライン環境では効かない——記事の実測知見)"""
    c = os.environ.get("COLUMNS")
    if c and c.isdigit() and int(c) >= 40:
        return int(c), "COLUMNS"
    try:
        import fcntl
        import termios
        import struct
        with open("/dev/tty") as t:
            rows_cols = struct.unpack("hhhh",
                                      fcntl.ioctl(t, termios.TIOCGWINSZ, b"\0" * 8))
        if rows_cols[1] >= 40:
            return rows_cols[1], "tty"
    except Exception:
        pass
    return 100, "fallback"

測り方は3段構えです。まず COLUMNS を読みます。無ければ、/dev/ttyに端末の大きさを問い合わせます。それも失敗したら、100マスとみなします。2段目が必要なのは、ステータスラインのコマンドが、つながっている端末(制御端末)を持たない場合があるからです。ターミナルでよく使う tput cols という幅を調べるコマンドも、ステータスラインの中では効かないことが、ほかの方の記事で報告されていました。

測った幅から2マスを引いたものを、1行に使ってよい幅(予算)にしています。右端の2マスは安全のための余白です。さらに、どこから幅を取ったか(COLUMNS か、tty か、既定値か)を、小さなファイルに書き残しています。表示がおかしいときに、まず「幅はいくつと判断されていたか」を確かめられるようにするためです。

文字数と、画面の幅は違う

幅が分かったら、次は行の長さを測ります。ここにも落とし穴がありました。Python で文字列の長さを測るいちばん手軽な方法は len() ですが、len() が数えるのは文字の数で、画面のマスの数ではありません。英数字は1文字が1マスですが、漢字やかな、多くの絵文字は、1文字で2マスを使います。

図4.2-1 文字数と、画面の幅は違う
図4.2-1 文字数と、画面の幅は違う。現在のしくみ。端末は文字を「マス」に並べます。行の長さは文字数ではなく、マスの数で測ります。図4.2-1 文字数と、画面の幅は違う。現在のしくみ。端末は文字を「マス」に並べます。行の長さは文字数ではなく、マスの数で測ります。

たとえば「⚡ Claude 5h 68%」は15文字ですが、⚡ が2マスを使うので、画面の上では16マスです。「残 864k」は6文字で7マスです。1つずつならわずかな差ですが、日本語や絵文字の多い行では差が積み上がります。記事の冒頭の画面の16行で測ってみると、len() で数えた長さは、実際のマスの数より最大で約2割少なくなりました。これを見逃して len() で幅を測ると、「予算内に収まったはず」の行が、実際には右端からはみ出して、黙って切られます。

そこで、マスの数を数える関数 cells() を用意しました。

Pythonstatusline.py(表示セル幅の概算)
def cells(s):
    """表示セル幅の概算(CJK・絵文字=2セル)。len() は幅の指標にならない。"""
    w = 0
    for ch in s:
        if unicodedata.east_asian_width(ch) in ("W", "F") or \
                0x1F000 <= ord(ch) <= 0x1FAFF or ch in "⚡♨⌛⚑⇅♻⏱⚠":
            w += 2
        else:
            w += 1
    return w

判定は3つの条件の組み合わせです。1つ目は、Unicode が文字ごとに決めているEast Asian Widthの表で、W(広い)か F(全角)の文字。漢字やかなは、ここで2マスと判定されます。2つ目は、絵文字がまとまって置かれている番号の範囲(U+1F000〜U+1FAFF)の文字です。3つ目は、⚡ のように、表の上では別の分類なのに、実際には2マスで描かれることが多い文字を、名指しで並べたものです。

関数の説明に「概算」と書いたのは、正直に言って、これで全部の文字を正しく測れるわけではないからです。どの文字を何マスで描くかは、端末とフォントによって少しずつ違います。この関数は、この計器盤で実際に使う文字について、私の端末で正しく測れることを確かめたものです。新しい記号を使いたくなったら、まず画面で描かせて、マスの数を目で確かめてから、この関数に加えます。次の節は、その確かめをせずに使って失敗した話です。

幅をごまかす絵文字は、隣の文字を踏む

💻 System の行は、最初は 🖥(デスクトップ・コンピュータの絵文字)で始めていました。ところが、画面で見ると、🖥 の絵が、すぐ後ろのマスまではみ出して、次の文字と重なって見えていたのです。

図4.3-1 幅をごまかす絵文字は、隣の文字を踏む
図4.3-1 幅をごまかす絵文字は、隣の文字を踏む。実測。幅の表と、実際の描き方が食い違う文字があります。表を信じず、画面で確かめて使える文字だけを選びました。図4.3-1 幅をごまかす絵文字は、隣の文字を踏む。実測。幅の表と、実際の描き方が食い違う文字があります。表を信じず、画面で確かめて使える文字だけを選びました。

調べてみると、🖥 は、幅の表では1マスの文字として扱われていました。ところが端末は、これを絵文字として2マスの大きさで描いていたのです。計算の上では1マス分の場所しか取っていないので、次の文字は、そのすぐ隣のマスに置かれます。そこへ2マス分の絵が描かれるので、はみ出した半分が、隣の文字を踏むことになります。🏷(荷札)も同じでした。逆に、⇅ のような矢印の記号は、2マスで数えていたのに1マスで描かれ、行の頭のラベルが1マスずれていました。

こうした文字は、幅の計算をどれだけ工夫しても、端末とフォントの組み合わせしだいで結果が変わります。そこで、計算で合わせにいくのをやめて、行の先頭には、必ず2マスで描かれる絵文字だけを置くことにしました。🖥 は 💻 に、🏷 は 🔖 に、⇅ は 📊 に置き換えています。

もう1つ、行の頭をそろえる工夫もしています。ほとんどの行は2マスの絵文字で始まるので、分類名は3マス目から始まります。ところが、⚡ Claude の行のように、チップの中に見出しが含まれていて、絵文字以外の文字から始まる行もあります。そういう行は、先頭に3マス分の空白を入れて、分類名の書き出しを3マス目にそろえます。

Pythonstatusline.py(行の頭をそろえる)
def lead_pad(chip):
    """行頭チップの先頭が2セル絵文字ならラベルは col3 に来る。
    絵文字なしで始まる行は3スペースで埋め、全行のラベル列を col3 に揃える。"""
    txt = strip_ansi(chip)
    return 0 if txt and cells(txt[0]) == 2 else 3

分類ごと、幅に合わせて詰める

幅と長さを正しく測れるようになったので、いよいよ並べ方です。この計器盤では、分類を1つの荷物として扱い、棚に詰めていく考え方をとっています。棚の幅は、1節目で測った予算です。

図4.4-1 分類ごと、幅に合わせて詰める
図4.4-1 分類ごと、幅に合わせて詰める。現在のしくみ。分類を1つの荷物として扱い、入る棚に入れていきます。折り返しても、どの分類の続きかが見出しで分かります。図4.4-1 分類ごと、幅に合わせて詰める。現在のしくみ。分類を1つの荷物として扱い、入る棚に入れていきます。折り返しても、どの分類の続きかが見出しで分かります。

手順は3つの場合分けです。まず、いま詰めている行に、次の分類がまるごと入るなら、┃ をはさんで同じ行に載せます。冒頭の画面で🔥 Burn の右に📈 KPI が乗っていたのは、この場合です。入らなければ、新しい行を始めて、そこに分類を置きます。そして、分類だけで1行の幅を超えてしまうときに限って、分類の中でチップごとに折り返します。

Pythonstatusline.py(分類を幅に合わせて詰める・抜粋。行末の短い注釈は記事で足した)
for col, name, g in groups:
    h1 = head_txt(1)
    unit = ([h1] if h1 else []) + g
    gtext = SEP.join(unit)
    gw = cells(strip_ansi(gtext))
    gpad = lead_pad(unit[0])
    if cur and cur_w + GSEP_W + gw <= budget:
        cur += GSEP + gtext                      # 同じ行に ┃ でつなぐ
        cur_w += GSEP_W + gw
    elif gpad + gw <= budget:
        if cur:
            out_lines.append(" " * cur_pad + cur)
        cur, cur_pad, cur_w = gtext, gpad, gpad + gw   # 新しい行から始める
    else:
        # 分類単体が幅を超える: 分類内で折り返し、継続行のヘッダは連番で別名にする
        # …(チップごとに入るだけ詰め、2行目以降の見出しは「💻 System 2」とする)

ここで大事にしたのは、1つの分類を、ほかの分類とまぜて折り返さないことです。チップを1つずつ、入るところへ詰め込んでいけば、行の数はもっと少なくできます。けれどそうすると、💰 Cost のチップの続きが、🔥 Burn の行の途中に紛れ込むようなことが起きます。どの数字がどの分類のものかが分からなくなっては、計器盤の意味がありません。

分類の中で折り返したときは、2行目以降の見出しに「💻 System 2」のように番号を付けます。1ページ目のチップの文法で決めた「分類名は画面全体で重ならない」の決まりを、折り返しでも守るためです。同じ「💻 System」が2行あると、どちらが本体でどちらが続きか迷いますが、番号があれば続きだと一目で分かります。

ちなみに、この番号付きの見出しが、ふだんの幅でいつも出てしまうなら、それは分類の中身の分け方を見直す合図だと考えています。実際、以前は🚀 Perf の行がいつも2行に分かれていて、「🚀 Perf 2」が恒常的に出ていました。そこで、ブロックの終わりの見込み(Est)を💰 Cost の行へ、追加した行数(Lines)を📈 KPI の行へ移しました。意味の上でも、見込みの金額は料金の行にあるほうが自然です。折り返しの番号は、見出しの重複を防ぐ仕組みであると同時に、分類の設計のずれを知らせる警告灯にもなっています。

最後は、警告の点滅です。コンテキストやレート制限の枠が90%を超えたとき、バッテリーが20%を切ったとき、そして3ページ目の枯渇予測が出たときには、そのチップを点滅させています。数字の色が変わるだけでは、見落とすことがあるからです。

端末の文字を点滅させる方法は、本来は用意されています。エスケープシーケンスの中の、SGRという種類の命令の5番(点滅)です。目立たせる別の方法として、文字と背景の色を入れ替える7番(反転)もあります。ところが、どちらも期待どおりに動きませんでした。私の使っている macOS のターミナル(Terminal.app)では、文字の点滅の設定が最初から切られていて、設定を入れても、動いているアプリは設定を読み直しません。さらに、Claude Code がステータスラインを描くときに、5番と7番が効かない環境があったのです。

そこで、端末の点滅に頼らず、2枚の絵を交互に出して、点滅に見せることにしました。パラパラ漫画と同じ考え方で、ソフトウェア点滅と呼んでいます。1枚目は、ふつうの色の文字。2枚目は、赤い背景に白い太字の文字です。どちらも、色を指定する命令だけでできているので、確実に描かれます。

Pythonstatusline.py(ソフトウェア点滅)
BG_ALERT = "\033[48;5;196m\033[38;5;231m"   # 赤背景+白文字(警告帯)


def blinkify(seg):
    """警告セグメントのソフトウェア点滅。描画のたびに秒の偶奇で
    「赤背景+白太字の帯」⇔「通常の色」を切り替える。
    SGR5(点滅)/SGR7(反転)は Claude Code のステータスライン描画で
    効かない環境があるため使わず、確実に描画される色コードだけで作る。"""
    if int(time.time()) % 2:
        return BG_ALERT + BOLD + strip_ansi(seg) + R
    return seg

どちらの絵を出すかは、描画したときの時刻の秒で決めます。秒が奇数なら赤い帯、偶数なら通常の色です。1ページ目で見たとおり、会話が動いているあいだは、ステータスラインが1秒に何回も描き直されます。秒が変わるたびに絵が入れ替わるので、見ている側には、1秒ごとに点滅しているように見えます。描画が頻繁に起きるという性質を、ここでは点滅の時計として使っているわけです。

赤い帯のほうの絵を作るときに、strip_ansi() で元の色の命令をいったん全部取り除いているのにも理由があります。チップの中には、数字だけ黄色、ラベルだけ灰色、というように色の命令が何か所も入っています。その途中にある「色を元に戻す」命令が、赤い背景まで一緒に消してしまうのです。色を全部はがしてから、赤い帯でまとめて包み直すことで、チップ全体を1本の帯にしています。

この方法にも弱点はあります。会話が止まって描画が止まると、そのときの絵のまま静止します。赤い帯の絵で止まればよく目立ちますが、通常の色の絵で止まると、点滅していないように見えます。それでも、警告の文字そのもの(たとえば「枯渇まで」)は画面に残るので、完全に見落とすことはありません。点滅は注意を引く補助で、警告の中身は文字で伝える、という分担にしています。

考えてみる:幅を測るのに、なぜ cells() のような「概算」で満足しているのだろうか?

正確に測る方法を突き詰めると、端末とフォントの組み合わせごとに、文字の描かれ方を調べることになります。それは、ステータスラインを使う環境が増えるほど、終わりの無い作業になります。この計器盤では、代わりに「使う文字を、どこでも同じ幅で描かれるものに絞る」ことで、概算で足りる状態を作りました。測り方を精密にするか、測るものを単純にするか。どちらも問題を解きますが、後者のほうが、確かめる範囲が小さく済みます。自分の作るものでも、精密な計算で合わせにいく前に、「計算しなくて済む形に変えられないか」を考えてみると、よい抜け道が見つかることがあります。

COMMENTS
コメント…

このページ(4ページ目)の感想・質問・設計へのコメント

PASS IT ON

この気づきを、誰かにも。

この記事を書いた人

Takaomi Murasaki

Promari SNS Shareの開発を通して、Webの仕組みや設計の選び方を紹介しています。動く実装と、その判断に至るまでを記事に残しています。

公開記事 10 件

プロフィールを見る
THANK YOU FOR READING.すべての記事へ ↗

FROM INSIGHT TO IMPACT

「できたらいいな」を、
動く仕組みに。

記事で見つけたヒントを、あなたの事業へ。
新しいサービスも、手間のかかる業務も。
いまの課題から、つくるべきものを一緒に考えます。

開発・AI・研修の実績を見る

まだ、仕様書はいりません。

「何から始める?」から、ご一緒に。

課題がまとまっていなくても大丈夫。テーマを選ぶと相談文をご用意します。
連絡先の必須入力は、お名前とメールアドレスだけ。

まずは課題の整理から相談する

ご相談後の流れ

  1. 01 内容を確認
  2. 02 メールでご連絡
  3. 03 課題・進め方をご相談
送信だけで契約やお申込みが確定することはありません。