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

컨텍스트 창이 무엇(대화·도구·스킬·메모리…)으로 차 있는지 입력창 위 막대로 보여주고, 서브에이전트가 돌면 한 줄로 알려줘요.
/plugin marketplace add SeongGwangJu/k-mods
/plugin install ctx-strip@k-mods
display: band)이면 따로 할 일이 없어요. 입력창 위에 막대·퍼센트·범례가 자동으로 떠요.컨텍스트 38% 한 줄만.+3.7k처럼 증가량이, 자동 압축 지점이 가까워지면 "압축까지 Nk"가 덧붙어요./ctx를 치면 더 자세한 내용(분류별 토큰, 대화에서 가장 큰 항목, 메모리 파일, MCP 서버, 스킬, 에이전트)을 담은 리포트를 만들어서 바로 열어줘요.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`를 한 번이라도 실행해야 생겨요. 원본과 달리 매 턴 자동으로 쓰지 않아요(아래 "한계" 참고).dark는 어두운 터미널용 파스텔로, 분류마다 색이 겹치지 않고 빈 구간은 배경보다 살짝 밝은 옅은 홈으로 그려요. 밝은 터미널이면 theme, 회색 배경 터미널이면 gray로 바꾸세요. statusline 모드의 상태줄 스크립트도 이 설정을 따라가요. /ctx가 여는 HTML 리포트는 브라우저에서 보는 별도 페이지라 이 설정과 상관없이 항상 같은 모습이에요.$.session.usage({ breakdown: 'summary' })로 컨텍스트 구성을 다시 재요. 로컬 추정이라 API를 부르지 않아요. /clear·압축 뒤에는(classic.SessionStart) 구성이 크게 바뀌니 증가량 표시를 지우고 다시 재요.next(e))은 지우지 않고 그 아래에 이어 그려요. 설문(survey)이 떠 있으면 아예 양보해요./ctx를 실행할 때: 지금 구성을 다시 재서 HTML 리포트를 쓰고, macOS는 open, 그 외(Linux)는 xdg-open으로 열어요.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
hooks/register.ts 275 lines1// 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}
275hooks/bar.ts 113 lines1// 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}
113hooks/categories.ts 25 lines1// 컨텍스트 분류 이름(엔진이 주는 영어 이름) → 한국어 표시.
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}
25hooks/format.ts 37 lines1// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
37hooks/palette.ts 103 lines1// 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}
103hooks/paths.ts 42 lines1// 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}
42hooks/report.ts 104 lines1// /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