SLOPSHOPPER

context-bar

입력창 위에 컨텍스트 창 사용량을 카테고리별 색 막대로 보여준다

newbandcommand
v0.1.0no licenseupdated 2026-10-11urban6/my-harness/mods/context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar
› 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 › /context-bar ⎿ context-bar: 켜짐 · 경고 70% / 85% · 마지막 측정 아직 없음 ⟨Claude Code's own drawing⟩ 컨텍스트 측정 전 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 컨텍스트 측정 전
README

my-harness

새 백엔드 프로젝트를 시작할 때 가져다 쓰는 Claude Code 기본 세팅입니다.

에이전트, 스킬, 커맨드를 이 저장소에서 만들고 검증한 뒤, install.sh로 전역(~/.claude)이나 프로젝트(.claude/)에 심링크를 걸어 씁니다. 다루는 범위는 백엔드(REST API, 서비스, 영속성)이고, 특정 프로젝트나 스택에 묶이지 않게 작성합니다.

구조

my-harness/
├── agents/      # 서브에이전트
├── skills/      # 스택별 구현 패턴
├── commands/    # 슬래시 커맨드
├── rules/       # 규칙 (경로별 지침)
├── hooks/       # 훅 (폴더 하나 = 훅 하나, install.sh가 settings에 병합)
├── mods/        # Claude Code 모드 (폴더 하나 = 모드 하나, install.sh가 전역 settings에 등록)
├── plugins/     # 외부 플러그인 목록 (파일 하나 = 플러그인 하나, install.sh가 claude plugin install로 설치)
├── prompts/     # 복사해 쓰는 프롬프트 (설치 대상 아님)
├── docs/        # 플러그인 사용법, guide/ AI 사용 가이드 (설치 대상 아님)
├── playground/  # 방식별 비교 실험 (설치 대상 아님)
├── install.sh
└── CLAUDE.md

에이전트

기능 개발 — feature-pm이 나머지 넷을 순서대로 부립니다.

이름하는 일
feature-pm요구사항 분해, 스택 확정, 워커 조율 (메인 세션에서 직접 호출)
backend-designerAPI와 DB 스키마를 함께 설계
backend-impl설계를 코드로 구현
boundary-verifier설계와 구현이 서로 맞는지 검증
test-writer단위·통합 테스트 작성

진단 — 코드를 직접 고치지 않고 원인과 개선안만 알려 줍니다.

이름하는 일
architecture-expert구조·의존성 진단, 기술 선택 비교
code-reviewer가독성, 네이밍, 에러 처리, 중복 리뷰
debugger버그 재현, 근본 원인 특정
performance-optimizer병목, N+1, 복잡도 진단
security-auditor인증·인가, 인젝션 등 보안 점검

스킬

이름하는 일
spring-bootSpring Boot 3.x 구현 패턴
nestjsNestJS 구현 패턴
screen-spec-analyzer화면 캡처나 화면정의서에서 API 응답 필드 추출

커맨드

이름하는 일
/commit변경을 논리 단위로 나눠 커밋
/push보호 브랜치와 강제 푸시를 확인하고 푸시
/pre-prPR 올리기 전 코드 리뷰 (테스트·린트·진단 후 BLOCK/READY 판정)
/post-prPR의 CI 결과와 리뷰 코멘트 대응, 머지 후 브랜치 정리

모드

이름하는 일
next-steps턴이 끝나면 다음 할 일을 최대 4개 제안, 체크박스로 골라 입력창에 채움 (mods/README.md)
context-bar입력창 위에 컨텍스트 창 사용량을 카테고리별 색 막대와 비율로 표시 (mods/README.md)
agent-monitor서브에이전트의 상태·경과 시간·도구 호출 수·토큰을 사이드 패널에 실시간 표시 (mods/README.md)

프롬프트 예시

프롬프트를 복사해 {빈칸}만 채워 씁니다.

상황프롬프트
기능 개발 (설계 → 구현 → 테스트)prompts/feature-development.md
커밋·배포 전 코드 리뷰prompts/code-review.md
화면 캡처로 API 응답 설계prompts/screen-spec.md

디버깅 · 성능 개선

{증상 — 무엇이 언제부터 어떻게}. 순서대로 진행해줘.

1) debugger로 재현 경로를 잡고 문제 구간을 file:line으로 특정
2) performance-optimizer로 {관찰 지점}을 포함해 병목을 우선순위로 진단
3) architecture-expert 관점에서 구조적 개선안을 트레이드오프로 비교

셋 다 진단만 하고 코드는 고치지 마. 마지막에 안 A/B/C로 정리해줘.

플러그인

AI 사용 가이드

설치

./install.sh            # 대화형: 대상·유형·항목을 골라 설치
./install.sh list       # 설치 상태 확인
./install.sh uninstall  # 우리 심링크만 제거

스크립트에서 쓰거나 세부 옵션(--type, --project, --force, --dry-run 등)이 필요하면 ./install.sh -h를 본다.

Source 3 files
hooks/register.tsx 151 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot, Warn } from '../types'
5import type { Cell } from './bar'
6import {
7  barWidth,
8  capacityLabel,
9  DEFAULT_WARN,
10  describe,
11  extras,
12  formatTokens,
13  HELP,
14  layout,
15  normalizeWarn,
16  parseWarn,
17  percentColor,
18  toSnapshot,
19  warnPercent,
20} from './bar'
21
22const enabled = atom({ plugin: 'context-bar', key: 'enabled' } as const, true)
23const warn = atom({ plugin: 'context-bar', key: 'warn' } as const, DEFAULT_WARN as Warn)
24// 마지막 측정값. 측정 전·압축 직후·꺼짐이면 null.
25const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null as Snapshot | null)
26
27const STORE_ENABLED = 'enabled'
28const STORE_WARN = 'warn'
29
30const GLYPH = { used: '█', free: '░', buffer: '·' } as const
31
32function cellStyle(cell: Cell) {
33  if (cell.kind === 'used') return { color: cell.color }
34  if (cell.kind === 'free') return { color: 'inactive' }
35  return { dimColor: true }
36}
37
38// summary는 로컬 추정이라 호출 비용이 없다.
39async function measure($: EngineInterface) {
40  try {
41    const { context } = await $.session.usage({ breakdown: 'summary' })
42    const breakdown = context.breakdown
43    if (breakdown) await update($, snapshot, prev => toSnapshot(breakdown, prev))
44  } catch (error) {
45    $.ui.log(`context-bar: 컨텍스트 사용량을 읽지 못했어요 (${String(error)})`)
46  }
47}
48
49export const register: Register = on => {
50  on('session.start', async ($, e, next) => {
51    const storedEnabled = await $.store.get(STORE_ENABLED)
52    await update($, enabled, () => storedEnabled !== false)
53    const storedWarn = normalizeWarn(await $.store.get(STORE_WARN))
54    await update($, warn, () => storedWarn)
55    await update($, snapshot, () => null)
56    await $.command.register({
57      name: 'context-bar',
58      description: '컨텍스트 막대: on/off, warn <노랑> <빨강>, 인자 없으면 현재 설정',
59      argumentHint: '[on|off|warn]',
60    })
61    return next(e)
62  })
63
64  // /clear·resume에는 session.start가 오지 않는다. 대화가 바뀌었으니 이전 측정값을 버린다.
65  on('session.end', async ($, e, next) => {
66    if (e.reason === 'clear' || e.reason === 'resume') await update($, snapshot, () => null)
67    return next(e)
68  })
69
70  // 메인 턴이 끝날 때마다 온다. 서브에이전트 턴에는 오지 않는다.
71  on('session.measure', async ($, e, next) => {
72    const done = await next(e)
73    if (e.changed.includes('context') && (await read($, enabled))) await measure($)
74    return done
75  })
76
77  // 압축된 대화는 다음 응답이 올 때까지 측정값이 없다.
78  on('session.compact', async ($, e, next) => {
79    const result = await next(e)
80    const isApplied = e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result)
81    if (isApplied) await update($, snapshot, () => null)
82    return result
83  })
84
85  on('command.run', { command: 'context-bar' }, async ($, e) => {
86    const [sub = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/)
87
88    if (sub === '') {
89      return { text: describe(await read($, enabled), await read($, warn), await read($, snapshot)) }
90    }
91    if ((sub === 'on' || sub === 'off') && rest.length === 0) {
92      const isOn = sub === 'on'
93      await update($, enabled, () => isOn)
94      await $.store.set(STORE_ENABLED, isOn)
95      // 꺼진 동안 측정하지 않으므로, 다시 켰을 때 오래된 값을 보여주지 않는다.
96      if (!isOn) await update($, snapshot, () => null)
97      return { text: isOn ? '컨텍스트 막대를 켰어요. 다음 턴부터 다시 측정해요.' : '컨텍스트 막대를 껐어요.' }
98    }
99    if (sub === 'warn') {
100      const parsed = parseWarn(rest.join(' '))
101      if (typeof parsed === 'string') return { text: parsed }
102      await update($, warn, () => parsed)
103      await $.store.set(STORE_WARN, parsed)
104      return { text: `경고 기준: ${parsed.yellow}% 노랑 · ${parsed.red}% 빨강` }
105    }
106    return { text: HELP }
107  })
108
109  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110    const above = await next(e)
111    if (e.props.hasSurvey || !(await read($, enabled))) return above
112
113    const { Box, Text } = $.ui.resolve(e)
114    const snap = await read($, snapshot)
115    if (snap === null) {
116      return (
117        <Box flexDirection="column">
118          {above}
119          <Text key="context-bar" dimColor>컨텍스트 측정 전</Text>
120        </Box>
121      )
122    }
123
124    const width = barWidth(e.props.bodyColumns)
125    const cells = layout(snap.segments, width)
126    const color = percentColor(warnPercent(snap), await read($, warn))
127    const { top, delta } = extras(snap, e.props.bodyColumns, width)
128    return (
129      <Box flexDirection="column">
130        {above}
131        <Box key="context-bar" flexDirection="row">
132          {cells.map((cell, i) => (
133            <Text key={`cell-${i}`} {...cellStyle(cell)}>
134              {GLYPH[cell.kind].repeat(cell.cells)}
135            </Text>
136          ))}
137          <Text>  </Text>
138          <Text key="percent" {...(color ? { color } : {})}>
139            {`${snap.percent}%`}
140          </Text>
141          <Text dimColor>{` · ${capacityLabel(snap)}`}</Text>
142          {top && <Text dimColor> · </Text>}
143          {top && <Text key="top" color={top.color}>{top.name}</Text>}
144          {top && <Text dimColor>{` ${formatTokens(top.tokens)}`}</Text>}
145          {delta && <Text dimColor>{` · ${delta}`}</Text>}
146        </Box>
147      </Box>
148    )
149  })
150}
151
hooks/bar.ts 157 lines
1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { Segment, Snapshot, Warn } from '../types'
4
5export const DEFAULT_WARN: Warn = { yellow: 70, red: 85 }
6export const HELP = '사용법: /context-bar [on|off|warn <노랑> <빨강>]'
7const WARN_RULE = '경고 기준은 0 < 노랑 < 빨강 ≤ 100 인 정수여야 해요. 예: /context-bar warn 70 85'
8
9const MIN_WIDTH = 10
10const MAX_WIDTH = 30
11// 막대 뒤 "  104% · 압축까지 999k" 글자와 여유 칸.
12const LABEL_COLUMNS = 24
13const ORDER = { used: 0, free: 1, buffer: 2 } as const
14
15export type Cell = Segment & { cells: number }
16
17export function barWidth(columns: number): number {
18  return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, columns - LABEL_COLUMNS))
19}
20
21// prev는 직전 측정값. 없으면(첫 측정·압축 직후) 증가량을 남기지 않는다.
22export function toSnapshot(b: SessionContextBreakdown, prev: Snapshot | null): Snapshot {
23  const segments: Segment[] = b.categories
24    .filter(c => c.kind !== 'deferred')
25    .map(c => ({ kind: c.kind as Segment['kind'], name: c.name, color: c.color, tokens: c.tokens }))
26    // 압축 버퍼는 남은 공간 뒤, 막대 끝에 둔다. 같은 kind끼리는 받은 순서를 지킨다.
27    .sort((x, y) => ORDER[x.kind] - ORDER[y.kind])
28  return {
29    segments,
30    totalTokens: b.totalTokens,
31    maxTokens: b.rawMaxTokens,
32    percent: b.percentage,
33    compactAt: b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
34    delta: prev === null ? null : b.totalTokens - prev.totalTokens,
35  }
36}
37
38function largest(ns: number[]): number {
39  let best = 0
40  ns.forEach((n, i) => {
41    if (n > ns[best]!) best = i
42  })
43  return best
44}
45
46export function layout(segments: Segment[], width: number): Cell[] {
47  const shown = segments.filter(s => s.tokens > 0)
48  const total = shown.reduce((sum, s) => sum + s.tokens, 0)
49  if (total === 0) return []
50
51  // 최대 나머지 방식: 내림한 뒤 남은 칸을 소수부가 큰 구간부터 하나씩 준다.
52  // 구간마다 1칸씩 보장하면 작은 구간이 많을 때 사용 칸이 실제보다 부푼다.
53  const exact = shown.map(s => (s.tokens / total) * width)
54  const cells = exact.map(Math.floor)
55  const byRemainder = exact.map((_, i) => i).sort((a, b) => (exact[b]! % 1) - (exact[a]! % 1))
56  let left = width - cells.reduce((sum, n) => sum + n, 0)
57  for (const i of byRemainder) {
58    if (left === 0) break
59    cells[i] = cells[i]! + 1
60    left -= 1
61  }
62
63  // 쓰고 있는 게 있으면 1칸은 보이게 한다. 그 칸은 가장 큰 구간에서 뺀다.
64  const used = shown.flatMap((s, i) => (s.kind === 'used' ? [i] : []))
65  if (used.length > 0 && used.every(i => cells[i] === 0)) {
66    const give = used.reduce((a, b) => (shown[b]!.tokens > shown[a]!.tokens ? b : a))
67    const take = largest(cells)
68    cells[take] = cells[take]! - 1
69    cells[give] = 1
70  }
71  return shown.map((s, i) => ({ ...s, cells: cells[i]! })).filter(c => c.cells > 0)
72}
73
74// 경고 색의 기준 비율. 자동 압축이 켜져 있으면 압축 시점 대비, 꺼져 있으면 창 대비.
75export function warnPercent(snap: Snapshot): number {
76  if (snap.compactAt === null || snap.compactAt <= 0) return snap.percent
77  return Math.round((snap.totalTokens / snap.compactAt) * 100)
78}
79
80export function capacityLabel(snap: Snapshot): string {
81  if (snap.compactAt === null) return `${formatTokens(snap.totalTokens)} / ${formatTokens(snap.maxTokens)}`
82  return `압축까지 ${formatTokens(Math.max(0, snap.compactAt - snap.totalTokens))}`
83}
84
85export function formatDelta(n: number | null): string | null {
86  if (n === null || n === 0) return null
87  return `${n > 0 ? '+' : '-'}${formatTokens(Math.abs(n))}`
88}
89
90export function largestUsed(segments: Segment[]): Segment | undefined {
91  return segments
92    .filter(s => s.kind === 'used' && s.tokens > 0)
93    .reduce<Segment | undefined>((best, s) => (best === undefined || s.tokens > best.tokens ? s : best), undefined)
94}
95
96// 터미널 칸 수. 한글은 두 칸을 차지한다.
97export function displayWidth(text: string): number {
98  return [...text].reduce((n, ch) => n + (/[\u1100-\u11ff\u3130-\u318f\uac00-\ud7a3]/.test(ch) ? 2 : 1), 0)
99}
100
101export type Extras = { top?: Segment; delta?: string }
102
103// 막대와 비율·용량 뒤에 남는 칸에 덧붙일 것. 좁으면 증가량, 그다음 카테고리 이름 순으로 뺀다.
104export function extras(snap: Snapshot, columns: number, barCells: number): Extras {
105  let room = columns - barCells - displayWidth(`  ${snap.percent}% · ${capacityLabel(snap)}`)
106  const top = largestUsed(snap.segments)
107  if (top === undefined) return {}
108  const topWidth = displayWidth(` · ${top.name} ${formatTokens(top.tokens)}`)
109  if (room < topWidth) return {}
110  room -= topWidth
111
112  const delta = formatDelta(snap.delta)
113  if (delta === null || room < displayWidth(` · ${delta}`)) return { top }
114  return { top, delta }
115}
116
117export function percentColor(percent: number, warn: Warn): 'error' | 'warning' | undefined {
118  if (percent >= warn.red) return 'error'
119  if (percent >= warn.yellow) return 'warning'
120  return undefined
121}
122
123export function formatTokens(n: number): string {
124  if (n < 1_000) return String(n)
125  if (n < 999_500) return `${Math.round(n / 1_000)}k`
126  return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
127}
128
129export function parseWarn(text: string): Warn | string {
130  const parts = text.trim().split(/\s+/)
131  if (parts.length !== 2 || !parts.every(p => /^\d+$/.test(p))) return HELP
132  const [yellow, red] = parts.map(Number) as [number, number]
133  if (yellow <= 0 || yellow >= red || red > 100) return WARN_RULE
134  return { yellow, red }
135}
136
137export function normalizeWarn(stored: unknown): Warn {
138  if (typeof stored !== 'object' || stored === null) return DEFAULT_WARN
139  const { yellow, red } = stored as Partial<Warn>
140  const parsed = parseWarn(`${yellow} ${red}`)
141  return typeof parsed === 'string' ? DEFAULT_WARN : parsed
142}
143
144// 앱이 명령 이름(context-bar:)을 앞에 붙이므로 여기서는 쓰지 않는다.
145export function describe(enabled: boolean, warn: Warn, snap: Snapshot | null): string {
146  const head = `${enabled ? '켜짐' : '꺼짐'} · 경고 ${warn.yellow}% / ${warn.red}% · 마지막 측정`
147  if (snap === null) return `${head} 아직 없음`
148
149  const compact = snap.compactAt === null ? '' : ` · ${capacityLabel(snap)}`
150  const parts = snap.segments
151    .filter(s => s.kind === 'used' && s.tokens > 0)
152    .sort((a, b) => b.tokens - a.tokens)
153    .map(s => `${s.name} ${formatTokens(s.tokens)}`)
154  const makeup = parts.length === 0 ? '' : `\n구성: ${parts.join(' · ')}`
155  return `${head} ${snap.percent}% (${formatTokens(snap.totalTokens)} / ${formatTokens(snap.maxTokens)})${compact}${makeup}`
156}
157
types/index.d.ts 37 lines
1// 막대 한 구간. breakdown의 categories에서 deferred(창 밖)를 뺀 것.
2export type Segment = {
3  kind: 'used' | 'free' | 'buffer'
4  // /context가 쓰는 행 이름 (Messages, System prompt, ...)
5  name: string
6  // /context가 쓰는 테마 색 키 (promptBorder, permission, inactive, ...)
7  color: string
8  tokens: number
9}
10
11// 마지막 측정값. 그리는 데 필요한 값만 남긴다.
12export type Snapshot = {
13  segments: Segment[]
14  totalTokens: number
15  // breakdown.rawMaxTokens
16  maxTokens: number
17  // breakdown.percentage (rawMaxTokens 대비, 넘치면 100 초과)
18  percent: number
19  // 자동 압축이 실행되는 토큰 수. 자동 압축이 꺼져 있으면 null.
20  compactAt: number | null
21  // 직전 측정 대비 증가량. 첫 측정·압축 직후에는 null.
22  delta: number | null
23}
24
25// 비율 숫자 색 기준(%)
26export type Warn = { yellow: number; red: number }
27
28declare module 'claude-code' {
29  interface PluginState {
30    'context-bar': {
31      enabled: boolean
32      warn: Warn
33      snapshot: Snapshot | null
34    }
35  }
36}
37