Creator HUD above the prompt: live usage bars, a REC mode that masks secrets on screen, and an Explain pane that recaps the last turn.

Mods for Claude Code by DFA Course Academy.
| Mod | What it does |
|---|---|
| studio | Creator HUD above the prompt: live context, 5-hour and weekly limit bars, session cost and a last-turn summary. REC mode masks secrets, emails and paths on screen while you record. Explain opens a pane that recaps the last turn. |
| token-meter | Tokens and estimated API cost of the last prompt and the whole session in the status line. /token-cost prints the breakdown. |
In a Claude Code terminal session, type:
/plugin install studio --marketplace krystianwoj22/claude-mods
Answer y to add the marketplace, then press Enter to install at the user scope (all your sessions). The mod is active right away.
For token-meter:
/plugin install token-meter --marketplace krystianwoj22/claude-mods
The /plugin command only works in a terminal session. You can also install from any shell:
claude plugin marketplace add krystianwoj22/claude-mods
claude plugin install studio@krystian-mods
A mod installed at the user scope also loads in the Code tab of the Claude desktop app.
| Control | What it does |
|---|---|
R / REC button / /rec | Toggle REC mode: secrets, API keys, emails and file paths are masked on screen |
/rec hide <text> · /rec show <text> · /rec list | Add, remove or list your own words to mask |
E / Explain button / /explain | Recap the last turn in the Explain pane |
REC mode masks what is drawn on screen. It is a recording aid, not a security boundary: always check your recording before publishing.
claude plugin marketplace update krystian-mods
Then restart Claude Code or run /reload-plugins.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module. Check and test a mod with:
claude plugin validate studio
claude plugin test studiohooks/register.tsx 720 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Explanation, Limit, ToolStep, TurnRecord, Usage } from '../types'
5import { redactDeep, redactText } from './redact'
6
7const rec = atom({ plugin: 'studio', key: 'rec' } as const, false)
8const recWords = atom({ plugin: 'studio', key: 'recWords' } as const, [])
9const usage = atom({ plugin: 'studio', key: 'usage' } as const, null)
10const now = atom({ plugin: 'studio', key: 'now' } as const, 0)
11const prompts = atom({ plugin: 'studio', key: 'prompts' } as const, 0)
12const branch = atom({ plugin: 'studio', key: 'branch' } as const, '')
13const project = atom({ plugin: 'studio', key: 'project' } as const, '')
14const turns = atom({ plugin: 'studio', key: 'turns' } as const, [])
15const ctxTrend = atom({ plugin: 'studio', key: 'ctxTrend' } as const, [])
16const explains = atom({ plugin: 'studio', key: 'explains' } as const, [])
17const view = atom({ plugin: 'studio', key: 'view' } as const, -1)
18const busy = atom({ plugin: 'studio', key: 'busy' } as const, false)
19const error = atom({ plugin: 'studio', key: 'error' } as const, '')
20const termsLearned = atom({ plugin: 'studio', key: 'termsLearned' } as const, 0)
21
22const PANE = 'studio-explain'
23const STORE_REC = 'studio.rec'
24const STORE_WORDS = 'studio.recWords'
25const STORE_TERMS = 'studio.terms'
26
27// ── formatting ────────────────────────────────────────────────────────────
28
29const dur = (ms: number): string => {
30 const s = Math.max(0, Math.round(ms / 1000))
31 if (s < 60) return `${s}s`
32 const m = Math.floor(s / 60)
33 if (m < 60) return `${m}m ${s % 60}s`
34 const h = Math.floor(m / 60)
35 if (h < 24) return `${h}h ${m % 60}m`
36 return `${Math.floor(h / 24)}d ${h % 24}h`
37}
38
39const short = (ms: number): string => {
40 const m = Math.max(0, Math.round(ms / 60000))
41 if (m < 60) return `${m}m`
42 const h = Math.floor(m / 60)
43 return h < 24 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`
44}
45
46const tokens = (n: number): string =>
47 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
48
49const money = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
50
51const heat = (pct: number): 'success' | 'warning' | 'error' => (pct < 50 ? 'success' : pct < 80 ? 'warning' : 'error')
52
53// Fixed twins of the theme's status colours for the Svg bar: an Svg is drawn as an image and reads no theme.
54const HEAT_HEX = { success: '#3fb950', warning: '#d29922', error: '#f85149' } as const
55
56const clampPct = (pct: number): number => Math.min(100, Math.max(0, pct))
57
58// Terminal bar: a thin line, the used part in the heat colour.
59const bar = (pct: number, width: number): [string, string] => {
60 const p = clampPct(pct)
61 const filled = p > 0 ? Math.max(1, Math.round((p / 100) * width)) : 0
62 return ['━'.repeat(filled), '─'.repeat(width - filled)]
63}
64
65// Desktop bar: a slim rounded track, readable on light and dark themes.
66const barSvg = (pct: number, px: number): string => {
67 const p = clampPct(pct)
68 const fill = p > 0 ? Math.max(6, Math.round((p / 100) * px)) : 0
69 return [
70 `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="6" viewBox="0 0 ${px} 6">`,
71 `<rect width="${px}" height="6" rx="3" fill="#8b949e" fill-opacity="0.3"/>`,
72 fill ? `<rect width="${fill}" height="6" rx="3" fill="${HEAT_HEX[heat(p)]}"/>` : '',
73 '</svg>',
74 ].join('')
75}
76
77const SPARK = '▁▂▃▄▅▆▇█'
78const spark = (values: number[]): string =>
79 values.map(v => SPARK[Math.min(7, Math.floor((Math.max(0, v) / 100) * 8))]).join('')
80
81const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
82const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
83const clockStamp = (ms: number): string => {
84 const d = new Date(ms)
85 const hh = String(d.getHours()).padStart(2, '0')
86 const mm = String(d.getMinutes()).padStart(2, '0')
87 return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} · ${hh}:${mm}`
88}
89
90const modelName = (id: string): string => id.replace(/^claude-/, '').replace(/-\d{8}$/, '')
91
92const basename = (p: string): string => p.replace(/\/+$/, '').split('/').pop() ?? p
93
94const shortPath = (p: string): string => {
95 const parts = p.split('/').filter(Boolean)
96 return parts.length <= 2 ? parts.join('/') : parts.slice(-2).join('/')
97}
98
99// ── data ──────────────────────────────────────────────────────────────────
100
101const snapshot = async ($: EngineInterface): Promise<Usage> => {
102 const u = await $.session.usage()
103 const limits: Limit[] = u.rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt ?? null }))
104 return {
105 ctxPercent: u.context.percent ?? null,
106 ctxTokens: u.context.tokens ?? null,
107 window: u.context.window,
108 limits,
109 usd: u.cost?.usd ?? null,
110 startedAt: u.startedAt,
111 }
112}
113
114const refreshRepo = async ($: EngineInterface) => {
115 const cwd = await $.session.cwd()
116 await update($, project, () => basename(cwd))
117 try {
118 const r = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: 2000 })
119 await update($, branch, () => (r.exitCode === 0 ? r.stdout.trim() : ''))
120 } catch {
121 await update($, branch, () => '')
122 }
123}
124
125const targetOf = (e: Record<string, unknown>): string => {
126 const pick = ['file_path', 'notebook_path', 'command', 'pattern', 'url', 'query', 'description', 'prompt']
127 for (const k of pick) {
128 const v = e[k]
129 if (typeof v === 'string' && v.trim()) return v.trim().replace(/\s+/g, ' ').slice(0, 90)
130 }
131 return ''
132}
133
134const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
135
136const toolTally = (steps: ToolStep[]): string => {
137 const counts = new Map<string, number>()
138 for (const s of steps) {
139 const name = (s.tool.startsWith('mcp__') ? s.tool.split('__').pop() : s.tool) ?? s.tool
140 counts.set(name, (counts.get(name) ?? 0) + 1)
141 }
142 return [...counts.entries()]
143 .sort((a, b) => b[1] - a[1])
144 .slice(0, 6)
145 .map(([t, n]) => (n > 1 ? `${t}×${n}` : t))
146 .join(' ')
147}
148
149// ── explain ───────────────────────────────────────────────────────────────
150
151const explainPrompt = (turn: TurnRecord | undefined): string => {
152 const activity = turn
153 ? [
154 `The person's last prompt: """${turn.prompt}"""`,
155 `Tool calls recorded during the turn (${turn.tools.length}):`,
156 ...turn.tools.slice(0, 40).map((t, i) => `${i + 1}. ${t.tool}${t.target ? ` — ${t.target}` : ''}`),
157 turn.files.length ? `Files edited: ${turn.files.join(', ')}` : 'No files edited.',
158 `Turn took ${dur(turn.durationMs)}.`,
159 `The assistant's final reply (may be cut): """${turn.answer.slice(0, 4000)}"""`,
160 ].join('\n')
161 : 'No turn activity was recorded; use the conversation itself.'
162
163 return [
164 'Write a short recap card for the person driving this Claude Code session.',
165 'Cover ONLY the most recent user prompt and what the assistant did in response to it.',
166 'Audience: a smart creator who is not a software engineer. Plain English. Concrete, compressed, no filler.',
167 'Do not call any tools. Reply with ONLY one JSON object, no code fences, no prose around it:',
168 '{"title": "max 8 words",',
169 ' "tldr": "1-2 sentences: what changed and the outcome",',
170 ' "steps": ["3-7 items, each max 16 words, past tense, action + result"],',
171 ' "why": "1-2 sentences on the approach / reasoning",',
172 ' "terms": [{"term": "a technical word actually used", "meaning": "max 18 words, an everyday analogy is welcome"}],',
173 ' "risk": "anything irreversible, risky or still unverified; empty string if none",',
174 ' "next": "what the person should do now, or \\"Nothing — all done.\\""}',
175 'Use 0-4 terms. Never include secrets, keys or personal data.',
176 '',
177 activity,
178 ].join('\n')
179}
180
181const parseExplanation = (text: string, turnId: string, tokens: number, at: number): Explanation => {
182 const start = text.indexOf('{')
183 const end = text.lastIndexOf('}')
184 try {
185 const j = JSON.parse(text.slice(start, end + 1)) as Partial<Explanation>
186 const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
187 return {
188 turnId,
189 title: str(j.title) || 'Last turn',
190 tldr: str(j.tldr),
191 steps: Array.isArray(j.steps) ? j.steps.map(str).filter(Boolean).slice(0, 8) : [],
192 why: str(j.why),
193 terms: Array.isArray(j.terms)
194 ? j.terms
195 .map(t => ({ term: str(t?.term), meaning: str(t?.meaning) }))
196 .filter(t => t.term && t.meaning)
197 .slice(0, 5)
198 : [],
199 risk: str(j.risk),
200 next: str(j.next),
201 createdAt: at,
202 tokens,
203 }
204 } catch {
205 return { turnId, title: 'Last turn', tldr: text.trim().slice(0, 1200), steps: [], why: '', terms: [], risk: '', next: '', createdAt: at, tokens }
206 }
207}
208
209const learnTerms = async ($: EngineInterface, ex: Explanation) => {
210 const known = new Set(((await $.store.get(STORE_TERMS)) as string[] | undefined) ?? [])
211 for (const t of ex.terms) known.add(t.term.toLowerCase())
212 await $.store.set(STORE_TERMS, [...known].slice(-2000))
213 await update($, termsLearned, () => known.size)
214}
215
216const openPane = ($: EngineInterface) =>
217 $.ui.open({ id: PANE, title: 'Explain', focus: true, rows: 34, columns: 76 })
218
219const explainLatest = async ($: EngineInterface) => {
220 await openPane($)
221 if (await read($, busy)) return
222 await update($, busy, () => true)
223 await update($, error, () => '')
224 try {
225 const list = await read($, turns)
226 const turn = list[list.length - 1]
227 const already = (await read($, explains)).findIndex(x => turn && x.turnId === turn.id)
228 if (already >= 0) {
229 await update($, view, () => already)
230 return
231 }
232 const r = await $.model.fork({ prompt: explainPrompt(turn) })
233 if (!r.isAnswered) {
234 const why =
235 r.reason === 'nothing-to-fork'
236 ? 'Nothing to explain yet — send a prompt first.'
237 : r.reason === 'api-error'
238 ? `The model call failed (${r.error}${r.status ? `, ${r.status}` : ''}).`
239 : r.reason === 'aborted'
240 ? 'The explanation was interrupted.'
241 : 'The model returned no text.'
242 await update($, error, () => why)
243 return
244 }
245 const fresh = r.usage.input_tokens + r.usage.output_tokens + r.usage.cache_creation_input_tokens
246 const ex = parseExplanation(r.text, turn?.id ?? 'session', fresh, await $.clock.now())
247 let index = 0
248 await update($, explains, xs => {
249 const next = [...xs, ex].slice(-30)
250 index = next.length - 1
251 return next
252 })
253 await update($, view, () => index)
254 await learnTerms($, ex)
255 } finally {
256 await update($, busy, () => false)
257 }
258}
259
260const setRec = async ($: EngineInterface, on: boolean) => {
261 await update($, rec, () => on)
262 await $.store.set(STORE_REC, on)
263 $.ui.status(on ? '● REC — screen masking on' : undefined)
264 $.ui.toast(on ? '● REC on — secrets, emails and paths are masked on screen' : 'REC off')
265}
266
267// ── hooks ─────────────────────────────────────────────────────────────────
268
269export const register: Register = on => {
270 let current: { prompt: string; startedAt: number; usdBefore: number; tools: ToolStep[]; files: Set<string> } | null = null
271
272 on('session.start', async ($, e, next) => {
273 const r = await next(e)
274 await $.command.register({ name: 'rec', description: 'Toggle REC mode (mask secrets on screen). /rec hide <text> · /rec show <text> · /rec list' })
275 await $.command.register({ name: 'explain', description: 'Recap the last turn in the Explain pane' })
276 const storedRec = (await $.store.get(STORE_REC)) === true
277 const words = ((await $.store.get(STORE_WORDS)) as string[] | undefined) ?? []
278 const terms = ((await $.store.get(STORE_TERMS)) as string[] | undefined) ?? []
279 await update($, rec, () => storedRec)
280 await update($, recWords, () => words)
281 await update($, termsLearned, () => terms.length)
282 if (storedRec) $.ui.status('● REC — screen masking on')
283 await update($, usage, () => null)
284 try {
285 const snap = await snapshot($)
286 await update($, usage, () => snap)
287 } catch {}
288 await update($, now, () => 0)
289 const tick = async () => {
290 const t = await $.clock.now()
291 await update($, now, () => t)
292 }
293 await tick()
294 await refreshRepo($)
295 $.clock.every(30_000, () => void tick())
296 return r
297 })
298
299 on('session.measure', async ($, e, next) => {
300 const r = await next(e)
301 const t = await $.clock.now()
302 await update($, usage, u => ({
303 ctxPercent: e.context.percent ?? null,
304 ctxTokens: e.context.tokens ?? null,
305 window: e.context.window,
306 limits: e.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt ?? null })),
307 usd: e.cost?.usd ?? null,
308 startedAt: u?.startedAt ?? t,
309 }))
310 return r
311 })
312
313 on('prompt.submit', async ($, e, next) => {
314 if (e.turnId === undefined) {
315 const u = await read($, usage)
316 current = { prompt: e.text.slice(0, 600), startedAt: await $.clock.now(), usdBefore: u?.usd ?? 0, tools: [], files: new Set() }
317 await update($, prompts, n => n + 1)
318 }
319 return next(e)
320 }).catch(($, e, next) => next(e))
321
322 on('tool.call', async ($, e, next) => {
323 if (current && e.agentId === undefined) {
324 const input = e as unknown as Record<string, unknown>
325 current.tools.push({ tool: String(e.tool), target: targetOf(input) })
326 const file = input.file_path ?? input.notebook_path
327 if (EDIT_TOOLS.has(String(e.tool)) && typeof file === 'string') current.files.add(shortPath(file))
328 }
329 return next(e)
330 }).catch(($, e, next) => next(e))
331
332 on('turn.complete', async ($, e, next) => {
333 const r = await next(e)
334 if (e.agentId !== undefined) return r
335 const u = e.usage
336 const totalIn = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
337 let snap: Usage | null = null
338 try {
339 snap = await snapshot($)
340 await update($, usage, () => snap)
341 } catch {}
342 const record: TurnRecord = {
343 id: e.turnId,
344 prompt: current?.prompt ?? '',
345 answer: e.answer,
346 tools: current?.tools ?? [],
347 files: [...(current?.files ?? [])],
348 durationMs: e.durationMs,
349 model: modelName(u?.model ?? (await $.session.model())),
350 // -1: the mod did not see this turn start, so its own cost is unknown
351 usd: current && snap?.usd != null ? Math.max(0, snap.usd - current.usdBefore) : -1,
352 cacheHit: u && totalIn > 0 ? Math.round((u.cache_read_input_tokens / totalIn) * 100) : null,
353 endedAt: await $.clock.now(),
354 }
355 current = null
356 await update($, turns, xs => [...xs, record].slice(-30))
357 if (snap?.ctxPercent != null) await update($, ctxTrend, xs => [...xs, snap!.ctxPercent as number].slice(-16))
358 const t = await $.clock.now()
359 await update($, now, () => t)
360 await refreshRepo($)
361 if (e.durationMs > 120_000 && !e.isAborted) $.ui.toast(`Done in ${dur(e.durationMs)} — press ctrl+x tab, then e to explain`)
362 return r
363 })
364
365 on('command.run', { command: 'explain' }, async $ => {
366 await explainLatest($)
367 const err = await read($, error)
368 return { text: err || 'Recap ready in the Explain pane.' }
369 })
370
371 on('command.run', { command: 'rec' }, async ($, e) => {
372 const [verb, ...rest] = e.args.trim().split(/\s+/)
373 const word = rest.join(' ').trim()
374 const words = await read($, recWords)
375 if (verb === 'hide' && word) {
376 const next = [...new Set([...words, word])]
377 await update($, recWords, () => next)
378 await $.store.set(STORE_WORDS, next)
379 return { text: `REC will also mask that term (${next.length} custom terms).` }
380 }
381 if (verb === 'show' && word) {
382 const next = words.filter(w => w.toLowerCase() !== word.toLowerCase())
383 await update($, recWords, () => next)
384 await $.store.set(STORE_WORDS, next)
385 return { text: `Removed. ${next.length} custom terms left.` }
386 }
387 if (verb === 'list') return { text: words.length ? `${words.length} custom masked terms (not shown on purpose).` : 'No custom masked terms.' }
388 const target = verb === 'on' ? true : verb === 'off' ? false : !(await read($, rec))
389 await setRec($, target)
390 return { text: target ? 'REC on.' : 'REC off.' }
391 })
392
393 // ── REC: mask what the transcript draws ─────────────────────────────────
394
395 on('ui.render', { component: 'UserMessage' }, async ($, e, next) =>
396 (await read($, rec)) ? next({ ...e, props: { ...e.props, text: redactText(e.props.text, await read($, recWords)) } }) : next(e),
397 )
398
399 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) =>
400 (await read($, rec)) ? next({ ...e, props: { ...e.props, text: redactText(e.props.text, await read($, recWords)) } }) : next(e),
401 )
402
403 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
404 if (!(await read($, rec))) return next(e)
405 const w = await read($, recWords)
406 return next({
407 ...e,
408 props: { ...e.props, input: redactDeep(e.props.input, w), ...(e.props.output === undefined ? {} : { output: redactDeep(e.props.output, w) }) },
409 })
410 })
411
412 on('ui.render', { component: 'ToolResult' }, async ($, e, next) =>
413 (await read($, rec)) ? next({ ...e, props: { ...e.props, output: redactDeep(e.props.output, await read($, recWords)) } }) : next(e),
414 )
415
416 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
417 if (!(await read($, rec))) return next(e)
418 const w = await read($, recWords)
419 return next({
420 ...e,
421 props: {
422 ...e.props,
423 calls: e.props.calls.map(c => ({ ...c, input: redactDeep(c.input, w), ...(c.output === undefined ? {} : { output: redactDeep(c.output, w) }) })),
424 },
425 })
426 })
427
428 // ── HUD above the prompt ────────────────────────────────────────────────
429
430 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
431 if (e.props.hasSurvey || e.props.view.agentId) return next(e)
432 const { Box, Text, Button } = $.ui.resolve(e)
433 const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e).Svg
434
435 const u = await read($, usage)
436 const t = await read($, now)
437 const list = await read($, turns)
438 const last = list[list.length - 1]
439 const isRec = await read($, rec)
440 const n = await read($, prompts)
441 const proj = await read($, project)
442 const br = await read($, branch)
443 const trend = await read($, ctxTrend)
444
445 const five = u?.limits.find(l => l.kind === 'five_hour')
446 const week = u?.limits.find(l => l.kind === 'seven_day')
447 const spend = u?.limits.find(l => l.kind === 'spend_limit')
448 const resetIn = (l: Limit) => (l.resetsAt ? `resets in ${short(Date.parse(l.resetsAt) - t)}` : '')
449
450 // Layout: a header, one card per metric sharing the width, a footer for the last turn.
451 const GAP = 3
452 const inner = e.props.bodyColumns - 2
453 const cardCount = 2 + (five ? 1 : 0) + (week ? 1 : 0) + (spend ? 1 : 0)
454 const cardW = Math.max(18, Math.min(30, Math.floor((inner - GAP * (cardCount - 1)) / cardCount)))
455 const barW = cardW - 6
456 // Blank rows between the sections, dropped when the band is short of room.
457 const air = e.props.maxRows >= 7 ? 1 : 0
458
459 const card = (label: string, value: RenderElement, detail: RenderElement | string) => (
460 <Box flexDirection="column" width={cardW}>
461 <Text dimColor bold>
462 {label}
463 </Text>
464 {value}
465 {typeof detail === 'string' ? (
466 <Text dimColor wrap="truncate-end">
467 {detail || ' '}
468 </Text>
469 ) : (
470 detail
471 )}
472 </Box>
473 )
474
475 const meter = (pct: number | null) => {
476 const p = clampPct(pct ?? 0)
477 const label = pct == null ? '—' : `${Math.round(p)}%`
478 const [full, empty] = bar(p, barW)
479 return (
480 <Box flexDirection="row" alignItems="center">
481 {Svg ? (
482 <Svg source={barSvg(p, barW * 6)} alt={`${label} used`} width={barW * 6} height={6} />
483 ) : (
484 <Box flexDirection="row">
485 <Text color={heat(p)}>{full}</Text>
486 <Text dimColor>{empty}</Text>
487 </Box>
488 )}
489 <Text bold color={p >= 50 ? heat(p) : undefined}>
490 {' '}
491 {label}
492 </Text>
493 </Box>
494 )
495 }
496
497 const ctxText = u?.ctxTokens != null ? `${tokens(u.ctxTokens)} of ${tokens(u.window)}` : ''
498 const sparkRoom = cardW - ctxText.length - 2
499 const ctxDetail = (
500 <Box flexDirection="row">
501 <Text dimColor>{ctxText || ' '}</Text>
502 {trend.length > 1 && sparkRoom >= 4 ? <Text color="suggestion">{` ${spark(trend.slice(-sparkRoom))}`}</Text> : null}
503 </Box>
504 )
505
506 const elapsed = u ? short(t - u.startedAt) : ''
507 const session = [elapsed, `${n} ${n === 1 ? 'prompt' : 'prompts'}`].filter(Boolean).join(' · ')
508
509 const turnFacts = last
510 ? [
511 dur(last.durationMs),
512 last.usd > 0 ? money(last.usd) : '',
513 last.model,
514 last.cacheHit != null ? `cache ${last.cacheHit}%` : '',
515 `${last.tools.length} tool ${last.tools.length === 1 ? 'call' : 'calls'}${last.tools.length ? ` (${toolTally(last.tools)})` : ''}`,
516 last.files.length ? `${last.files.length} ${last.files.length === 1 ? 'file' : 'files'} edited` : '',
517 ]
518 .filter(Boolean)
519 .join(' · ')
520 : ''
521
522 return (
523 <Box flexDirection="column" paddingX={1}>
524 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
525 <Box flexDirection="row" flexShrink={1}>
526 <Text bold wrap="truncate-end">
527 {proj || 'Session'}
528 </Text>
529 {br ? (
530 <Text color="success" wrap="truncate-end">
531 {' ⎇ '}
532 {br}
533 </Text>
534 ) : null}
535 </Box>
536 <Box flexDirection="row" gap={1}>
537 <Button
538 key="rec"
539 hotkey="r"
540 label={isRec ? '● REC' : '○ REC'}
541 variant={isRec ? 'primary' : 'secondary'}
542 onPress={() => setRec($, !isRec)}
543 />
544 <Button key="explain" hotkey="e" label="✦ Explain" onPress={() => explainLatest($)} />
545 </Box>
546 </Box>
547
548 <Box flexDirection="row" flexWrap="wrap" columnGap={GAP} rowGap={air} marginTop={air}>
549 {card('CONTEXT', meter(u?.ctxPercent ?? null), ctxDetail)}
550 {five ? card('5-HOUR LIMIT', meter(five.percent), resetIn(five)) : null}
551 {week ? card('WEEKLY LIMIT', meter(week.percent), resetIn(week)) : null}
552 {spend ? card('SPEND LIMIT', meter(spend.percent), '') : null}
553 {card('SESSION', <Text bold>{u?.usd != null ? money(u.usd) : '—'}</Text>, session)}
554 </Box>
555
556 <Box flexDirection="row" marginTop={air}>
557 <Text dimColor bold>
558 {'LAST TURN '}
559 </Text>
560 <Text dimColor={!last} wrap="truncate-end">
561 {last ? turnFacts : 'waiting for the first prompt'}
562 </Text>
563 </Box>
564 </Box>
565 )
566 })
567
568 // ── Explain pane ────────────────────────────────────────────────────────
569
570 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
571 const { Box, Text, Button } = $.ui.resolve(e)
572 const xs = await read($, explains)
573 const i = await read($, view)
574 const isBusy = await read($, busy)
575 const err = await read($, error)
576 const isRec = await read($, rec)
577 const words = await read($, recWords)
578 const learned = await read($, termsLearned)
579 const list = await read($, turns)
580 const proj = await read($, project)
581 const cols = e.props.bodyColumns
582 const mask = (s: string) => (isRec ? redactText(s, words) : s)
583 const rule = <Text dimColor>{'─'.repeat(Math.max(10, Math.min(cols - 2, 60)))}</Text>
584 const ex = xs[i]
585 const turn = ex ? list.find(t => t.id === ex.turnId) : undefined
586
587 const heading = (icon: string, label: string, color: string) => (
588 <Box marginTop={1}>
589 <Text color={color} bold>
590 {icon} {label}
591 </Text>
592 </Box>
593 )
594
595 const nav = (
596 <Box flexDirection="row" gap={1} marginTop={1} flexWrap="wrap">
597 <Button key="older" hotkey="p" label="◀ Older" dimColor={i <= 0} onPress={() => void update($, view, v => Math.max(0, v - 1))} />
598 <Button key="newer" hotkey="n" label="Newer ▶" dimColor={i >= xs.length - 1} onPress={() => void update($, view, v => Math.min(xs.length - 1, v + 1))} />
599 <Button key="again" hotkey="e" label="↻ Explain latest" variant="primary" onPress={() => explainLatest($)} />
600 {ex ? (
601 <Button
602 key="copy"
603 hotkey="c"
604 label="⧉ Copy"
605 onPress={p =>
606 void $.ui.copy({
607 surface: p.surface,
608 text: mask(
609 [`# ${ex.title}`, ex.tldr, '', ...ex.steps.map((s, k) => `${k + 1}. ${s}`), '', `Why: ${ex.why}`, ex.next ? `Next: ${ex.next}` : '']
610 .filter(l => l !== undefined)
611 .join('\n'),
612 ),
613 })
614 }
615 />
616 ) : null}
617 <Button key="close" hotkey="x" label="Close" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
618 </Box>
619 )
620
621 if (!ex) {
622 return (
623 <Box flexDirection="column" paddingX={1}>
624 <Text color="claude" bold>
625 ✦ EXPLAIN
626 </Text>
627 <Box marginTop={1}>
628 <Text dimColor>{isBusy ? 'Reading the last turn and compressing it…' : err || 'No recap yet. Press ↻ Explain latest after a turn.'}</Text>
629 </Box>
630 {nav}
631 </Box>
632 )
633 }
634
635 const stamp = clockStamp(ex.createdAt)
636
637 return (
638 <Box flexDirection="column" paddingX={1}>
639 <Box flexDirection="row" justifyContent="space-between">
640 <Text color="claude" bold>
641 ✦ EXPLAIN
642 </Text>
643 <Text dimColor>
644 {i + 1} of {xs.length}
645 </Text>
646 </Box>
647 <Text bold>{mask(ex.title)}</Text>
648 <Text dimColor>
649 {proj ? `${proj} · ` : ''}
650 {stamp}
651 {turn ? ` · ${dur(turn.durationMs)}${turn.usd > 0 ? ` · ${money(turn.usd)}` : ''}` : ''}
652 </Text>
653 {isBusy ? <Text color="suggestion">↻ explaining the newest turn…</Text> : null}
654 {err ? <Text color="error">{err}</Text> : null}
655
656 {heading('◆', 'TL;DR', 'suggestion')}
657 <Text>{mask(ex.tldr)}</Text>
658
659 {ex.steps.length ? (
660 <Box flexDirection="column">
661 {heading('▸', 'WHAT HAPPENED', 'warning')}
662 {ex.steps.map((s, k) => (
663 <Box flexDirection="row">
664 <Text color="warning" bold>
665 {String(k + 1).padStart(2)}{' '}
666 </Text>
667 <Text>{mask(s)}</Text>
668 </Box>
669 ))}
670 </Box>
671 ) : null}
672
673 {ex.why ? (
674 <Box flexDirection="column">
675 {heading('?', 'WHY', 'success')}
676 <Text>{mask(ex.why)}</Text>
677 </Box>
678 ) : null}
679
680 {ex.terms.length ? (
681 <Box flexDirection="column">
682 {heading('≡', 'TERMS', 'permission')}
683 {ex.terms.map(t => (
684 <Box flexDirection="row">
685 <Text color="permission" bold>
686 {mask(t.term)}
687 </Text>
688 <Text dimColor> — </Text>
689 <Text>{mask(t.meaning)}</Text>
690 </Box>
691 ))}
692 </Box>
693 ) : null}
694
695 {ex.risk ? (
696 <Box flexDirection="column">
697 {heading('!', 'WATCH OUT', 'error')}
698 <Text>{mask(ex.risk)}</Text>
699 </Box>
700 ) : null}
701
702 {heading('✓', 'DO NEXT', 'success')}
703 <Text>{mask(ex.next || 'Nothing — all done.')}</Text>
704
705 <Box marginTop={1}>{rule}</Box>
706 {turn && turn.tools.length ? (
707 <Text dimColor wrap="truncate-end">
708 tools {toolTally(turn.tools)}
709 {turn.files.length ? ` · files ${mask(turn.files.slice(0, 4).join(', '))}${turn.files.length > 4 ? ` +${turn.files.length - 4}` : ''}` : ''}
710 </Text>
711 ) : null}
712 <Text dimColor>
713 terms learned {learned} · recap cost {tokens(ex.tokens)} new tokens
714 </Text>
715 {nav}
716 </Box>
717 )
718 })
719}
720hooks/redact.ts 82 lines1// REC mode: masks what would leak on a screen recording. Display only —
2// what the model reads is never touched.
3
4const tag = (label: string) => `⟦${label}⟧`
5
6const luhn = (digits: string): boolean => {
7 let sum = 0
8 let isDouble = false
9 for (let i = digits.length - 1; i >= 0; i--) {
10 let d = digits.charCodeAt(i) - 48
11 if (isDouble) {
12 d *= 2
13 if (d > 9) d -= 9
14 }
15 sum += d
16 isDouble = !isDouble
17 }
18 return sum % 10 === 0
19}
20
21const isPesel = (s: string): boolean => {
22 const w = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3]
23 let sum = 0
24 for (let i = 0; i < 10; i++) sum += (w[i] ?? 0) * (s.charCodeAt(i) - 48)
25 const month = Number(s.slice(2, 4)) % 20
26 return (10 - (sum % 10)) % 10 === s.charCodeAt(10) - 48 && month >= 1 && month <= 12
27}
28
29const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
30
31type Rule = [RegExp, string | ((m: string, ...g: string[]) => string)]
32
33const RULES: Rule[] = [
34 [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, tag('private key')],
35 [/\bsk-ant-[A-Za-z0-9_-]{10,}/g, tag('api key')],
36 [/\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}/g, tag('stripe key')],
37 [/\bwhsec_[A-Za-z0-9]{10,}/g, tag('webhook secret')],
38 [/\bsk-[A-Za-z0-9_-]{20,}/g, tag('api key')],
39 [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, tag('github token')],
40 [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, tag('slack token')],
41 [/\bAKIA[0-9A-Z]{16}\b/g, tag('aws key')],
42 [/\bAIza[0-9A-Za-z_-]{30,}/g, tag('google key')],
43 [/\bhf_[A-Za-z0-9]{20,}/g, tag('hf token')],
44 [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, tag('jwt')],
45 [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/g, (_m, kind) => `${kind} ${tag('token')}`],
46 [
47 /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PWD|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CLIENT_?SECRET)[A-Za-z0-9_]*)(\s*[=:]\s*)(["']?)([^\s"'`,;]{4,})\3/gi,
48 (_m, key, sep, q) => `${key}${sep}${q}${tag('secret')}${q}`,
49 ],
50 [/:\/\/([^\s:/@]+):([^\s@/]+)@/g, (_m, user) => `://${user}:${tag('password')}@`],
51 [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, tag('email')],
52 [/\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,4})?\b/g, m => (/\d{10}/.test(m.replace(/ /g, '')) ? tag('iban') : m)],
53 [/\b(?:\d[ -]?){12,18}\d\b/g, m => (luhn(m.replace(/[ -]/g, '')) ? tag('card') : m)],
54 [/\b\d{11}\b/g, m => (isPesel(m) ? tag('pesel') : m)],
55 [/(?<![\w+])\+\d{1,3}[ -]?\d{2,3}[ -]?\d{3}[ -]?\d{3,4}\b/g, tag('phone')],
56 [/\/Users\/[^/\s'"`]+/g, '~'],
57 [/\/home\/[^/\s'"`]+/g, '~'],
58]
59
60export const redactText = (text: string, words: readonly string[] = []): string => {
61 let out = text
62 for (const [re, to] of RULES) out = out.replace(re, to as never)
63 for (const w of words) {
64 if (w.trim().length < 2) continue
65 out = out.replace(new RegExp(escape(w.trim()), 'gi'), tag('hidden'))
66 }
67 return out
68}
69
70// Walks any value and masks every string in it, keeping its shape.
71export const redactDeep = <T>(value: T, words: readonly string[] = [], depth = 0): T => {
72 if (depth > 24) return value
73 if (typeof value === 'string') return redactText(value, words) as T
74 if (Array.isArray(value)) return value.map(v => redactDeep(v, words, depth + 1)) as T
75 if (value !== null && typeof value === 'object') {
76 const out: Record<string, unknown> = {}
77 for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, words, depth + 1)
78 return out as T
79 }
80 return value
81}
82types/index.d.ts 62 lines1export type ToolStep = { tool: string; target: string }
2
3export type TurnRecord = {
4 id: string
5 prompt: string
6 answer: string
7 tools: ToolStep[]
8 files: string[]
9 durationMs: number
10 model: string
11 usd: number
12 cacheHit: number | null
13 endedAt: number
14}
15
16export type Term = { term: string; meaning: string }
17
18export type Explanation = {
19 turnId: string
20 title: string
21 tldr: string
22 steps: string[]
23 why: string
24 terms: Term[]
25 next: string
26 risk: string
27 createdAt: number
28 tokens: number
29}
30
31export type Limit = { kind: string; percent: number; resetsAt: string | null }
32
33export type Usage = {
34 ctxPercent: number | null
35 ctxTokens: number | null
36 window: number
37 limits: Limit[]
38 usd: number | null
39 startedAt: number
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 studio: {
45 rec: boolean
46 recWords: string[]
47 usage: Usage | null
48 now: number
49 prompts: number
50 branch: string
51 project: string
52 turns: TurnRecord[]
53 ctxTrend: number[]
54 explains: Explanation[]
55 view: number
56 busy: boolean
57 error: string
58 termsLearned: number
59 }
60 }
61}
62