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

A Claude Code mod that shows a due Anki card above the prompt while Claude works.
Medan is Swedish for "while".


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.
2055492159)If Anki is closed, the band shows a dim Anki offline line and nothing else.
/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
Change them in /config, or in ~/.claude/settings.json:
"pluginConfigs": {
"medan": { "options": { "deck": "Svensk" } }
}
| Option | Default | What it does |
|---|---|---|
deck | Default | Deck to review, subdecks included |
ankiConnectUrl | http://localhost:8765 | Where AnkiConnect listens |
showKey | 1 | Shows the answer |
againKey | 2 | Grades Again |
hardKey | (empty) | Grades Hard |
goodKey | (empty) | Grades Good |
easyKey | 3 | Grades 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.
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.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 300 lines1import { 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}
300types/index.d.ts 10 lines1export 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