SLOPSHOPPER

medan

Review due Anki cards in the band above the Claude Code prompt while Claude works; answers go straight to Anki through AnkiConnect.

newbandnetwork
v0.2.0MITupdated 2026-10-03avlihachev/medan
A shopper browsing a rack in a slop shop
README

medan

A Claude Code mod that shows a due Anki card above the prompt while Claude works.

Medan is Swedish for "while".

A due card above the prompt

The same card with its answer and grades

Press 1 to show the answer, then 2 for again or 3 for easy. The grade goes to Anki through AnkiConnect, so it counts as a normal review and Anki schedules the card as usual. The band only appears while a turn is running. When Claude finishes, it hides and leaves the prompt alone.

Requirements

  • Claude Code 2.1.287 or later (mods support)
  • Anki desktop, running, with the AnkiConnect add-on (code 2055492159)

If Anki is closed, the band shows a dim Anki offline line and nothing else.

Install

/plugin marketplace add avlihachev/medan
/plugin install medan@medan
/reload-plugins

Or run it from a clone:

git clone https://github.com/avlihachev/medan
claude --plugin-dir ./medan

Settings

Change them in /config, or in ~/.claude/settings.json:

"pluginConfigs": {
  "medan": { "options": { "deck": "Svensk" } }
}
OptionDefaultWhat it does
deckDefaultDeck to review, subdecks included
ankiConnectUrlhttp://localhost:8765Where AnkiConnect listens
showKey1Shows the answer
againKey2Grades Again
hardKey(empty)Grades Hard
goodKey(empty)Grades Good
easyKey3Grades Easy

A key is one digit or one lowercase letter. An empty key hides that button. For Anki's own layout, set againKey 1, hardKey 2, goodKey 3 and easyKey 4.

Any note type works. Cards are shown the way Anki renders them, as text: question on top, the part after <hr id=answer> as the answer.

Keys

Digits work straight from an empty composer. Letters only reach the band once it has focus: click it or press ctrl+x tab. If showKey is also a grade's key, a grade pressed within 0.4 s of show is ignored, so a double tap doesn't grade a card you haven't read.

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 300 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Card, Counts, Deck } from '../types'
5
6const GRADES = [
7  { name: 'again', ease: 1 },
8  { name: 'hard', ease: 2 },
9  { name: 'good', ease: 3 },
10  { name: 'easy', ease: 4 },
11] as const
12const AREDUE_WINDOW = 20
13const GRADE_DELAY_MS = 400
14const SEP = '\u0001'
15
16const card = atom({ plugin: 'medan', key: 'card' } as const, null)
17const isRevealed = atom({ plugin: 'medan', key: 'isRevealed' } as const, false)
18const deck = atom({ plugin: 'medan', key: 'deck' } as const, { name: '', status: 'idle', queue: [] })
19const counts = atom({ plugin: 'medan', key: 'counts' } as const, null)
20
21type Grade = (typeof GRADES)[number]['name']
22type Settings = { deck: string; ankiConnectUrl: string; showKey: string } & Record<`${Grade}Key`, string>
23type CardInfo = { cardId: number; question: string; answer: string }
24
25const ENTITIES: Record<string, string> = {
26  nbsp: ' ', lt: '<', gt: '>', quot: '"', apos: "'", amp: '&',
27  mdash: '—', ndash: '–', hellip: '…', laquo: '«', raquo: '»',
28}
29
30let settings: Settings = {
31  deck: 'Default',
32  ankiConnectUrl: 'http://localhost:8765',
33  showKey: '1',
34  againKey: '2',
35  hardKey: '',
36  goodKey: '',
37  easyKey: '3',
38}
39let dealing: Promise<void> | null = null
40let answering: Promise<unknown> | null = null
41let revealedAt = 0
42
43function decodeEntities(text: string): string {
44  return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
45    if (code[0] !== '#') return ENTITIES[code.toLowerCase()] ?? whole
46    const point = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
47
48    return Number.isFinite(point) && point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : whole
49  })
50}
51
52export function plain(html: string): string {
53  const text = decodeEntities(
54    html
55      .replace(/<style[\s\S]*?<\/style>/gi, '')
56      .replace(/<script[\s\S]*?<\/script>/gi, '')
57      .replace(/\[sound:[^\]]*\]/g, '')
58      .replace(/<img\b[^>]*>/gi, '[image]')
59      .replace(/<br\s*\/?>|<\/(div|p|li|td|tr|h\d)>/gi, SEP)
60      .replace(/<[^>]+>/g, ''),
61  )
62
63  return text
64    .split(SEP)
65    .map(part => part.replace(/\s+/g, ' ').trim())
66    .filter(part => part !== '')
67    .join(' / ')
68}
69
70// the rendered answer repeats the question above <hr id=answer>; keep only what follows it
71export function answerSide(html: string): string {
72  const parts = html.split(/<hr[^>]*id=["']?answer["']?[^>]*>/i)
73
74  return plain(parts[parts.length - 1] ?? '')
75}
76
77export function hotkey(key: string | undefined): string | undefined {
78  const value = key?.trim().toLowerCase() ?? ''
79
80  return /^[0-9a-z]$/.test(value) ? value : undefined
81}
82
83export function gradeButtons(keys: Settings): { name: Grade; ease: number; key: string }[] {
84  const taken = new Set<string>()
85
86  return GRADES.flatMap(({ name, ease }) => {
87    const key = hotkey(keys[`${name}Key`])
88    if (key === undefined || taken.has(key)) return []
89    taken.add(key)
90    return [{ name, ease, key }]
91  })
92}
93
94export function deckFilter(name: string): string {
95  return `"deck:${name.replace(/[\\"*_]/g, '\\$&')}"`
96}
97
98export function deckQuery(name: string): string {
99  return `${deckFilter(name)} is:due`
100}
101
102function deckName(): string {
103  return settings.deck.trim() || 'Default'
104}
105
106async function anki<T>($: EngineInterface, action: string, params: object = {}): Promise<T | null> {
107  try {
108    const res = await $.http.fetch(settings.ankiConnectUrl, {
109      method: 'POST',
110      body: JSON.stringify({ action, version: 6, params }),
111    })
112    if (!res.ok) return null
113    const { result, error } = JSON.parse(res.text)
114
115    return error ? null : (result as T)
116  } catch {
117    return null
118  }
119}
120
121async function refreshCounts($: EngineInterface): Promise<void> {
122  const filter = deckFilter(deckName())
123  const today = await anki<number[]>($, 'findCards', { query: `${filter} rated:1` })
124  const due = await anki<number[]>($, 'findCards', { query: `${filter} is:due` })
125  if (today === null || due === null) return
126  await update($, counts, (): Counts => ({ today: today.length, due: due.length }))
127}
128
129async function goOffline($: EngineInterface): Promise<void> {
130  await update($, deck, (): Deck => ({ name: deckName(), status: 'offline', queue: [] }))
131  await update($, card, () => null)
132}
133
134export function shuffled<T>(items: readonly T[]): T[] {
135  const out = [...items]
136  for (let i = out.length - 1; i > 0; i--) {
137    const j = Math.floor(Math.random() * (i + 1))
138    ;[out[i], out[j]] = [out[j]!, out[i]!]
139  }
140
141  return out
142}
143
144// learning steps first, as Anki does; reviews shuffled so a session doesn't always open on the oldest cards
145async function dueCards($: EngineInterface): Promise<number[] | null> {
146  const query = deckQuery(deckName())
147  const learning = await anki<number[]>($, 'findCards', { query: `${query} is:learn` })
148  const reviews = await anki<number[]>($, 'findCards', { query: `${query} -is:learn` })
149  if (learning === null || reviews === null) return null
150
151  return [...learning, ...shuffled(reviews)]
152}
153
154async function skipUnopened($: EngineInterface): Promise<void> {
155  const current = await read($, card)
156  if (current === null || (await read($, isRevealed))) return
157  await update($, card, () => null)
158  await update($, deck, d => ({ ...d, queue: [...d.queue, current.id] }))
159}
160
161async function deal($: EngineInterface): Promise<void> {
162  await answering
163
164  let queue = (await read($, deck)).queue
165  let hasRefilled = false
166  let id: number | undefined
167
168  while (id === undefined) {
169    if (queue.length === 0) {
170      if (hasRefilled) break
171      hasRefilled = true
172      const ids = await dueCards($)
173      if (ids === null) return goOffline($)
174      queue = ids
175      continue
176    }
177
178    const head = queue.slice(0, AREDUE_WINDOW)
179    const due = await anki<boolean[]>($, 'areDue', { cards: head })
180    if (due === null) return goOffline($)
181    const index = due.findIndex(Boolean)
182    id = index >= 0 ? head[index] : undefined
183    queue = queue.slice(index >= 0 ? index + 1 : head.length)
184  }
185
186  if (id === undefined) {
187    await update($, deck, (): Deck => ({ name: deckName(), status: 'empty', queue: [] }))
188    await update($, card, () => null)
189    return
190  }
191
192  const info = (await anki<CardInfo[]>($, 'cardsInfo', { cards: [id] }))?.[0]
193  if (!info) return goOffline($)
194
195  const next: Card = { id, prompt: plain(info.question) || '(empty)', answer: answerSide(info.answer) || '(empty)' }
196  const rest = queue
197  await update($, deck, (): Deck => ({ name: deckName(), status: 'ready', queue: rest }))
198  await update($, isRevealed, () => false)
199  await update($, card, current => current ?? next)
200}
201
202async function dealNext($: EngineInterface): Promise<void> {
203  dealing ??= deal($).finally(() => {
204    dealing = null
205  })
206
207  return dealing
208}
209
210async function reveal($: EngineInterface): Promise<void> {
211  revealedAt = await $.clock.now()
212  await update($, isRevealed, () => true)
213}
214
215async function answer($: EngineInterface, shown: Card, ease: number): Promise<void> {
216  const sharesShowKey = gradeButtons(settings).some(grade => grade.key === hotkey(settings.showKey))
217  if (sharesShowKey && (await $.clock.now()) - revealedAt < GRADE_DELAY_MS) return
218
219  let isClaimed = false
220  await update($, card, c => {
221    isClaimed = c?.id === shown.id
222    return isClaimed ? null : c
223  })
224  if (!isClaimed) return
225
226  const request = anki<boolean[]>($, 'answerCards', { answers: [{ cardId: shown.id, ease }] })
227  answering = request
228  const ok = await request
229  answering = null
230  await dealNext($)
231  if (ok?.[0]) await refreshCounts($)
232}
233
234export const register: Register = (on, options) => {
235  settings = options as Settings
236
237  on('turn.start', async ($, e, next) => {
238    if ((await read($, deck)).name !== deckName()) {
239      await update($, deck, (): Deck => ({ name: deckName(), status: 'idle', queue: [] }))
240      await update($, card, () => null)
241    }
242    await skipUnopened($)
243    if ((await read($, card)) === null) await dealNext($)
244    await refreshCounts($)
245
246    return next(e)
247  })
248
249  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
250    if (e.props.hasSurvey || !e.props.isWorking) return next(e)
251
252    const { Box, Button, Text } = $.ui.resolve(e)
253    const current = await read($, card)
254    const { status } = await read($, deck)
255    const tally = await read($, counts)
256    const title = deckName().toLowerCase()
257
258    if (current === null) {
259      if (status === 'offline') return <Text dimColor>  {title} · Anki offline</Text>
260      if (status === 'empty') return <Text dimColor>  {title} · nothing due ✓ {tally && tally.today > 0 ? `(${tally.today} today)` : ''}</Text>
261      return next(e)
262    }
263
264    const shown = await read($, isRevealed)
265
266    return (
267      <Box
268        flexDirection="column"
269        borderStyle="round"
270        borderColor="cyan"
271        paddingX={1}
272        width={Math.min(e.props.bodyColumns, 64)}
273      >
274        <Box justifyContent="space-between">
275          <Text color="cyan" bold>{title}</Text>
276          <Text dimColor>{tally ? `${tally.today} today · ${tally.due} due` : ''}</Text>
277        </Box>
278        <Text bold color="yellow">{current.prompt}</Text>
279        {shown
280          ? (
281            <Box flexDirection="column">
282              <Text>→ {current.answer}</Text>
283              <Box gap={2}>
284                {gradeButtons(settings).map(grade => (
285                  <Button key={grade.name} hotkey={grade.key} plain label={grade.name} onPress={() => answer($, current, grade.ease)} />
286                ))}
287              </Box>
288            </Box>
289          )
290          : (
291            <Box gap={2}>
292              <Text dimColor>→ ···</Text>
293              <Button key="show" hotkey={hotkey(settings.showKey)} plain label="show" onPress={() => reveal($)} />
294            </Box>
295          )}
296      </Box>
297    )
298  })
299}
300
types/index.d.ts 10 lines
1export type Card = { id: number; prompt: string; answer: string }
2export type Counts = { today: number; due: number }
3export type Deck = { name: string; status: 'idle' | 'offline' | 'empty' | 'ready'; queue: number[] }
4
5declare module 'claude-code' {
6  interface PluginState {
7    medan: { card: Card | null; isRevealed: boolean; deck: Deck; counts: Counts | null }
8  }
9}
10