SLOPSHOPPER

dashboard

모델, 컨텍스트, 요금, 사용 한도(Fable 포함), 브랜치를 입력창 아래에 표시합니다. /dashboard-style과 /dashboard-theme으로 모양과 색을 바꿉니다

newspinnercommandpromptprocesstimer
★ 1v0.5.1MITupdated 2026-10-16oobg/claude-code-mods/mods/dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dashboard
› 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 › /dashboard-style ⎿ dashboard: 대시보드 모양을 차분한 두 줄(calm)로 바꿨어요. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ◆ Opus │ █████░░░░░ 49% │ 97K/200K │ $0.42 │ 5시간 31% 📁 app (feat/auth-refresh) │ ⏱ 30분 │ 🔥 3.2K/min

Draws

Prompt hint
? for shortcuts ◆ Opus │ █████░░░░░ 49% │ 97K/200K │ $0.42 │ 5시간 31% 📁 app (feat/auth-refresh) │ ⏱ 30분 │ 🔥 3.2K/min
README

<img alt="claude-code-mods 로고" src="./docs/images/logo.png" width="160">

<h1 align="center">claude-code-mods</h1>

<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-111111?style=flat-square"></a> <a href="https://claude.com/claude-code"><img alt="Claude Code 2.1.299" src="https://img.shields.io/badge/Claude%20Code-2.1.299-111111?style=flat-square"></a> <a href="#설치"><img alt="marketplace oobg" src="https://img.shields.io/badge/marketplace-oobg-111111?style=flat-square"></a> <img alt="5 mods" src="https://img.shields.io/badge/mods-5-D97757?style=flat-square"> <a href="https://github.com/oobg/claude-code-mods/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/oobg/claude-code-mods?style=flat-square&color=111111"></a>

<a href="#주요-기능"><b>주요 기능</b></a> &nbsp;|&nbsp; <a href="#설치"><b>설치</b></a> &nbsp;|&nbsp; <a href="#모드-목록"><b>모드 목록</b></a> &nbsp;|&nbsp; <a href="#상세-문서"><b>상세 문서</b></a> &nbsp;|&nbsp; <a href="https://oobg.github.io/claude-code-mods/"><b>문서 사이트</b></a>

claude-code-mods는 Claude Code의 입력창과 도구 결과에 기능을 추가하는 모드 5개를 모은 저장소입니다. 입력창 위에서는 다음 요청을 제안하고 자주 쓰는 문구 프리셋을 표시하며, 입력창 아래에서는 세션 상태를 보여 줍니다. Finder에서 복사한 파일을 붙여넣으면 파일 이름을 @경로 멘션으로 바꾸고, Cmd+Z로 입력 내용을 되돌릴 수 있습니다. 넓은 터미널에서는 Edit와 Write 결과의 diff를 좌우로 비교할 수 있습니다. 모드는 필요한 것만 골라 설치하면 되고, 따로 설치할 프로그램은 없습니다.

주요 기능

<table class="cards"> <tr> <td width="50%" valign="top"> <a href="./docs/next-prompts.md"><picture> <img alt="next-prompts: 턴이 끝나면 다음에 보낼 만한 요청이 하나씩 나타난다" src="./docs/images/showcase/suggest-light.gif" width="100%"> <sub>턴이 끝나면 다음에 보낼 만한 요청을 2~3개 제안합니다. <code>$1</code>을 입력하고 스페이스를 누르거나 번호를 클릭하면 문구가 입력창에 들어가고, 문구를 클릭하면 바로 보냅니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="quick-replies: 입력창에 $1을 치고 스페이스를 누르면 $1이 1번 프리셋 문구로 바뀐다" src="./docs/images/showcase/preset-light.gif" width="100%"> <sub>자주 쓰는 문구를 전역 또는 저장소별 프리셋으로 저장합니다. 입력창에 <code>$4</code>를 치고 스페이스를 누르면 해당 문구로 바뀝니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/dashboard.md"><picture> <img alt="dashboard: 컨텍스트 게이지가 차면서 색이 바뀐다" src="./docs/images/showcase/gauge-light.gif" width="100%"> <sub>모델, 컨텍스트, 사용 한도, 브랜치를 입력창 아래 두 줄에 표시합니다. 게이지가 찰수록 색이 노란색, 주황색, 빨간색 순으로 바뀝니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/key-assist.md"><picture> <img alt="key-assist: 붙여넣은 파일 이름이 @경로로 바뀐다" src="./docs/images/showcase/paste-light.gif" width="100%"> <sub>Finder에서 복사한 파일을 붙여넣으면 파일 이름이 <code>@경로</code>로 바뀝니다. Cmd+Z로 입력 내용을 되돌릴 수 있습니다</sub> </td> </tr> <tr> <td colspan="2" valign="top"> <a href="./docs/split-diff.md"><picture> <img alt="split-diff: 터미널 폭이 120칸 이상이면 좌우 diff, 미만이면 원래 세로 diff로 전환된다" src="./docs/images/showcase/split-diff-light.gif" width="100%"> <sub>터미널 폭이 120칸 이상이면 Edit와 Write 결과의 diff에서 원래 내용은 왼쪽에, 바뀐 내용은 오른쪽에 표시합니다. 바뀐 단어는 더 밝은 색으로 강조합니다</sub> </td> </tr> </table>

특징

  • $번호로 문구를 불러옵니다. 다음 제안과 프리셋에 각각 번호가 붙습니다. 입력창에 $2를 입력하고 스페이스를 누르면 2번 문구가 그 자리에 들어갑니다. $12처럼 두 자리 번호도 쓸 수 있습니다.
  • 프리셋도 입력창에서 편집합니다. 편집 화면에서 프리셋을 추가하거나 수정할 때는 입력창에 내용을 쓰고 Enter를 누르면 됩니다.
  • 상태 표시줄 대신 입력창 아래에 대시보드를 표시합니다. 컨텍스트 사용량, 5시간과 7일 한도, Fable 한도, 모델, 브랜치를 두 줄로 확인할 수 있습니다.
  • 파일을 경로 멘션으로 붙여넣습니다. Finder에서 복사한 파일을 붙여넣으면 파일 이름 대신 @경로 멘션이 입력됩니다.
  • 넓은 터미널에서는 diff를 좌우로 나눠 표시합니다. 원래 줄과 바뀐 줄이 같은 높이에 나란히 표시됩니다.
  • 따로 설치할 프로그램이나 직접 고칠 설정 파일이 없습니다. 마켓플레이스에서 필요한 모드만 골라 설치합니다.

설치

요구 사항

  • Claude Code 2.1.296 이상이 필요합니다. 2.1.296~2.1.299에서 테스트했습니다. 모드 기능은 아직 공개 초기 단계라 이후 버전에서는 동작이 달라질 수 있습니다.
  • key-assist는 macOS 전용입니다. Cmd+Z를 사용하려면 Command 키 입력을 Claude Code에 전달할 수 있는 터미널이 필요합니다. Orca, Ghostty, kitty, WezTerm처럼 Kitty 키보드 프로토콜을 지원하는 터미널이 해당합니다.

마켓플레이스에서 설치

이 저장소가 oobg 마켓플레이스입니다. 먼저 Claude Code 입력창에서 마켓플레이스를 등록합니다.

/plugin marketplace add oobg/claude-code-mods

그다음 필요한 모드만 골라 설치합니다.

/plugin install next-prompts@oobg
/plugin install quick-replies@oobg
/plugin install dashboard@oobg
/plugin install key-assist@oobg
/plugin install split-diff@oobg

설치한 모드는 Claude Code를 다시 시작하거나 /reload-plugins를 실행하면 적용됩니다. 설치 여부는 /plugin 화면에서 확인하거나 터미널에서 claude plugin list를 실행해 확인할 수 있습니다.

Claude Code를 시작하면 quick-replies의 기본 프리셋과 dashboard가 바로 표시됩니다. next-prompts의 제안은 첫 턴이 끝난 뒤에 나타납니다.

모드 목록

모드기능위치
next-prompts턴이 끝나면 다음에 보낼 만한 요청을 2~3개 제안합니다입력창 위
quick-replies자주 쓰는 문구를 전역 프리셋이나 저장소별 프리셋에 저장합니다입력창 위
dashboard모델, 컨텍스트, 사용 한도, 브랜치를 두 줄로 표시합니다입력창 아래
key-assist붙여넣은 파일 이름을 @경로로 바꾸고, Cmd+Z로 입력한 내용을 되돌립니다입력창
split-diff넓은 터미널에서 Edit와 Write 결과의 diff를 좌우로 나눠 표시합니다도구 결과

$번호로 문구 입력하기

next-prompts와 quick-replies를 함께 쓰면 입력창 위에 표시되는 두 목록의 번호가 이어집니다. 다음 제안은 $1부터, 프리셋은 그다음 번호부터 시작합니다.

<table class="cards"> <tr> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="입력창에 $2를 치고 스페이스를 누르면 $2가 2번 문구로 바뀐다" src="./docs/images/showcase/substitute-light.gif" width="100%"> <sub>입력창에 <code>$2</code>를 치고 스페이스를 누르면 <code>$2</code>가 2번 문구로 바뀝니다. 문장 중간에서도 사용할 수 있습니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="번호를 클릭하면 입력창 내용이 그 문구로 바뀐다" src="./docs/images/showcase/pick-light.gif" width="100%"> <sub>번호(<code>$2:</code>)를 클릭하면 입력창의 기존 내용을 모두 2번 문구로 바꿉니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/next-prompts.md"><picture> <img alt="문구를 클릭하면 그 문구만 바로 보내진다" src="./docs/images/showcase/send-light.gif" width="100%"> <sub>문구를 클릭하면 해당 문구만 바로 보냅니다. 입력창에 적어 둔 내용은 그대로 남습니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="$e를 입력하면 프리셋 편집 화면이 열린다" src="./docs/images/showcase/editor-light.gif" width="100%"> <sub>입력창에 <code>$e</code>를 입력하면 프리셋 편집 화면이 열립니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="$0을 입력하면 프리셋을 한 줄로 접고 다시 입력하면 펼친다" src="./docs/images/showcase/fold-light.gif" width="100%"> <sub>입력창에 <code>$0</code>을 입력하면 프리셋을 한 줄로 접고, 다시 입력하면 펼칩니다</sub> </td> <td width="50%"></td> </tr> </table>

입력창 맨 앞이나 공백 뒤에 $를 입력하고 번호와 스페이스를 쳐야 문구로 바뀌며, 바뀐 내용은 ctrl+_로 되돌릴 수 있습니다.

업데이트와 삭제

먼저 마켓플레이스 목록을 갱신합니다.

/plugin marketplace update oobg

그다음 /plugin 화면에서 설치한 모드를 업데이트하거나, 터미널에서 claude plugin update <모드>@oobg를 실행합니다. 업데이트한 모드는 Claude Code를 다시 시작하면 적용됩니다.

모드를 삭제하려면 다음 명령을 실행합니다.

/plugin uninstall next-prompts@oobg
/plugin uninstall quick-replies@oobg
/plugin uninstall dashboard@oobg
/plugin uninstall key-assist@oobg
/plugin uninstall split-diff@oobg

[!NOTE] key-assist를 삭제해도 키 설정에 추가된 cmd+z 항목은 남습니다. 필요하지 않으면 ~/.claude/keybindings.json에서 직접 지웁니다.

문제 해결

증상해결
설치 목록에는 있는데 모드가 표시되지 않습니다Claude Code를 다시 시작합니다
next-prompts 제안이 나오지 않습니다첫 턴이 끝나야 제안이 나옵니다. 답 본문이 120자 미만이면 제안하지 않습니다
Cmd+Z가 동작하지 않습니다Command 키 입력을 전달하는 터미널인지 확인합니다. 아니면 ctrl+_를 씁니다
Ctrl+Z를 눌렀더니 Claude Code가 멈췄습니다Ctrl+Z는 터미널에서 실행 중인 프로세스를 일시 중지합니다. fg를 입력하면 다시 실행됩니다

모드별 자세한 해결 방법은 각 상세 문서의 "문제 해결" 절에서 확인할 수 있습니다.

상세 문서

각 모드의 상세 문서는 문서 사이트에서도 볼 수 있습니다.

문서내용
next-prompts제안이 나오는 조건, 나타나는 방식, 모델 호출과 사용량
quick-replies프리셋 범위와 번호, 접기와 자동 제목, 편집 화면 키, 저장 데이터
dashboard항목 설명, 표시 형태와 색상, /usage 조회, 참고 사항
key-assist파일 붙여넣기 동작, Cmd+Z 키 설정, 제한 사항
split-diff좌우 표시 조건, 줄 맞추기, 단어 강조, 제한 사항

라이선스

Copyright (c) 2026 oobg

이 프로젝트는 MIT 라이선스에 따라 배포됩니다.

Source 4 files
hooks/register.tsx 427 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Segment, Snapshot, Style, Theme } from '../types'
5import { launchPermissionMode, lastPermissionMode, modeFromHint, pullFor, withSection } from './footer'
6import { GAPS, STYLES, THEME_NAMES, buildLayout, parseUsageLimits } from './lines'
7import type { PolledLimit } from './lines'
8
9// 바닥 틀 안의 순서. 다른 구역이 생기면 이 숫자로 위아래를 정한다.
10const ORDER = 20
11// 세션 시간, 요금 한도 같은 값이 메시지 없이도 바뀌므로 이 간격으로 다시 그린다.
12const REFRESH_MS = 30_000
13
14// 마지막으로 모은 세션 값. 모양은 그릴 때 화면 너비를 보고 고른다.
15const snapshot = atom({ plugin: 'dashboard', key: 'snapshot' } as const, null as Snapshot | null)
16const permissionMode = atom({ plugin: 'dashboard', key: 'permissionMode' } as const, null as string | null)
17// 모델 요청에 실린 effort. 모드가 effort를 따로 읽을 길이 없어서, 메인 대화의 요청이 나갈 때 받아 둔다. 첫 요청 전에는 null.
18const effort = atom({ plugin: 'dashboard', key: 'effort' } as const, null as string | null)
19// 대시보드 모양과 이번 주 Fable 사용량. 둘 다 저장해 두고 다음 세션에도 쓴다.
20// 세션 상태는 플러그인을 다시 불러와도 남는다. 0.4.0 전 값('style')이 남아 있어도 쓰지 않도록 이름을 바꿨다.
21const style = atom({ plugin: 'dashboard', key: 'layoutStyle' } as const, null as Style | null)
22const fableWeek = atom({ plugin: 'dashboard', key: 'fableWeek' } as const, null as number | null)
23const theme = atom({ plugin: 'dashboard', key: 'theme' } as const, null as Theme | null)
24
25// 0.4.0에서 기본 모양이 gauge로 바뀌었다. 그 전 버전에서 고른 모양('style')은 무시하고 새 키로 저장한다.
26const STYLE_KEY = 'style.v2'
27const FABLE_KEY = 'fableWeek'
28// /usage에서 읽은 한도들. 첫 응답 전에는 Claude Code가 5시간, 주간 한도를 모르므로 이 값으로 그린다.
29const POLLED_KEY = 'polledLimits'
30const polledLimits = atom({ plugin: 'dashboard', key: 'polledLimits' } as const, null as PolledLimit[] | null)
31const STYLE_COMMAND = 'dashboard-style'
32const THEME_KEY = 'theme'
33const THEME_COMMAND = 'dashboard-theme'
34// Fable 한도는 모드가 받는 한도 값에 없어서, 따로 띄운 Claude Code에 /usage를 물어 읽는다.
35// --safe-mode는 훅, 플러그인, MCP를 띄우지 않고, --no-session-persistence는 기록 파일을 남기지 않는다. 모델은 부르지 않는다.
36const USAGE_ARGS = ['-p', '/usage', '--safe-mode', '--no-session-persistence']
37const USAGE_EVERY_MS = 10 * 60_000
38const STYLE_NAMES: Record<Style, string> = {
39  gauge: '게이지',
40  calm: '차분한 두 줄',
41  emoji: '이모지 두 줄',
42  compact: '한 줄',
43}
44
45async function currentStyle($: EngineInterface): Promise<Style> {
46  const held = (await read($, style)) ?? ((await $.store.get(STYLE_KEY)) as Style | undefined)
47  return held !== undefined && held !== null && STYLES.includes(held) ? held : 'gauge'
48}
49
50async function currentTheme($: EngineInterface): Promise<Theme> {
51  const held = (await read($, theme)) ?? ((await $.store.get(THEME_KEY)) as Theme | undefined)
52  return held !== undefined && held !== null && THEME_NAMES.includes(held) ? held : 'everforest'
53}
54
55async function saveUsage($: EngineInterface, limits: PolledLimit[]): Promise<void> {
56  if (limits.length === 0) {
57    return
58  }
59  await update($, polledLimits, () => limits)
60  await $.store.set(POLLED_KEY, limits)
61  const fable = limits.find(l => l.kind === 'fable')
62  if (fable !== undefined) {
63    await update($, fableWeek, () => fable.percentUsed)
64    await $.store.set(FABLE_KEY, fable.percentUsed)
65  }
66}
67
68async function currentPolled($: EngineInterface): Promise<PolledLimit[]> {
69  return (await read($, polledLimits)) ?? (((await $.store.get(POLLED_KEY)) as PolledLimit[] | undefined) ?? [])
70}
71
72// 뒤에서 /usage를 돌려 Fable 사용량을 새로 읽는다. 실패하면 이전 값을 둔다.
73async function pollFable($: EngineInterface): Promise<void> {
74  // PATH에 claude가 없을 수 있어서, 기본 설치 경로도 차례로 본다.
75  const home = (await $.env.get('HOME')) ?? ''
76  for (const bin of ['claude', `${home}/.local/bin/claude`, '/opt/homebrew/bin/claude', '/usr/local/bin/claude']) {
77    try {
78      const { exitCode, stdout } = await $.process.run([bin, ...USAGE_ARGS], { timeoutMs: 30_000 })
79      if (exitCode === 0) {
80        await saveUsage($, parseUsageLimits(stdout, await $.clock.now()))
81        await refresh($)
82      }
83      return
84    } catch {
85      // 이 경로에 없으면 다음 경로를 본다.
86    }
87  }
88}
89
90async function currentFable($: EngineInterface): Promise<number | null> {
91  return (await read($, fableWeek)) ?? (((await $.store.get(FABLE_KEY)) as number | undefined) ?? null)
92}
93
94// 이 세션의 기록 파일 끝부분을 읽는다. 기록은 세션을 연 폴더 이름 아래 저장되는데, 현재 폴더는 세션 중에 바뀔 수 있다.
95// 프로젝트 루트, 현재 폴더 순서로 읽어 보고, 둘 다 없으면 기록 폴더 전체에서 세션 id로 찾는다.
96// 기록 파일은 금방 4MB(모드의 파일 읽기 한도)를 넘으므로, tail로 마지막 부분만 가져온다.
97async function readTranscript($: EngineInterface): Promise<{ path: string; text: string } | null> {
98  const home = (await $.env.get('HOME')) ?? ''
99  const id = await $.session.id()
100  const base = `${home}/.claude/projects`
101  const candidates = [await $.session.root(), await $.session.cwd()].map(
102    dir => `${base}/${dir.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`,
103  )
104  try {
105    for (const entry of await $.fs.list(base)) {
106      if (entry.kind === 'dir') {
107        candidates.push(`${base}/${entry.name}/${id}.jsonl`)
108      }
109    }
110  } catch {
111    // 목록을 못 읽으면 위 두 후보만 본다.
112  }
113  for (const path of [...new Set(candidates)]) {
114    try {
115      const { exitCode, stdout } = await $.process.run(['tail', '-c', '262144', path])
116      if (exitCode === 0) {
117        return { path, text: stdout }
118      }
119    } catch {
120      // 다음 후보를 본다.
121    }
122  }
123  return null
124}
125
126// Claude Code에 로그인한 계정의 이메일. ~/.claude.json에 들어 있고, API 키로 쓰면 없다.
127async function readEmail($: EngineInterface, home: string): Promise<string | null> {
128  try {
129    const config = JSON.parse(await $.fs.read(`${home}/.claude.json`)) as {
130      oauthAccount?: { emailAddress?: string }
131    }
132    return config.oauthAccount?.emailAddress ?? null
133  } catch {
134    return null
135  }
136}
137
138// 지금 폴더의 git 브랜치. git 저장소가 아니거나 브랜치가 없으면(분리된 HEAD) null.
139async function readBranch($: EngineInterface): Promise<string | null> {
140  try {
141    const { exitCode, stdout } = await $.process.run(['git', 'branch', '--show-current'])
142    return exitCode === 0 && stdout.trim() !== '' ? stdout.trim() : null
143  } catch {
144    return null
145  }
146}
147
148// 메시지 전송과 턴 마무리를 막지 않도록, 다시 그리기에서 난 오류는 여기서 삼킨다.
149async function refresh($: EngineInterface): Promise<void> {
150  try {
151    await render($)
152  } catch {
153    // 이전 화면을 그대로 둔다.
154  }
155}
156
157// 세션 값을 모아 두 줄을 다시 짠다.
158async function render($: EngineInterface): Promise<void> {
159  const home = (await $.env.get('HOME')) ?? ''
160  const [usage, model, root, now, branch, email, transcript] = await Promise.all([
161    $.session.usage(),
162    $.session.model(),
163    $.session.root(),
164    $.clock.now(),
165    readBranch($),
166    readEmail($, home),
167    readTranscript($),
168  ])
169  const mode = transcript === null ? null : lastPermissionMode(transcript.text)
170  const polled = await currentPolled($)
171  const polledFable = polled.find(p => p.kind === 'fable')
172  const taken: Snapshot = {
173    model,
174    effort: await read($, effort),
175    context: usage.context,
176    costUsd: usage.cost?.usd ?? null,
177    // 첫 응답 전에는 Claude Code가 한도를 모르므로, /usage에서 읽어 둔 값으로 빈 칸을 채운다.
178    rateLimits: [
179      ...usage.rateLimits,
180      ...polled.filter(p => p.kind !== 'fable' && !usage.rateLimits.some(r => r.kind === p.kind)),
181    ],
182    fableWeek: await currentFable($),
183    ...(polledFable?.resetsAt === undefined ? {} : { fableResetsAt: polledFable.resetsAt }),
184    folder: root.split('/').filter(Boolean).at(-1) ?? root,
185    branch,
186    email,
187    elapsedMs: now - usage.startedAt,
188    nowMs: now,
189  }
190  await update($, snapshot, () => taken)
191  // 새 세션은 기록 파일에 권한 모드가 아직 없다. 그때는 훅 이벤트로 받아 둔 값을 그대로 둔다.
192  if (mode !== null) {
193    await update($, permissionMode, () => mode)
194  }
195}
196
197// Claude Code의 기본 훅 이벤트에는 지금 권한 모드가 실려 온다. 새 세션은 기록 파일에 아직 권한 모드가 없어서,
198// 이 값으로 권한 모드 표시 폭만큼 당긴다.
199async function notePermissionMode($: EngineInterface, e: { permission_mode?: string }): Promise<void> {
200  const mode = e.permission_mode
201  if (typeof mode === 'string') {
202    // 훅 이벤트가 실어 온 값이 가장 새롭다. 안내 줄에서 읽어 둔 값은 버린다.
203    hintedMode = null
204    if (mode !== (await read($, permissionMode))) {
205      await update($, permissionMode, () => mode)
206    }
207  }
208}
209
210// Shift+Tab으로 바꾼 권한 모드. 그리기는 상태를 쓰지 못하므로 모듈 변수에 둔다.
211// 기록 파일은 다음 메시지 전까지 예전 모드를 담고 있으므로, 이 값이 있으면 기록에서 읽은 값보다 먼저 쓴다.
212let hintedMode: string | null = null
213
214// 모드가 띄운 프로세스의 부모를 따라 올라가 Claude Code의 실행 줄을 찾는다. 못 찾으면 빈 문자열.
215const LAUNCH_SCRIPT =
216  'p=$PPID; for i in 1 2 3 4 5 6; do a=$(ps -o args= -p "$p" 2>/dev/null); ' +
217  'case "$a" in claude|claude\\ *|*/claude|*/claude\\ *) echo "$a"; exit 0;; esac; ' +
218  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); [ -z "$p" ] && break; done'
219
220async function readLaunchMode($: EngineInterface): Promise<string | null> {
221  try {
222    const { exitCode, stdout } = await $.process.run(['sh', '-c', LAUNCH_SCRIPT], { timeoutMs: 5_000 })
223    return exitCode === 0 ? launchPermissionMode(stdout.trim()) : null
224  } catch {
225    return null
226  }
227}
228
229// 세션마다 한 번: 저장된 선택을 읽고, 명령을 등록하고, 타이머를 건다.
230// /reload-plugins는 세션 시작을 다시 알리지 않고 타이머도 지우므로, 다음 메시지나 턴 끝에서 다시 부른다.
231let started = false
232
233async function start($: EngineInterface): Promise<void> {
234  if (started) {
235    return
236  }
237  started = true
238  // 기록에도 훅 이벤트에도 권한 모드가 아직 없으면(새 세션, 다시 불러온 직후) 실행 인자로 처음 값을 정한다.
239  if ((await read($, permissionMode)) === null) {
240    const launched = (await readLaunchMode($)) ?? 'default'
241    if ((await read($, permissionMode)) === null) {
242      await update($, permissionMode, () => launched)
243    }
244  }
245  const [chosen, palette, fable] = [await currentStyle($), await currentTheme($), await currentFable($)]
246  await update($, style, () => chosen)
247  await update($, theme, () => palette)
248  await update($, fableWeek, () => fable)
249  await refresh($)
250  // 명령 등록이 막혀도 대시보드는 그린다.
251  await $.command
252    .register({
253      name: STYLE_COMMAND,
254      description: '입력창 아래 대시보드 모양을 바꿔요. 이름을 빼면 다음 모양으로 바뀌어요',
255      argumentHint: `[${STYLES.join('|')}]`,
256      immediate: true,
257    })
258    .catch(() => {})
259  await $.command
260    .register({
261      name: THEME_COMMAND,
262      description: '입력창 아래 대시보드 색을 바꿔요. 이름을 빼면 다음 색으로 바뀌어요',
263      argumentHint: `[${THEME_NAMES.join('|')}]`,
264      immediate: true,
265    })
266    .catch(() => {})
267  void pollFable($)
268  $.clock.every(USAGE_EVERY_MS, () => {
269    void pollFable($)
270  })
271  $.clock.every(REFRESH_MS, () => {
272    void refresh($)
273  })
274}
275
276export const register: Register = on => {
277  on('session.start', async ($, e, next) => {
278    const result = await next(e)
279    await start($)
280    return result
281  })
282  on('prompt.submit', async ($, e, next) => {
283    const result = await next(e)
284    await start($)
285    await refresh($)
286    return result
287  })
288  // 요청은 그대로 흘려보내고, 메인 대화의 effort만 받아 둔다.
289  on('turn.step', async function* ($, e, next) {
290    if (e.agentId === undefined && typeof e.effort === 'string' && e.effort !== (await read($, effort))) {
291      const level = e.effort
292      await update($, effort, () => level)
293      void refresh($)
294    }
295    return yield* next(e)
296  })
297  on('turn.complete', async ($, e, next) => {
298    const result = await next(e)
299    await start($)
300    await refresh($)
301    return result
302  })
303
304  // 받기만 하고 이벤트는 그대로 흘려보낸다.
305  on('classic.SessionStart', async ($, e, next) => {
306    await notePermissionMode($, e)
307    return next(e)
308  })
309  on('classic.UserPromptSubmit', async ($, e, next) => {
310    await notePermissionMode($, e)
311    return next(e)
312  })
313
314  // /dashboard-style [이름]: 모양을 바꾼다. 이름이 없으면 다음 모양으로 넘어간다.
315  on('command.run', { command: STYLE_COMMAND }, async ($, e) => {
316    const asked = e.args.trim()
317    const now = await currentStyle($)
318    const next = asked === '' ? (STYLES[(STYLES.indexOf(now) + 1) % STYLES.length] ?? 'gauge') : (asked as Style)
319    if (!STYLES.includes(next)) {
320      return { text: `없는 모양이에요. ${STYLES.join(', ')} 중에서 골라 주세요.` }
321    }
322    await update($, style, () => next)
323    await $.store.set(STYLE_KEY, next)
324    await refresh($)
325    return { text: `대시보드 모양을 ${STYLE_NAMES[next]}(${next})로 바꿨어요.` }
326  })
327
328  // /dashboard-theme [이름]: 색을 바꾼다. 이름이 없으면 다음 색으로 넘어간다.
329  on('command.run', { command: THEME_COMMAND }, async ($, e) => {
330    const asked = e.args.trim()
331    const now = await currentTheme($)
332    const next = asked === '' ? (THEME_NAMES[(THEME_NAMES.indexOf(now) + 1) % THEME_NAMES.length] ?? 'everforest') : (asked as Theme)
333    if (!THEME_NAMES.includes(next)) {
334      return { text: `없는 색이에요. ${THEME_NAMES.join(', ')} 중에서 골라 주세요.` }
335    }
336    await update($, theme, () => next)
337    await $.store.set(THEME_KEY, next)
338    await refresh($)
339    return { text: `대시보드 색을 ${next}로 바꿨어요.` }
340  })
341
342  // 사람이 /usage를 실행하면 그 출력에서 이번 주 Fable 사용량을 읽어 둔다. 모드가 /usage를 직접 부르면 대화에 출력이 남아서 부르지 않는다.
343  on('command.run', { command: 'usage' }, async ($, e, next) => {
344    const result = await next(e)
345    const found = parseUsageLimits(result.text ?? '', await $.clock.now())
346    if (found.length > 0) {
347      await saveUsage($, found)
348      void refresh($)
349    }
350    return result
351  })
352
353  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
354    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
355      return next(e)
356    }
357    const below = await next(e)
358    const taken = await read($, snapshot)
359    hintedMode = modeFromHint(e.props.hint) ?? hintedMode
360    const pull = pullFor(hintedMode ?? (await read($, permissionMode)))
361    if (taken === null) {
362      return withSection(below, e.props.hint, 'dashboard', ORDER, null, pull)
363    }
364    // 넓으면 게이지 모양, 좁으면 이름표 위 값 아래 표로 바꾼다. 너비를 모르면 넓다고 본다.
365    const width = e.viewport?.columns
366    const layout = buildLayout(taken, await currentStyle($), await currentTheme($), width === undefined ? undefined : width - 2)
367
368    const { Box, Text } = $.ui.resolve(e)
369    // 색이 지정되지 않은 글자는 원래 상태줄처럼 흐리게(회색) 그린다. 그대로 두면 새하얗게 보인다.
370    const line = (row: Segment[], key: string) => (
371      <Text key={key} wrap="truncate-end">
372        {row.map(seg => (
373          <Text color={seg.color} bold={seg.bold} dimColor={seg.dim === true || seg.color === undefined}>
374            {seg.text}
375          </Text>
376        ))}
377      </Text>
378    )
379    const section =
380      layout.kind === 'rows' ? (
381        <Box flexDirection="row" columnGap={1}>
382          {layout.spine !== undefined && (
383            <Box key="spine" flexDirection="column" flexShrink={0}>
384              {layout.rows.map((_, i) => (
385                <Text key={`spine:${i}`} color={layout.spine}>
386                  ▌
387                </Text>
388              ))}
389            </Box>
390          )}
391          <Box key="rows" flexDirection="column">
392            {layout.rows.map((row, i) => line(row, `row:${i}`))}
393          </Box>
394        </Box>
395      ) : (
396        <Box flexDirection="row" justifyContent="space-between" columnGap={GAPS.left}>
397          {/* 맨 앞 세로줄과 왼쪽 구역을 한 묶음으로 둔다. 세로줄 색은 컨텍스트가 차면 경고 색을 따른다. */}
398          <Box key="lead" flexDirection="row" columnGap={1} flexShrink={0}>
399            {layout.spine !== undefined && (
400              <Box key="spine" flexDirection="column">
401                {(layout.left.length > 0 ? layout.left : (layout.columns[0] ?? [])).map((_, i) => (
402                  <Text key={`spine:${i}`} color={layout.spine}>
403                    ▌
404                  </Text>
405                ))}
406              </Box>
407            )}
408            {layout.left.length > 0 && (
409              <Box key="left" flexDirection="column">
410                {layout.left.map((row, i) => line(row, `left:${i}`))}
411              </Box>
412            )}
413          </Box>
414          <Box key="columns" flexDirection="row" columnGap={GAPS.column} flexShrink={0}>
415            {layout.columns.map((col, i) => (
416              <Box key={`col:${i}`} flexDirection="column">
417                {col.map((cell, j) => line(cell, `cell:${i}:${j}`))}
418              </Box>
419            ))}
420          </Box>
421        </Box>
422      )
423
424    return withSection(below, e.props.hint, 'dashboard', ORDER, section, pull)
425  })
426}
427
hooks/footer.ts 114 lines
1// 입력창 아래 안내 줄(PromptHint)을 여러 모드가 나눠 쓰는 약속.
2// 지금은 dashboard만 쓰지만, 다른 모드도 같은 틀에 구역을 끼워 넣을 수 있게 남겨 둔다.
3//
4// 안내 줄 자리에는 한 트리만 그려지므로, 각 모드는 아래 모양의 "바닥 틀"을 주고받는다.
5//   Box(key=oobg-footer, column)
6//     Text(안내 문구)                       ← 권한 모드 표시 바로 뒤에 붙는 첫 줄
7//     Box(key=oobg-footer:<순서>:<이름>)    ← 각 모드의 구역, 순서대로
8// 모드는 next(e)로 받은 틀에 자기 구역만 끼워 넣어 돌려준다. 틀이 아니면 새로 만든다.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const FOOTER_KEY = 'oobg-footer'
12const SECTION_PREFIX = `${FOOTER_KEY}:`
13
14// 권한 모드마다 안내 줄 맨 앞에 엔진이 그리는 표시("▸▸ … on · ")의 폭.
15// 둘째 줄부터는 이만큼 왼쪽으로 당겨야 화면 왼쪽 끝에 맞는다. 모르는 모드는 당기지 않는다.
16const PILL_WIDTH: Record<string, number> = {
17  default: 19, // ⏸ manual mode on ·
18  acceptEdits: 21, // ▸▸ accept edits on ·
19  plan: 17, // ⏸ plan mode on ·
20  bypassPermissions: 27, // ▸▸ bypass permissions on ·
21  auto: 18, // ▸▸ auto mode on ·
22}
23
24// 지금 권한 모드는 세션 기록 파일의 마지막 "permissionMode"에서 찾는다. 기록 파일 내용을 받아 그 값을 고른다.
25// ($를 쓰는 파일 읽기는 각 모드의 register 안에 있다. $는 다른 파일로 넘길 수 없다.)
26export function lastPermissionMode(transcript: string): string | null {
27  const found = [...transcript.matchAll(/"permissionMode":"([^"]+)"/g)]
28  return found.at(-1)?.[1] ?? null
29}
30
31// Claude Code 실행 줄에서 처음 권한 모드를 읽는다. 플래그가 없으면 null(설정의 기본값을 따른다).
32export function launchPermissionMode(args: string): string | null {
33  if (/(^|\s)--dangerously-skip-permissions(\s|$)/.test(args)) {
34    return 'bypassPermissions'
35  }
36  const found = /(^|\s)--permission-mode[ =](\S+)/.exec(args)?.[2]
37  return found ?? null
38}
39
40// Shift+Tab으로 권한 모드를 바꾸면 엔진은 안내 줄을 새 모드 이름(`plan mode on (shift+tab to cycle)`)으로 한 번 그린 뒤
41// 이름 없이 다시 그린다. 모드가 바뀌었다는 이벤트는 따로 없어서, 이 이름을 보고 지금 모드를 안다. 이름이 없으면 null.
42const HINT_LABELS: Record<string, string> = {
43  'manual mode on': 'default',
44  'accept edits on': 'acceptEdits',
45  'plan mode on': 'plan',
46  'bypass permissions on': 'bypassPermissions',
47  'auto mode on': 'auto',
48}
49
50export function modeFromHint(hint: string): string | null {
51  const label = Object.keys(HINT_LABELS).find(name => hint.startsWith(name))
52  return label === undefined ? null : (HINT_LABELS[label] ?? null)
53}
54
55export function pullFor(permissionMode: string | null): number {
56  return permissionMode === null ? 0 : (PILL_WIDTH[permissionMode] ?? 0)
57}
58
59function isElement(node: RenderNode | undefined | null): node is RenderElement {
60  return typeof node === 'object' && node !== null && 'type' in node
61}
62
63function keyOf(node: RenderNode): string | undefined {
64  if (!isElement(node) || node.type !== 'Box') {
65    return undefined
66  }
67  const key = node.props?.key
68  return typeof key === 'string' ? key : undefined
69}
70
71function orderOf(node: RenderNode): number {
72  return Number(keyOf(node)?.split(':')[1] ?? 0)
73}
74
75// `below`(next(e)가 돌려준 트리)에 이 모드의 구역을 넣은 바닥 틀을 만든다.
76// `section`이 null이면 이 모드의 구역을 뺀다(예: 편집 중이라 버튼을 숨길 때).
77export function withSection(
78  below: RenderNode | null | undefined,
79  hint: string,
80  name: string,
81  order: number,
82  section: RenderNode | null,
83  pull: number,
84): RenderElement {
85  const ownKey = `${SECTION_PREFIX}${order}:${name}`
86  const others =
87    isElement(below) && below.type === 'Box' && below.props?.key === FOOTER_KEY
88      ? (below.children ?? []).filter(child => {
89          const key = keyOf(child)
90          return key !== undefined && key.startsWith(SECTION_PREFIX) && key !== ownKey
91        })
92      : []
93
94  const mine: RenderNode[] =
95    section === null
96      ? []
97      : [{ type: 'Box', props: { key: ownKey, flexDirection: 'column' }, children: [section] }]
98
99  // 구역마다 모드가 다르게 당기지 않도록, 끼워 넣을 때마다 모든 구역의 당김을 지금 값으로 맞춘다.
100  const sections = [...others, ...mine]
101    .sort((a, b) => orderOf(a) - orderOf(b))
102    .map(child =>
103      isElement(child) && child.type === 'Box'
104        ? { ...child, props: { ...child.props, marginLeft: -pull } }
105        : child,
106    )
107
108  return {
109    type: 'Box',
110    props: { key: FOOTER_KEY, flexDirection: 'column' },
111    children: [{ type: 'Text', props: { dimColor: true }, children: [hint] }, ...sections],
112  }
113}
114
hooks/lines.ts 451 lines
1// 세션 값으로 대시보드 줄을 짜는 부분. $를 쓰지 않는 순수 함수만 둔다.
2// 화면은 이 모드가 정한 모양이다. 다른 상태줄 도구의 코드를 옮기지 않았다.
3import type { Layout, Segment, Snapshot, Style, Theme } from '../types'
4
5export type { Snapshot }
6
7export const STYLES: readonly Style[] = ['gauge', 'calm', 'emoji', 'compact']
8
9const BAR_CELLS = 10
10const SEPARATOR: Segment = { text: ' │ ', dim: true }
11
12// 색은 칸마다 따로 주지 않고 역할로 묶는다. 어두운 배경에서는 색 수를 줄이고 채도를 낮춰야 눈이 편하고,
13// 상태 색도 같은 밝기로 맞춰야 한 칸만 튀지 않는다. 테마마다 널리 쓰이는 터미널 색 구성표의 값을 가져왔다.
14//   accent: 모델 이름   primary: 폴더   secondary: 브랜치
15//   text: 숫자(토큰, 요금, 시간, 분당 토큰)   muted: 이름표(한도 이름, 이메일)
16//   ok / warn / orange / high: 컨텍스트와 한도가 찬 정도(40%까지 여유, 55% 노랑, 72% 주황, 90% 빨강으로 섞어 바뀐다)
17export type Palette = {
18  accent: string
19  primary: string
20  secondary: string
21  text: string
22  muted: string
23  ok: string
24  warn: string
25  orange: string
26  high: string
27}
28
29export const THEMES: Record<Theme, Palette> = {
30  // Everforest: 숲 색. 초록과 베이지 위주로 따뜻하다.
31  everforest: {
32    accent: '#a7c080',
33    primary: '#7fbbb3',
34    secondary: '#d699b6',
35    text: '#d3c6aa',
36    muted: '#859289',
37    ok: '#a7c080',
38    warn: '#dbbc7f',
39    orange: '#e69875',
40    high: '#e67e80',
41  },
42  // Nord: 차가운 푸른 회색 계열. 대비가 낮고 색이 적다.
43  nord: {
44    accent: '#88c0d0',
45    primary: '#81a1c1',
46    secondary: '#b48ead',
47    text: '#d8dee9',
48    muted: '#8691a8',
49    ok: '#a3be8c',
50    warn: '#ebcb8b',
51    orange: '#d08770',
52    high: '#bf616a',
53  },
54  // Rosé Pine Moon: 보라와 장밋빛이 섞인 따뜻한 어둠.
55  'rose-pine': {
56    accent: '#c4a7e7',
57    primary: '#9ccfd8',
58    secondary: '#ea9a97',
59    text: '#e0def4',
60    muted: '#908caa',
61    ok: '#3e8fb0',
62    warn: '#f6c177',
63    orange: '#ea9a97',
64    high: '#eb6f92',
65  },
66  // Kanagawa: 일본 판화 색을 딴 차분한 남색과 황토.
67  kanagawa: {
68    accent: '#7e9cd8',
69    primary: '#7fb4ca',
70    secondary: '#957fb8',
71    text: '#dcd7ba',
72    muted: '#727169',
73    ok: '#98bb6c',
74    warn: '#e6c384',
75    orange: '#ffa066',
76    high: '#e46876',
77  },
78}
79
80export const THEME_NAMES = Object.keys(THEMES) as Theme[]
81
82// effort 단계를 한 글자로 줄인다. 모르는 값(숫자 예산 등)은 그대로 보인다.
83const EFFORT_MARK: Record<string, string> = { low: 'L', medium: 'M', high: 'H', xhigh: 'X', max: 'Max' }
84
85// "claude-opus-5-5"에서 모델 계열 이름("Opus")만 꺼낸다.
86export function family(model: string): string {
87  const name = /^claude-([a-z]+)/.exec(model)?.[1]
88  return name === undefined ? model : name.replace(/^./, c => c.toUpperCase())
89}
90
91// 토큰 수를 409K, 1.0M처럼 줄인다.
92export function compactTokens(n: number): string {
93  if (n >= 1_000_000) {
94    return `${(n / 1_000_000).toFixed(1)}M`
95  }
96  if (n >= 1_000) {
97    return n >= 10_000 ? `${Math.round(n / 1_000)}K` : `${(n / 1_000).toFixed(1)}K`
98  }
99  return String(Math.round(n))
100}
101
102export function duration(ms: number): string {
103  const minutes = Math.max(0, Math.floor(ms / 60_000))
104  const hours = Math.floor(minutes / 60)
105  return hours === 0 ? `${minutes}분` : `${hours}시간 ${minutes % 60}분`
106}
107
108// 찬 정도의 색. 여유 색으로 있다가 노랑, 주황, 빨강으로 부드럽게 넘어간다(칸마다 색을 섞어 그라데이션을 만든다).
109const LEVEL_STOPS: readonly [number, keyof Palette][] = [
110  [0, 'ok'],
111  [40, 'ok'],
112  [55, 'warn'],
113  [72, 'orange'],
114  [90, 'high'],
115]
116
117function mix(from: string, to: string, t: number): string {
118  const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
119  const [a, b] = [rgb(from), rgb(to)]
120  return `#${a.map((v, i) => Math.round(v + ((b[i] ?? v) - v) * t).toString(16).padStart(2, '0')).join('')}`
121}
122
123export function levelColor(c: Palette, percent: number): string {
124  const p = Math.min(100, Math.max(0, percent))
125  for (let i = 1; i < LEVEL_STOPS.length; i++) {
126    const [hiAt, hiKey] = LEVEL_STOPS[i] as [number, keyof Palette]
127    const [loAt, loKey] = LEVEL_STOPS[i - 1] as [number, keyof Palette]
128    if (p <= hiAt) {
129      return mix(c[loKey], c[hiKey], hiAt === loAt ? 1 : (p - loAt) / (hiAt - loAt))
130    }
131  }
132  return c.high
133}
134
135const filledCells = (percent: number) => Math.min(BAR_CELLS, Math.max(0, Math.round(percent / (100 / BAR_CELLS))))
136// i번째 칸이 나타내는 위치(%). 칸의 오른쪽 끝을 쓴다.
137const cellAt = (i: number) => ((i + 1) / BAR_CELLS) * 100
138
139function joined(parts: Segment[][]): Segment[] {
140  return parts.flatMap((part, i) => (i === 0 ? part : [SEPARATOR, ...part]))
141}
142
143// `/usage`에서 "Current week (Fable): 12% used" 줄의 숫자를 꺼낸다. 없으면 null.
144export function parseFableWeek(usageText: string): number | null {
145  const found = /Current week \(Fable\):\s*([\d.]+)%/.exec(usageText)?.[1]
146  return found === undefined ? null : Number(found)
147}
148
149export type PolledLimit = { kind: 'five_hour' | 'seven_day' | 'fable'; percentUsed: number; resetsAt?: string }
150
151const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
152
153// "Oct 14 at 4:59pm", "Oct 17 at 6pm"을 이 컴퓨터 시간대의 시각으로 읽는다. 지난 날짜면 내년으로 본다.
154export function parseResetTime(text: string, nowMs: number): string | undefined {
155  const m = /([A-Za-z]{3})\w*\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i.exec(text)
156  if (m === null) {
157    return undefined
158  }
159  const month = MONTHS.indexOf((m[1] ?? '').toLowerCase())
160  if (month < 0) {
161    return undefined
162  }
163  let hour = Number(m[3]) % 12
164  if ((m[5] ?? '').toLowerCase() === 'pm') {
165    hour += 12
166  }
167  const year = new Date(nowMs).getFullYear()
168  const at = (y: number) => new Date(y, month, Number(m[2]), hour, Number(m[4] ?? 0)).getTime()
169  const time = at(year) < nowMs - 24 * 60 * 60_000 ? at(year + 1) : at(year)
170  return new Date(time).toISOString()
171}
172
173// /usage 출력에서 5시간(Current session), 주간(Current week (all models)), Fable 한도를 읽는다.
174// 첫 응답이 오기 전에는 Claude Code가 한도 값을 갖고 있지 않아서, 그동안 이 값을 쓴다.
175export function parseUsageLimits(usageText: string, nowMs: number): PolledLimit[] {
176  const rows: [PolledLimit['kind'], RegExp][] = [
177    ['five_hour', /Current session:\s*([\d.]+)% used(?:\s*·\s*resets\s+([^\n(]+))?/],
178    ['seven_day', /Current week \(all models\):\s*([\d.]+)% used(?:\s*·\s*resets\s+([^\n(]+))?/],
179    ['fable', /Current week \(Fable\):\s*([\d.]+)% used(?:\s*·\s*resets\s+([^\n(]+))?/],
180  ]
181  const out: PolledLimit[] = []
182  for (const [kind, re] of rows) {
183    const m = re.exec(usageText)
184    if (m !== null) {
185      const resetsAt = m[2] === undefined ? undefined : parseResetTime(m[2], nowMs)
186      out.push({ kind, percentUsed: Number(m[1]), ...(resetsAt === undefined ? {} : { resetsAt }) })
187    }
188  }
189  return out
190}
191
192type Parts = {
193  model: Segment[]
194  bar: Segment[] | null
195  percent: Segment[] | null
196  tokens: Segment[] | null
197  cost: Segment[] | null
198  limits: Segment[][]
199  folder: Segment[]
200  email: Segment[] | null
201  time: Segment[]
202  rate: Segment[] | null
203}
204
205// 칸마다 조각을 만든다. `icons`가 켜지면 칸 앞에 이모지를 붙인다.
206function parts(s: Snapshot, icons: boolean, c: Palette): Parts {
207  const icon = (emoji: string) => (icons ? `${emoji} ` : '')
208  const mark = s.effort === null ? '' : `(${EFFORT_MARK[s.effort] ?? s.effort})`
209  const percent =
210    s.context.percent ?? (s.context.tokens === undefined ? undefined : (s.context.tokens / s.context.window) * 100)
211  const level = (value: number) => ({ color: levelColor(c, value) })
212
213  const limits: Segment[][] = []
214  // 한도 이름은 이름표 색, 비율은 찬 정도에 따른 색.
215  const limit = (label: string, value: number) => [
216    { text: `${label} `, color: c.muted },
217    { text: `${Math.round(value)}%`, ...level(value) },
218  ]
219  const five = s.rateLimits.find(r => r.kind === 'five_hour')
220  const week = s.rateLimits.find(r => r.kind === 'seven_day')
221  if (five !== undefined) {
222    limits.push(limit(`${icon('⏳')}5시간`, five.percentUsed))
223  }
224  if (week !== undefined) {
225    limits.push(limit(`${icon('📅')}7일`, week.percentUsed))
226  }
227  if (s.fableWeek !== null) {
228    limits.push(limit(`${icon('🪶')}Fable`, s.fableWeek))
229  }
230
231  const minutes = s.elapsedMs / 60_000
232  return {
233    model: [{ text: `${icons ? '🧠 ' : '◆ '}${family(s.model)}${mark}`, color: c.accent, bold: true }],
234    bar:
235      percent === undefined
236        ? null
237        : (() => {
238            // 칸마다 그 위치의 색을 칠해, 차오를수록 끝쪽이 노랑, 주황, 빨강으로 물든다.
239            const cells: Segment[] = Array.from({ length: filledCells(percent) }, (_, i) => ({
240              text: '█',
241              ...level(cellAt(i)),
242            }))
243            return [...cells, { text: '░'.repeat(BAR_CELLS - cells.length), dim: true }]
244          })(),
245    // 비율 숫자는 막대 마지막 칸과 같은 색이다.
246    percent:
247      percent === undefined
248        ? null
249        : [{ text: `${Math.round(percent)}%`, ...level(filledCells(percent) === 0 ? 0 : cellAt(filledCells(percent) - 1)) }],
250    tokens:
251      s.context.tokens === undefined
252        ? null
253        : [{ text: `${icon('💬')}${compactTokens(s.context.tokens)}/${compactTokens(s.context.window)}`, color: c.text }],
254    cost: s.costUsd === null ? null : [{ text: `${icon('💰')}$${s.costUsd.toFixed(2)}`, color: c.text }],
255    limits,
256    folder: [
257      { text: `📁 ${s.folder}`, color: c.primary },
258      ...(s.branch === null ? [] : [{ text: icons ? ` 🌿 ${s.branch}` : ` (${s.branch})`, color: c.secondary }]),
259    ],
260    email: s.email === null ? null : [{ text: `🔑 ${s.email}`, color: c.muted }],
261    time: [{ text: `⏱ ${duration(s.elapsedMs)}`, color: c.text }],
262    // 분당 토큰: 지금 컨텍스트에 쌓인 토큰을 세션 시간(분)으로 나눈 값. 첫 1분은 계산하지 않는다.
263    rate:
264      s.context.tokens === undefined || minutes < 1
265        ? null
266        : [{ text: `🔥 ${compactTokens(s.context.tokens / minutes)}/min`, color: c.text }],
267  }
268}
269
270const present = (list: (Segment[] | null)[]): Segment[][] => list.filter((p): p is Segment[] => p !== null)
271
272export function buildLines(s: Snapshot, style: Style = 'calm', theme: Theme = 'everforest'): Segment[][] {
273  const c = THEMES[theme]
274  const p = parts(s, style === 'emoji', c)
275  if (style === 'compact') {
276    // 한 줄: 모델, 비율, 요금, 한도, 브랜치만.
277    const branch = s.branch === null ? null : [{ text: `🌿 ${s.branch}`, color: c.secondary }]
278    return [joined(present([p.model, p.percent, p.cost, ...p.limits, branch]))]
279  }
280  const bar = p.bar === null ? null : [...p.bar, ...(p.percent === null ? [] : [{ text: ' ' }, ...p.percent])]
281  return [
282    joined(present([p.model, bar, p.tokens, p.cost, ...p.limits])),
283    joined(present([p.folder, p.email, p.time, p.rate])),
284  ]
285}
286
287// 한도가 다시 차기까지 남은 시간. 게이지 이름표(5h, 7d)와 단위를 맞추고 단위 사이를 /로 나눠 "2h/13m", "3d/4h"처럼 쓴다.
288export function resetLabel(resetsAt: string | undefined, nowMs: number): string | null {
289  const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
290  if (Number.isNaN(at)) {
291    return null
292  }
293  const minutes = Math.max(0, Math.ceil((at - nowMs) / 60_000))
294  if (minutes < 60) {
295    return `${minutes}m`
296  }
297  const hours = Math.floor(minutes / 60)
298  if (hours < 24) {
299    return `${hours}h/${minutes % 60}m`
300  }
301  return `${Math.floor(hours / 24)}d/${hours % 24}h`
302}
303
304// 게이지 한 칸: "5h ▰▱▱▱▱  6%". 막대는 칸마다 색을 섞고, 숫자는 마지막 칸 색을 따른다.
305function gauge(c: Palette, label: string, percent: number, cells: number): Segment[] {
306  const filled = Math.min(cells, Math.max(0, Math.round(percent / (100 / cells))))
307  const at = (i: number) => ((i + 1) / cells) * 100
308  return [
309    { text: `${label} `, color: c.text },
310    ...Array.from({ length: filled }, (_, i) => ({ text: '▰', color: levelColor(c, at(i)) })),
311    { text: '▱'.repeat(cells - filled), dim: true },
312    { text: ` ${String(Math.round(percent)).padStart(2)}%`, color: levelColor(c, filled === 0 ? 0 : at(filled - 1)) },
313  ]
314}
315
316// gauge 모양: 윗줄은 게이지만, 각 게이지 옆에 흐린 글자로 딸린 정보(토큰, 다시 차기까지 남은 시간)를 붙인다.
317// 아랫줄은 무엇으로 어디서 일하는지. 맨 앞 세로줄이 두 줄을 묶는다.
318//   ▌ ctx ▰▰▰▰▱▱▱▱▱▱ 41% (409K/1.0M)   5h ▰▱▱▱▱  6% (2h/13m)   7d ▰▱▱▱▱ 26% (3d/4h)   fable ▰▱▱▱▱ 12% (3d/4h)
319//   ▌ 🧠 Opus xhigh · 📁 claude-code-mods ⎇ main · 🔑 you@example.com
320function gaugeLayout(s: Snapshot, c: Palette): Layout {
321  const gap: Segment = { text: '   ' }
322  const note = (text: string | null): Segment[] => (text === null ? [] : [{ text: ` ${text}`, dim: true }])
323  // 쓴 토큰과 다시 차기까지 남은 시간은 괄호로 감싸 비율 숫자와 구분한다.
324  const left = (text: string | null) => note(text === null ? null : `(${text})`)
325  const gauges: Segment[][] = []
326  const percent =
327    s.context.percent ?? (s.context.tokens === undefined ? undefined : (s.context.tokens / s.context.window) * 100)
328  if (percent !== undefined) {
329    const tokens =
330      s.context.tokens === undefined ? null : `${compactTokens(s.context.tokens)}/${compactTokens(s.context.window)}`
331    gauges.push([...gauge(c, 'ctx', percent, BAR_CELLS), ...left(tokens)])
332  }
333  const now = s.nowMs ?? Date.now()
334  const five = s.rateLimits.find(r => r.kind === 'five_hour')
335  const week = s.rateLimits.find(r => r.kind === 'seven_day')
336  if (five !== undefined) {
337    gauges.push([...gauge(c, '5h', five.percentUsed, 5), ...left(resetLabel(five.resetsAt, now))])
338  }
339  if (week !== undefined) {
340    gauges.push([...gauge(c, '7d', week.percentUsed, 5), ...left(resetLabel(week.resetsAt, now))])
341  }
342  if (s.fableWeek !== null) {
343    // Fable 주간 한도는 전체 주간 한도와 같은 때 다시 찬다(/usage에서 확인).
344    gauges.push([...gauge(c, 'fable', s.fableWeek, 5), ...left(resetLabel(s.fableResetsAt ?? week?.resetsAt, now))])
345  }
346
347  const dot: Segment = { text: ' · ', dim: true }
348  const place: Segment[][] = [
349    [
350      { text: `🧠 ${family(s.model)}`, color: c.accent, bold: true },
351      ...(s.effort === null ? [] : [{ text: ` ${s.effort}`, dim: true }]),
352    ],
353    [
354      { text: `📁 ${s.folder}`, color: c.primary },
355      ...(s.branch === null ? [] : [{ text: ` ⎇ ${s.branch}`, color: c.secondary }]),
356    ],
357    ...(s.email === null ? [] : [[{ text: `🔑 ${s.email}`, color: c.muted }]]),
358  ]
359
360  // 맨 앞 세로줄: 평소에는 모델 색, 컨텍스트가 50%를 넘으면 게이지와 같은 그라데이션 색.
361  const spine = percent !== undefined && percent >= 50 ? levelColor(c, percent) : c.accent
362  const rows = [
363    gauges.flatMap((g, i) => (i === 0 ? g : [gap, ...g])),
364    place.flatMap((p, i) => (i === 0 ? p : [dot, ...p])),
365  ].filter(row => row.length > 0)
366  return { kind: 'rows', rows, spine }
367}
368
369// 맨 앞 세로줄과 그 뒤 한 칸이 차지하는 폭.
370export const SPINE_WIDTH = 2
371
372// 터미널에서 차지하는 칸 수. 한글과 이모지는 두 칸이다.
373export function cellWidth(text: string): number {
374  let width = 0
375  for (const ch of text) {
376    const cp = ch.codePointAt(0) ?? 0
377    if (cp === 0xfe0f || cp === 0x200d) {
378      continue
379    }
380    const wide =
381      (cp >= 0x1100 && cp <= 0x115f) ||
382      (cp >= 0x2e80 && cp <= 0xa4cf) ||
383      (cp >= 0xac00 && cp <= 0xd7a3) ||
384      (cp >= 0xf900 && cp <= 0xfaff) ||
385      (cp >= 0xff00 && cp <= 0xff60) ||
386      (cp >= 0x23e9 && cp <= 0x23fa) ||
387      (cp >= 0x1f300 && cp <= 0x1faff)
388    width += wide ? 2 : 1
389  }
390  return width
391}
392
393const rowWidth = (row: Segment[]) => row.reduce((sum, seg) => sum + cellWidth(seg.text), 0)
394const LEFT_GAP = 4
395const COLUMN_GAP = 3
396
397// 레이아웃이 차지하는 폭. 그리는 쪽(register)도 같은 간격을 쓴다.
398export function layoutWidth(layout: Layout): number {
399  const spine = layout.spine === undefined ? 0 : SPINE_WIDTH
400  if (layout.kind === 'rows') {
401    return spine + Math.max(0, ...layout.rows.map(rowWidth))
402  }
403  const left = Math.max(0, ...layout.left.map(rowWidth))
404  const columns = layout.columns.map(col => Math.max(0, ...col.map(rowWidth)))
405  const right = columns.reduce((a, b) => a + b, 0) + COLUMN_GAP * Math.max(0, columns.length - 1)
406  return spine + (left > 0 ? left + LEFT_GAP : 0) + right
407}
408
409export const GAPS = { left: LEFT_GAP, column: COLUMN_GAP }
410
411// 좁은 화면용: 이름표 줄 위, 값 줄 아래. 모델부터 브랜치까지 열을 맞춘다.
412//   MODEL          CONTEXT          5H   7D   FABLE  BRANCH
413//   🧠 Opus xhigh  ▰▰▰▱▱▱▱▱ 41%     6%   26%  12%    ⎇ main
414function tilesLayout(s: Snapshot, c: Palette): Layout {
415  const head = (text: string): Segment[] => [{ text, color: c.muted }]
416  const pct = (value: number): Segment[] => [{ text: `${Math.round(value)}%`, color: levelColor(c, value) }]
417  const columns: Segment[][][] = [
418    [head('MODEL'), [{ text: `🧠 ${family(s.model)}`, color: c.accent, bold: true }, ...(s.effort === null ? [] : [{ text: ` ${s.effort}`, dim: true }])]],
419  ]
420  const percent =
421    s.context.percent ?? (s.context.tokens === undefined ? undefined : (s.context.tokens / s.context.window) * 100)
422  if (percent !== undefined) {
423    columns.push([head('CONTEXT'), gauge(c, '', percent, 8).slice(1)])
424  }
425  const five = s.rateLimits.find(r => r.kind === 'five_hour')
426  const week = s.rateLimits.find(r => r.kind === 'seven_day')
427  if (five !== undefined) {
428    columns.push([head('5H'), pct(five.percentUsed)])
429  }
430  if (week !== undefined) {
431    columns.push([head('7D'), pct(week.percentUsed)])
432  }
433  if (s.fableWeek !== null) {
434    columns.push([head('FABLE'), pct(s.fableWeek)])
435  }
436  if (s.branch !== null) {
437    columns.push([head('BRANCH'), [{ text: `⎇ ${s.branch}`, color: c.secondary }]])
438  }
439  const spine = percent !== undefined && percent >= 50 ? levelColor(c, percent) : c.accent
440  return { kind: 'split', left: [], columns, spine }
441}
442
443// `columns`(그릴 수 있는 칸 수)가 주어지면, gauge 모양이 들어가지 않을 때 표 모양으로 바꾼다.
444export function buildLayout(s: Snapshot, style: Style = 'gauge', theme: Theme = 'everforest', columns?: number): Layout {
445  if (style !== 'gauge') {
446    return { kind: 'rows', rows: buildLines(s, style, theme) }
447  }
448  const wide = gaugeLayout(s, THEMES[theme])
449  return columns !== undefined && layoutWidth(wide) > columns ? tilesLayout(s, THEMES[theme]) : wide
450}
451
types/index.d.ts 53 lines
1/** 대시보드 한 줄을 색이 같은 조각으로 나눈 것. `color`는 테마 이름이고, 없으면 흐리게 그린다. */
2export type Segment = { text: string; color?: string; bold?: boolean; dim?: boolean }
3
4/**
5 * 대시보드 모양: 왼쪽 구역과 게이지 열을 나눈 `gauge`(기본), 차분한 두 줄 `calm`,
6 * 칸마다 이모지를 붙인 `emoji`, 한 줄로 줄인 `compact`.
7 */
8export type Style = 'gauge' | 'calm' | 'emoji' | 'compact'
9
10/**
11 * 그릴 모양. `rows`는 줄 목록, `split`은 왼쪽 줄들과 오른쪽 열들(열마다 위아래 두 칸)이다.
12 * `spine`이 있으면 맨 앞에 그 색의 세로줄을 세워 두 줄을 한 덩어리로 묶는다.
13 */
14export type Layout =
15  | { kind: 'rows'; rows: Segment[][]; spine?: string }
16  | { kind: 'split'; left: Segment[][]; columns: Segment[][][]; spine?: string }
17
18/** 대시보드 색 구성: 널리 쓰이는 터미널 색 구성표에서 가져온 네 가지. */
19export type Theme = 'nord' | 'rose-pine' | 'kanagawa' | 'everforest'
20
21/** 대시보드를 그리는 데 쓰는 세션 값. 모양은 그릴 때 화면 너비를 보고 고른다. */
22export type Snapshot = {
23  model: string
24  effort: string | null
25  context: { tokens?: number; window: number; percent?: number }
26  costUsd: number | null
27  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
28  /** 이번 주 Fable 한도를 쓴 정도. /usage에서 읽어 둔 값이고, 아직 없으면 null. */
29  fableWeek: number | null
30  /** Fable 주간 한도가 다시 차는 시각. /usage에서 읽었고, 없으면 주간 한도와 같은 때로 본다. */
31  fableResetsAt?: string
32  folder: string
33  branch: string | null
34  email: string | null
35  elapsedMs: number
36  /** 지금 시각(ms). 한도가 다시 차는 시각을 "38분 뒤"처럼 쓰는 데 쓴다. */
37  nowMs?: number
38}
39
40declare module 'claude-code' {
41  interface PluginState {
42    dashboard: {
43      snapshot: Snapshot | null
44      permissionMode: string | null
45      effort: string | null
46      layoutStyle: Style | null
47      theme: Theme | null
48      fableWeek: number | null
49      polledLimits: { kind: 'five_hour' | 'seven_day' | 'fable'; percentUsed: number; resetsAt?: string }[] | null
50    }
51  }
52}
53