SLOPSHOPPER

ctx-strip

컨텍스트가 무엇으로 차 있는지 입력창 위 막대로 보여주고, 서브에이전트가 돌면 한 줄로 알려줘요

newbandspinnerguardcommandprocess
★ 1v1.1.0MITupdated 2026-10-06SeongGwangJu/k-mods/mods/ctx-strip
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx-strip
› 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 › /ctx ⎿ ctx-strip: 컨텍스트 정보를 아직 잴 수 없어요. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

컨텍스트 막대

컨텍스트 창이 무엇(대화·도구·스킬·메모리…)으로 차 있는지 입력창 위 막대로 보여주고, 서브에이전트가 돌면 한 줄로 알려줘요.

설치

/plugin marketplace add SeongGwangJu/k-mods
/plugin install ctx-strip@k-mods

쓰는 법

  • 기본값(display: band)이면 따로 할 일이 없어요. 입력창 위에 막대·퍼센트·범례가 자동으로 떠요.
  • 터미널 폭에 따라 3단계로 줄어들어요: 넓으면(100칸 이상) 막대+전체 범례, 보통(70~99칸)은 막대+상위 3개 범례, 좁으면(70칸 미만) 컨텍스트 38% 한 줄만.
  • 지난 측정 대비 많이 늘었으면 +3.7k처럼 증가량이, 자동 압축 지점이 가까워지면 "압축까지 Nk"가 덧붙어요.
  • 서브에이전트(Task 도구로 띄운 에이전트)가 돌고 있으면 입력창 힌트 줄 아래에 종류·모델·경과 시간이 한 줄로 떠요.
  • /ctx를 치면 더 자세한 내용(분류별 토큰, 대화에서 가장 큰 항목, 메모리 파일, MCP 서버, 스킬, 에이전트)을 담은 리포트를 만들어서 바로 열어줘요.

설정 (/config 또는 /plugin configure ctx-strip@k-mods)

  • display: 어디에 보여줄지.
  • band(기본): 이 mod가 입력창 위에 직접 그려요.
  • hint: 입력창 아래, 모드 표시 줄(⏵⏵ auto mode on 같은 줄) 밑에 그려요. 상태줄처럼 아래쪽이 편하면 고르세요.
  • statusline: 이 mod는 그리지 않고, 턴마다 사람별 임시 폴더에 JSON 스냅샷만 써요. 그 대신 extras/statusline-ctx.py를 직접 상태줄 명령에 연결해야 그림이 보여요. 이미 상태줄을 커스텀해서 쓰고 있고 거기에 컨텍스트 칸을 더 넣고 싶을 때 고르세요. 설치된 플러그인 경로는 /plugin에서 확인할 수 있고, 상태줄 명령(예: ~/.claude/statusline-command.sh) 끝에 이렇게 한 줄 추가하면 돼요(세션 정보는 stdin으로 들어오는 JSON에서 꺼내 씀): ``sh session_id=$(echo "$input" | jq -r '.session_id // empty') python3 "<설치 경로>/extras/statusline-ctx.py" "$session_id" "${COLUMNS:-100}" ` 상세 리포트(상세↗) 링크는 /ctx`를 한 번이라도 실행해야 생겨요. 원본과 달리 매 턴 자동으로 쓰지 않아요(아래 "한계" 참고).
  • palette: 막대·범례·서브에이전트 줄 색. 기본값 dark는 어두운 터미널용 파스텔로, 분류마다 색이 겹치지 않고 빈 구간은 배경보다 살짝 밝은 옅은 홈으로 그려요. 밝은 터미널이면 theme, 회색 배경 터미널이면 gray로 바꾸세요. statusline 모드의 상태줄 스크립트도 이 설정을 따라가요. /ctx가 여는 HTML 리포트는 브라우저에서 보는 별도 페이지라 이 설정과 상관없이 항상 같은 모습이에요.

어떻게 동작하나요

  • 세션이 시작할 때, 턴이 끝날 때, 요금제 한도가 바뀔 때: $.session.usage({ breakdown: 'summary' })로 컨텍스트 구성을 다시 재요. 로컬 추정이라 API를 부르지 않아요. /clear·압축 뒤에는(classic.SessionStart) 구성이 크게 바뀌니 증가량 표시를 지우고 다시 재요.
  • 입력창 위를 그릴 때: 방금 잰 값으로 막대·퍼센트·범례를 그려요. 다른 mod가 이미 그 자리에 그려 둔 내용(next(e))은 지우지 않고 그 아래에 이어 그려요. 설문(survey)이 떠 있으면 아예 양보해요.
  • 도구를 부를 때·서브에이전트가 시작·끝날 때: Task 도구 호출을 지켜보다가 서브에이전트가 실제로 시작하면 그 정보(종류·모델·설명)와 짝지어 두고, 끝나면 8초 동안 "완료" 표시로 남겨 둔 뒤 지워요.
  • /ctx를 실행할 때: 지금 구성을 다시 재서 HTML 리포트를 쓰고, macOS는 open, 그 외(Linux)는 xdg-open으로 열어요.

권한 (이 mod가 내 컴퓨터에서 하는 일)

  • display: statusline일 때만, 턴마다 작은 JSON 파일을 사람별 임시 폴더($TMPDIR 또는 /tmp 아래 ctx-strip-<내 계정 이름>)에 써요.
  • /ctx를 실행하면 같은 폴더에 HTML 리포트를 쓰고 그 파일을 여는 프로그램(open/xdg-open)을 한 번 실행해요.
  • 그 외 네트워크 호출이나 모델 호출은 없어요. 대화 내용은 기기 밖으로 나가지 않아요. 리포트도 내 컴퓨터 안의 파일일 뿐이에요.

한계

  • 구성은 /context와 같은 방식의 로컬 추정치예요. 정확한 토큰 수가 아니라 "대략 이 정도"로 보세요.
  • /ctx를 한 번도 안 눌렀으면 상세 리포트 파일이 아직 없어요(원본은 매 턴 자동으로 썼지만, 공개판은 디스크에 불필요하게 쓰지 않으려고 /ctx를 눌렀을 때만 만들어요).
  • display: statusline은 내가 직접 상태줄 명령을 고쳐야 그림이 나와요. 원하는 터미널 자동 적용 기능이 아니에요.

출처·라이선스

메인테이너(SeongGwangJu)가 자기 기기에서 쓰던 오리지널 모드(모드 본체 + ~/.claude/statusline-ctx.py)를 공개용으로 옮긴 것으로, 외부 upstream은 없습니다. extras/statusline-ctx.py는 그 상태줄 스크립트를 사람별 임시 폴더 공식에 맞게 손본 것입니다. 저장소 루트의 MIT 라이선스를 따릅니다.

테스트한 Claude Code 버전: 2.1.291

Source 7 files
hooks/register.ts 275 lines
1// ctx-strip
2//   턴마다 컨텍스트 구성(/context와 같은 분류)을 재서 막대와 범례로 보여준다. 어디에 그릴지는 display:
3//   "band"(기본): 입력창 위 띠.
4//   "hint": 입력창 아래, 모드 표시 줄 밑.
5//   "statusline": 그리지 않고 사람별 임시 폴더에 JSON 스냅샷만 써서, 사용자가 자기 상태줄
6//     명령에서 extras/statusline-ctx.py로 읽어 상태줄 바로 아래에 그리게 한다(README 참고).
7//   모든 모드: 서브에이전트가 돌 때만 힌트 줄 아래에 한 줄(종류·모델·설명·경과 시간).
8//   /ctx: 상세 리포트(HTML)를 써서 연다. 유일한 명령.
9//
10// 구성은 $.session.usage({ breakdown: "summary" })로 받는다. summary는 로컬 추정이라 API 호출이 없다.
11import type { ElementConstructor, EngineInterface, On, PluginOptions, RenderElement, SessionContextBreakdown, TextProps } from 'claude-code'
12
13import { barSegments, barWidth, deltaText, legendChips, percentSeverity, widthTier } from './bar'
14import { SHORT_LABEL } from './categories'
15import { k } from './format'
16import { categoryColor, paletteName, paletteOf, type CtxPalette } from './palette'
17import { snapshotPayload, tempDirFrom } from './paths'
18import { buildReportHtml, heaviestItems } from './report'
19
20const DONE_LINGER_MS = 8000
21
22type Display = 'hint' | 'band' | 'statusline'
23type PendingAgent = { type: string; description: string; model: string | null }
24type RunningAgent = PendingAgent & { id: string; startedAt: number; endedAt: number | null }
25
26let previousTotal: number | null = null
27let delta: number | null = null
28let latestBreakdown: SessionContextBreakdown | null = null
29
30// 서브에이전트. pending은 Agent 도구 호출로 알게 된 설명·모델을 SubagentStart와 짝지을 때까지 둔다.
31let pending: PendingAgent[] = []
32let agents: RunningAgent[] = []
33let stopTicker: { cancel: () => void } | null = null
34
35export function register(on: On, options: PluginOptions): void {
36  const palette = paletteOf(options.palette)
37  const paletteId = paletteName(options.palette)
38  const display: Display = options.display === 'statusline' ? 'statusline' : options.display === 'hint' ? 'hint' : 'band'
39
40  on('session.start', async ($, e, next) => {
41    await $.command.register({ name: 'ctx', description: '컨텍스트 상세 리포트 열기', immediate: true })
42    if (display === 'statusline') await ensureDir($, await tempDir($))
43    await measure($, display, paletteId)
44    return next(e)
45  })
46
47  // 턴이 끝날 때, 요금제 한도 비율이 바뀔 때 엔진이 알려준다. 타이머로 폴링하지 않는다.
48  on('session.measure', async ($, e, next) => {
49    await measure($, display, paletteId)
50    return next(e)
51  })
52
53  // /clear, 압축 뒤에는 구성이 크게 바뀌므로 증가량을 지우고 다시 잰다.
54  on('classic.SessionStart', async ($, e, next) => {
55    previousTotal = null
56    delta = null
57    await measure($, display, paletteId)
58    return next(e)
59  })
60
61  on('command.run', { command: 'ctx' }, async ($) => {
62    const usage = await $.session.usage({ breakdown: 'summary', columns: 80 }).catch(() => null)
63    const breakdown = usage?.context.breakdown ?? latestBreakdown
64    if (!breakdown) return { text: '컨텍스트 정보를 아직 잴 수 없어요.' }
65    const dir = await tempDir($)
66    await ensureDir($, dir)
67    const path = `${dir}/${await $.session.id()}.html`
68    const messages = await $.session.messages().catch(() => [])
69    const heavy = heaviestItems(Array.isArray(messages) ? messages : [])
70    const home = await $.env.get('HOME')
71    await $.fs.write(path, buildReportHtml(breakdown, heavy, home))
72    await openPath($, path)
73    return { text: `상세 리포트를 열었어요: ${path}` }
74  })
75
76  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
77    pending.push({
78      type: String(e.subagent_type ?? 'general-purpose'),
79      description: String(e.description ?? ''),
80      model: e.model ? String(e.model) : null,
81    })
82    return next(e)
83  }).catch(async ($, e, next) => next(e))
84
85  on('classic.SubagentStart', async ($, e, next) => {
86    const index = pending.findIndex((p) => p.type === e.agent_type)
87    const meta: PendingAgent = index >= 0 ? pending.splice(index, 1)[0]! : { type: String(e.agent_type), description: '', model: null }
88    agents.push({ id: String(e.agent_id), ...meta, startedAt: await $.clock.now(), endedAt: null })
89    startTicker($)
90    $.ui.invalidate('ui.render')
91    return next(e)
92  })
93
94  on('classic.SubagentStop', async ($, e, next) => {
95    const agent = agents.find((a) => a.id === e.agent_id)
96    if (agent) agent.endedAt = await $.clock.now()
97    $.ui.invalidate('ui.render')
98    return next(e)
99  })
100
101  // 입력창 아래: hint 모드면 컨텍스트 막대, 모든 모드에서 서브에이전트가 돌 때만 그 줄.
102  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
103    const theirs = await next(e)
104    if (e.surface !== 'terminal' && e.surface !== 'desktop') return theirs
105    const { Box, Text } = $.ui.resolve(e)
106    const columns = e.viewport?.columns ?? 100
107    const rows: RenderElement[] = []
108    // 힌트 줄은 왼쪽 2칸 들여쓰기로 그려지므로 그만큼 빼고 잰다
109    if (display === 'hint') rows.push(...contextRows(Text, palette, Math.max(20, columns - 4), '  '))
110    // 시계는 서브에이전트가 있을 때만 읽는다 (힌트 줄은 자주 다시 그려진다)
111    if (agents.length > 0) {
112      const now = await $.clock.now()
113      agents = agents.filter((a) => a.endedAt === null || now - a.endedAt < DONE_LINGER_MS)
114      if (agents.length === 0) stopTickerIfIdle()
115      else rows.push(agentLine(Text, palette, columns, now))
116    }
117    if (rows.length === 0) return theirs
118    return Box({ flexDirection: 'column', children: [theirs, ...rows] })
119  })
120
121  // 입력창 위: band 모드일 때만.
122  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123    const theirs = await next(e)
124    if (e.props.hasSurvey) return theirs
125    if (display !== 'band') return theirs
126    const { Box, Text } = $.ui.resolve(e)
127    const rows = contextRows(Text, palette, e.props.bodyColumns, '')
128    if (rows.length === 0) return theirs
129    return Box({ flexDirection: 'column', children: [theirs, ...rows] })
130  })
131}
132
133/** 서브에이전트 한 줄: ⎇ N  종류·모델 설명 경과시간 │ ... */
134function agentLine(Text: ElementConstructor<TextProps>, palette: CtxPalette, columns: number, now: number): RenderElement {
135  const running = agents.filter((a) => a.endedAt === null).length
136  const showDescription = columns >= 110
137  const parts = [Text({ color: palette.agentAccent, bold: true, children: running > 0 ? ` ⎇ ${running}` : ' ⎇' })]
138  agents.forEach((a, i) => {
139    const elapsed = clock((a.endedAt ?? now) - a.startedAt)
140    const model = a.model ? `·${a.model}` : ''
141    const label = showDescription && a.description ? `${a.type}${model} ${a.description}` : `${a.type}${model}`
142    const isDone = a.endedAt !== null
143    parts.push(Text({ children: i === 0 ? '  ' : ' │ ' }))
144    parts.push(Text({ color: isDone ? palette.agentDone : palette.bright, children: `${label} ${elapsed}${isDone ? ' ✓' : ''}` }))
145  })
146  return Text({ wrap: 'truncate-end', children: parts })
147}
148
149/** 컨텍스트 막대와 범례. 넓음: 막대+전체 범례, 보통: 막대+상위 3개, 좁음: 한 줄 요약. 아직 못 쟀으면 빈 배열. */
150function contextRows(Text: ElementConstructor<TextProps>, palette: CtxPalette, bodyColumns: number, indent: string): RenderElement[] {
151  const breakdown = latestBreakdown
152  if (!breakdown) return []
153  const tier = widthTier(bodyColumns)
154  const used = breakdown.categories.filter((c) => c.kind === 'used' && c.tokens > 0)
155  const max = breakdown.rawMaxTokens || breakdown.maxTokens || 1
156  const threshold = breakdown.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null
157  const colorFor = (name: string) => categoryColor(palette, name, Math.max(0, used.findIndex((c) => c.name === name)))
158  const severity = percentSeverity(breakdown.percentage)
159  const severityColor = severity === 'danger' ? palette.danger : severity === 'warn' ? palette.warn : palette.bright
160  const dInfo = deltaText(delta)
161  const lead = indent ? [Text({ children: indent })] : []
162
163  if (tier === 'narrow') {
164    const showDelta = delta !== null && Math.abs(delta) >= 1000
165    const arrow = showDelta && delta !== null ? (delta > 0 ? '▲' : '▼') : ''
166    const deltaPart = showDelta && delta !== null ? `${arrow}${k(Math.abs(delta))}` : ''
167    return [
168      Text({
169        wrap: 'truncate-end',
170        children: [
171          ...lead,
172          Text({ color: severityColor, bold: true, children: `컨텍스트 ${breakdown.percentage}%` }),
173          ...(deltaPart ? [Text({ color: palette.dim, children: ` ${deltaPart}` })] : []),
174        ],
175      }),
176    ]
177  }
178
179  const width = barWidth(bodyColumns)
180  const runs = barSegments(
181    used.map((c) => ({ name: c.name, tokens: c.tokens })),
182    width,
183    max,
184    threshold,
185  )
186  const barCells = runs.map((run) => {
187    if (run.hasMark) return Text({ backgroundColor: palette.track, color: palette.mark, children: '▕' })
188    const bg = run.key ? colorFor(run.key) : palette.track
189    return Text({ backgroundColor: bg, children: ' '.repeat(run.length) })
190  })
191  const nearCompact = threshold !== null && threshold - breakdown.totalTokens < max * 0.15
192  const barLine = Text({
193    wrap: 'truncate-end',
194    children: [
195      ...lead,
196      ...barCells,
197      Text({ bold: true, color: severityColor, children: ` ${breakdown.percentage}%` }),
198      ...(dInfo.text ? [Text({ color: dInfo.isBig ? palette.warn : palette.dim, children: `  ${dInfo.text}` })] : []),
199      ...(nearCompact ? [Text({ color: palette.danger, bold: true, children: ` 압축까지 ${k(Math.max(0, threshold! - breakdown.totalTokens))}` })] : []),
200    ],
201  })
202
203  const chips = legendChips(
204    used.map((c) => ({ name: c.name, tokens: c.tokens })),
205    max,
206    tier === 'medium' ? 3 : undefined,
207  )
208  const legendLine = Text({
209    wrap: 'truncate-end',
210    children: [
211      ...lead,
212      ...chips.map((c) => Text({ backgroundColor: colorFor(c.name), color: palette.chipInk, children: ` ${SHORT_LABEL[c.name] ?? c.name} ${c.pct} ` })),
213    ],
214  })
215  return [barLine, legendLine]
216}
217
218async function measure($: EngineInterface, display: Display, paletteId: string): Promise<void> {
219  try {
220    const usage = await $.session.usage({ breakdown: 'summary', columns: 80 })
221    const breakdown = usage.context.breakdown
222    if (!breakdown) return
223    const total = breakdown.totalTokens
224    if (previousTotal !== null && total !== previousTotal) delta = total - previousTotal
225    previousTotal = total
226    latestBreakdown = breakdown
227    // hint·band는 이 mod가 직접 그리므로, 상태줄과 달리 다시 그리라고 알려야 한다.
228    $.ui.invalidate('ui.render')
229    if (display === 'statusline') {
230      const dir = await tempDir($)
231      await ensureDir($, dir)
232      const id = await $.session.id()
233      await $.fs.write(`${dir}/${id}.json`, JSON.stringify(snapshotPayload(breakdown, delta, paletteId)))
234    }
235  } catch {
236    // 잴 수 없으면 이전 상태를 그대로 둔다
237  }
238}
239
240/** 사람별 임시 폴더 경로. $.env.get을 두 번 읽어 순수 계산(tempDirFrom)에 넘긴다. */
241async function tempDir($: EngineInterface): Promise<string> {
242  const tmpdir = await $.env.get('TMPDIR')
243  const user = (await $.env.get('USER')) ?? (await $.env.get('USERNAME'))
244  return tempDirFrom(tmpdir, user)
245}
246
247async function ensureDir($: EngineInterface, dir: string): Promise<void> {
248  await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5000 }).catch(() => {})
249}
250
251/** macOS는 open, 그 외(주로 Linux)는 xdg-open. 하나가 실패하면 다른 쪽을 시도한다. */
252async function openPath($: EngineInterface, path: string): Promise<void> {
253  try {
254    await $.process.run(['open', path], { timeoutMs: 5000 })
255  } catch {
256    await $.process.run(['xdg-open', path], { timeoutMs: 5000 }).catch(() => {})
257  }
258}
259
260function startTicker($: EngineInterface): void {
261  if (stopTicker !== null) return
262  stopTicker = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
263}
264
265function stopTickerIfIdle(): void {
266  if (stopTicker === null) return
267  stopTicker.cancel()
268  stopTicker = null
269}
270
271function clock(ms: number): string {
272  const s = Math.max(0, Math.floor(ms / 1000))
273  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
274}
275
hooks/bar.ts 113 lines
1// ctx-strip 띠(band)의 핵심 계산. $를 받지 않는 순수 함수라 테스트하기 쉽다.
2// 막대 채우기 규칙은 원본 statusline-ctx.py의 슬롯 채우기 그대로 옮겼다: 분류별로 누적
3// 비율을 반올림해 칸 수를 정하고, 토큰이 있는 분류는 최소 1칸은 보이게 한다. 자동 압축
4// 지점 표시(▕)는 "아직 쓰지 않은 칸"에만 그린다 — 이미 그 지점을 지나 채워진 칸은 분류
5// 색이 우선이다(원본 파이썬의 렌더 순서와 같다: 채워진 칸이 먼저 걸리면 표시는 안 보인다).
6
7import { k } from './format'
8
9export type Tier = 'wide' | 'medium' | 'narrow'
10
11/** CONTRIBUTING.md 화면 원칙의 폭 3단계: 넓음 ≥100, 보통 70~99, 좁음 <70. */
12export function widthTier(columns: number): Tier {
13  if (columns >= 100) return 'wide'
14  if (columns >= 70) return 'medium'
15  return 'narrow'
16}
17
18/** 막대가 쓸 칸 수. 퍼센트 글자·여백 몫으로 8칸을 비워 둔다. */
19export function barWidth(bodyColumns: number): number {
20  return Math.max(10, bodyColumns - 8)
21}
22
23export type BarRun = {
24  /** 분류 이름, 또는 빈 칸(아직 안 쓴 구간)이면 null. */
25  key: string | null
26  length: number
27  /** 이 칸이 자동 압축 지점 표시인지(항상 length === 1, 빈 구간 안에서만). */
28  hasMark: boolean
29}
30
31/**
32 * 분류별 토큰을 막대 칸으로 슬롯 채우기 한 뒤 연속 구간으로 묶는다(run-length encoding).
33 *
34 * @param categories kind==='used' && tokens>0 로 이미 걸러진, 엔진이 준 순서 그대로
35 * @param width 막대 전체 칸 수
36 * @param maxTokens 막대가 나타내는 전체 창 크기(토큰)
37 * @param thresholdTokens 자동 압축 지점(토큰), 없으면 null
38 */
39export function barSegments(
40  categories: ReadonlyArray<{ name: string; tokens: number }>,
41  width: number,
42  maxTokens: number,
43  thresholdTokens: number | null,
44): BarRun[] {
45  if (width <= 0) return []
46  const slots: (string | null)[] = []
47  if (maxTokens > 0) {
48    let acc = 0
49    for (const c of categories) {
50      acc += c.tokens
51      let end = Math.round((acc / maxTokens) * width)
52      if (end <= slots.length) end = slots.length + 1
53      end = Math.min(width, end)
54      while (slots.length < end) slots.push(c.name)
55    }
56  }
57  while (slots.length < width) slots.push(null)
58
59  const markIndex =
60    thresholdTokens != null && maxTokens > 0 ? Math.min(width - 1, Math.round((thresholdTokens / maxTokens) * width)) : null
61
62  const runs: BarRun[] = []
63  for (let i = 0; i < slots.length; i++) {
64    const key = slots[i] ?? null
65    const hasMark = markIndex === i && key === null
66    const prev = runs[runs.length - 1]
67    if (prev && prev.key === key && !hasMark && !prev.hasMark) {
68      prev.length += 1
69    } else {
70      runs.push({ key, length: 1, hasMark })
71    }
72  }
73  return runs
74}
75
76/** 10% 이상은 정수, 0.1% 이상은 소수 한 자리, 그 밑은 "<0.1%". */
77export function pctText(tokens: number, total: number): string {
78  if (total <= 0) return '0%'
79  const p = (tokens / total) * 100
80  if (p >= 10) return `${p.toFixed(0)}%`
81  if (p >= 0.1) return `${p.toFixed(1)}%`
82  return '<0.1%'
83}
84
85export type Chip = { name: string; pct: string }
86
87/** 토큰 많은 순 범례. limit을 주면(보통 폭) 상위 N개만. */
88export function legendChips(categories: ReadonlyArray<{ name: string; tokens: number }>, maxTokens: number, limit?: number): Chip[] {
89  const sorted = [...categories].sort((a, b) => b.tokens - a.tokens)
90  const sliced = limit !== undefined ? sorted.slice(0, limit) : sorted
91  return sliced.map((c) => ({ name: c.name, pct: pctText(c.tokens, maxTokens) }))
92}
93
94export type DeltaInfo = { text: string; isBig: boolean }
95
96/**
97 * 지난 턴 대비 증가량 문구. 1000 토큰 밑이면 안 보여준다(너무 자잘해서).
98 * isBig은 "많이 늘었을 때"만 켜진다 — 압축 뒤처럼 크게 줄 때는 경고가 아니라 좋은 소식이라
99 * 원본과 같이 양수 쪽만 큰 증가로 본다.
100 */
101export function deltaText(delta: number | null): DeltaInfo {
102  if (delta === null || Math.abs(delta) < 1000) return { text: '', isBig: false }
103  const sign = delta > 0 ? '+' : '−'
104  return { text: `${sign}${k(Math.abs(delta))}`, isBig: delta >= 20000 }
105}
106
107/** 퍼센트에 따른 팔레트 색 선택(심각도). */
108export function percentSeverity(percentage: number): 'danger' | 'warn' | 'bright' {
109  if (percentage >= 85) return 'danger'
110  if (percentage >= 70) return 'warn'
111  return 'bright'
112}
113
hooks/categories.ts 25 lines
1// 컨텍스트 분류 이름(엔진이 주는 영어 이름) → 한국어 표시.
2// SHORT는 띠(band)의 범례 칩에, LONG은 /ctx가 여는 HTML 상세 리포트에 쓴다.
3
4export const SHORT_LABEL: Record<string, string> = {
5  'System prompt': '시스템',
6  'System tools': '도구',
7  'MCP tools': 'MCP',
8  'MCP server instructions': 'MCP 안내',
9  'Custom agents': '에이전트',
10  'Memory files': '메모리',
11  Skills: '스킬',
12  Messages: '대화',
13}
14
15export const LONG_LABEL: Record<string, string> = {
16  'System prompt': '시스템 프롬프트',
17  'System tools': '내장 도구',
18  'MCP tools': 'MCP 도구',
19  'Memory files': '메모리 파일 (CLAUDE.md 등)',
20  Skills: '스킬 목록',
21  'Custom agents': '에이전트 목록',
22  Messages: '대화',
23  'MCP server instructions': 'MCP 서버 안내문',
24}
25
hooks/format.ts 37 lines
1// ctx-strip의 표시용 순수 함수. $를 받지 않아 테스트하기 쉽다.
2
3/** 1200 → "1.2k", 125000 → "125k". 1000 밑이면 그대로. */
4export function k(n: number): string {
5  if (n < 1000) return String(n)
6  return `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`
7}
8
9/** HTML에 안전하게 넣을 수 있게 &<>" 를 이스케이프한다. */
10export function esc(text: unknown): string {
11  return String(text ?? '').replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch] ?? ch)
12}
13
14/** 공백을 한 칸으로 줄이고, n자를 넘으면 자른 뒤 … 를 붙인다. */
15export function oneLine(text: unknown, n: number): string {
16  const t = String(text).replace(/\s+/g, ' ').trim()
17  return t.length > n ? t.slice(0, n) + '…' : t
18}
19
20/** 글자 수 ÷ 3.5 반올림 — 토큰 수 추정. */
21export function est(text: unknown): number {
22  return Math.round(String(text).length / 3.5)
23}
24
25/** 도구 호출 한 줄 설명(대화 기록에서 가장 큰 항목 표에 쓴다). */
26export function describe(toolUse: { tool: string; input?: unknown }): string {
27  const i = (toolUse.input ?? {}) as Record<string, unknown>
28  const target = i.file_path ?? i.path ?? i.command ?? i.pattern ?? i.url ?? i.description ?? i.query ?? i.prompt ?? ''
29  return oneLine(target, 90) || toolUse.tool
30}
31
32/** 밀리초를 "M:SS" 시계 표시로(서브에이전트 경과 시간). */
33export function clockMs(ms: number): string {
34  const s = Math.max(0, Math.floor(ms / 1000))
35  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
36}
37
hooks/palette.ts 103 lines
1// ctx-strip의 색 팔레트.
2//
3// 기본값("dark")은 어두운 터미널 기준 파스텔이다. 분류끼리 색이 겹치지 않게 원본
4// statusline-ctx.py의 파스텔을 그대로 쓰고, 빈 구간은 배경보다 살짝 밝은 옅은 홈으로 둔다.
5// "theme"은 Claude Code 테마 키를 따라가고(밝은 터미널용), "gray"는 회색 배경(#bdbec6)용 원본 색이다.
6// HTML 상세 리포트(report.ts)는 브라우저에서 보는 별도 페이지라 팔레트를 타지 않고
7// 원본 색을 그대로 쓴다 — 화면 원칙(테마 키)은 터미널·Desktop UI에만 해당된다.
8export type CtxPalette = {
9  category: Record<string, string>
10  fallback: readonly string[]
11  track: string
12  mark: string
13  chipInk: string
14  dim: string
15  bright: string
16  warn: string
17  danger: string
18  /** 서브에이전트 줄의 "⎇ N" 표시 색(원본 AGENT). */
19  agentAccent: string
20  /** 서브에이전트 줄에서 끝난 항목 색(원본 DONE). */
21  agentDone: string
22}
23
24// 원본 파스텔. dark와 gray가 함께 쓴다.
25const PASTEL: Record<string, string> = {
26  'System prompt': '#86A0DC',
27  'System tools': '#8EC5CC',
28  'MCP tools': '#9F8FF2',
29  'MCP server instructions': '#B9AEF5',
30  'Custom agents': '#A8C98A',
31  'Memory files': '#E3C56A',
32  Skills: '#E8A6C4',
33  Messages: '#DE8E62',
34}
35
36const DARK: CtxPalette = {
37  category: PASTEL,
38  fallback: ['#C8C8C8', '#9CD3B0', '#D9B38C'],
39  track: '#3A3E47',
40  mark: '#F28B9B',
41  chipInk: '#1E2127',
42  dim: '#8A909B',
43  bright: '#E8EAED',
44  warn: '#F0C987',
45  danger: '#F28B9B',
46  agentAccent: '#C3B1F5',
47  agentDone: '#A8C98A',
48}
49
50const THEME: CtxPalette = {
51  category: {
52    Messages: 'claude',
53    'System tools': 'suggestion',
54    Skills: 'warning',
55    'Memory files': 'remember',
56    'System prompt': 'inactive',
57    'MCP tools': 'permission',
58    'Custom agents': 'success',
59    'MCP server instructions': 'planMode',
60  },
61  fallback: ['subtle'],
62  track: 'inactive',
63  mark: 'error',
64  chipInk: 'inverseText',
65  dim: 'subtle',
66  bright: 'text',
67  warn: 'warning',
68  danger: 'error',
69  agentAccent: 'claude',
70  agentDone: 'success',
71}
72
73// 원본 statusline-ctx.py의 PASTEL/TRACK/MARK/CHIP_INK/DIM/BRIGHT/WARN/DANGER 그대로.
74const GRAY: CtxPalette = {
75  category: PASTEL,
76  fallback: ['#C8C8C8', '#9CD3B0', '#D9B38C'],
77  track: '#AEB0BA',
78  mark: '#9F1239',
79  chipInk: '#1F232E',
80  dim: '#5B6070',
81  bright: '#111827',
82  warn: '#7C4A03',
83  danger: '#9F1239',
84  agentAccent: '#5B21B6',
85  agentDone: '#166534',
86}
87
88export type PaletteName = 'dark' | 'theme' | 'gray'
89
90export function paletteName(name: unknown): PaletteName {
91  return name === 'gray' || name === 'theme' ? name : 'dark'
92}
93
94export function paletteOf(name: unknown): CtxPalette {
95  const n = paletteName(name)
96  return n === 'gray' ? GRAY : n === 'theme' ? THEME : DARK
97}
98
99/** 분류 이름의 색. 모르는 분류면 fallback을 순서대로 돌려 쓴다. */
100export function categoryColor(palette: CtxPalette, name: string, index: number): string {
101  return palette.category[name] ?? palette.fallback[index % palette.fallback.length] ?? palette.dim
102}
103
hooks/paths.ts 42 lines
1// ctx-strip의 경로·스냅샷 계산. $를 받지 않는 순수 함수만 둔다 — mods API는 항상
2// $.네임스페이스.메서드(...)로 호출 지점에서 끝까지 적어야 하므로, $.env/$.fs/$.process를
3// 값으로 넘기는 도우미를 만들 수 없다(정적 분석 규칙). 실제 $ 호출은 register.ts 쪽
4// 최상위 함수(tempDir/ensureDir/openPath/writeSnapshot)에 그대로 두고, 그 함수들이 여기
5// 순수 함수에 "이미 읽어 온 값"만 건넨다.
6import type { SessionContextBreakdown } from 'claude-code'
7
8/**
9 * 세션마다 다른 사람이 쓸 수 있는 공용 /tmp 대신, 사람별 임시 폴더 이름.
10 * TMPDIR(맥은 사람마다 다른 경로)이 없으면 /tmp, 사용자 이름도 없으면 "user".
11 * 같은 공식을 extras/statusline-ctx.py 쪽에서도 그대로 써서 서로 다른 경로를 보지 않게 맞춘다.
12 */
13export function tempDirFrom(tmpdir: string | undefined, user: string | undefined): string {
14  const base = tmpdir && tmpdir.length > 0 ? tmpdir : '/tmp'
15  const name = user && user.length > 0 ? user : 'user'
16  return `${base.replace(/\/+$/, '')}/ctx-strip-${name}`
17}
18
19export type SnapshotPayload = {
20  /** 상태줄 스크립트가 같은 색을 쓰도록 팔레트 이름을 함께 적는다 */
21  palette: string
22  percentage: number
23  totalTokens: number
24  maxTokens: number
25  autoCompactThreshold: number | null
26  delta: number | null
27  categories: Array<{ name: string; tokens: number }>
28}
29
30/** statusline 모드가 쓰는 JSON 스냅샷의 내용(원본 writeSnapshot과 같은 필드). */
31export function snapshotPayload(breakdown: SessionContextBreakdown, delta: number | null, palette: string = 'dark'): SnapshotPayload {
32  return {
33    palette,
34    percentage: breakdown.percentage,
35    totalTokens: breakdown.totalTokens,
36    maxTokens: breakdown.rawMaxTokens || breakdown.maxTokens,
37    autoCompactThreshold: breakdown.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
38    delta,
39    categories: breakdown.categories.filter((c) => c.kind === 'used' && c.tokens > 0).map((c) => ({ name: c.name, tokens: c.tokens })),
40  }
41}
42
hooks/report.ts 104 lines
1// /ctx가 여는 HTML 상세 리포트. 원본 register.mjs의 writeReport를 그대로 옮긴 것으로,
2// 브라우저에서 보는 별도 페이지라 터미널 팔레트(palette.ts)를 타지 않고 원본 색을 그대로 쓴다.
3import type { SessionContextBreakdown, SessionMessage } from 'claude-code'
4
5import { describe, esc, est, k, oneLine } from './format'
6import { LONG_LABEL } from './categories'
7
8// 흰 배경 브라우저용 색(회색 터미널용 파스텔과는 다른, 원본 register.mjs의 고정 색).
9const REPORT_COLOR: Record<string, string> = {
10  Messages: '#166534',
11  'System tools': '#1d4ed8',
12  Skills: '#9a3412',
13  'Memory files': '#a21caf',
14  'System prompt': '#475569',
15  'MCP tools': '#6d28d9',
16  'Custom agents': '#0f766e',
17  'MCP server instructions': '#5b21b6',
18}
19
20export type HeavyItem = { kind: string; label: string; tokens: number }
21
22type ToolUseLike = { tool: string; input?: unknown; text?: string }
23
24/** 대화 기록에서 글자 수로 추정해 가장 큰 항목 15개를 고른다(토큰 ≈ 글자 수 / 3.5). */
25export function heaviestItems(messages: ReadonlyArray<SessionMessage>): HeavyItem[] {
26  const items: HeavyItem[] = []
27  for (const m of messages) {
28    if (m.text) items.push({ kind: m.role === 'user' ? '내 메시지' : 'Claude 답변', label: oneLine(m.text, 90), tokens: est(m.text) })
29    for (const u of (m.toolUses ?? []) as ToolUseLike[]) {
30      const size = est(JSON.stringify(u.input ?? {})) + est(u.text ?? '')
31      items.push({ kind: `도구 · ${u.tool}`, label: describe(u), tokens: size })
32    }
33  }
34  return items.sort((x, y) => y.tokens - x.tokens).slice(0, 15)
35}
36
37/** HOME 아래 경로를 ~로 줄인다. */
38export function shortenHome(path: string, home: string | undefined): string {
39  return home && path.startsWith(home) ? '~' + path.slice(home.length) : path
40}
41
42export function buildReportHtml(b: SessionContextBreakdown, heavy: HeavyItem[], home: string | undefined): string {
43  const max = b.rawMaxTokens || b.maxTokens || 1
44  const used = b.categories.filter((c) => c.kind === 'used' && c.tokens > 0)
45  const short = (p: string) => shortenHome(p, home)
46  const pct = (n: number) => `${((n / max) * 100).toFixed(n / max >= 0.1 ? 0 : 1)}%`
47
48  const bar = used
49    .map((c) => `<span style="width:${(c.tokens / max) * 100}%;background:${REPORT_COLOR[c.name] ?? '#374151'}" title="${esc(c.name)}"></span>`)
50    .join('')
51  const mark = b.isAutoCompactEnabled && b.autoCompactThreshold ? `<i style="left:${(b.autoCompactThreshold / max) * 100}%"></i>` : ''
52
53  const rows = <T,>(list: readonly T[], cols: Array<{ head: string; num?: boolean; get: (r: T) => string | number }>) =>
54    list.map((r) => `<tr>${cols.map((c) => `<td${c.num ? ' class="n"' : ''}>${c.get(r)}</td>`).join('')}</tr>`).join('')
55  const table = <T,>(title: string, list: readonly T[], cols: Array<{ head: string; num?: boolean; get: (r: T) => string | number }>) =>
56    list.length === 0 ? '' : `<h2>${title}</h2><table><tr>${cols.map((c) => `<th${c.num ? ' class="n"' : ''}>${c.head}</th>`).join('')}</tr>${rows(list, cols)}</table>`
57  const tok = { head: '토큰', num: true, get: (r: { tokens: number }) => k(r.tokens) }
58  const share = { head: '창 대비', num: true, get: (r: { tokens: number }) => pct(r.tokens) }
59
60  const mcpByServer = Object.values(
61    (b.mcpTools ?? []).reduce<Record<string, { name: string; tokens: number; count: number }>>((acc, t) => {
62      const key = t.serverName
63      acc[key] = acc[key] ?? { name: key, tokens: 0, count: 0 }
64      acc[key].tokens += t.tokens
65      acc[key].count += 1
66      return acc
67    }, {}),
68  ).sort((x, y) => y.tokens - x.tokens)
69
70  return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta http-equiv="refresh" content="15">
71<title>컨텍스트 ${b.percentage}%</title><style>
72body{font:14px/1.5 -apple-system,"Apple SD Gothic Neo",sans-serif;background:#eceef2;color:#1f2937;max-width:880px;margin:24px auto;padding:0 16px}
73h1{font-size:22px;margin:0 0 4px}h2{font-size:15px;margin:28px 0 8px}.sub{color:#4b5563;font-size:13px}
74.bar{position:relative;display:flex;height:22px;background:#c9cbd3;border-radius:6px;overflow:hidden;margin:14px 0 6px}
75.bar span{display:block;height:100%}.bar i{position:absolute;top:0;bottom:0;width:2px;background:#9f1239}
76table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden}
77th,td{padding:6px 10px;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top}th{background:#f3f4f6;font-weight:600;font-size:12px;color:#4b5563}
78td.n,th.n{text-align:right;white-space:nowrap}.dot{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:6px;vertical-align:-1px}
79code{font:12px ui-monospace,Menlo,monospace;color:#374151;word-break:break-all}
80</style></head><body>
81<h1>컨텍스트 ${b.percentage}% <span class="sub">${k(b.totalTokens)} / ${k(max)} · ${esc(b.model)}</span></h1>
82<div class="sub">${new Date().toLocaleString('ko-KR')} 기준 · /ctx를 다시 실행하면 갱신 · 값은 추정치(/context의 summary 방식)${b.isAutoCompactEnabled && b.autoCompactThreshold ? ` · 빨간 선 = 자동 압축 지점(${k(b.autoCompactThreshold)})` : ''}</div>
83<div class="bar">${bar}${mark}</div>
84${table(
85  '분류별',
86  used,
87  [
88    { head: '분류', get: (r) => `<span class="dot" style="background:${REPORT_COLOR[r.name] ?? '#374151'}"></span>${esc(LONG_LABEL[r.name] ?? r.name)}` },
89    tok,
90    share,
91  ],
92)}
93${table('대화에서 가장 큰 항목 (추정)', heavy, [{ head: '종류', get: (r) => esc(r.kind) }, { head: '내용', get: (r) => `<code>${esc(r.label)}</code>` }, tok])}
94${table('메모리 파일', [...(b.memoryFiles ?? [])].sort((x, y) => y.tokens - x.tokens), [{ head: '경로', get: (r) => `<code>${esc(short(r.path))}</code>` }, tok])}
95${table('MCP 서버', mcpByServer, [{ head: '서버', get: (r) => esc(r.name) }, { head: '도구 수', num: true, get: (r) => r.count }, tok])}
96${table(
97  '스킬 (상위 15)',
98  [...(b.skills?.skillFrontmatter ?? [])].sort((x, y) => y.tokens - x.tokens).slice(0, 15),
99  [{ head: '스킬', get: (r) => esc(r.name) }, { head: '출처', get: (r) => esc(r.pluginName ?? r.source) }, tok],
100)}
101${table('에이전트', [...(b.agents ?? [])].sort((x, y) => y.tokens - x.tokens), [{ head: '에이전트', get: (r) => esc(r.agentType) }, { head: '출처', get: (r) => esc(r.source) }, tok])}
102</body></html>`
103}
104