-
Notifications
You must be signed in to change notification settings - Fork 0
Dev Guide claude token usage view
Claude API 사용 한도와 다음 리셋까지 남은 시간을 한눈에 보여주는 플러그인의 심층 개발 가이드. /usage 한도 막대(세션 5h · 주 전체 · 주 Sonnet)와 가장 이른 리셋의 카운트다운(대형 시계 폰트)을 팝업 · 탭 · 패널 오버레이의 세 모드로 그린다.
이 플러그인은 자체적으로 데이터를 수집하지 않는다. claude-code 플러그인이 숨은 /usage 스크랩으로 status 에 실어둔 데이터를 재사용한다(아래 데이터 출처와 cross-plugin 관계 참조). 추가 네트워크 호출 · 자격증명 · 의존성이 없어 가볍다.
- 목적과 표시 모드
- 등록 — 메타데이터와 훅
- 팝업/탭 화면 구조 (
screen.py) - 패널 오버레이 (
overlay.py) - 데이터 출처와 cross-plugin 관계
- 리셋 카운트다운 수학 (
reset.py) - delete-to-disable 와 안전 가드
- 확장하기
- 테스트하기
- 관련 문서
명령 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 오버레이로.
플러그인 객체는 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. |
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_viewdef 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 Truerefresh_usage 명령은 claude-code 가 받아 숨은 /usage 를 재실행한다. 팝업/탭은 1초 틱이, 오버레이는 다음 합성이 갱신값을 자동 반영한다.
__init__.py 는 textual/rich/datetime 을 최상단에서 import 하지 않는다(서버 프로세스도 plugins.load() 로 이 모듈을 읽기 때문). 화면(screen)·오버레이(overlay)·시각 파서(reset)는 실제로 쓸 때 메서드 안에서 지연 import 한다.
사용자 표면(명령 설명 · 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 …", …},
})UsageScreen(ModalScreen) 은 full 플래그로 중앙 모달(팝업)과 풀스크린(탭)을 같은 위젯 트리로 표현한다. [t] 로 두 형태를 즉석 전환한다(add_class("full")/remove).
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
[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:SS5행 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_key—escape/q닫기,u갱신,t토글,a→ pane. -
_do_refresh는refresh_usage를 보내고 "사용량 갱신 중…" 메시지를 3초 띄운다._do_pane은 화면을 dismiss 한 뒤open_usage_view("pane")으로 패널 오버레이를 켠다.
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__.py 의 client_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))이 플러그인은 소비자다. 데이터는 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
-
읽는 상태:
app.status.usage_limits(버킷별{reset, used, limit, …}dict)와app.status.usage_age_sec(스냅샷 나이). 항상getattr(..., None)로 부드럽게 읽는다. -
하드 참조 금지: claude-code 의 심볼을 직접 import 하지 않는다. claude-code 가 없거나(delete-to-disable) 아직 실측 전이면
usage_limits가None이고, 화면/오버레이는 "한도 데이터 없음" 안내만 표시한다. -
갱신 트리거:
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 를 참조.
스크랩 데이터의 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| 입력 형태 | 결과 |
|---|---|
시각만 "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인자는 테스트 결정성을 위한 주입점이다.
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 또는 음수)에 호출부가 이 텍스트로 폴백한다.
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 경로가 필요하며, 이 플러그인은 그 경로를 쓰지 않는다(추가 네트워크/자격증명 회피).
이 디렉토리(pytmuxlib/plugins/claude-token-usage-view/)를 통째로 지우면:
-
usage-view명령이 검색 · 자동완성 · 디스패치 어디에도 안 잡힌다. - 패널 오버레이도
client_overlay훅이 사라져 안 그려진다. - 코어는 무에러로 그대로 동작한다(하드 참조 없음).
지우지 않고 끄려면 :plugins(별칭 plugin-manager) 플러그인 관리 팝업에서 토글로 끌 수 있다. 가역적이며 opts.json 의 disabled_plugins 에 영속된다.
안전 가드 요약:
- claude-code 심볼을 직접 import 하지 않는다 — 모든 cross-plugin 접근은
getattr(app, ..., None)/getattr(app.status, ..., None). -
open_token_log가 없으면(claude-code 비활성)UsageScreen폴백. -
usage_limits가None이면 화면/오버레이는 빈 화면 대신 "데이터 없음" 안내를 그린다.
흔한 확장 시나리오:
-
새 한도 버킷 추가 —
screen.py의_BUCKETS리스트에("key", "라벨")을 추가한다.soonest_reset과 막대 렌더링이 같은 키 집합을 공유하므로usage_bar_lines가 그 키를 알면 자동으로 잡힌다. 라벨은 i18nuview.*키로 옮기는 것을 권장. -
긴급도 임계값/색 조정 — 시간 임계값은
reset.py의urgency(), 색 매핑은screen.py의_URGENCY_STYLE에서 각각 분리되어 있으니 한쪽만 고치면 된다. -
새 reset 문자열 형식 지원 —
reset.py의_TIME_RE/_MD_RE와parse_reset_to_dt의 분기를 확장한다. 새 형식마다 회귀 테스트를 추가할 것(아래). -
오버레이 레이아웃 변경 —
overlay.py의 블록 vs 한 줄 분기 조건(pw>=30, 높이>=5)을 조정. 순수 함수라 단위 테스트가 쉽다. -
i18n 표면 추가 — 새 사용자 표면 문자열은 반드시
uview.*네임스페이스로i18n.register에 ko/en 양쪽을 등록(코어usage.*와 충돌 회피).
설계 원칙: 무거운 import 는 메서드 안에서 지연, cross-plugin 접근은 getattr, 렌더 프리미티브는 코어 공용만.
reset.py 는 순수 함수라 테스트가 가장 견고하다. now 와 usage 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_overlay 도 now 주입으로 결정적으로 테스트할 수 있다(draw_usage_overlay 는 셀 그리드를 직접 받아 put_cell 결과를 검사). 화면(UsageScreen)은 Textual run_test 하네스로 구동하고 save_screenshot 으로 실 렌더를 SVG 로 확인한다.
전체 스위트(커밋 전 필수):
python3 tests/run.py
run.py는 실패해도 종료코드가 0 일 수 있으니 요약줄(N passed, M failed) 을 꼭 본다. 서브셋 실행은 플러그인 믹스인 poison 으로 가짜 실패가 날 수 있어, 권위는 항상 전체 스위트다.
소개 · 사용
- Project-Overview
- User-Manual
- Screenshots
- Settings-Popup
- Single-Session-Model
- Tmux-Feature-Comparison
플러그인
Claude Code 플러그인
- Claude-Code-Plugins
- Dev-Guide-claude-code
- Dev-Guide-claude-token-usage-view
- Dev-Guide-claude-prompt-history
- Dev-Guide-claude-resume
- Dev-Guide-claude-disable-feedback
플랫폼 · 성능
품질 · 보안
리뷰·분석 보고서
연혁
기여