Claude Code od razu ma wbudowany status line: aktualny katalog, czasem nazwa modelu. Wystarcza na pierwszy tydzień. Potem chciałem wiedzieć, jednym rzutem oka, czy mój host z Dockerem wciąż żyje, jak blisko jestem limitu kontekstu i czy nie wyrabiam się z pięciogodzinnym limitem zapytań w trakcie zadania. Jeden skrypt bash później, statusline.sh ma 1543 linie. Nikt go o to nie prosił. Tak po prostu wyszło.

Kontrakt na stdin

Status line w Claude Code to po prostu komenda zarejestrowana w settings.json:

1
2
3
4
"statusLine": {
  "type": "command",
  "command": "/home/tomakl/projects/statusline.sh"
}

Przy każdym renderze Claude Code wysyła na stdin blok JSON, a to, co skrypt wypisze na stdout, trafia na pasek statusu. Blok zawiera nazwę modelu, bieżący katalog roboczy, rozmiar i zużycie okna kontekstu, koszt, liczbę tokenów, czas trwania sesji, dodane i usunięte linie, a od niedawna też procenty zużycia limitu zapytań dla okna pięciogodzinnego i siedmiodniowego. Całe zadanie skryptu to: sparsować ten JSON przez jq, zdecydować co pokazać, i wypisać to na tyle szybko, żeby nikt tego nie zauważył.

I tu jest haczyk. “Przy każdym renderze” to nie przesada. Ten skrypt uruchamia się bez przerwy w trakcie pracy, więc wszystko co w nim wolne, jest wolne dla mnie, w kółko.

Cache, żeby interfejs się nie zacinał

Część tego, co chciałem pokazywać, nie jest tania w obliczeniu. Sprawdzenie, czy kontenery Dockera są zdrowe, oznacza SSH do maszyny, która je uruchamia. Sprawdzenie dysku i pamięci to wywołanie df i free. Zrób to przy każdym renderze, a status line staje się powodem, żeby przełączyć terminal i poczekać.

Rozwiązanie jest bez polotu: płaskie pliki w /tmp jako cache, z własnym oknem ważności dla każdego komponentu.

1
2
3
4
5
6
7
8
local cache_file="/tmp/.claude-docker-cache"
if [[ ${cache_age} -gt 30 ]]; then
  stats=$(ssh -o BatchMode=yes -o ConnectTimeout=2 "${DOCKER_HOST_ADDR}" \
    'docker ps --format "{{.Status}}"' 2>/dev/null) || return
  echo "${total}|${running}|${unhealthy}" > "${cache_file}"
else
  IFS='|' read -r total running unhealthy <<< "$(cat "${cache_file}")"
fi

Docker i RAM dostają 30-sekundowe okno, szerszy blok systemowy (hostname, uptime, dysk, load average) dostaje 60. Zbyt krótkie okno i wracamy do ciągłego płacenia za rundę SSH. Zbyt długie i status line mówi, że Docker jest zdrowy trzydzieści sekund po tym, jak przestał być. To jedyne dwie prawdziwe gałki do strojenia w całym pliku, a doszedłem do nich obserwując, jak często faktycznie patrzę w tę część paska względem tego, jak często coś się tam realnie zmienia.

Bash 3.2, cichy warunek brzegowy

Piszę i testuję to na Linuksie, ale Claude Code działa też na macOS, który wciąż domyślnie ma basha 3.2 (ostatnie wydanie na licencji GPLv2, zamrożone tam od kiedy Apple przestało dostarczać oprogramowanie na GPLv3). To wyklucza tablice asocjacyjne, konwersję wielkości liter przez ${var,,} i parę innych udogodnień, które dla kogoś piszącego pod basha 4+ są oczywiste.

Efekt widać w miejscach, które wyglądają na niepotrzebnie ręczne. Pasek postępu dla zużycia kontekstu nie używa sztuczek z rozwijaniem nawiasów klamrowych ani printf '%.0s', tylko buduje wypełniony i pusty fragment znak po znaku w pętli:

1
2
3
for ((i=0; i<filled; i++)); do
  filled_bar+="${BAR_FILLED}"
done

Niezgrabne, ale działa identycznie bezpiecznie na UTF-8 i tak samo na bashu 3.2 i 5.x. Komentarz nad get_context_message wprost mówi o tym ograniczeniu: “Bash 3.2 compatible: uses bash arrays”. Jak już wiesz czego szukać, połowa dziwnie wyglądającego kodu w tym pliku to podatek za kompatybilność, nie wybór stylistyczny.

Dwa wywołania gita zamiast siedmiu

Sekcja gita ma własny komentarz: “Optimized - 7 calls reduced to 2”. Wcześniejsze wersje wołały osobno git status, git branch, git rev-list i parę innych, przy każdym renderze. Skoro to uruchamia się praktycznie przy każdej turze, każdy dodatkowy podproces to koszt płacony stale, a nie raz. Obecna wersja wyciąga nazwę brancha, liczbę commitów ahead/behind i liczbę brudnych plików z dwóch wywołań, parsując połączony wynik z IFS ustawionym lokalnie, żeby nie wyciekło do reszty skryptu.

To drobiazg, ale taki, który staje się widoczny dopiero gdy skrypt odpala się dziesiątki razy na minutę zamiast raz przy starcie powłoki. Kod, który jako jednorazowy skrypt diagnostyczny byłby bez znaczenia, zamienia się w problem wydajnościowy wyłącznie przez to, jak często jest wywoływany.

Co faktycznie ląduje na ekranie

W praktyce cztery linie:

  1. Znacznik czasu, wersja Claude Code, nazwa modelu, bieżący katalog i procenty limitu zapytań (pokazywane dopiero gdy zużycie jest warte uwagi).
  2. Rotująca jednolinijkowa wiadomość, koszt sesji, liczba tur, czas trwania i stan kontenerów Dockera (uruchomione/razem, plus czerwone !N jeśli któryś jest niezdrowy).
  3. Dodane i usunięte linie oraz stan gita dla bieżącego repo.
  4. Hostname, uptime, RAM, wolne miejsce na dwóch dyskach i load average względem liczby rdzeni.

Wszystko, co może pójść źle, dostaje to samo traktowanie czerwony/pomarańczowy/zielony przy stałych progach: RAM powyżej 80%, dysk powyżej 85%, load na poziomie liczby rdzeni lub wyższym, zużycie kontekstu w czterech progach od “bardzo niskiego” do “krytycznego”. Spójne progi dla zupełnie różnych metryk sprawiają, że pasek da się przeskanować wzrokiem bez czytania liczb, a o to w statusie w ogóle chodzi.

Czy 1500 linii było warte zachodu

Przeważnie tak, z zastrzeżeniem, że ten rozrost nigdy nie był jedną decyzją. Każdy dodatek był mały i uzasadniony sam w sobie: jedna rzecz więcej, którą sprawdzałem ręcznie, przeniesiona do skryptu. Sprawdzanie Dockera istnieje, bo zmęczyło mnie pamiętanie, że kontenery mogą nie żyć. Limit zapytań istnieje, bo raz złapałem pięciogodzinny limit w połowie zadania i wolę to widzieć z wyprzedzeniem. Nic z tego nie było “zbudujmy dashboard do monitoringu”, a mimo to mam skrypt bash, który co kilka sekund sprawdza stan mojej infrastruktury, żebym nie musiał go o to prosić.

Jeśli budujesz coś podobnego, dwie rzeczy warte podkradnięcia to wzorzec cache’owania i świadoma dyscyplina kompatybilności z bashem 3.2. Reszta to zwykła akrecja, normalna dla każdego narzędzia, którego faktycznie używasz codziennie.