Skip to content

Dev Guide claude token usage view

Woojin Kim edited this page Jun 20, 2026 · 2 revisions

개발자 가이드 — claude-token-usage-view 플러그인

Claude API 사용 한도와 다음 리셋까지 남은 시간을 한눈에 보여주는 플러그인의 심층 개발 가이드. /usage 한도 막대(세션 5h · 주 전체 · 주 Sonnet)와 가장 이른 리셋의 카운트다운(대형 시계 폰트)을 팝업 · 탭 · 패널 오버레이의 세 모드로 그린다.

이 플러그인은 자체적으로 데이터를 수집하지 않는다. claude-code 플러그인이 숨은 /usage 스크랩으로 status 에 실어둔 데이터를 재사용한다(아래 데이터 출처와 cross-plugin 관계 참조). 추가 네트워크 호출 · 자격증명 · 의존성이 없어 가볍다.

사용 한도 화면


목차

  1. 목적과 표시 모드
  2. 등록 — 메타데이터와 훅
  3. 팝업/탭 화면 구조 (screen.py)
  4. 패널 오버레이 (overlay.py)
  5. 데이터 출처와 cross-plugin 관계
  6. 리셋 카운트다운 수학 (reset.py)
  7. delete-to-disable 와 안전 가드
  8. 확장하기
  9. 테스트하기
  10. 관련 문서

1. 목적과 표시 모드

명령 usage-view [popup|tab|pane](별칭 token-viewer · usage-clock)로 연다. 표시 3모드:

모드 동작 구현
popup(기본) claude-code 의 token-log '한도'(/usage) 탭을 연다. claude-code 가 없으면 중앙 모달 UsageScreen 으로 폴백. app.open_token_log("limit") 또는 UsageScreen(full=False)
tab 풀스크린 클라이언트 화면(UsageScreen(full=True)). UsageScreen.full
pane 현재 패널 오버레이 토글(client_overlay 훅). app.usage_view_panes 집합

tab 이 서버 탭이 아닌가: pytmux 서버 탭은 항상 fresh 셸이고 스크랩 데이터는 클라이언트 측에 있다. 따라서 tab 모드는 서버 탭이 아니라 클라이언트 풀스크린 화면이다(데이터 일관성 + delete-to-disable 보존).

화면 안에서의 키: Esc/q 닫기 · [u] 갱신(숨은 /usage 재실행) · [t] 팝업↔탭 전환 · [a] → pane 오버레이로.


2. 등록 — 메타데이터와 훅

플러그인 객체는 PLUGIN = _UsageViewPlugin() 으로 노출되며, 코어 Registry 가 아래 속성/메서드를 수집한다.

명령 메타데이터(클래스 속성)

COMMANDS = [
    ("usage-view", "Claude 사용 한도 + 다음 리셋 카운트다운 화면 …", "Claude"),
]
NOARG = {"usage-view", "token-viewer", "usage-clock"}   # 인자 없이도 유효
PANE_SCOPED = {"usage-view"}   # pane 모드 대상(활성) 패널을 프롬프트에서 밝게 표시

_ALIASES = ("usage-view", "token-viewer", "usage-clock")
_MODES = ("popup", "tab", "pane")

이 메타데이터는 코어 COMMANDS / COMPLETIONS / COMMAND_NOARG / PANE_SCOPED_CMDS 레지스트리에 병합된다.

훅 표

시점 역할
attach_client(app) 클라이언트 기동 인스턴스 글루 설치: app.usage_view_panes 집합과 app.open_usage_view(mode) 메서드.
handle_command(app, c, args) 명령 디스패치 _ALIASES 의 명령이면 모드를 파싱하고 refresh_usage 를 보낸 뒤 화면을 연다.
client_overlay(app, cells, W, H, active) 매 프레임 합성 pane 모드가 켜진 패널을 한도 막대 + 카운트다운으로 덮는다(clock/calendar 패턴).
client_tick(app) 1초 틱 오버레이가 떠 있으면 True 반환 → 코어가 재합성 → 카운트다운 매 초 갱신.
client_close_overlay(app, pane_id) Shift+ESC / 패널 클릭 해당 패널의 오버레이를 닫는다. 닫았으면 True.

attach_client — 인스턴스 글루

clock 플러그인의 toggle_clock/clock_panes 패턴을 그대로 따른다.

def attach_client(self, app):
    app.usage_view_panes = set()   # pane 오버레이가 켜진 패널 id 집합

    def open_usage_view(mode="popup"):
        if mode not in _MODES:
            mode = "popup"
        if mode == "pane":
            pid = app.layout.get("active")
            if pid is None:
                return
            if pid in app.usage_view_panes:
                app.usage_view_panes.discard(pid)   # 토글 off
            else:
                app.usage_view_panes.add(pid)        # 토글 on
            app._composite()
            return
        if mode == "popup":
            fn = getattr(app, "open_token_log", None)   # claude-code 통합
            if fn is not None:
                fn("limit")
                return
        from .screen import UsageScreen
        app.push_screen(UsageScreen(full=(mode == "tab")))

    app.open_usage_view = open_usage_view

handle_command — 디스패치

def handle_command(self, app, c, args):
    if c not in _ALIASES:
        return False
    mode = args[0].lower() if args else "popup"
    if mode not in _MODES:
        mode = "popup"
    # 갱신 시도 — claude-code 의 server_command 가 처리(없으면 무응답·무에러).
    app.send_cmd("refresh_usage")
    app.open_usage_view(mode)
    return True

refresh_usage 명령은 claude-code 가 받아 숨은 /usage 를 재실행한다. 팝업/탭은 1초 틱이, 오버레이는 다음 합성이 갱신값을 자동 반영한다.

무게(lazy import)

__init__.py 는 textual/rich/datetime 을 최상단에서 import 하지 않는다(서버 프로세스도 plugins.load() 로 이 모듈을 읽기 때문). 화면(screen)·오버레이(overlay)·시각 파서(reset)는 실제로 쓸 때 메서드 안에서 지연 import 한다.

i18n 등록

사용자 표면(명령 설명 · UsageScreen · 패널 오버레이)은 uview.* 네임스페이스로 ko/en 카탈로그에 등록된다(코어 claude-code 의 usage.* 와 충돌 회피). 등록은 플러그인 import 시점에 일어나 delete-to-disable 일관성을 유지한다.

from pytmuxlib import i18n
i18n.register({
    "ko": {"uview.title": "Claude 사용 한도 (/usage)", "uview.no_data": "한도 데이터 없음 …", …},
    "en": {"uview.title": "Claude usage limit (/usage)", "uview.no_data": "No limit data …", …},
})

3. 팝업/탭 화면 구조 (screen.py)

사용 한도 화면 전체

UsageScreen(ModalScreen)full 플래그로 중앙 모달(팝업)과 풀스크린(탭)을 같은 위젯 트리로 표현한다. [t] 로 두 형태를 즉석 전환한다(add_class("full")/remove).

위젯 트리(compose)

flowchart TD
    ubox["#ubox (Vertical) — round $accent border, $panel background"]
    uhead["#uhead (Horizontal)"]
    utitle["#utitle (Static) — 'Claude 사용 한도 (/usage)'"]
    uclose["#uclose (Label) — '[x]' 닫기 버튼 (markup=False)"]
    ubars["#ubars (Static) — 한도 막대들"]
    uclock["#uclock (Static) — 다음 리셋 카운트다운(블록 시계)"]
    uhint["#uhint (Horizontal) — 하단 동작 버튼 3개"]
    uref["#uref (Label) — '↻ 갱신 [u]'"]
    utgl["#utgl (Label) — '⤢ 팝업/탭 [t]'"]
    upane["#upane (Label) — '▭ 패널 보기 [a]'"]
    ubox --> uhead
    ubox --> ubars
    ubox --> uclock
    ubox --> uhint
    uhead --> utitle
    uhead --> uclose
    uhint --> uref
    uhint --> utgl
    uhint --> upane
Loading

[x] 와 하단 버튼의 대괄호가 Textual 마크업으로 해석돼 사라지지 않도록 markup=False 를 쓴다.

한도 막대 — 빈 트랙 회색화

_redraw() 가 매 초 코어 usage_bar_lines(...) 로 막대 줄을 만든다.

lines = usage_bar_lines(usage, min(w - 6, 76), age_sec=age,
                        right_align=True, track_char=_TRACK)   # _TRACK = "░"
  • right_align=True — 퍼센트를 막대 줄 오른쪽 끝에 정렬한다.
  • track_char="░" — 빈 트랙을 구분 글자 로 받아온다. 표시 단계(_colorize_tracks)에서 그 칸만 회색(grey50) 막대로 치환하고, 채움()과 그 외 글자는 기본색(흰색) 그대로 둔다. 결과: 막대=흰색 · 빈 부분=회색으로 배경(검정)과 구분된다. 화면에 자체는 보이지 않는다.

데이터가 없으면(lines 가 비면) "한도 데이터 없음" 안내(uview.no_data)만 노란색으로 표시하고 시계는 비운다.

다음 리셋 카운트다운 블록

label, dt = soonest_reset(usage, now)
if dt is None:
    clock.update(Text(i18n.t("uview.reset_unparsable"), style="dim"))
    return
td = dt - now
style = _URGENCY_STYLE[urgency(td)]      # red/yellow/cyan → rich 스타일
out.append(i18n.t("uview.next_reset", label=label) + "\n", style="bold cyan")
big = big_clock_text(td, style)
out.append(big if big is not None else Text(fmt_countdown(td), style=style))
  • soonest_reset(usage, now)_BUCKETS = [("session","세션 5h"), ("week_all","주 전체"), ("week_sonnet","주 Sonnet")] 중 가장 이른(곧 도래) 리셋의 (label, dt) 를 고른다. 화면과 오버레이가 공유하는 선택 규칙.
  • big_clock_text(td, style)<24h 이고 음수가 아니면 _CLOCK_FONT(시계·달력과 공유하는 3×5 블록 폰트)로 HH:MM:SS 5행 Text 를 만든다. 24시간 이상이거나 음수면 None 을 반환하고, 호출부가 fmt_countdown(td) 텍스트 카운트다운으로 폴백한다.
  • 긴급도 색(_URGENCY_STYLE): red = bold bright_red, yellow = bold yellow, cyan = bold bright_cyan.

동작 라우팅 — 키보드와 버튼 공유

키 단축키([u]/[t]/[a])와 하단 버튼 탭이 같은 동작 메서드를 공유한다(모바일에서 키를 못 누르는 경우 대비).

_BTN_ACTIONS = {"uref": _do_refresh, "utgl": _do_toggle, "upane": _do_pane}
  • on_click — 위젯 조상 체인을 거슬러 올라가며 #uclose(닫기)·uref/utgl/upane(버튼) id 를 찾아 동작을 호출한다. #ubox 안이면 유지, 바깥(백드롭) 클릭이면 닫는다.
  • on_keyescape/q 닫기, u 갱신, t 토글, a → pane.
  • _do_refreshrefresh_usage 를 보내고 "사용량 갱신 중…" 메시지를 3초 띄운다. _do_pane 은 화면을 dismiss 한 뒤 open_usage_view("pane") 으로 패널 오버레이를 켠다.

4. 패널 오버레이 (overlay.py)

draw_usage_overlay(...)앱 상태에 의존하지 않는 순수 함수다(clock/calendar 의 render.py 미러). pane 모드가 켜진 각 패널을 한도 막대 + 카운트다운으로 덮는다.

def draw_usage_overlay(cells, panes, view_panes, W, H, text_st, digit_st,
                       usage, age_sec=None, now=None):
    for p in panes:
        if p["id"] not in view_panes:
            continue
        px, py, pw, ph = p["x"], p["y"], p["w"], p["h"]
        # 1) 뒤 화면 흐리게 — dim_pane + _darken_style(균일 dim, 터미널 무관)
        dim_pane(cells, px, py, pw, ph, W, H, lambda c, st: (c, _darken_style(st)))
        # 2) 한도 막대 줄(없으면 안내 한 줄)
        lines = usage_bar_lines(usage, max(8, min(pw - 4, 60)),
                                age_sec=age_sec, right_align=True) \
            or [i18n.t("uview.overlay_no_data")]
        … put_cell   칸씩 찍음# 3) 다음 리셋 카운트다운 — 공간 충분(pw>=30, 높이>=5)하면 블록 HH:MM:SS,
        #    아니면 한 줄 텍스트(overlay_next_reset + fmt_countdown).

핵심 포인트:

  • 공유 프리미티브만 사용put_cell(범용 그리드 프리미티브)·_CLOCK_FONT·_darken_style·dim_pane·usage_bar_lines 는 모두 코어 공용이라, 이 플러그인을 지워도 코어에 죽은 코드가 남지 않는다.
  • 블록 vs 한 줄 분기0 <= total < 86400 and pw >= 30 and (py+ph-cy) >= 5 이면 _CLOCK_FONT 글리프로 중앙 정렬 블록 시계를 찍고, 아니면 한 줄 텍스트 카운트다운으로 폴백한다.
  • now 파라미터 — 기본은 datetime.now(). 테스트에서 결정적 시각을 주입하기 위한 인자다.
  • 오버레이는 client_overlay 훅에서만 지연 import 되므로(서버는 안 읽음), 모듈 최상단에서 clientrender/clientutil/clientscreens 헬퍼를 import 해도 안전하다.

__init__.pyclient_overlay 훅은 테마 색으로 스타일을 만들어 이 함수에 넘긴다.

def client_overlay(self, app, cells, W, H, active):
    if not getattr(app, "usage_view_panes", None):
        return
    text_st  = Style(color=theme_color(app, "foreground"))
    digit_st = Style(color=theme_color(app, "success"), bold=True)
    draw_usage_overlay(cells, app.layout.get("panes", []), app.usage_view_panes,
                       W, H, text_st, digit_st,
                       getattr(app.status, "usage_limits", None),
                       age_sec=getattr(app.status, "usage_age_sec", None))

5. 데이터 출처와 cross-plugin 관계

이 플러그인은 소비자다. 데이터는 claude-code 플러그인의 토큰/사용량 회계가 생산한다.

flowchart TD
    P["claude-code (생산자)"]
    S["app.status.usage_limits<br/>app.status.usage_age_sec"]
    C["claude-token-usage-view (소비자) — 막대/카운트다운"]
    P -- "숨은 /usage 스크랩 · refresh_usage 명령" --> S
    S -- "getattr (부드럽게)" --> C
Loading
  • 읽는 상태: app.status.usage_limits(버킷별 {reset, used, limit, …} dict)와 app.status.usage_age_sec(스냅샷 나이). 항상 getattr(..., None)부드럽게 읽는다.
  • 하드 참조 금지: claude-code 의 심볼을 직접 import 하지 않는다. claude-code 가 없거나(delete-to-disable) 아직 실측 전이면 usage_limitsNone 이고, 화면/오버레이는 "한도 데이터 없음" 안내만 표시한다.
  • 갱신 트리거: app.send_cmd("refresh_usage") 는 claude-code 의 server_command 가 처리한다. claude-code 가 없으면 무응답·무에러로 흘려보낸다.
  • usage_limits 의 형상(소비자 관점): 버킷 키 session · week_all · week_sonnet, 각 값은 reset(사람이 읽는 문자열) 등을 담은 dict. 막대 렌더링과 퍼센트 계산은 코어 usage_bar_lines 가 담당한다.

데이터 생산 쪽(스크랩 · 회계 · DB)은 Dev-Guide-claude-code 를 참조.


6. 리셋 카운트다운 수학 (reset.py)

스크랩 데이터의 reset 은 사람이 읽는 문자열이라("2pm (Asia/Seoul)", "Jun 13 at 3am (Asia/Seoul)", "1:40pm") 정확한 datetime 이 아니다. reset.py 는 이를 가장 가까운 미래 로컬 시각으로 근사해 분 단위 카운트다운을 만든다(순수 함수, 이 플러그인 테스트의 핵심). datetime/re 만 쓰고 textual/rich 를 import 하지 않는다.

정규식 토큰

_TIME_RE = re.compile(r"(\d{1,2})(?::(\d{2}))?\s*([ap])m", re.I)   # 3am, 2pm, 1:40pm, 11:05 am
_MD_RE   = re.compile(r"([A-Za-z]{3,9})\s+(\d{1,2})|(\d{1,2})\s+([A-Za-z]{3,9})")  # Jun 13 / 13 Jun

parse_reset_to_dt(s, now=None) — 근사 규칙

입력 형태 결과
시각만 "2pm" 오늘 그 시각. 이미 지났으면 내일.
날짜+시각 "Jun 13 at 3am" 올해 그 날짜·시각. 이미 지났으면 내년.
날짜만 "Jun 13" 그 날짜 00:00.
  • 타임존 괄호((Asia/Seoul))는 s.split("(")[0] 으로 버린다 — 스크랩 값이 이미 사용자 로캘 기준이라 로컬 시각으로 간주한다.
  • 무효 날짜(예: 2/30)나 윤년 경계(2/29 → 비윤년)는 ValueError 를 잡아 None 을 돌려준다.
  • _parse_time 은 12시간제를 24시간제로 변환(h = int(...) % 12, pm 이면 +12)하고 범위(h<=23, mn<=59)를 검증한다.
  • now 인자는 테스트 결정성을 위한 주입점이다.

fmt_countdown(td) — 텍스트 폴백 포맷

total = int(td.total_seconds())
if total <= 0: return "now"
# d 있으면  "Dd HHh MMm"
# h 있으면  "HHh MMm SSs"
# m 있으면  "MMm SSs"
# 그 외     "SSs"

블록 시계(big_clock_text)가 None 을 반환하는 경우(≥24h 또는 음수)에 호출부가 이 텍스트로 폴백한다.

urgency(td) — 긴급도 토큰

secs = int(td.total_seconds())
if secs < 30*60: return "red"      # <30분
if secs < 60*60: return "yellow"   # <1시간
return "cyan"                       # 그 외

호출부(screen.py_URGENCY_STYLE)가 이 토큰을 테마 색/스타일로 매핑한다. 시각 정책(임계값)과 색 매핑을 분리한 설계라, 색을 바꿔도 시간 로직은 손대지 않는다.

정밀도 한계: 스크랩 문자열은 분 단위까지만 신뢰할 수 있어, 카운트다운도 분 단위 근사다. 초 단위 정밀 카운트다운은 별도 웹 API 경로가 필요하며, 이 플러그인은 그 경로를 쓰지 않는다(추가 네트워크/자격증명 회피).


7. delete-to-disable 와 안전 가드

이 디렉토리(pytmuxlib/plugins/claude-token-usage-view/)를 통째로 지우면:

  • usage-view 명령이 검색 · 자동완성 · 디스패치 어디에도 안 잡힌다.
  • 패널 오버레이도 client_overlay 훅이 사라져 안 그려진다.
  • 코어는 무에러로 그대로 동작한다(하드 참조 없음).

지우지 않고 끄려면 :plugins(별칭 plugin-manager) 플러그인 관리 팝업에서 토글로 끌 수 있다. 가역적이며 opts.jsondisabled_plugins 에 영속된다.

안전 가드 요약:

  • claude-code 심볼을 직접 import 하지 않는다 — 모든 cross-plugin 접근은 getattr(app, ..., None) / getattr(app.status, ..., None).
  • open_token_log 가 없으면(claude-code 비활성) UsageScreen 폴백.
  • usage_limitsNone 이면 화면/오버레이는 빈 화면 대신 "데이터 없음" 안내를 그린다.

8. 확장하기

흔한 확장 시나리오:

  • 새 한도 버킷 추가screen.py_BUCKETS 리스트에 ("key", "라벨") 을 추가한다. soonest_reset 과 막대 렌더링이 같은 키 집합을 공유하므로 usage_bar_lines 가 그 키를 알면 자동으로 잡힌다. 라벨은 i18n uview.* 키로 옮기는 것을 권장.
  • 긴급도 임계값/색 조정 — 시간 임계값은 reset.pyurgency(), 색 매핑은 screen.py_URGENCY_STYLE 에서 각각 분리되어 있으니 한쪽만 고치면 된다.
  • 새 reset 문자열 형식 지원reset.py_TIME_RE/_MD_REparse_reset_to_dt 의 분기를 확장한다. 새 형식마다 회귀 테스트를 추가할 것(아래).
  • 오버레이 레이아웃 변경overlay.py 의 블록 vs 한 줄 분기 조건(pw>=30, 높이>=5)을 조정. 순수 함수라 단위 테스트가 쉽다.
  • i18n 표면 추가 — 새 사용자 표면 문자열은 반드시 uview.* 네임스페이스로 i18n.register 에 ko/en 양쪽을 등록(코어 usage.* 와 충돌 회피).

설계 원칙: 무거운 import 는 메서드 안에서 지연, cross-plugin 접근은 getattr, 렌더 프리미티브는 코어 공용만.


9. 테스트하기

reset.py 는 순수 함수라 테스트가 가장 견고하다. nowusage dict 를 주입해 결정적으로 검증한다.

from datetime import datetime
from pytmuxlib.plugins.__init__ import ...   # 또는 모듈 직접 import
from pytmuxlib.plugins... .reset import parse_reset_to_dt, fmt_countdown, urgency

NOW = datetime(2026, 6, 20, 13, 0, 0)

# 시각만 — 이미 지났으면 내일
assert parse_reset_to_dt("2pm", NOW) == NOW.replace(hour=14, minute=0)
assert parse_reset_to_dt("11am", NOW).day == NOW.day + 1   # 지난 시각 → 내일

# 날짜+시각 — 지났으면 내년
dt = parse_reset_to_dt("Jun 13 at 3am", NOW)
assert dt.year == NOW.year + 1

# 타임존 괄호 무시
assert parse_reset_to_dt("2pm (Asia/Seoul)", NOW).hour == 14

# 포맷 / 긴급도
from datetime import timedelta
assert fmt_countdown(timedelta(seconds=0)) == "now"
assert urgency(timedelta(minutes=20)) == "red"
assert urgency(timedelta(minutes=45)) == "yellow"
assert urgency(timedelta(hours=3))    == "cyan"

soonest_reset / draw_usage_overlaynow 주입으로 결정적으로 테스트할 수 있다(draw_usage_overlay 는 셀 그리드를 직접 받아 put_cell 결과를 검사). 화면(UsageScreen)은 Textual run_test 하네스로 구동하고 save_screenshot 으로 실 렌더를 SVG 로 확인한다.

전체 스위트(커밋 전 필수):

python3 tests/run.py

run.py 는 실패해도 종료코드가 0 일 수 있으니 요약줄(N passed, M failed) 을 꼭 본다. 서브셋 실행은 플러그인 믹스인 poison 으로 가짜 실패가 날 수 있어, 권위는 항상 전체 스위트다.


10. 관련 문서

Clone this wiki locally