SLOPSHOPPER

token-speedometer

응답이 나오는 속도(초당 출력 토큰)를 입력창 위에 속도계처럼 보여 준다

newbandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-speedometer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ░░░░░░░░░░ 턴 종료 0 tok/s · 평균 — · 최고 0 · 출발 — │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ░░░░░░░░░░ 턴 종료 0 tok/s · 평균 — · 최고 0 · 출발 — │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

claude-code-mods

한국어 | English

Claude Code 함수 훅 플러그인(모드) 모음.

모드하는 일
state-compact긴 세션에서 자리를 비워도 캐시 재생성 비용을 크게 물지 않게 한다
token-speedometer답이 출력되는 속도를 입력창 위에 레이싱 게임 계기판처럼 보여 준다

state-compact

긴 세션을 열어 둔 채 자리를 비우면 프롬프트 캐시가 만료된다(1시간 또는 5분). 돌아와서 메시지를 하나 보내면 그동안 쌓인 컨텍스트 전체를 다시 캐시한다. 86만 토큰 세션이면 메시지 하나에 약 $6.9가 든다.

이 모드가 하는 일은 두 가지다.

1. 캐시가 만료되기 전에 정리하고 압축한다

Claude가 질문을 남기고 기다리거나 백그라운드 작업(서브에이전트 등)이 끝나기를 기다리는 동안 자리를 비우면, 캐시가 만료되기 직전에 모드가 대신 움직인다.

  1. Claude에게 handoff 문서(예: STATE.md)에 진행 상황과 다음 할 일을 적게 한다
  2. 그 턴이 끝나면 대화를 압축한다

돌아왔을 때는 압축된 요약만 다시 캐시하면 되고, 세부 내용은 handoff 문서에 남아 있다.

2. 만료된 긴 세션에 보내는 첫 메시지를 막는다

캐시가 이미 만료된 세션에 메시지를 보내면, 컨텍스트가 10만 토큰 이상일 때 한 번 막고 비용을 알린다.

state-compact: 캐시가 만료됐습니다. 보내면 컨텍스트 약 861k 토큰을 다시 캐시합니다(약 $6.88).
그대로 보내려면 다시 보내고, 아니면 /compact나 새 세션을 쓰세요.

막힌 메시지는 입력창에 돌아온다. 같은 메시지를 다시 보내면 그대로 진행되고, /compact 같은 슬래시 명령은 막지 않는다.

그 밖의 기능

  • 수동 압축 때도 문서 반영. /compact를 치거나 컨텍스트가 85%를 넘으면, 압축하기 전에 handoff 문서를 먼저 고치게 한다.
  • 답 끝 표시. 마지막 답 끝에 작은 상자로 압축·캐시 상태를 보여 준다. 압축 예정 줄의 취소를 누르면 예약이 풀린다.
  ◇ 압축 예정                     11:27  취소
  ◆ 압축됨 · 자리 비움             11:27
  ○ 캐시 만료 (컨텍스트 861k)       18:16
  ✕ 압축 실패 · 이유               11:27

설치

함수 훅 플러그인을 지원하는 Claude Code가 필요하다. 저장소를 받은 뒤 모드 폴더를 플러그인 폴더로 지정한다.

git clone https://github.com/iceberggymnast/claude-code-mods.git

터미널에서는 실행할 때 폴더를 넘긴다.

claude --plugin-dir <repo>/plugins/state-compact

데스크톱 앱처럼 실행 인자를 줄 수 없는 곳에서는 ~/.claude/settings.json의 env에 적는다. 여러 폴더는 경로 구분자(Windows ;, 그 밖 :)로 잇는다.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "<repo>/plugins/state-compact"
  }
}

설치를 확인하려면 claude plugin validate <repo>/plugins/state-compact를 실행한다.

설정

항목기본값설명
handoff_file(비어 있음)압축 전에 반영할 파일. 저장소 루트 기준 이름. 비우면 반영 없이 압축만 한다
languageko답 끝 표시, 경고 문구, Claude에게 보내는 지시의 언어. ko 또는 en

/config의 플러그인 항목에서 바꾸거나, settings.json의 pluginConfigs에 적는다. --plugin-dir·CLAUDE_CODE_PLUGIN_DIRS로 불러온 경우 키는 state-compact 또는 state-compact@inline이다.

{
  "pluginConfigs": {
    "state-compact@inline": {
      "options": {
        "handoff_file": "STATE.md",
        "language": "ko"
      }
    }
  }
}

자세한 동작

만료 전 압축의 조건

턴이 끝났을 때 아래를 모두 만족하면 예약한다.

캐시 TTL컨텍스트압축 시점
1시간20만 토큰 이상마지막 요청 후 55분
5분30만 토큰 이상마지막 요청 후 4분
  • 마지막 답이 사용자의 대답을 기다리는 경우(질문, 확인 요청 등)나, 턴이 끝날 때 백그라운드 작업(서브에이전트, 백그라운드 셸 등)이 돌고 있는 경우만 예약한다. 작업이 끝나면 세션이 다시 깨어나기 때문이다. 끝난 보고면 돌아올 가능성이 낮아 압축 비용만 남는다. 대답을 기다리는지는 Haiku로 판정한다(백그라운드 작업이 없고 컨텍스트가 기준을 넘을 때만 턴마다 한 번)
  • 입력창에 쓰던 글이 있으면 자리에 있다고 보고 압축하지 않는다
  • 절전에서 깨어나 시점을 넘겼으면 캐시가 이미 만료됐다고 보고 건너뛴다
  • TTL은 세션 기록 파일의 마지막 응답에서 읽는다. 읽지 못하면 예약하지 않는다

handoff 문서 반영

  • 수동 /compact에 준 요약 지시는 압축할 때 그대로 넘긴다
  • 85% 선제 압축은 자동 압축보다 먼저 한다
  • 문서가 저장소 루트에 있으면 내용과 상관없이 매번 반영한다. 진행 중인 작업이 없는 세션에서도 반영 턴이 한 번 돈다
  • 문서가 git이 추적하는 파일이면 그 파일만 커밋하게 한다

답 끝 표시

  • 압축 예정과 압축 중(◆ 압축 중…)은 마지막 답에만 뜬다
  • 압축됨, 캐시 만료, 압축 실패는 그때의 마지막 답 끝에 남고 지워지지 않는다
  • 캐시 만료는 마지막 요청 뒤 TTL이 지나면 토큰 양과 상관없이 표시하고, 그 전에 압축했으면 표시하지 않는다. 괄호 안은 만료 때의 컨텍스트 크기다

판단 기록

만료 전 압축을 예약하지 않았거나 건너뛴 이유는 세션 기록에 남지 않는다. 그래서 턴이 끝날 때와 예약한 시각에 내린 판단을 세션 기록 파일(~/.claude/projects/<프로젝트>/<세션 id>.jsonl) 옆의 <세션 id>.state-compact.log에 한 줄씩 남긴다. TTL, 컨텍스트 토큰 수, 백그라운드 작업 수, Haiku 판정 결과와 걸린 시간, 건너뛴 이유가 들어가고 답 본문은 넣지 않는다. 25만 자를 넘으면 앞부분부터 지운다.

한계

  • resume하거나 앱을 재시작한 직후에는 마지막 요청 시각을 모르므로, 그 세션에서 요청을 한 번 보내기 전까지 만료 전 압축과 수동 /compact 앞의 반영을 하지 않는다. 캐시 만료 표시는 마지막 응답 시각을 기준으로 붙인다. 꺼져 있던 동안 만료됐으면 그 세션에 다시 메시지를 보낼 때 그 전의 마지막 답에 붙인다(데스크톱 앱은 목록에서 세션을 열기만 해서는 모드를 실행하지 않는다). 이때 표시 시각은 실제 만료보다 응답에 걸린 시간만큼 늦다
  • 데스크톱 앱은 플러그인 토스트를 표시하지 않는다. 실패 이유는 답 끝 표시에서 확인한다
  • 수동 /compact를 하면 대화에 /compact 말풍선이 두 개 남는다. 첫 번째는 사용자가 입력한 것(문서 반영을 위해 멈춤)이고, 두 번째는 반영이 끝난 뒤 모드가 다시 실행한 것이다

확인 범위

Windows 데스크톱 앱에서 수동 /compact → 문서 반영 → 압축 순서(요약 지시 인자 포함), 1시간 TTL 세션의 만료 전 압축(마지막 요청 55분 뒤 실행), 수동 /compact 때 반영한 답 끝의 ◆ 압축 중…→◆ 15:35 압축됨 · 수동 표시, 다른 세션을 보는 동안 TTL이 지난 세션의 ○ 18:16 캐시 만료 표시, 캐시가 만료된 86만 토큰 세션에서 첫 메시지를 막고 경고한 뒤 메시지를 입력창에 되돌리는 것을 확인했다. 아래는 아직 확인하지 못했다.

  • 만료 전 압축 앞의 문서 반영 (확인한 압축은 문서 반영 없이 바로 압축했다)
  • 답 끝 표시 중 압축 예정과 그 취소 버튼, 압축 실패, 그리고 앱을 재시작한 뒤에도 표시가 남는지
  • 막힌 첫 메시지를 다시 보냈을 때 통과하는지, 꺼져 있던 동안 지난 캐시 만료가 다시 연 세션의 이전 답에 붙는지
  • 85% 선제 압축
  • language를 en으로 바꿨을 때의 표시와 지시
  • 5분 TTL 세션
  • 터미널 CLI (함수 훅이 꺼진 빌드라 로드되지 않았다)
  • macOS·Linux
  • 마켓플레이스(claude plugin marketplace add)로 설치했을 때 모드가 로드되는지

token-speedometer

답이 출력되는 속도(초당 토큰)를 입력창 위에 레이싱 게임 계기판처럼 보여 준다.

  • 큰 숫자: 지금 속도. 앞자리 0은 어둡게 그린다
  • 막대: 0~200 tok/s, 160부터 빨간 구간. 속도가 0이어도 3칸은 켜 둔다. 세모는 이번 턴의 최고 속도다
  • 기어: 이번 턴의 몇 번째 모델 요청인지(초록). 생각하거나 도구를 실행하는 동안은 N, 턴이 끝나면 P(빨강)
  • 표시등: REQ 응답 대기 · THK 생각 중 · OUT 출력 중 · TOOL 도구 실행
  • AVG · TOP · LAUNCH: 끝난 응답들의 평균, 이번 턴의 최고, 첫 조각이 오기까지 걸린 시간

턴이 끝나면 숫자와 막대가 0으로 내려가고 P로 남는다. 설치는 설치와 같고, 폴더만 <repo>/plugins/token-speedometer로 준다. 설정 항목은 없다.

속도를 재는 방법

모델 비교 차트의 숫자와 견줄 수 있게 Artificial Analysis의 출력 속도와 같은 기준으로 잰다.

  • 토큰은 Claude 토크나이저가 아니라 OpenAI o200k_base 기준으로 센다. 화면에 나온 답(본문과 도구 입력)을 문자 종류별 비용으로 추정한다: 한글 0.90, 영문·숫자 0.25, 공백 0.10, 기호 0.61, 그 밖 1.0토큰/글자. Claude Code 세션 기록의 답 6,000개로 tiktoken과 맞춘 값이고, 따로 둔 6,000개에서 전체 오차는 -0.6%, 한글 비중별로 나눠도 ±4% 안이다
  • 생각 구간은 빼고 답이 나오는 동안만 잰다. 생각하는 동안과 도구를 실행하는 동안 숫자는 0을 향해 내려간다
  • 지금 속도는 최근 1초 동안의 값이다. 초당 10번 갱신하면서 매번 30%씩 따라가게 해서 숫자가 차례로 오르내린다
  • 서브에이전트의 응답은 세지 않는다

Artificial Analysis는 답 조각의 처음 20%를 빼고 재지만 이 모드는 처음부터 잰다. 서빙 환경과 effort도 다르므로 차트의 숫자와 똑같이 나오지는 않는다.

한계

  • 데스크톱 앱은 플러그인 그림을 초당 10번까지 다시 그린다. 그 사이의 움직임은 그릴 수 없다. SVG 애니메이션은 대화형 그림(isInteractive)에서만 도는데, 대화형 그림은 갱신할 때마다 깜박이고 크기가 줄어서 쓰지 않는다
  • 터미널에서는 그림 대신 글자 막대와 숫자 한 줄로 보여 준다
  • 밝은·어두운 모드는 그림 안의 prefers-color-scheme으로 가른다

확인 범위

Windows 데스크톱 앱(라이트 모드)에서 계기판 표시, 상태·기어 전환, 출력이 끝나면 바로 감속하는 것, 턴이 끝난 뒤 0으로 내려가 P로 남는 것, 창을 좁히면 비율대로 줄어드는 것을 확인했다. 아래는 아직 확인하지 못했다.

  • 다크 모드에서 색이 바뀌는지
  • 터미널 CLI 표시
  • macOS·Linux

라이선스

MIT

Source 2 files
hooks/register.tsx 221 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { bar, hudSvg, PHASE_LABEL, seconds } from './hud'
4import type { Phase } from './hud'
5
6// 속도를 다시 재는 주기(데스크톱이 다시 그리는 한도인 초당 10회)와, 속도를 재는 구간
7const TICK_MS = 100
8const WINDOW_MS = 1000
9// 표시값이 매 주기 측정값 쪽으로 다가가는 비율. 숫자가 한 번에 뛰지 않고 차례로 올라가고 내려간다.
10const DISPLAY_EASE = 0.3
11// 턴이 끝나 0으로 내려가던 표시값이 이보다 작아지면 0으로 두고 타이머를 멈춘다
12const PARKED_TPS = 0.5
13// 그림을 다시 그리는 기준 해상도. 표시값이 이만큼도 안 바뀌었으면 다시 그리지 않는다.
14const REDRAW_TPS_STEP = 0.1
15
16// 속도는 Artificial Analysis 기준으로 잰다: 화면에 나온 답(본문과 도구 입력)을 OpenAI o200k_base 토큰으로 세고,
17// 답이 나오는 동안의 시간으로 나눈다. 생각 구간은 넣지 않는다.
18// o200k 토큰은 문자 종류별 글자당 비용으로 추정한다. Claude Code 세션 기록의 답 6,000개로 tiktoken과 맞춘 값이고,
19// 따로 둔 6,000개에서 전체 오차 -0.6%, 한글 비중별로 나눠도 ±4% 안이다.
20const O200K_PER_HANGUL = 0.8987
21const O200K_PER_ALNUM = 0.2533
22const O200K_PER_SPACE = 0.1001
23const O200K_PER_PUNCT = 0.6116
24const O200K_PER_OTHER = 1
25
26// 글자는 UTF-16 단위로 센다. 비용은 코드 포인트 단위로 맞췄으므로 이모지(2단위)는 두 배로 잡히고,
27// U+3000 같은 유니코드 공백은 기타로 센다. Claude Code의 답에는 드물어 오차에 거의 들어가지 않는다.
28function o200kTokens(text: string): number {
29  let n = 0
30  for (let i = 0; i < text.length; i++) {
31    const c = text.charCodeAt(i)
32    if ((c >= 0xac00 && c <= 0xd7a3) || (c >= 0x1100 && c <= 0x11ff) || (c >= 0x3130 && c <= 0x318f)) n += O200K_PER_HANGUL
33    else if ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122)) n += O200K_PER_ALNUM
34    else if (c === 32 || (c >= 9 && c <= 13)) n += O200K_PER_SPACE
35    else if (c >= 33 && c <= 126) n += O200K_PER_PUNCT
36    else n += O200K_PER_OTHER
37  }
38  return n
39}
40
41type Sample = { at: number; tokens: number }
42
43// 이번 턴의 측정값. 조각이 올 때는 토큰 추정치만 더하고, 속도는 타이머가 계산한다.
44// 턴이 끝나도 다음 턴이 시작될 때까지 남겨 P 표시에 쓴다.
45let hasTurn = false
46let isParked = false
47let timer: Timer | undefined
48let turnStartAt = 0
49let phase: Phase = 'request'
50let gear = '1'
51let tokens = 0
52let launchTaken = false
53let launchMs: number | undefined
54let answerTokens = 0
55let answerMs = 0
56let samples: Sample[] = []
57let liveTps = 0
58let maxTps = 0
59let closing: Promise<void> = Promise.resolve()
60
61// 마지막으로 그린 그림의 속도와 표시 내용
62let shownTps = 0
63let frameKey = ''
64
65// 끝난 응답들의 평균: 답의 토큰 / 첫 글자부터 끝까지의 시간
66function averageTps(): number | undefined {
67  return answerMs > 0 ? answerTokens / (answerMs / 1000) : undefined
68}
69
70// 표시 내용이 바뀌었을 때만 다시 그린다. 데스크톱은 초당 10번까지 다시 그린다.
71function redraw($: EngineInterface): void {
72  const avg = averageTps()
73  const key = [
74    isParked,
75    phase,
76    gear,
77    Math.round(liveTps / REDRAW_TPS_STEP),
78    Math.round(maxTps),
79    avg === undefined ? '' : Math.round(avg),
80    launchMs ?? '',
81  ].join('|')
82  if (key === frameKey) return
83  frameKey = key
84  shownTps = liveTps
85  $.ui.invalidate('ui.render')
86}
87
88async function tick($: EngineInterface): Promise<void> {
89  const now = await $.clock.now()
90  samples.push({ at: now, tokens })
91  // 구간 시작 직전의 표본 하나를 남긴다. 타이머가 몰려서 울려도 속도를 재는 구간이 WINDOW_MS보다 짧아지지 않는다.
92  while ((samples[1]?.at ?? now) <= now - WINDOW_MS) samples.shift()
93  const oldest = samples[0] ?? { at: now, tokens }
94  // 답이 나오는 중이 아니면 바로 0을 향한다. 구간 안에 남은 지난 출력 때문에 생각·도구 실행 중에 숫자가 오르지 않게 한다.
95  const isOutput = phase === 'output' && !isParked
96  const measured = !isOutput || now <= oldest.at ? 0 : (tokens - oldest.tokens) / ((now - oldest.at) / 1000)
97  liveTps += (measured - liveTps) * DISPLAY_EASE
98  maxTps = Math.max(maxTps, liveTps)
99  if (isParked && liveTps < PARKED_TPS) {
100    liveTps = 0
101    timer?.cancel()
102    timer = undefined
103  }
104  redraw($)
105}
106
107async function closeStep($: EngineInterface, answerAt: Promise<number>, stepTokens: number): Promise<void> {
108  const [start, end] = await Promise.all([answerAt, $.clock.now()])
109  if (end <= start) return
110  answerTokens += stepTokens
111  answerMs += end - start
112}
113
114export const register: Register = on => {
115  on('turn.start', async ($, e, next) => {
116    timer?.cancel()
117    turnStartAt = await $.clock.now()
118    hasTurn = true
119    isParked = false
120    phase = 'request'
121    gear = '1'
122    tokens = 0
123    launchTaken = false
124    launchMs = undefined
125    answerTokens = 0
126    answerMs = 0
127    samples = [{ at: turnStartAt, tokens: 0 }]
128    liveTps = 0
129    maxTps = 0
130    shownTps = 0
131    timer = $.clock.every(TICK_MS, () => void tick($))
132    redraw($)
133    return next(e)
134  })
135
136  // 메인 대화의 응답만 잰다. 서브에이전트의 응답이 섞이면 속도가 부풀어 오른다.
137  // 조각을 넘기는 길에서는 기다리지 않는다. 시각은 $.clock.now()를 걸어 두기만 하고 끝날 때 모아 읽는다.
138  on('turn.step', async function* ($, e, next) {
139    if (e.agentId !== undefined || !hasTurn || isParked) return yield* next(e)
140
141    phase = 'request'
142    gear = String(e.index + 1)
143    let answerAt: Promise<number> | undefined
144    let stepTokens = 0
145    try {
146      for await (const c of next(e)) {
147        if (!launchTaken) {
148          launchTaken = true
149          void $.clock.now().then(t => {
150            launchMs = t - turnStartAt
151          })
152        }
153        if (phase === 'request') phase = 'thinking'
154        const text = c.kind === 'text' ? c.text : c.kind === 'input' ? c.json : ''
155        if (text.length > 0) {
156          if (answerAt === undefined) {
157            answerAt = $.clock.now()
158            phase = 'output'
159          }
160          const n = o200kTokens(text)
161          tokens += n
162          stepTokens += n
163        }
164        yield c
165      }
166    } finally {
167      phase = 'tool'
168      if (answerAt !== undefined) closing = closeStep($, answerAt, stepTokens)
169    }
170  })
171
172  // 턴이 끝나면 P로 두고, 표시값은 타이머가 0까지 내린 뒤 멈춘다.
173  on('turn.complete', async ($, e, next) => {
174    if (e.agentId !== undefined || !hasTurn || isParked) return next(e)
175
176    await closing
177    isParked = true
178    redraw($)
179    return next(e)
180  })
181
182  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183    if (e.props.hasSurvey || !hasTurn) return next(e)
184
185    const avg = averageTps()
186    const title = isParked ? '턴 종료' : PHASE_LABEL[phase]
187    const summary =
188      `${title} ${Math.round(shownTps)} tok/s · 평균 ${avg === undefined ? '—' : Math.round(avg)}` +
189      ` · 최고 ${Math.round(maxTps)} · 출발 ${seconds(launchMs)}`
190    const elements = $.ui.resolve(e)
191
192    // 터미널은 고정폭 글자로 막대를 그린다. 'Svg' in 검사는 타입을 좁히는 용도이고, 실행 중 표에는 터미널에도 그 키가 있다.
193    if (e.surface === 'terminal' || !('Svg' in elements)) {
194      const { Box, Text } = elements
195      return (
196        <Box borderStyle="round" borderDimColor paddingX={1}>
197          <Text dimColor={isParked}>{`${bar(shownTps)}  ${summary}`}</Text>
198        </Box>
199      )
200    }
201
202    const { Box, Svg } = elements
203    const shownGear = isParked ? 'P' : phase === 'thinking' || phase === 'tool' ? 'N' : gear
204    const view = {
205      tps: shownTps,
206      avgTps: avg,
207      maxTps,
208      launchMs,
209      phase: isParked ? undefined : phase,
210      gear: shownGear,
211    }
212    // 그림 모드로 그린다. 대화형(isInteractive)은 그림 안의 애니메이션이 돌지만 데스크톱에서 크기가 줄고 갱신마다 깜박였다.
213    // 그림 모드에서는 애니메이션이 돌지 않는다(녹화로 확인: 막대가 6프레임마다 바뀜).
214    return (
215      <Box>
216        <Svg source={hudSvg(view)} alt={`토큰 속도계: ${summary}`} />
217      </Box>
218    )
219  })
220}
221
hooks/hud.ts 174 lines
1// 속도계 그림. 측정과 상관없는 순수 함수만 둔다.
2
3// 응답 사이: 모델 요청을 보내고 첫 조각을 기다림 · 첫 조각은 왔지만 보이는 출력이 아직 없음 · 출력이 나오는 중 · 도구 실행
4export type Phase = 'request' | 'thinking' | 'output' | 'tool'
5
6export const PHASE_LABEL: Record<Phase, string> = {
7  request: '응답 대기',
8  thinking: '생각 중',
9  output: '출력 중',
10  tool: '도구 실행',
11}
12const PHASE_LAMP: Record<Phase, string> = { request: 'REQ', thinking: 'THK', output: 'OUT', tool: 'TOOL' }
13const PHASES: readonly Phase[] = ['request', 'thinking', 'output', 'tool']
14
15// 막대가 끝까지 차는 속도와 빨간 구간이 시작되는 비율
16const GAUGE_MAX_TPS = 200
17const REDLINE_FRACTION = 0.8
18
19// 데스크톱 계기판. 배경 없이 그리고, 색은 앱의 밝은·어두운 모드에 따라 아래 두 벌 중 하나를 쓴다.
20// 흰색·검은색 요소의 글로우(gw)는 밝은 배경에서 번져 보여서 어두운 모드에서만 켠다. 초록·빨강은 늘 빛난다.
21const HUD_WIDTH = 720
22const HUD_HEIGHT = 34
23const BAR_SEGMENTS = 56
24// 속도가 0이어도 켜 두는 칸 수(공회전)
25const IDLE_SEGMENTS = 3
26// 칸을 기울이는 정도(칸 높이 대비 가로 밀림)
27const SEGMENT_SLANT = 0.36
28const HUD_STYLE =
29  '.d{fill:#151515}.dd{fill:#d0d0d0}.l{fill:#707070}.on{fill:#151515}.off{fill:#cfcfcf}' +
30  '.g0{stop-color:#b5b5b5}.g1{stop-color:#1a1a1a}.so{fill:#e6e6e6}.r{fill:#e8323c}.ro{fill:#f19ca1}' +
31  '.u{fill:#1a1a1a;stroke:#1a1a1a}.g{fill:#18a058}.gr{stroke:#18a058}.n{fill:#e8323c}.nr{stroke:#e8323c}' +
32  '@media (prefers-color-scheme: dark){' +
33  '.d{fill:#f5f5f5}.dd{fill:#3f4247}.l{fill:#9aa0a6}.on{fill:#f5f5f5}.off{fill:#3a3d42}' +
34  '.g0{stop-color:#5f6368}.g1{stop-color:#ffffff}.so{fill:#2a2d31}.r{fill:#ff3b3b}.ro{fill:#a3222b}' +
35  '.u{fill:#ffffff;stroke:#ffffff}.g{fill:#3ddc84}.gr{stroke:#3ddc84}.n{fill:#ff4d4d}.nr{stroke:#ff4d4d}' +
36  '.gw{filter:url(#glow)}}'
37
38// 터미널 막대
39const BAR_CELLS = 10
40const BAR_GLYPHS = '▁▂▃▄▅▆▇█'
41const EMPTY_GLYPH = '░'
42
43export type HudView = {
44  tps: number
45  // 끝난 응답들의 정확한 평균. 아직 끝난 응답이 없으면 없다.
46  avgTps: number | undefined
47  maxTps: number
48  launchMs: number | undefined
49  // 응답이 진행 중일 때의 상태. 턴이 끝나면(P) 없다.
50  phase: Phase | undefined
51  // 몇 번째 모델 요청인지. 생각 중·도구 실행이면 N, 턴이 끝나면 P(둘 다 빨간색)
52  gear: string
53}
54
55function fraction(tps: number): number {
56  return Math.min(Math.max(tps / GAUGE_MAX_TPS, 0), 1)
57}
58
59export function seconds(ms: number | undefined): string {
60  return ms === undefined ? '—' : `${(ms / 1000).toFixed(1)}s`
61}
62
63export function hudSvg(v: HudView): string {
64  const digits = String(Math.min(Math.round(v.tps), 999)).padStart(3, '0')
65  const lead = digits.length - digits.replace(/^0+(?=\d)/, '').length
66  const isNeutral = v.gear === 'P' || v.gear === 'N'
67
68  const barLeft = 232
69  const barRight = 562
70  const barTop = 8
71  const barHeight = 16
72  const gap = 2.2
73  const slant = barHeight * SEGMENT_SLANT
74  const pitch = (barRight - slant - barLeft + gap) / BAR_SEGMENTS
75  const segmentWidth = pitch - gap
76  const redFrom = Math.ceil(BAR_SEGMENTS * REDLINE_FRACTION)
77  // 켜진 칸 수(공회전 칸 수보다 적어지지 않는다)와 그 끝(마지막 켜진 칸의 아랫변 오른쪽 x)
78  const lit = Math.max(Math.round(fraction(v.tps) * BAR_SEGMENTS), IDLE_SEGMENTS)
79  const edge = barLeft + lit * pitch - gap
80  const segment = (i: number) => {
81    const x = barLeft + i * pitch
82    const b = barTop + barHeight
83    return `M${(x + slant).toFixed(1)} ${barTop}H${(x + slant + segmentWidth).toFixed(1)}L${(x + segmentWidth).toFixed(1)} ${b}H${x.toFixed(1)}Z`
84  }
85
86  let litNormal = ''
87  let litRed = ''
88  let offNormal = ''
89  let offRed = ''
90  for (let i = 0; i < BAR_SEGMENTS; i++) {
91    const d = segment(i)
92    if (i < lit) {
93      if (i >= redFrom) litRed += d
94      else litNormal += d
95    } else if (i >= redFrom) offRed += d
96    else offNormal += d
97  }
98
99  let s =
100    `<svg xmlns="http://www.w3.org/2000/svg" width="${HUD_WIDTH}" height="${HUD_HEIGHT}" viewBox="0 0 ${HUD_WIDTH} ${HUD_HEIGHT}"` +
101    ` font-family="'Segoe UI', 'Helvetica Neue', system-ui, sans-serif"><style>${HUD_STYLE}</style>` +
102    `<defs>` +
103    `<filter id="glow" x="-50%" y="-80%" width="200%" height="260%">` +
104    `<feGaussianBlur stdDeviation="1.6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>` +
105    `<linearGradient id="fill" gradientUnits="userSpaceOnUse" x1="${barLeft}" y1="0" x2="${edge.toFixed(1)}" y2="0">` +
106    `<stop offset="0" class="g0"/><stop offset="1" class="g1"/></linearGradient>` +
107    `</defs>`
108
109  // 기어
110  s +=
111    `<g filter="url(#glow)">` +
112    `<circle cx="17" cy="17" r="12" fill="none" stroke-width="2" class="${isNeutral ? 'nr' : 'gr'}"/>` +
113    `<text x="17" y="21.5" text-anchor="middle" font-size="${v.gear.length > 1 ? 10 : 13}" font-weight="700" class="${isNeutral ? 'n' : 'g'}">${v.gear}</text>` +
114    `</g>`
115
116  // 큰 숫자. 칸마다 따로 그려 자릿수가 바뀌어도 위치가 흔들리지 않는다. 앞자리 0은 어둡게, 나머지는 빛나게.
117  let dim = ''
118  let bright = ''
119  for (let i = 0; i < digits.length; i++) {
120    const t = `<text x="${50 + i * 22}" y="30" text-anchor="middle">${digits[i]}</text>`
121    if (i < lead) dim += t
122    else bright += t
123  }
124  s +=
125    `<g font-size="36" font-weight="300" font-style="italic">` +
126    `<g class="dd">${dim}</g><g class="d gw">${bright}</g></g>`
127
128  // 단위와 상태 표시등
129  s += `<text x="116" y="12" font-size="9" letter-spacing="1" class="l">TOK/S</text>`
130  PHASES.forEach((p, i) => {
131    const isOn = p === v.phase
132    s += `<text x="${116 + i * 26}" y="27" font-size="8" font-weight="600" letter-spacing="0.5" class="${isOn ? 'on gw' : 'off'}">${PHASE_LAMP[p]}</text>`
133  })
134
135  // 기울인 분할 막대: 꺼진 칸, 켜진 칸(그라데이션과 글로우), 아래 진행선과 끝점
136  s +=
137    `<path d="${offNormal}" class="so"/>` +
138    `<path d="${offRed}" class="ro"/>` +
139    `<path d="${litNormal}" fill="url(#fill)" class="gw"/>` +
140    `<path d="${litRed}" class="r" filter="url(#glow)"/>` +
141    `<rect x="${barLeft}" y="28" width="${(edge - barLeft).toFixed(1)}" height="1.2" fill="url(#fill)"/>` +
142    `<circle cx="${edge.toFixed(1)}" cy="28.6" r="1.8" class="u gw"/>`
143
144  // 이번 턴의 최고 속도
145  if (v.maxTps > 0) {
146    const x = barLeft + slant + fraction(v.maxTps) * (barRight - slant - barLeft)
147    s += `<path d="M${(x - 3.5).toFixed(1)} 1L${(x + 3.5).toFixed(1)} 1L${x.toFixed(1)} 5.5Z" class="d"/>`
148  }
149
150  // 평균 · 최고 · 출발
151  const stats: [string, string][] = [
152    ['AVG', v.avgTps === undefined ? '—' : String(Math.round(v.avgTps))],
153    ['TOP', String(Math.round(v.maxTps))],
154    ['LAUNCH', seconds(v.launchMs)],
155  ]
156  stats.forEach(([label, value], i) => {
157    const x = 584 + i * 46
158    s +=
159      `<text x="${x}" y="11" font-size="8" letter-spacing="0.8" class="l">${label}</text>` +
160      `<text x="${x}" y="28" font-size="14" font-style="italic" class="d">${value}</text>`
161  })
162
163  return s + '</svg>'
164}
165
166export function bar(tps: number): string {
167  const filled = Math.round(fraction(tps) * BAR_CELLS)
168  let out = ''
169  for (let i = 0; i < BAR_CELLS; i++) {
170    out += i < filled ? BAR_GLYPHS[Math.floor((i * BAR_GLYPHS.length) / BAR_CELLS)] : EMPTY_GLYPH
171  }
172  return out
173}
174