SLOPSHOPPER

Flash cards

Software development flash cards on a side board: three cards that flip on a press, written by the model only while the board is open, with Got it / Again…

newpanebandcommandmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flashcards
│ ┃ Flash cards ✕ › fix the failing auth test and add an audit log call │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0/0 learned · │ ┃ Press a term to flip its card. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Topic: anything: Kubernetes, Rust lifetimes, ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ Couldn't write a card: the card reply │ ⎿ 3 pass, 1 fail │ ┃ │ holds no JSON │ │ ┃ │ [ try again ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Couldn't write a card: the card reply │ › /cards │ ┃ │ holds no JSON │ ⎿ flashcards: Flash card board opened. │ ┃ │ [ try again ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Couldn't write a card: the card reply │ │ ┃ │ holds no JSON │ │ ┃ │ [ try again ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ [ new set ] [ close ] │ ⟨Claude Code's own drawing⟩ 📇 cards 0 due · 0/0 learned f: board ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 📇 cards 0 due · 0/0 learned f: board
Pane · Flash cards
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0/0 learned · 0 due Press a term to flip its card. Topic: anything: Kubernetes, Rust lifetimes, OAuth… (empty: ╭──────────────────────────────────────────────────────────╮ │ Couldn't write a card: the card reply holds no JSON │ │ [ try again ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Couldn't write a card: the card reply holds no JSON │ │ [ try again ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Couldn't write a card: the card reply holds no JSON │ │ [ try again ] │ ╰──────────────────────────────────────────────────────────╯ [ new set ] [ close ]
README

flashcards

A Claude Code mod: software development flash cards on a side board, written by the model on demand, with light spaced repetition.

Learn a term or two while Claude works: a one-line counter sits under the prompt band, and one key opens a board of three cards that flip on a press. Grade each card got it or again and it comes back when it should. Pick any topic (Kubernetes, Rust lifetimes, OAuth…) or stay on general software development.

Mods are an early-access Claude Code API (function hooks). The API may change between releases.

Install

At the prompt of a terminal session:

/plugin install flashcards --marketplace rquintino/claude-code-xtras

Answer y to add the marketplace, then pick a scope (user is the usual). Hooks start in that session right away.

To run it from a clone instead, for one session:

claude --plugin-dir ./mods/flashcards

What you get

The line under the band

📇 cards 2 due · 5/12 learned · Kubernetes  board

Due count (highlighted when > 0), cards learned out of the deck, the current topic, and the way to the board (f). Before the first deal the button reads start learning. It draws under whatever band sits above the prompt (the statusline-hud band included), whichever order Claude Code runs the mods in.

The board

f on that line, or /cards, opens a side pane:

██████████░░░░░░░░░░ 5/12 learned · 2 due
✓ CAP theorem · back in 3d

Topic [ Kubernetes                    ] learn this   general

╭────────────────────────────────────────────╮
│ ▾ Pod disruption budget                    │
│ reliability ●●○○○                          │
│                                            │
│ Caps how many pods of a workload can be    │
│ down at once during voluntary evictions.   │
│ e.g. minAvailable: 2 keeps two replicas up │
│ while a node drains.                       │
│                                            │
│ ✓ got it   ↺ again                         │
╰────────────────────────────────────────────╯
╭────────────────────────────────────────────╮
│ ▸ Readiness probe                          │
│ health checks ○○○○○                        │
╰────────────────────────────────────────────╯
╭────────────────────────────────────────────╮
│ ▸ Taints and tolerations                   │
│ scheduling ●○○○○                           │
╰────────────────────────────────────────────╯

new set   close
  • Flip: press a term (or 1–3) to show its definition and an example. Several cards can be open at once.
  • Grade: on the back of a card. The header says what happened (✓ CAP theorem · back in 3d) and the slot refills.
  • Topic: type one and learn this deals a fresh board on it; general goes back to software development at large.
  • A card that couldn't be written shows why (Couldn't write a card: api-error 529 overloaded) with try again.

Keys

KeyWhereDoes
fline under the bandopen the board
1 2 3boardflip that card
gboard✓ got it on the first card turned over
aboard↺ again on the first card turned over
nboardnew set: three fresh cards
xboardclose the board

A keyboard run is 1, g, 2, a, … Focus the band with ctrl+x tab (or a click) to use its hotkey.

/cards command

CommandDoes
/cardsopen the board (deals one if it is empty)
/cards <topic>open the board on that topic, e.g. /cards Rust lifetimes

Spaced repetition

Each card has a place on a five-step ladder, shown as mastery dots ●●○○○.

ActionCard returns inLadder
✓ got it1d → 3d → 7d → 21d → 60done step up
↺ again10 minutesback to the bottom
never graded1 dayunchanged
  • A card counts as learned once it has one got it.
  • To fill a slot, a due card comes first, then a new one. With a topic set, only that topic's due cards are dealt; the due count on the line covers the whole deck.
  • The deck (last 500 cards) and the topic live in the mod's store, so they survive reloads and new sessions.

Tokens: only while the board is open

The model option (Haiku 5.5 by default) writes cards three per call, and only while the board is open:

  • nothing is asked at session start, and the line under the band costs nothing;
  • while the board is open, the next batch is written ahead so new cards land at once;
  • closing the board (its button or the close mark) cancels a batch still being written, and nothing more is written until it opens again;
  • changing topic drops the cards written ahead for the old one.

Each batch is one small request (≤ 1,200 output tokens) on your own account.

How cards are written

  • General: each batch asks for one card from each of three different categories, drawn at random from 20 (design patterns, data structures, distributed systems, application security, LLM and AI engineering, observability, …).
  • Topic: each card covers a different idea within it, with the sub-area as its category.
  • The request lists the 60 most recently seen terms, plus those on the board and in the queue, as terms not to use. Duplicates of a term already in the deck are dropped.
  • The reply must be a JSON array of {term, category, definition, example}; a card missing a field fails its slot with the reason.

The prompts live in hooks/cards.ts (CARD_SYSTEM, cardPrompt, CATEGORIES).

Settings

/config (or /plugin configure flashcards@claude-code-xtras):

OptionValuesDefault
modelthe model that writes the cardsclaude-haiku-5-5

Develop

claude plugin validate ./mods/flashcards   # what it hooks and calls, what the engine would refuse
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test ./mods/flashcards   # 10 tests: parsing, ladder, prompts, board on terminal and desktop, topics, errors

Files: hooks/cards.ts (pure helpers: prompts, parsing, ladder, deck), hooks/register.tsx (hooks and drawing), types/index.d.ts ($.state contract), tests/.

Source 3 files
hooks/register.tsx 339 lines
1// flashcards: a side board of three software development flash cards. Each flips on a press of
2// its term and is graded on its back (Got it climbs a review ladder, Again brings it back soon).
3// The model writes cards three per call, and only while the board is open: no request is made
4// at session start, none is written ahead with the board closed, and closing it cancels one in flight.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, PluginOptions, Register } from 'claude-code'
8
9import type { FlashCard, FlashSlot, FlashView } from '../types'
10import {
11  boardOf,
12  CARD_SYSTEM,
13  cardPrompt,
14  cleanTopic,
15  DECK_KEY,
16  deckCounts,
17  EMPTY_FLASH,
18  emptySlot,
19  fmtIn,
20  grade,
21  masteryDots,
22  parseCards,
23  pickCategories,
24  pickDue,
25  progressBar,
26  sameCard,
27  SLOTS,
28  withCard,
29} from './cards'
30
31const BOARD = 'flashcards'
32const BOARD_TITLE = 'Flash cards'
33const TOPIC_KEY = 'flash:topic'
34
35const board = atom({ plugin: 'flashcards', key: 'board' } as const, EMPTY_FLASH)
36
37type Opts = { model: string }
38
39function readOptions(o: PluginOptions): Opts {
40  return { model: typeof o.model === 'string' && o.model ? o.model : 'claude-haiku-5-5' }
41}
42
43// The module's working copy of the stored deck, the cards written ahead and the call in flight;
44// session.start rebuilds them, and it runs again on every reload.
45let deck: FlashCard[] = []
46let ahead: FlashCard[] = []
47let writing: Promise<void> | null = null
48let cancel: AbortController | null = null
49/** Whether the board is open: the only time the model is asked for cards. */
50let isBoardOpen = false
51
52/** Writes a batch of new cards into the queue; one batch in the works at a time. */
53function writeBatch($: EngineInterface, opts: Opts): Promise<void> {
54  writing ??= (async () => {
55    const f = await read($, board)
56    const call = new AbortController()
57    cancel = call
58    const r = await $.model.complete(
59      {
60        model: opts.model,
61        system: CARD_SYSTEM,
62        prompt: cardPrompt(pickCategories(SLOTS), deck, [...boardOf(f), ...ahead].map(c => c.term), f.topic),
63        maxTokens: 1200,
64        timeoutMs: 45_000,
65      },
66      { signal: call.signal },
67    )
68    if (!r.isAnswered) throw new Error(r.reason === 'api-error' ? `api-error ${r.status ?? ''} ${r.error}`.trim() : r.reason)
69    // a batch for a topic the person has since changed is dropped
70    if ((await read($, board)).topic !== f.topic) return
71    const cards = parseCards(r.text, await $.clock.now(), f.topic)
72    ahead = [...ahead, ...cards.filter(c => !deck.some(d => sameCard(d, c)) && !ahead.some(a => sameCard(a, c)))]
73  })().finally(() => {
74    writing = null
75    cancel = null
76  })
77  return writing
78}
79
80/** The next card for a slot: a due one not on the board, then the queue, else a fresh batch. */
81async function takeCard($: EngineInterface, opts: Opts): Promise<FlashCard> {
82  const f = await read($, board)
83  const due = pickDue(deck, await $.clock.now(), boardOf(f), f.topic)
84  if (due) return due
85  // two slots can drain one batch between them: a second batch, then give up
86  for (let tries = 0; tries < 2 && ahead.length === 0; tries++) await writeBatch($, opts)
87  const card = ahead.shift()
88  if (!card) throw new Error('the model wrote no card that is new to the deck')
89  return card
90}
91
92async function saveCard($: EngineInterface, card: FlashCard): Promise<void> {
93  deck = withCard(deck, card)
94  await $.store.set(DECK_KEY, deck)
95}
96
97async function setSlot($: EngineInterface, i: number, slot: Partial<FlashSlot>): Promise<void> {
98  await update($, board, f => ({ ...f, slots: f.slots.map((s, j) => (j === i ? { ...s, ...slot } : s)) }))
99}
100
101/** Turns card `i` over, from the state as it is now, not as it was last drawn. */
102async function flipSlot($: EngineInterface, i: number): Promise<void> {
103  await update($, board, f => ({ ...f, slots: f.slots.map((s, j) => (j === i ? { ...s, isFlipped: !s.isFlipped } : s)) }))
104}
105
106async function recount($: EngineInterface): Promise<void> {
107  const now = await $.clock.now()
108  await update($, board, f => ({ ...f, ...deckCounts(deck, now, boardOf(f)) }))
109}
110
111/** Puts a new card in slot `i`: loading while it is written, the reason when it could not be. */
112async function fillSlot($: EngineInterface, opts: Opts, i: number): Promise<void> {
113  await setSlot($, i, { status: 'loading', error: undefined, isFlipped: false })
114  try {
115    const card = await takeCard($, opts)
116    const known = deck.find(c => sameCard(c, card))
117    if (!known) await saveCard($, card)
118    await setSlot($, i, { status: 'idle', card: known ?? card })
119  } catch (err) {
120    await setSlot($, i, { status: 'error', error: err instanceof Error ? err.message : String(err) })
121  }
122}
123
124/** While the board is open and nothing is due, keeps a batch written ahead so the next cards land at once. */
125async function afterChange($: EngineInterface, opts: Opts): Promise<void> {
126  await recount($)
127  const f = await read($, board)
128  if (!isBoardOpen || ahead.length > 0 || writing || pickDue(deck, await $.clock.now(), boardOf(f), f.topic)) return
129  // written in the background: a failure here resurfaces on the next fill, which writes in the open
130  await writeBatch($, opts).catch(() => undefined)
131}
132
133/** Deals a full board: every slot gets a new card. */
134async function dealBoard($: EngineInterface, opts: Opts): Promise<void> {
135  await update($, board, f => ({ ...f, slots: Array.from({ length: SLOTS }, (_, i) => f.slots[i] ?? emptySlot()) }))
136  for (let i = 0; i < SLOTS; i++) await fillSlot($, opts, i)
137  await afterChange($, opts)
138}
139
140/** Sets the topic new cards are written about (empty: general); a change drops the cards written for the old one. */
141async function applyTopic($: EngineInterface, text: string): Promise<boolean> {
142  const topic = cleanTopic(text)
143  if (topic === (await read($, board)).topic) return false
144  cancel?.abort()
145  ahead = []
146  await update($, board, f => ({ ...f, topic, last: topic ? `Topic: ${topic}` : 'Topic cleared: general software development' }))
147  if (topic) await $.store.set(TOPIC_KEY, topic)
148  else await $.store.delete(TOPIC_KEY)
149  return true
150}
151
152async function setTopic($: EngineInterface, opts: Opts, text: string): Promise<void> {
153  if (await applyTopic($, text)) await dealBoard($, opts)
154}
155
156/**
157 * Opens the board with the keyboard, so the first click lands on a card; deals when it is empty
158 * or the topic changed.
159 */
160async function openBoard($: EngineInterface, opts: Opts, topic?: string): Promise<boolean> {
161  const opened = await $.ui.open({ id: BOARD, title: BOARD_TITLE, focus: true })
162  isBoardOpen = true
163  const isNewTopic = topic !== undefined && (await applyTopic($, topic))
164  if (isNewTopic || boardOf(await read($, board)).length === 0) await dealBoard($, opts)
165  return opened.isPlaced
166}
167
168async function rateSlot($: EngineInterface, opts: Opts, i: number, isKnown: boolean): Promise<void> {
169  const card = (await read($, board)).slots[i]?.card
170  if (!card) return
171  const now = await $.clock.now()
172  const graded = grade(card, isKnown, now)
173  await saveCard($, graded)
174  await update($, board, f => ({ ...f, last: `${isKnown ? '✓' : '↺'} ${card.term} · back in ${fmtIn(graded.dueAt - now)}` }))
175  await fillSlot($, opts, i)
176  await afterChange($, opts)
177}
178
179type Els = ReturnType<EngineInterface['ui']['resolve']>
180
181/** The line under the band: the deck's counts and the way to the board. */
182async function CardsLine($: EngineInterface, els: Els, opts: Opts) {
183  const { Box, Button, Text } = els
184  const f = await read($, board)
185  return (
186    <Box flexDirection="row" gap={1}>
187      <Text color="cyan">📇 cards</Text>
188      <Text color={f.due > 0 ? 'warning' : undefined} dimColor={f.due === 0}>{`${f.due} due`}</Text>
189      <Text dimColor>{`· ${f.learned}/${f.total} learned${f.topic ? ` · ${f.topic}` : ''}`}</Text>
190      <Button key="flash-open" label={f.slots.length > 0 ? 'board' : 'start learning'} hotkey="f" plain onPress={() => void openBoard($, opts)} />
191    </Box>
192  )
193}
194
195/** The board: a topic field, three cards that flip on a press of their term, graded on their back. */
196async function Board($: EngineInterface, els: Els, opts: Opts, columns: number) {
197  const { Box, Button, Text } = els
198  const f = await read($, board)
199  const w = Math.max(10, Math.min(30, columns - 24))
200  const pct = f.total > 0 ? (f.learned / f.total) * 100 : 0
201  // g and a grade the first card turned over, so a keyboard run is: 1, g, 2, a, ...
202  const graded = f.slots.findIndex(s => s.isFlipped && s.status === 'idle' && s.card)
203  return (
204    <Box flexDirection="column" gap={1}>
205      <Box flexDirection="column">
206        <Box flexDirection="row" gap={1}>
207          <Text color="success">{progressBar(pct, w)}</Text>
208          <Text>{`${f.learned}/${f.total} learned`}</Text>
209          <Text color={f.due > 0 ? 'warning' : undefined} dimColor={f.due === 0}>{`· ${f.due} due`}</Text>
210        </Box>
211        <Text dimColor italic>
212          {f.last ?? 'Press a term to flip its card.'}
213        </Text>
214      </Box>
215      {'Input' in els && (
216        <Box flexDirection="row" gap={1}>
217          <els.Input
218            key="flash-topic"
219            label="Topic"
220            placeholder="anything: Kubernetes, Rust lifetimes, OAuth… (empty: general)"
221            value={f.topic ?? ''}
222            submitLabel="learn this"
223            onSubmit={text => setTopic($, opts, text)}
224          />
225          {f.topic && <Button key="flash-topic-clear" label="general" plain onPress={() => setTopic($, opts, '')} />}
226        </Box>
227      )}
228      {f.slots.length === 0 && <Text dimColor>No cards yet: press new set.</Text>}
229      {f.slots.map((s, i) => (
230        <Box
231          key={`flash-card-${i}`}
232          flexDirection="column"
233          borderStyle="round"
234          borderColor={s.status === 'error' ? 'error' : s.isFlipped ? 'cyan' : undefined}
235          borderDimColor={!s.isFlipped && s.status !== 'error'}
236          paddingX={1}
237        >
238          {s.status === 'loading' ? (
239            <Text dimColor italic>
240              ✎ writing a card…
241            </Text>
242          ) : s.status === 'error' || !s.card ? (
243            <Box flexDirection="column">
244              <Text color="error" wrap="wrap">{`Couldn't write a card: ${s.error ?? 'no card'}`}</Text>
245              <Button key={`flash-retry-${i}`} label="try again" onPress={() => fillSlot($, opts, i)} />
246            </Box>
247          ) : (
248            <Box flexDirection="column">
249              <Button
250                key={`flash-flip-${i}`}
251                label={`${s.isFlipped ? '▾' : '▸'} ${s.card.term}`}
252                hotkey={String(i + 1)}
253                plain
254                hover={{ color: 'cyan', underline: true }}
255                onPress={() => flipSlot($, i)}
256              />
257              <Box flexDirection="row" gap={1}>
258                <Text dimColor>{s.card.category}</Text>
259                <Text color={s.card.box > 0 ? 'success' : undefined} dimColor={s.card.box === 0}>
260                  {masteryDots(s.card.box)}
261                </Text>
262              </Box>
263              {s.isFlipped && (
264                <Box flexDirection="column" marginTop={1}>
265                  <Text wrap="wrap">{s.card.definition}</Text>
266                  <Text color="cyan" italic wrap="wrap">{`e.g. ${s.card.example}`}</Text>
267                  <Box flexDirection="row" gap={1} marginTop={1}>
268                    <Button key={`flash-got-${i}`} label="✓ got it" {...(i === graded ? { hotkey: 'g' } : {})} variant="primary" onPress={() => rateSlot($, opts, i, true)} />
269                    <Button key={`flash-again-${i}`} label="↺ again" {...(i === graded ? { hotkey: 'a' } : {})} onPress={() => rateSlot($, opts, i, false)} />
270                  </Box>
271                </Box>
272              )}
273            </Box>
274          )}
275        </Box>
276      ))}
277      <Box flexDirection="row" gap={1}>
278        <Button key="flash-deal" label="new set" hotkey="n" onPress={() => dealBoard($, opts)} />
279        <Button key="flash-close" label="close" hotkey="x" role="dismiss" onPress={() => $.ui.close({ id: BOARD })} />
280      </Box>
281    </Box>
282  )
283}
284
285export const register: Register = (on, options) => {
286  const opts = readOptions(options)
287
288  on('session.start', async ($, e, next) => {
289    await $.command.register({ name: 'cards', description: 'Flash cards: open the board of software development terms', argumentHint: '[topic]' })
290    const [stored, topic] = await Promise.all([$.store.get(DECK_KEY), $.store.get(TOPIC_KEY)])
291    deck = Array.isArray(stored) ? (stored as FlashCard[]) : []
292    ahead = []
293    isBoardOpen = false
294    // a card being written when the module reloaded is written again on the next fill
295    await update($, board, f => ({
296      ...f,
297      topic: typeof topic === 'string' ? topic : undefined,
298      slots: (f.slots ?? []).map(s => (s.status === 'loading' ? { ...s, status: 'idle' as const } : s)),
299    }))
300    await recount($)
301    $.clock.every(60_000, () => {
302      void (async () => {
303        const [now, f] = await Promise.all([$.clock.now(), read($, board)])
304        if (deckCounts(deck, now, boardOf(f)).due !== f.due) await recount($)
305      })().catch(() => undefined)
306    })
307    return next(e)
308  })
309
310  on('command.run', { command: 'cards' }, async ($, e) => {
311    const topic = e.args.trim()
312    const isPlaced = await openBoard($, opts, topic || undefined)
313    return { text: isPlaced ? `Flash card board opened${topic ? ` on ${topic}` : ''}.` : 'Flash card board queued: widen the terminal to place it.' }
314  })
315
316  // Closing the board, by its button or the person's close mark, stops the model: the call in flight is cancelled.
317  on('ui.close', { id: BOARD }, async ($, e, next) => {
318    isBoardOpen = false
319    cancel?.abort()
320    return next(e)
321  })
322
323  // The line goes under whatever the band above it draws (another mod's band included).
324  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
325    const above = await next(e)
326    if (e.props.hasSurvey) return above
327    const els = $.ui.resolve(e)
328    const { Box } = els
329    return (
330      <Box flexDirection="column">
331        {above}
332        {await CardsLine($, els, opts)}
333      </Box>
334    )
335  })
336
337  on('ui.render', { component: 'Pane', requestId: BOARD }, async ($, e) => Board($, $.ui.resolve(e), opts, e.props.bodyColumns))
338}
339
hooks/cards.ts 140 lines
1// Flash cards on a side board: software development terms the model writes in batches,
2// with light spaced repetition (Got it pushes a card out, Again brings it back soon).
3// Pure helpers; register.tsx owns the engine calls.
4
5import type { FlashCard, FlashSlot, FlashView } from '../types'
6
7export const DECK_KEY = 'flash:deck'
8export const KEEP_CARDS = 500
9/** Cards on the board, and cards the model writes per call. */
10export const SLOTS = 3
11const RECENT = 60
12const MIN = 60_000
13const DAY = 86_400_000
14/** Again: back in ten minutes. Unrated: tomorrow. Got it: the next step of the ladder. */
15const AGAIN_MS = 10 * MIN
16const UNRATED_MS = DAY
17const LADDER_MS = [DAY, 3 * DAY, 7 * DAY, 21 * DAY, 60 * DAY]
18
19export const CATEGORIES = [
20  'design patterns', 'data structures', 'algorithms', 'git and version control', 'testing',
21  'distributed systems', 'databases', 'application security', 'networking', 'concurrency',
22  'CI/CD and DevOps', 'cloud infrastructure', 'frontend', 'API design', 'operating systems',
23  'programming languages and compilers', 'LLM and AI engineering', 'software architecture',
24  'performance', 'observability',
25]
26
27export const CARD_SYSTEM =
28  'You write flash cards that teach software development terms to an experienced developer. ' +
29  'Reply with a JSON array and nothing else (no prose, no code fence), one object per card: ' +
30  '{"term": string, "category": string, "definition": string, "example": string}. ' +
31  'definition: plain language, at most 220 characters. example: a concrete usage, snippet or analogy, at most 160 characters. ' +
32  'Every term must be different.'
33
34export const EMPTY_FLASH: FlashView = { slots: [], due: 0, learned: 0, total: 0 }
35
36/** A topic as typed, trimmed and capped; empty means general. */
37export const cleanTopic = (text: string): string | undefined => text.trim().replace(/\s+/g, ' ').slice(0, 120) || undefined
38
39export const emptySlot = (): FlashSlot => ({ isFlipped: false, status: 'idle' })
40
41/** `count` different categories, in random order. */
42export function pickCategories(count: number, random: () => number = Math.random): string[] {
43  const pool = [...CATEGORIES]
44  return Array.from({ length: Math.min(count, pool.length) }, () => pool.splice(Math.floor(random() * pool.length), 1)[0]!)
45}
46
47/**
48 * The request for a batch of new cards, clear of the terms already studied: on the person's
49 * topic when they gave one, else one per category.
50 */
51export function cardPrompt(categories: readonly string[], deck: readonly FlashCard[], extra: readonly string[], topic?: string): string {
52  const recent = [...deck].sort((a, b) => b.seenAt - a.seenAt).slice(0, RECENT).map(c => c.term)
53  const avoid = [...recent, ...extra]
54  const ask = topic
55    ? `Write ${categories.length} flash cards about this topic: ${topic}. Each card covers a different idea within it; set category to the sub-area. `
56    : `Write ${categories.length} flash cards, one for each of these categories, in order: ${categories.join('; ')}. `
57  return (
58    ask +
59    'Pick terms worth knowing, from fundamentals to advanced.' +
60    (avoid.length ? ` Do not use any of these terms: ${avoid.join('; ')}.` : '')
61  )
62}
63
64function cardFrom(o: Record<string, unknown>, now: number, topic?: string): FlashCard {
65  for (const k of ['term', 'category', 'definition', 'example'] as const)
66    if (typeof o[k] !== 'string' || !(o[k] as string).trim()) throw new Error(`a card in the reply has no ${k}`)
67  return {
68    term: (o.term as string).trim(),
69    category: (o.category as string).trim(),
70    definition: (o.definition as string).trim(),
71    example: (o.example as string).trim(),
72    box: 0,
73    dueAt: now + UNRATED_MS,
74    seenAt: now,
75    ...(topic ? { topic } : {}),
76  }
77}
78
79/** The cards of the reply as the model wrote them, held to the fields asked for; a lone object is one card. */
80export function parseCards(text: string, now: number, topic?: string): FlashCard[] {
81  const list = text.indexOf('[')
82  const obj = text.indexOf('{')
83  const isList = list >= 0 && (obj < 0 || list < obj)
84  const start = isList ? list : obj
85  const end = text.lastIndexOf(isList ? ']' : '}')
86  if (start < 0 || end < start) throw new Error('the card reply holds no JSON')
87  const parsed = JSON.parse(text.slice(start, end + 1)) as unknown
88  const items = Array.isArray(parsed) ? parsed : [parsed]
89  const cards = items.map(o => cardFrom(o as Record<string, unknown>, now, topic))
90  return cards.filter((c, i) => cards.findIndex(d => sameCard(c, d)) === i)
91}
92
93/** Got it climbs the ladder; Again drops to the bottom and returns in ten minutes. */
94export function grade(card: FlashCard, isKnown: boolean, now: number): FlashCard {
95  if (!isKnown) return { ...card, box: 0, dueAt: now + AGAIN_MS, seenAt: now }
96  const box = card.box + 1
97  return { ...card, box, dueAt: now + LADDER_MS[Math.min(box, LADDER_MS.length) - 1]!, seenAt: now }
98}
99
100/** A card's place on the ladder as five dots, filled per Got it. */
101export const masteryDots = (box: number): string => '●'.repeat(Math.min(box, 5)) + '○'.repeat(5 - Math.min(box, 5))
102
103/** How long until a card is back: minutes, hours or days. */
104export function fmtIn(ms: number): string {
105  if (ms < 3_600_000) return `${Math.max(1, Math.round(ms / MIN))}m`
106  if (ms < DAY) return `${Math.round(ms / 3_600_000)}h`
107  return `${Math.round(ms / DAY)}d`
108}
109
110export const sameCard = (a: FlashCard, b: FlashCard) => a.term.toLowerCase() === b.term.toLowerCase()
111
112/** The cards on the board. */
113export const boardOf = (f: FlashView): FlashCard[] => f.slots.flatMap(s => (s.card ? [s.card] : []))
114
115/** The most overdue card not on the board; with a topic, only that topic's cards. */
116export function pickDue(deck: readonly FlashCard[], now: number, board: readonly FlashCard[] = [], topic?: string): FlashCard | undefined {
117  return deck
118    .filter(c => c.dueAt <= now && !board.some(b => sameCard(b, c)) && (!topic || c.topic === topic))
119    .sort((a, b) => a.dueAt - b.dueAt)[0]
120}
121
122/** The deck with `card` in it, replacing any card of the same term, newest kept. */
123export function withCard(deck: readonly FlashCard[], card: FlashCard): FlashCard[] {
124  return [...deck.filter(c => !sameCard(c, card)), card].slice(-KEEP_CARDS)
125}
126
127export function deckCounts(deck: readonly FlashCard[], now: number, board: readonly FlashCard[] = []) {
128  return {
129    due: deck.filter(c => c.dueAt <= now && !board.some(b => sameCard(b, c))).length,
130    learned: deck.filter(c => c.box > 0).length,
131    total: deck.length,
132  }
133}
134
135/** A progress bar `width` cells wide. */
136export function progressBar(pct: number, width: number): string {
137  const full = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
138  return '█'.repeat(full) + '░'.repeat(width - full)
139}
140
types/index.d.ts 45 lines
1// State contract for the flashcards mod: every value it keeps in $.state.
2
3/** One flash card: a term the model wrote, with its place on the review ladder. */
4export type FlashCard = {
5  term: string
6  category: string
7  definition: string
8  example: string
9  /** 0: new or marked Again; each Got it climbs one step (1d, 3d, 7d, 21d, 60d). */
10  box: number
11  /** When the card is due for review again. */
12  dueAt: number
13  seenAt: number
14  /** The topic the person asked for when the card was written; absent for general cards. */
15  topic?: string
16}
17
18/** One place on the board. */
19export type FlashSlot = {
20  card?: FlashCard
21  isFlipped: boolean
22  status: 'idle' | 'loading' | 'error'
23  error?: string
24}
25
26/** What the board and its line under the band draw. */
27export type FlashView = {
28  slots: FlashSlot[]
29  /** The topic new cards are written about; absent: general software development. */
30  topic?: string
31  /** What the last grade did, e.g. `✓ CAP theorem · back in 3d`. */
32  last?: string
33  due: number
34  learned: number
35  total: number
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    flashcards: {
41      board: FlashView
42    }
43  }
44}
45