SLOPSHOPPER

studio

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.

newpanebandrowsguardcommand
v0.2.0no licenseupdated 2026-10-13krystianwoj22/claude-mods/studio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · studio
│ ┃ Explain ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ✦ EXPLAIN 1 of 1 │ studio │ │ ┃ Last turn ⏺ Read(src/auth.ts) │ ● REC on — secrets, emails and paths are │ │ ┃ app · Thu 9 Oct · 08:53 · 42s ⎿ Read 6 lines │ masked on screen │ │ ┃ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ◆ TL;DR ⎿ Added 2 lines, removed 1 line │ ┃ OK ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ✓ DO NEXT │ ┃ Nothing — all done. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ──────────── │ ┃ tools Bash×4 Write×2 Read Grep Edit … › /rec │ ┃ terms learned 0 · recap cost 13 new tokens ⎿ studio: REC on. │ ┃ │ ┃ [ ◀ Older ] [ Newer ▶ ] [ ↻ Explain latest │ app ⎇ feat/auth-refresh [ ● REC ] [ ✦ Explain ] CONTEXT 5-HOUR LIMIT SESSION ━━━━━━━━━━━━──────────── 49% ━━━━━━━───────────────── 31% $0.42 97k of 200k resets in NaNd NaNh 30m · 1 prompt LAST TURN 42s · opus-5-5 · cache 93% · 9 tool calls (Bash×4 Write×2 Read Grep Edit) · 3 files edited ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ studio: ● REC — screen masking on

Draws

Band
app ⎇ feat/auth-refresh [ ● REC ] [ ✦ Explain ] CONTEXT 5-HOUR LIMIT SESSION ━━━━━━━━━━━━──────────── 49% ━━━━━━━───────────────── 31% $0.42 97k of 200k resets in NaNd NaNh 30m · 1 prompt LAST TURN 42s · opus-5-5 · cache 93% · 9 tool calls (Bash×4 Write×2 Read Grep Edit) ·
Pane · Explain
✦ EXPLAIN 1 of 1 Last turn app · Thu 9 Oct · 08:53 · 42s ◆ TL;DR OK ✓ DO NEXT Nothing — all done. ────────────────────────────────────────────────────── tools Bash×4 Write×2 Read Grep Edit · files src/aut… terms learned 0 · recap cost 13 new tokens [ ◀ Older ] [ Newer ▶ ] [ ↻ Explain latest ] [ ⧉ Copy ] [ C
README

claude-mods

Mods for Claude Code by DFA Course Academy.

ModWhat it does
studioCreator 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-meterTokens and estimated API cost of the last prompt and the whole session in the status line. /token-cost prints the breakdown.

Install

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

From a regular shell (or for the Claude desktop app)

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.

Using studio

ControlWhat it does
R / REC button / /recToggle REC mode: secrets, API keys, emails and file paths are masked on screen
/rec hide <text> · /rec show <text> · /rec listAdd, remove or list your own words to mask
E / Explain button / /explainRecap 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.

Update

claude plugin marketplace update krystian-mods

Then restart Claude Code or run /reload-plugins.

Develop

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 studio
Source 3 files
hooks/register.tsx 720 lines
1import { 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}
720
hooks/redact.ts 82 lines
1// 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}
82
types/index.d.ts 62 lines
1export 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