SLOPSHOPPER

interview-coach

Spoken mock interview in a pane: asks aloud, records you, measures WPM and fillers, coaches

newpanecommandmodelprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · interview-coach
│ ┃ Interview Coach ✕ › fix the failing auth test and add an audit log call │ ┃ [ New question ] │ ┃ Asks a placement question aloud, records ⏺ Read(src/auth.ts) │ ┃ your answer, times it, breaks it into a ⎿ Read 6 lines │ ┃ framework and suggests better examples from ⏺ Update(src/auth.ts) │ ┃ your own background. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /coach │ ⎿ interview-coach: Interview Coach opened. Press "New question", a │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Interview Coach
[ New question ] Asks a placement question aloud, records your answer, times it, breaks it into a framework and suggests better examples from your own background.
README

Claude Code mods

Thirteen mods I wrote for Claude Code in October 2026, the week mods were announced. A mod is a plugin of TypeScript hooks that runs inside Claude Code: it can draw panes and bands in the terminal, add slash commands, give Claude new tools, and block or react to tool calls. I built these to see how far the system goes, and a few of them I now use every day.

The one I use most is interview-coach: it asks a placement interview question aloud, records my spoken answer, transcribes it on the Mac with Whisper, and scores pace, filler words and structure against my own CV stories.

The mods

ModHow you use itWhat it does
interview-coach/coachSpoken mock interview in a side pane. Asks a question aloud, records from the mic, transcribes locally (Whisper large-v3-turbo via whisper.cpp), then shows speaking time against the target, words per minute, filler words, time spent on each step of the answer framework, and two stronger example answers drawn from your profile. Keeps a WPM trend across sessions.
nihongo/nihongoJapanese tutor pane: kana drill that weights look-alike pairs and brings misses back sooner, 44 N5 word cards, and a sentence builder where Claude Haiku sets a line from the week's grammar and corrects your kana answer. A word band above the prompt shows a new word each turn.
joka-pond/pondAnimated ASCII pond that earns XP for every tool Claude uses and climbs a consulting ladder from Intern to Managing Partner. Storms when a tool fails, keeps a daily streak.
aurora/visuals, /auroraTruecolor pixel art: an aurora band above the prompt whose colours follow the time of day, plus a gallery (plasma, ocean sunset, warp, a 4-4-6 breathing lotus).
avatar/buddyA pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight.
chartClaude calls itGives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends.
heatmapClaude calls itInline truecolor heatmaps with heat, green and diverging palettes.
diagramClaude calls itFlowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows.
animateClaude calls itPlays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps.
pixelClaude calls itDraws a pixel-art image in the chat from a JSON grid of colours.
change-ledger/changesPane listing every file Claude created or edited this session.
em-dash-guardautomaticBlocks any write or edit that adds an em dash, my house style for documents.
hello-mod/hiStarter mod: an instant reply with no model call, a guard that blocks rm -rf, and a toast at the end of each turn.

statusline/statusline-command.sh is my status line: model, effort, context used, 5-hour and weekly usage with time left, folder and git branch, colour-coded green, yellow and red.

Try one

claude --plugin-dir ./mods/chart

Pass --plugin-dir once per mod you want. claude plugin validate ./mods/chart checks a mod before loading it.

interview-coach needs ffmpeg, whisper-cpp (brew install ffmpeg whisper-cpp) and a Whisper model file. Set the paths at the top of mods/interview-coach/hooks/register.tsx, and copy profile.example.md to the profile path with your own experiences.

What I learned building them

  • The $ context object can't be stored or passed around. Long-running work has to start from a worker in session.start; buttons only queue jobs for it.
  • Redrawing a pane on a timer rebuilds its buttons and swallows clicks. Animations repaint pixels with $.ui.blit instead.
  • A tool registered in session.start becomes callable only from the next prompt.
  • Plugin tool results must be strings, so structured results go back as JSON text.
  • ffmpeg's -progress flag must come before -i, or progress output never arrives.
Source 2 files
hooks/register.tsx 371 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Better, Coach, Part, View } from '../types'
5
6const PANE = 'interview-coach'
7const DIR = '/tmp/interview-coach'
8const RAW = `${DIR}/answer.raw`
9const WAV = `${DIR}/answer.wav`
10// Edit the paths below for your machine. Mods run sandboxed, so there is no $HOME to read.
11const PROFILE = '/Users/YOU/.claude/interview-coach/profile.md'
12const FFMPEG = '/opt/homebrew/bin/ffmpeg'
13const WHISPER = '/opt/homebrew/bin/whisper-cli'
14const MODEL = '/Users/YOU/.whisper-models/ggml-large-v3-turbo.bin'
15
16const TRACKS = [
17  'behavioural (leadership, conflict or failure)',
18  'consulting fit',
19  'product management',
20  'why MBA / why IIM Calcutta / career goals',
21  'resume deep-dive on a past internship',
22]
23
24const FILLERS = /\b(um+|uh+|erm|basically|actually|literally|you know|i mean|sort of|kind of|like)\b/gi
25
26const blank: Coach = {
27  phase: 'idle', view: 'summary', track: '', question: '', seconds: 0, spoken: 0, idealMin: 0, idealMax: 0,
28  transcript: '', words: 0, wpm: 0, fillers: 0, fillerWords: '', framework: '', parts: [], better: [],
29  verdict: '', fixNext: '', error: '',
30}
31
32const coach = atom({ plugin: 'interview-coach', key: 'coach' } as const, blank)
33const history = atom({ plugin: 'interview-coach', key: 'history' } as const, [])
34
35const paceColor = (wpm: number) => (wpm > 175 ? 'red' : wpm > 155 || wpm < 110 ? 'yellow' : 'green')
36const paceWord = (wpm: number) =>
37  wpm > 175 ? 'too fast' : wpm > 155 ? 'a bit fast' : wpm < 110 ? 'a bit slow' : 'good pace'
38const verdictColor = (v: string) => (v === 'strong' ? 'green' : v === 'weak' ? 'yellow' : 'red')
39const verdictMark = (v: string) => (v === 'strong' ? '✓' : v === 'weak' ? '~' : '✗')
40const secs = (n: number) => (n >= 60 ? `${Math.floor(n / 60)}m${String(Math.round(n % 60)).padStart(2, '0')}s` : `${Math.round(n)}s`)
41
42// "[00:00:01.120 --> 00:00:04.500]  text" lines from whisper-cli
43const toSec = (t: string) => {
44  const [h, m, s] = t.split(':')
45  return Number(h) * 3600 + Number(m) * 60 + Number(s)
46}
47
48const parseJson = (text: string): any => {
49  const start = text.indexOf('{')
50  const end = text.lastIndexOf('}')
51  if (start < 0 || end <= start) return null
52  try {
53    return JSON.parse(text.slice(start, end + 1))
54  } catch {
55    return null
56  }
57}
58
59const COACH_SYSTEM = `You are a sharp MBA final-placements interview coach at IIM Calcutta. You know the candidate's real background (given). Be specific and blunt, no praise padding, no em dashes anywhere.
60Return ONLY a JSON object, no prose, with exactly these keys:
61{
62 "framework": "the best framework for this question type, e.g. STAR, CAR, Past-Present-Future, Problem-Approach-Impact",
63 "idealMinSeconds": number, "idealMaxSeconds": number,   // how long a strong spoken answer to THIS question should run
64 "parts": [ { "label": "framework step name", "said": "one sentence: what he actually said for this step, or 'Not covered'",
65              "words": number (approx words of his answer spent on this step, 0 if missing),
66              "idealShare": number (percent of ideal answer time this step deserves; all parts sum to 100),
67              "verdict": "strong" | "weak" | "missing",
68              "fix": "one concrete improvement for this step, max 25 words" } ],
69 "verdict": "one line overall judgement, max 20 words",
70 "fixNext": "the single most important change next attempt, max 20 words",
71 "better": [ { "title": "a better example from HIS profile", "why": "why it fits this question better, max 20 words",
72               "outline": "framework steps as 'Step: content' joined by ' | ', with real numbers from the profile, max 60 words" } ]   // exactly 2
73}
74Only use numbers that appear in the profile. Never invent metrics.`
75
76export const register: Register = on => {
77  // Buttons only queue a job; a worker started in session.start runs it,
78  // because $ cannot be stored and a button's dispatch is short-lived.
79  let isRecording = false
80  let isBusy = false
81  let pending: 'ask' | 'record' | null = null
82  let round = 0
83
84  on('session.start', async ($, e, next) => {
85    const set = (patch: Partial<Coach>) => update($, coach, c => ({ ...c, ...patch }))
86    const fail = (error: string) => set({ phase: 'error', error })
87
88    const ask = async () => {
89      const track = TRACKS[round++ % TRACKS.length]
90      await set({ ...blank, phase: 'asking', track })
91      const r = await $.model.complete({
92        model: 'haiku',
93        timeoutMs: 30000,
94        effort: 'low',
95        maxTokens: 120,
96        prompt:
97          `You interview MBA students at IIM Calcutta for final placements. Ask ONE ${track} question. ` +
98          'Under 30 words. Output only the question, no preamble, no quotes, no em dashes.',
99      })
100      if (!r.isAnswered) return fail(`Could not get a question (${r.reason}).`)
101      const question = r.text.trim()
102      await set({ phase: 'ready', question })
103      void $.audio.speak(question).catch(() => {})
104    }
105
106    const record = async () => {
107      if (isRecording) return
108      await $.process.run(['mkdir', '-p', DIR])
109      isRecording = true
110      await set({ phase: 'recording', seconds: 0 })
111
112      const rec = $.process.spawn({
113        argv: [FFMPEG, '-hide_banner', '-loglevel', 'error', '-y', '-progress', 'pipe:2', '-stats_period', '0.5',
114          '-f', 'avfoundation', '-i', ':0', '-ac', '1', '-ar', '16000', '-t', '300', '-f', 's16le', RAW],
115      })
116      let seconds = 0
117      try {
118        for await (const { text } of rec) {
119          const m = /out_time_us=(\d+)/.exec(text)
120          if (m) {
121            seconds = Number(m[1]) / 1e6
122            await set({ seconds })
123          }
124          if (!isRecording) break
125        }
126      } catch (err) {
127        isRecording = false
128        return fail(`Mic recording failed: ${String(err)}. Grant your terminal microphone access in System Settings.`)
129      }
130      isRecording = false
131      await score(seconds)
132    }
133
134    const score = async (seconds: number) => {
135      if (seconds < 3) return fail('That was under 3 seconds. Press Record and answer out loud.')
136      await set({ phase: 'transcribing', seconds })
137
138      const conv = await $.process.run([FFMPEG, '-hide_banner', '-loglevel', 'error', '-y',
139        '-f', 's16le', '-ar', '16000', '-ac', '1', '-i', RAW, WAV])
140      if (conv.exitCode !== 0) return fail(`Audio conversion failed: ${conv.stderr.slice(0, 120)}`)
141
142      const w = await $.process.run([WHISPER, '-m', MODEL, '-f', WAV, '-np', '-l', 'en'], { timeoutMs: 180000 })
143      const lines = [...w.stdout.matchAll(/\[(\d\d:\d\d:\d\d\.\d+) --> (\d\d:\d\d:\d\d\.\d+)\]\s*(.*)/g)]
144      const transcript = lines.map(l => l[3]).join(' ').replace(/\s+/g, ' ').trim()
145      if (w.exitCode !== 0 || transcript === '') return fail('Transcription came back empty. Speak closer to the mic.')
146
147      // Speaking time: first word to last word, so silence before and after does not count
148      const spoken = Math.max(1, toSec(lines[lines.length - 1]?.[2] ?? '0:0:0') - toSec(lines[0]?.[1] ?? '0:0:0'))
149      const words = transcript.split(' ').filter(Boolean).length
150      const wpm = Math.round(words / (spoken / 60))
151      const found = (transcript.match(FILLERS) ?? []).map(f => f.toLowerCase())
152      const tally = [...new Set(found)].map(f => `${f} x${found.filter(x => x === f).length}`).join(', ')
153      await set({ phase: 'scoring', transcript, words, wpm, spoken, fillers: found.length, fillerWords: tally })
154
155      const profile = await $.fs.read(PROFILE).catch(() => '')
156      const c = await read($, coach)
157      const r = await $.model.complete({
158        model: 'sonnet',
159        timeoutMs: 90000,
160        maxTokens: 1800,
161        system: COACH_SYSTEM,
162        prompt: `CANDIDATE PROFILE:\n${profile}\n\nQUESTION (${c.track}): ${c.question}\n\n` +
163          `TRANSCRIPT OF HIS SPOKEN ANSWER (${words} words in ${Math.round(spoken)}s, ${wpm} wpm, ${found.length} fillers):\n${transcript}`,
164      })
165      const j = r.isAnswered ? parseJson(r.text) : null
166      if (j === null) {
167        await set({ phase: 'done', verdict: r.isAnswered ? 'Coach reply was not readable; try again.' : `No feedback (${r.reason}).` })
168      } else {
169        const parts: Part[] = (j.parts ?? []).map((p: any) => ({
170          label: String(p.label ?? ''), said: String(p.said ?? ''), fix: String(p.fix ?? ''),
171          verdict: p.verdict === 'strong' || p.verdict === 'weak' ? p.verdict : 'missing',
172          words: Number(p.words) || 0, idealShare: Number(p.idealShare) || 0,
173        }))
174        const better: Better[] = (j.better ?? []).slice(0, 2).map((b: any) => ({
175          title: String(b.title ?? ''), why: String(b.why ?? ''), outline: String(b.outline ?? ''),
176        }))
177        await set({
178          phase: 'done', framework: String(j.framework ?? ''), parts, better,
179          idealMin: Number(j.idealMinSeconds) || 0, idealMax: Number(j.idealMaxSeconds) || 0,
180          verdict: String(j.verdict ?? ''), fixNext: String(j.fixNext ?? ''),
181        })
182      }
183
184      const past = ((await $.store.get('wpmHistory')) as number[] | undefined) ?? []
185      const trend = [...past, wpm].slice(-10)
186      await $.store.set('wpmHistory', trend)
187      await update($, history, () => trend)
188
189      const done = await read($, coach)
190      const timing = done.idealMax > 0
191        ? (spoken < done.idealMin ? `, ${Math.round(done.idealMin - spoken)} seconds short` : spoken > done.idealMax ? `, ${Math.round(spoken - done.idealMax)} seconds too long` : ', good length')
192        : ''
193      void $.audio.speak(`${wpm} words per minute, ${paceWord(wpm)}${timing}. ${found.length} filler words.`).catch(() => {})
194    }
195
196    $.clock.every(300, () => {
197      if (isBusy || pending === null) return
198      const job = pending
199      pending = null
200      isBusy = true
201      void (job === 'ask' ? ask() : record())
202        .catch(err => fail(`Something broke: ${String(err)}`))
203        .finally(() => { isBusy = false })
204    })
205
206    await $.command.register({ name: 'coach', description: 'Open the spoken interview coach pane' })
207    const past = ((await $.store.get('wpmHistory')) as number[] | undefined) ?? []
208    await update($, history, () => past)
209    await update($, coach, () => blank)
210    return next(e)
211  })
212
213  on('command.run', { command: 'coach' }, async $ => {
214    await $.ui.open({ id: PANE, title: 'Interview Coach' })
215    return { text: 'Interview Coach opened. Press "New question", answer out loud, then "Stop".' }
216  })
217
218  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
219    const { Box, Text, Button } = $.ui.resolve(e)
220    const c = await read($, coach)
221    const hist = await read($, history)
222    const busy = c.phase === 'asking' || c.phase === 'transcribing' || c.phase === 'scoring'
223    const width = Math.max(30, (e.viewport?.columns ?? 60) - 4)
224    const barW = Math.max(10, Math.min(30, width - 34))
225    const show = (v: View) => update($, coach, x => ({ ...x, view: v }))
226
227    const status =
228      c.phase === 'asking' ? 'Thinking of a question...'
229      : c.phase === 'recording' ? `● Recording ${Math.floor(c.seconds)}s, speak then press Stop`
230      : c.phase === 'transcribing' ? `Transcribing ${Math.round(c.seconds)}s of audio locally...`
231      : c.phase === 'scoring' ? 'Coach is breaking down your answer (up to 90s)...'
232      : ''
233
234    // Time bar: where his speaking time lands against the ideal window
235    const scaleMax = Math.max(c.idealMax * 1.4, c.spoken * 1.1, 1)
236    const pos = (s: number) => Math.min(barW - 1, Math.round((s / scaleMax) * barW))
237    const timeBar = Array.from({ length: barW }, (_, i) =>
238      i === pos(c.spoken) ? '●' : i >= pos(c.idealMin) && i <= pos(c.idealMax) ? '═' : '─').join('')
239    const timeColor = c.idealMax === 0 ? 'white'
240      : c.spoken < c.idealMin * 0.8 || c.spoken > c.idealMax * 1.2 ? 'red'
241      : c.spoken < c.idealMin || c.spoken > c.idealMax ? 'yellow' : 'green'
242    const idealMid = (c.idealMin + c.idealMax) / 2
243    const totalWords = Math.max(1, c.parts.reduce((n, p) => n + p.words, 0))
244
245    const tab = (v: View, label: string) => (
246      <Button key={`tab-${v}`} label={c.view === v ? `▸${label}` : label} onPress={() => void show(v)} />
247    )
248
249    return (
250      <Box flexDirection="column">
251        <Box gap={1}>
252          {!busy && c.phase !== 'recording' && <Button key="ask" label="New question" onPress={() => { pending = 'ask' }} />}
253          {(c.phase === 'ready' || c.phase === 'done' || c.phase === 'error') && c.question !== '' &&
254            <Button key="rec" label="Record answer" onPress={() => { pending = 'record' }} />}
255          {c.phase === 'recording' && <Button key="stop" label="Stop" onPress={() => { isRecording = false }} />}
256          {(busy || c.phase === 'error') && (
257            <Button key="reset" label="Reset" onPress={() => {
258              pending = null
259              isRecording = false
260              isBusy = false
261              void update($, coach, () => blank)
262            }} />
263          )}
264        </Box>
265        {status !== '' && <Text color={c.phase === 'recording' ? 'red' : 'cyan'}>{status}</Text>}
266        {c.phase === 'error' && <Text color="red">{c.error}</Text>}
267        {c.phase === 'idle' && <Text dimColor>Asks a placement question aloud, records your answer, times it, breaks it into a framework and suggests better examples from your own background.</Text>}
268
269        {c.question !== '' && <Text bold>{c.question}</Text>}
270
271        {c.wpm > 0 && (
272          <Box gap={1}>
273            {tab('summary', 'Summary')}
274            {c.parts.length > 0 && tab('breakdown', 'Breakdown')}
275            {c.better.length > 0 && tab('better', 'Better answers')}
276            {tab('transcript', 'Transcript')}
277          </Box>
278        )}
279
280        {c.wpm > 0 && c.view === 'summary' && (
281          <Box flexDirection="column">
282            <Text>
283              Spoke <Text bold color={timeColor}>{secs(c.spoken)}</Text>
284              {c.idealMax > 0 && <Text dimColor>  ideal {secs(c.idealMin)} to {secs(c.idealMax)}</Text>}
285              {c.idealMax > 0 && c.spoken < c.idealMin && <Text color={timeColor}>  ({secs(c.idealMin - c.spoken)} short)</Text>}
286              {c.idealMax > 0 && c.spoken > c.idealMax && <Text color={timeColor}>  ({secs(c.spoken - c.idealMax)} over)</Text>}
287            </Text>
288            {c.idealMax > 0 && <Text><Text dimColor>0 </Text><Text color={timeColor}>{timeBar}</Text><Text dimColor> ═ ideal  ● you</Text></Text>}
289            <Text>
290              Pace <Text bold color={paceColor(c.wpm)}>{c.wpm} wpm</Text><Text dimColor> {paceWord(c.wpm)}, aim 130 to 155</Text>
291              <Text>   Fillers </Text>
292              <Text bold color={c.fillers > 4 ? 'red' : c.fillers > 1 ? 'yellow' : 'green'}>{c.fillers}</Text>
293              <Text dimColor>{c.fillerWords !== '' ? `  ${c.fillerWords}` : ''}</Text>
294            </Text>
295            {c.framework !== '' && (
296              <Text>
297                {c.parts.map((p, i) => (
298                  <Text key={`flow-${i}`}>
299                    <Text bold color={verdictColor(p.verdict)}>{verdictMark(p.verdict)} {p.label}</Text>
300                    {i < c.parts.length - 1 ? <Text dimColor> ──▶ </Text> : ''}
301                  </Text>
302                ))}
303              </Text>
304            )}
305            {c.verdict !== '' && <Text>{c.verdict}</Text>}
306            {c.fixNext !== '' && <Text><Text bold color="cyan">Next time: </Text>{c.fixNext}</Text>}
307            {hist.length > 1 && <Text dimColor>Pace trend: {hist.map((w, i) => (i === hist.length - 1 ? `[${w}]` : `${w}`)).join(' › ')}</Text>}
308          </Box>
309        )}
310
311        {c.view === 'breakdown' && c.parts.length > 0 && (
312          <Box flexDirection="column" gap={1}>
313            <Text dimColor>{c.framework}: your time per step vs ideal</Text>
314            <Box flexDirection="column">
315              {c.parts.map((p, i) => {
316                const yours = (p.words / totalWords) * c.spoken
317                const ideal = (p.idealShare / 100) * idealMid
318                const scale = Math.max(1, ...c.parts.map(q => Math.max((q.words / totalWords) * c.spoken, (q.idealShare / 100) * idealMid)))
319                const yb = Math.round((yours / scale) * (barW - 6))
320                const ib = Math.round((ideal / scale) * (barW - 6))
321                return (
322                  <Box key={`bar-${i}`} flexDirection="column">
323                    <Text><Text bold color={verdictColor(p.verdict)}>{verdictMark(p.verdict)} {p.label}</Text></Text>
324                    <Text>  you   <Text color={verdictColor(p.verdict)}>{'█'.repeat(yb) || '·'}</Text><Text dimColor> {secs(yours)}</Text></Text>
325                    <Text dimColor>  ideal {'▒'.repeat(ib) || '·'} {secs(ideal)}</Text>
326                  </Box>
327                )
328              })}
329            </Box>
330            {c.parts.map((p, i) => (
331              <Box key={`para-${i}`} flexDirection="column">
332                <Text bold color={verdictColor(p.verdict)}>{i + 1}. {p.label} ({p.verdict})</Text>
333                <Text wrap="wrap"><Text dimColor>You said: </Text>{p.said}</Text>
334                <Text wrap="wrap"><Text color="cyan">Fix: </Text>{p.fix}</Text>
335              </Box>
336            ))}
337          </Box>
338        )}
339
340        {c.view === 'better' && c.better.length > 0 && (
341          <Box flexDirection="column" gap={1}>
342            {c.better.map((b, i) => (
343              <Box key={`better-${i}`} flexDirection="column">
344                <Text bold color="green">{i + 1}. {b.title}</Text>
345                <Text dimColor wrap="wrap">{b.why}</Text>
346                {b.outline.split('|').map((step, k) => {
347                  const [head, ...rest] = step.split(':')
348                  return (
349                    <Text key={`step-${i}-${k}`} wrap="wrap">
350                      {k === 0 ? '  ┌ ' : k === b.outline.split('|').length - 1 ? '  └▶ ' : '  ├▶ '}
351                      <Text bold>{(head ?? '').trim()}</Text>{rest.length > 0 ? `: ${rest.join(':').trim()}` : ''}
352                    </Text>
353                  )
354                })}
355              </Box>
356            ))}
357            <Text dimColor>Examples come from profile.md in the mod folder; edit it to add stories.</Text>
358          </Box>
359        )}
360
361        {c.view === 'transcript' && c.transcript !== '' && (
362          <Box flexDirection="column">
363            <Text dimColor>{c.words} words, {secs(c.spoken)} speaking, {secs(c.seconds)} recorded</Text>
364            <Text italic wrap="wrap">"{c.transcript}"</Text>
365          </Box>
366        )}
367      </Box>
368    )
369  })
370}
371
types/index.d.ts 42 lines
1export type Phase = 'idle' | 'asking' | 'ready' | 'recording' | 'transcribing' | 'scoring' | 'done' | 'error'
2export type View = 'summary' | 'breakdown' | 'better' | 'transcript'
3
4export type Part = {
5  label: string
6  said: string
7  fix: string
8  verdict: 'strong' | 'weak' | 'missing'
9  words: number
10  idealShare: number
11}
12
13export type Better = { title: string; why: string; outline: string }
14
15export type Coach = {
16  phase: Phase
17  view: View
18  track: string
19  question: string
20  seconds: number
21  spoken: number
22  idealMin: number
23  idealMax: number
24  transcript: string
25  words: number
26  wpm: number
27  fillers: number
28  fillerWords: string
29  framework: string
30  parts: Part[]
31  better: Better[]
32  verdict: string
33  fixNext: string
34  error: string
35}
36
37declare module 'claude-code' {
38  interface PluginState {
39    'interview-coach': { coach: Coach; history: number[] }
40  }
41}
42