Review Anki cards in a pane while Claude works

Review your Anki flashcards in a pane inside Claude Code while Claude works.
When Claude is busy on a long task, you usually wait. This mod turns that waiting time into study time: type /anki, pick a deck, and review cards with single keys. Every review goes into Anki itself, so your scheduling and statistics stay exactly as if you had used Anki's own window.
Deck New Learn Due
1: Spanish vocabulary 11 7 0
2: TypeScript terms 0 0 78
3: Finished deck 0 0 0 done
2055492159. Restart Anki afterwards.Clone the repository into Claude Code's skills folder:
git clone https://github.com/wickedurge/Claude-Mod-Anki-Review.git ~/.claude/skills/anki-review
Then start a new Claude Code session. Claude Code loads the mod from that folder by itself.
To update later, run git pull in ~/.claude/skills/anki-review and start a new session.
/anki. A pane opens with your decks and their New, Learn and Due counts, as Anki shows them.| On | Key | Does |
|---|---|---|
| A card's front | s | Show the answer |
| A card's front | x | Exit to the deck list |
| A card's back | a | Again |
| A card's back | h | Hard |
| A card's back | g | Good |
| A card's back | e | Easy |
When a deck is finished, the pane returns to the deck list.
The pane and Claude's prompt box share one keyboard.
ctrl+x then tab moves the keyboard into the pane. Clicking the pane works too.ctrl+x then x closes the pane.The pane dims while the keyboard is elsewhere and shows ctrl+x then tab to review here at the top, so you can always tell where your keys will go.
Tip: run /anki before you give Claude a long task. Commands typed while Claude is working wait until it finishes.
/anki again once it is fixed.Open /config and find anki-review:
| Setting | Default | What it does |
|---|---|---|
| AnkiConnect address | http://127.0.0.1:8765 | Where the AnkiConnect add-on listens. |
| AnkiConnect API key | empty | Only needed if you set an apiKey in AnkiConnect's config. Stored securely. |
| Sync with AnkiWeb when a deck is finished | off | After a deck's last card, asks Anki to sync so your reviews reach your other devices. |
| Take the keyboard back after a permission prompt | off | If a permission prompt takes the keyboard from the pane mid-review, gives it back once you answer the prompt. |
| Show developer details | off | Prints the mod's memory under the pane, for working on the mod. It shows answers early. |
The settings live in Claude Code's own settings file, so they travel with the rest of your Claude Code settings.
The mod sends requests to the AnkiConnect add-on on your computer. When you pick a deck, Anki opens it in its own review screen, and the pane shows the same card. Showing the answer and grading happen in Anki too, so Anki stays the single source of truth. Before grading, the mod checks that Anki is still on the card the pane shows, so a grade never lands on the wrong card.
Nothing leaves your computer except an optional AnkiWeb sync, which Anki performs itself.
git clone https://github.com/wickedurge/Claude-Mod-Anki-Review.git
cd Claude-Mod-Anki-Review
claude plugin validate .
claude plugin test .
claude --plugin-dir .
hooks/register.tsx: the wiring. The only file that talks to Claude Code.hooks/rules.ts: what memory should hold next, given what just happened.hooks/pane.tsx: what to draw, given what memory holds.hooks/text.ts: turns a card's HTML into plain text.hooks/ankiconnect.ts: requests to AnkiConnect and reading its replies.hooks/ankiconnect-stub.ts: a stand-in for Anki with sample decks and cards.hooks/*.test.ts: tests. review.test.ts drives the pane on the stand-in; anki.test.ts drives it against a fake AnkiConnect.types/index.d.ts: the shape of the mod's memory.claude plugin test . runs every test without Anki or an open pane. GitHub runs the validator, the tests and a type check on every push (.github/workflows/checks.yml).
/anki stub on switches to the stand-in, which has sample cards for awkward cases: no front, no back, broken HTML, images, long text and a leech. /anki stub down makes it fail as if Anki were not running, and /anki stub off returns to Anki.
MIT. See LICENSE.
hooks/register.tsx 194 lines1// The wiring: the one file that talks to the engine. It connects the
2// command, the pane, memory, ankiconnect and the rules to each other.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { Anki, Ease, StubMode } from '../types'
7
8import { realAnki } from './ankiconnect'
9import { stubAnki } from './ankiconnect-stub'
10import { drawPane } from './pane'
11import {
12 START,
13 afterDeckList,
14 afterExit,
15 afterGrade,
16 afterNextCard,
17 afterShowAnswer,
18 afterStart,
19 afterSync,
20 afterSyncStart,
21 isSameCard,
22 shouldTakeKeyboardBack,
23} from './rules'
24
25const PANE = 'anki-review'
26const TITLE = 'Anki review'
27
28const review = atom({ plugin: 'anki-review', key: 'review' } as const, START)
29const stubMode = atom({ plugin: 'anki-review', key: 'stubMode' } as const, 'off' as StubMode)
30
31const DEFAULT_URL = 'http://127.0.0.1:8765'
32
33// Settings from /config, set when the mod loads.
34let ankiconnectUrl = DEFAULT_URL
35let ankiconnectApiKey = ''
36let syncWhenFinished = false
37let takeKeyboardBack = false
38let showDeveloperDetails = false
39
40// What the pane's last drawing saw. Plain variables: they start over when the
41// mod reloads, and the next drawing sets them again.
42let isPaneOpen = false
43let hasKeyboard = false
44let keyboardLosses = 0
45
46// Real Anki, or the stub when the developer switch says so.
47async function anki($: EngineInterface): Promise<Anki> {
48 const mode = await read($, stubMode)
49 if (mode !== 'off') {
50 return stubAnki(mode === 'down')
51 }
52
53 return realAnki(
54 body =>
55 $.http.fetch(ankiconnectUrl, {
56 method: 'POST',
57 headers: { 'Content-Type': 'application/json' },
58 body,
59 }),
60 ankiconnectApiKey,
61 ms => new Promise(done => void $.clock.after(ms, () => done())),
62 )
63}
64
65async function openPicker($: EngineInterface) {
66 const reply = await (await anki($)).listDecks()
67 await update($, review, now => afterDeckList(now, reply))
68}
69
70async function pickDeck($: EngineInterface, name: string) {
71 const reply = await (await anki($)).startReview(name)
72 await update($, review, now => afterStart(now, name, reply))
73}
74
75async function showCardAnswer($: EngineInterface) {
76 const reply = await (await anki($)).showAnswer()
77 await update($, review, now => afterShowAnswer(now, reply))
78}
79
80async function gradeCard($: EngineInterface, ease: Ease) {
81 const source = await anki($)
82
83 // Grade only the card the pane shows; if Anki has moved on, show its card instead.
84 const current = await source.currentCard()
85 if (!isSameCard(await read($, review), current)) {
86 await update($, review, now => afterNextCard(now, current))
87 return
88 }
89
90 const cardId = (await read($, review)).cardId ?? 0
91 const graded = await source.answerCard(ease, cardId)
92 if (!graded.ok) {
93 await update($, review, now => afterNextCard(now, graded))
94 return
95 }
96
97 const next = await source.currentCard()
98 await update($, review, now => afterGrade(now, next, graded.value))
99
100 // The deck is finished: sync if the person asked for it in /config.
101 if (syncWhenFinished && next.ok && next.value === null) {
102 await update($, review, now => afterSyncStart(now))
103 const synced = await source.sync()
104 await update($, review, now => afterSync(now, synced))
105 }
106}
107
108async function exitReview($: EngineInterface) {
109 await update($, review, now => afterExit(now))
110}
111
112export const register: Register = (on, options) => {
113 ankiconnectUrl = String(options.ankiconnectUrl || DEFAULT_URL)
114 ankiconnectApiKey = String(options.ankiconnectApiKey || '')
115 syncWhenFinished = options.syncWhenFinished === true
116 takeKeyboardBack = options.takeKeyboardBack === true
117 showDeveloperDetails = options.showDeveloperDetails === true
118
119 on('session.start', async ($, e, next) => {
120 await $.command.register({
121 name: 'anki',
122 description: 'Review Anki cards in a pane',
123 })
124
125 return next(e)
126 })
127
128 on('command.run', { command: 'anki' }, async ($, e) => {
129 const args = e.args.trim()
130
131 // Developer-only: use the stub instead of Anki, working or failing.
132 const stub = /^stub (on|off|down)$/.exec(args)
133 if (stub) {
134 await update($, stubMode, () => stub[1] as StubMode)
135 const now = { on: 'the stub', down: 'the stub, failing', off: 'real Anki' }[stub[1] as StubMode]
136
137 return { text: `Now using ${now}. Run /anki.` }
138 }
139
140 await openPicker($)
141 await $.ui.open({ id: PANE, title: TITLE, focus: true })
142
143 return { text: 'Anki review pane opened.' }
144 })
145
146 // next(e) covers any permission prompt as well as the tool itself, so the
147 // lines after it run once the prompt is answered and the tool is done.
148 on('tool.call', async ($, e, next) => {
149 const hadKeyboardAtStart = hasKeyboard
150 const lossesAtStart = keyboardLosses
151 const ran = await next(e)
152
153 const takeBack = shouldTakeKeyboardBack({
154 isOn: takeKeyboardBack,
155 isPaneOpen,
156 hadKeyboardAtStart,
157 lostKeyboardDuring: keyboardLosses > lossesAtStart,
158 })
159 if (takeBack) {
160 await $.ui.open({ id: PANE, title: TITLE, focus: true })
161 }
162
163 return ran
164 })
165
166 on('ui.close', async ($, e, next) => {
167 if (e.id === PANE) {
168 isPaneOpen = false
169 }
170
171 return next(e)
172 })
173
174 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
175 const isFocused = e.props.isFocused ?? true
176 if (hasKeyboard && !isFocused) {
177 keyboardLosses += 1
178 }
179 hasKeyboard = isFocused
180 isPaneOpen = true
181
182 return drawPane($.ui.resolve(e), {
183 now: await read($, review),
184 bodyColumns: e.props.bodyColumns ?? 75,
185 hasKeyboard: isFocused,
186 showDeveloperDetails,
187 onPick: name => void pickDeck($, name),
188 onExit: () => void exitReview($),
189 onShowAnswer: () => void showCardAnswer($),
190 onGrade: ease => void gradeCard($, ease),
191 })
192 })
193}
194hooks/ankiconnect.ts 278 lines1// The real connection to Anki, through the AnkiConnect add-on.
2//
3// The engine only lets register.tsx touch the network, so this file never
4// does. It is handed a send function, builds each request, and reads each
5// reply into the shapes the rest of the mod uses.
6import type { Anki, Card, Deck, Ease, Leech, Problem, Reply } from '../types'
7
8// What send resolves with; it rejects when nothing answers at the address.
9export type HttpReply = { status: number; text: string }
10export type Send = (body: string) => Promise<HttpReply>
11export type Pause = (ms: number) => Promise<void>
12
13const NOT_RUNNING: Problem = {
14 message: 'Could not reach Anki.',
15 fix: 'Start Anki, check the AnkiConnect add-on is installed, then run /anki.',
16}
17
18const UNREADABLE: Problem = {
19 message: 'AnkiConnect sent a reply the mod could not read.',
20 fix: 'Check the AnkiConnect address in /config points at AnkiConnect.',
21}
22
23// Reads AnkiConnect's envelope, { result, error }, into a Reply.
24export function readEnvelope(reply: HttpReply): Reply<unknown> {
25 if (reply.status !== 200) {
26 return {
27 ok: false,
28 problem: {
29 message: `AnkiConnect answered with HTTP ${reply.status}.`,
30 fix: 'Check the AnkiConnect address in /config.',
31 },
32 }
33 }
34 let body: unknown
35 try {
36 body = JSON.parse(reply.text)
37 } catch {
38 return { ok: false, problem: UNREADABLE }
39 }
40 if (typeof body !== 'object' || body === null || !('result' in body) || !('error' in body)) {
41 return { ok: false, problem: UNREADABLE }
42 }
43 if (typeof body.error === 'string') {
44 const isKeyProblem = /api key/i.test(body.error)
45
46 return {
47 ok: false,
48 problem: {
49 message: `AnkiConnect: ${body.error}`,
50 fix: isKeyProblem
51 ? 'Set the AnkiConnect API key in /config to match the add-on.'
52 : 'Check Anki is open and not showing a dialog, then try again.',
53 },
54 }
55 }
56
57 return { ok: true, value: body.result }
58}
59
60// deckNames gives the names; getDeckStats gives counts keyed by deck ID.
61export function readDecks(names: unknown, stats: unknown): Reply<Deck[]> {
62 if (!Array.isArray(names) || typeof stats !== 'object' || stats === null) {
63 return { ok: false, problem: UNREADABLE }
64 }
65 const byName = new Map<string, Record<string, unknown>>()
66 for (const one of Object.values(stats)) {
67 if (typeof one === 'object' && one !== null && typeof one.name === 'string') {
68 byName.set(one.name, one)
69 }
70 }
71 const count = (value: unknown) => (typeof value === 'number' ? value : 0)
72
73 return {
74 ok: true,
75 value: names
76 .filter((name): name is string => typeof name === 'string')
77 // Counts come from the tree Anki draws its deck list from; a deck missing
78 // there (such as the empty built-in Default deck) is hidden in Anki too.
79 .filter(name => byName.has(name))
80 .map(name => {
81 const one = byName.get(name)
82
83 return {
84 name,
85 newCount: count(one?.new_count),
86 learnCount: count(one?.learn_count),
87 // Anki's deck list calls cards due for review "Due".
88 dueCount: count(one?.review_count),
89 }
90 }),
91 }
92}
93
94// Anki's answer side usually repeats the question above <hr id=answer>.
95export function answerOnly(html: string): string {
96 const parts = html.split(/<hr\s+id\s*=\s*["']?answer["']?\s*\/?>/i)
97
98 return parts.length > 1 ? parts.slice(1).join('') : html
99}
100
101// guiCurrentCard: a card, or null when Anki is not reviewing.
102export function readCard(result: unknown): Reply<Card | null> {
103 if (result === null) {
104 return { ok: true, value: null }
105 }
106 if (
107 typeof result !== 'object' ||
108 typeof (result as Record<string, unknown>).cardId !== 'number' ||
109 typeof (result as Record<string, unknown>).question !== 'string' ||
110 typeof (result as Record<string, unknown>).answer !== 'string'
111 ) {
112 return { ok: false, problem: UNREADABLE }
113 }
114 const card = result as { cardId: number; question: string; answer: string }
115
116 return {
117 ok: true,
118 value: { cardId: card.cardId, question: card.question, answer: answerOnly(card.answer) },
119 }
120}
121
122// The GUI actions answer true when Anki did what was asked.
123export function readDone(result: unknown, refused: Problem): Reply<null> {
124 return result === true ? { ok: true, value: null } : { ok: false, problem: refused }
125}
126
127// Anki's rule: a card becomes a leech when its lapses reach the deck's
128// threshold, and again every half-threshold after that (8, 12, 16, ...).
129export function isLeechCount(lapses: number, threshold: number): boolean {
130 if (threshold <= 0) {
131 return false
132 }
133 const half = Math.max(1, Math.ceil(threshold / 2))
134
135 return lapses >= threshold && (lapses - threshold) % half === 0
136}
137
138// One card's lapses, queue (-1 when suspended) and deck, from cardsInfo.
139export function readCardState(result: unknown): { lapses: number; queue: number; deckName: string } | null {
140 const card = Array.isArray(result) ? result[0] : undefined
141 if (
142 typeof card !== 'object' ||
143 card === null ||
144 typeof card.lapses !== 'number' ||
145 typeof card.queue !== 'number' ||
146 typeof card.deckName !== 'string'
147 ) {
148 return null
149 }
150
151 return { lapses: card.lapses, queue: card.queue, deckName: card.deckName }
152}
153
154// The deck's leech threshold, from getDeckConfig; Anki's default is 8.
155export function readLeechThreshold(result: unknown): number {
156 const fails = (result as { lapse?: { leechFails?: unknown } } | null)?.lapse?.leechFails
157
158 return typeof fails === 'number' ? fails : 8
159}
160
161export function realAnki(send: Send, apiKey: string, pause: Pause): Anki {
162 async function call(action: string, params: object = {}): Promise<Reply<unknown>> {
163 const request = { action, version: 6, params, ...(apiKey ? { key: apiKey } : {}) }
164 let reply: HttpReply
165 try {
166 reply = await send(JSON.stringify(request))
167 } catch {
168 return { ok: false, problem: NOT_RUNNING }
169 }
170
171 return readEnvelope(reply)
172 }
173
174 async function currentCard(): Promise<Reply<Card | null>> {
175 const reply = await call('guiCurrentCard')
176 // Real Anki reports "no card" as this error once it leaves the review
177 // screen (a finished deck), though the documentation says null.
178 if (!reply.ok && /review is not currently active/i.test(reply.problem.message)) {
179 return { ok: true, value: null }
180 }
181
182 return reply.ok ? readCard(reply.value) : reply
183 }
184
185 return {
186 async listDecks() {
187 const names = await call('deckNames')
188 if (!names.ok) {
189 return names
190 }
191 const stats = await call('getDeckStats', { decks: names.value })
192 if (!stats.ok) {
193 return stats
194 }
195
196 return readDecks(names.value, stats.value)
197 },
198
199 async startReview(deckName: string) {
200 const started = await call('guiDeckReview', { name: deckName })
201 if (!started.ok) {
202 return started
203 }
204 const done = readDone(started.value, {
205 message: `Anki would not open the deck "${deckName}".`,
206 fix: 'Check the deck still exists in Anki, then run /anki.',
207 })
208 if (!done.ok) {
209 return done
210 }
211 // Anki can take a moment to put the first card up after opening a deck.
212 let card = await currentCard()
213 for (let tries = 0; tries < 3 && card.ok && card.value === null; tries++) {
214 await pause(150)
215 card = await currentCard()
216 }
217
218 return card
219 },
220
221 currentCard,
222
223 async showAnswer() {
224 const reply = await call('guiShowAnswer')
225
226 return reply.ok
227 ? readDone(reply.value, {
228 message: 'Anki would not show the answer.',
229 fix: 'Check Anki is on the review screen, then run /anki.',
230 })
231 : reply
232 },
233
234 async sync(): Promise<Reply<null>> {
235 const reply = await call('sync')
236
237 return reply.ok ? { ok: true, value: null } : reply
238 },
239
240 async answerCard(ease: Ease, cardId: number): Promise<Reply<Leech>> {
241 // Only Again can make a leech, so only Again pays for the extra reads.
242 const before = ease === 1 ? await call('cardsInfo', { cards: [cardId] }) : null
243 if (before && !before.ok) {
244 return before
245 }
246
247 const reply = await call('guiAnswerCard', { ease })
248 const done = reply.ok
249 ? readDone(reply.value, {
250 message: 'Anki would not take the grade.',
251 fix: 'Check Anki is showing the answer, then run /anki.',
252 })
253 : reply
254 if (!done.ok) {
255 return done
256 }
257 if (!before) {
258 return { ok: true, value: 'none' }
259 }
260
261 // The grade is in; a failed read here only loses the notice.
262 const lapsesBefore = readCardState(before.value)?.lapses ?? 0
263 const afterReply = await call('cardsInfo', { cards: [cardId] })
264 const after = afterReply.ok ? readCardState(afterReply.value) : null
265 if (!after || after.lapses <= lapsesBefore) {
266 return { ok: true, value: 'none' }
267 }
268 const config = await call('getDeckConfig', { deck: after.deckName })
269 const threshold = readLeechThreshold(config.ok ? config.value : null)
270 if (!isLeechCount(after.lapses, threshold)) {
271 return { ok: true, value: 'none' }
272 }
273
274 return { ok: true, value: after.queue === -1 ? 'suspended' : 'leech' }
275 },
276 }
277}
278hooks/ankiconnect-stub.ts 117 lines1// Stand-in for the real ankiconnect piece: same job, made-up answers.
2// Nothing here contacts Anki.
3import type { Anki, Card, Deck, Ease, Leech, Problem, Reply } from '../types'
4
5const LONG_NAME =
6 'A deck whose name is long enough that it cannot fit on one line of the pane at any width'
7
8const SAMPLE_DECKS: Deck[] = [
9 { name: 'Spanish vocabulary', newCount: 11, learnCount: 7, dueCount: 0 },
10 { name: 'TypeScript terms', newCount: 0, learnCount: 0, dueCount: 78 },
11 { name: 'Finished deck', newCount: 0, learnCount: 0, dueCount: 0 },
12 { name: 'Languages::Spanish::Irregular verbs', newCount: 20, learnCount: 8, dueCount: 40 },
13 { name: LONG_NAME, newCount: 3, learnCount: 0, dueCount: 1204 },
14 { name: 'Sample: missing front', newCount: 1, learnCount: 0, dueCount: 0 },
15 { name: 'Sample: missing back', newCount: 1, learnCount: 0, dueCount: 0 },
16 { name: 'Sample: leech', newCount: 0, learnCount: 0, dueCount: 2 },
17]
18
19// The cards due in each sample deck; each first card exercises a different
20// case. A deck that is absent here has nothing due.
21// Pressing Again on this card makes it a leech that Anki suspends.
22const LEECH_CARD = 1007
23
24const CARDS: Record<string, Card[]> = {
25 'Spanish vocabulary': [
26 {
27 cardId: 1001,
28 question: 'el desarrollo',
29 answer: 'development; growth<br><i>El desarrollo del proyecto va bien.</i>',
30 },
31 { cardId: 1011, question: 'la herramienta', answer: 'tool' },
32 { cardId: 1012, question: 'aprender', answer: 'to learn' },
33 ],
34 'TypeScript terms': [{
35 cardId: 1002,
36 question:
37 'What does a union type such as <code>\'active\' | \'paused\' | \'exited\'</code> let the type checker do when code assigns a value to a variable declared with it, and why does that make some bugs impossible to write rather than merely unlikely to happen at run time?',
38 answer: 'It refuses any value outside the listed options.',
39 }],
40 'Languages::Spanish::Irregular verbs': [{
41 cardId: 1003,
42 question: '<div><b>tener<i> (yo, presente)</b></div><span style="color:red">& ir',
43 answer: 'tengo <irregular>',
44 }],
45 [LONG_NAME]: [{
46 cardId: 1004,
47 question: '<img src="paste-123.jpg">',
48 answer: 'A picture of a cat [sound:gato.mp3]',
49 }],
50 'Sample: missing front': [{ cardId: 1005, question: '', answer: 'An answer with no question' }],
51 'Sample: missing back': [{ cardId: 1006, question: 'A question with no answer', answer: '' }],
52 'Sample: leech': [
53 { cardId: LEECH_CARD, question: 'A card you keep forgetting', answer: 'Press Again to make it a leech' },
54 { cardId: 1017, question: 'The card after the leech', answer: 'The notice is gone now' },
55 ],
56}
57
58const NOT_RUNNING: Problem = {
59 message: 'Could not reach Anki.',
60 fix: 'Start Anki, check the AnkiConnect add-on is installed, then run /anki.',
61}
62
63// The cards left in the review under way, the one Anki is showing first.
64// Lost when the mod reloads, which is acceptable for a stand-in.
65let queue: Card[] = []
66
67// isDown makes every function fail as if Anki were not running.
68export function stubAnki(isDown: boolean): Anki {
69 const fail = { ok: false, problem: NOT_RUNNING } as const
70
71 return {
72 async listDecks(): Promise<Reply<Deck[]>> {
73 return isDown ? fail : { ok: true, value: SAMPLE_DECKS }
74 },
75
76 async startReview(deckName: string): Promise<Reply<Card | null>> {
77 if (isDown) {
78 return fail
79 }
80 queue = [...(CARDS[deckName] ?? [])]
81
82 return { ok: true, value: queue[0] ?? null }
83 },
84
85 async currentCard(): Promise<Reply<Card | null>> {
86 return isDown ? fail : { ok: true, value: queue[0] ?? null }
87 },
88
89 async sync(): Promise<Reply<null>> {
90 return isDown ? fail : { ok: true, value: null }
91 },
92
93 async showAnswer(): Promise<Reply<null>> {
94 return isDown ? fail : { ok: true, value: null }
95 },
96
97 // Again sends the card to the back of the queue, as Anki shows a failed
98 // card again soon; any other grade removes it. The leech sample card is
99 // suspended instead, so it does not come back.
100 async answerCard(ease: Ease, cardId: number): Promise<Reply<Leech>> {
101 if (isDown) {
102 return fail
103 }
104 const [graded, ...rest] = queue
105 if (ease === 1 && cardId === LEECH_CARD) {
106 queue = rest
107 return { ok: true, value: 'suspended' }
108 }
109 if (graded) {
110 queue = ease === 1 ? [...rest, graded] : rest
111 }
112
113 return { ok: true, value: 'none' }
114 },
115 }
116}
117hooks/pane.tsx 150 lines1// The pane: turns what memory holds into what is drawn. It decides nothing;
2// a key press is handed to the function it was given.
3import type { EngineInterface } from 'claude-code'
4
5import type { Ease, Review } from '../types'
6import { isComplete } from './rules'
7
8type Elements = ReturnType<EngineInterface['ui']['resolve']>
9
10type PaneProps = {
11 now: Review
12 bodyColumns: number
13 // Whether the person has given the pane the keyboard.
14 hasKeyboard: boolean
15 // The /config switch that prints memory under the pane, for development.
16 showDeveloperDetails: boolean
17 onPick: (name: string) => void
18 onExit: () => void
19 onShowAnswer: () => void
20 onGrade: (ease: Ease) => void
21}
22
23const LEECH_NOTICE = {
24 none: '',
25 leech: 'That card is now a leech.',
26 suspended: 'That card is now a leech. It has been suspended.',
27}
28
29const MAX_WIDTH = 75
30const KEY_WIDTH = 3 // the engine draws a hotkey as "1: "
31const COUNT_WIDTH = 6
32const DONE = ' done'
33
34const GRADES: { hotkey: string; label: string; ease: Ease }[] = [
35 { hotkey: 'a', label: '[A]gain', ease: 1 },
36 { hotkey: 'h', label: '[H]ard', ease: 2 },
37 { hotkey: 'g', label: '[G]ood', ease: 3 },
38 { hotkey: 'e', label: '[E]asy', ease: 4 },
39]
40
41function fit(text: string, width: number) {
42 return text.length > width ? `${text.slice(0, width - 1)}…` : text.padEnd(width)
43}
44
45function counts(a: string | number, b: string | number, c: string | number) {
46 return [a, b, c].map(one => String(one).padStart(COUNT_WIDTH)).join('')
47}
48
49export function drawPane({ Box, Button, Text }: Elements, { now, bodyColumns, hasKeyboard, showDeveloperDetails, onPick, onExit, onShowAnswer, onGrade }: PaneProps) {
50 const width = Math.min(MAX_WIDTH, bodyColumns)
51 const nameWidth = Math.max(8, width - KEY_WIDTH - 3 * COUNT_WIDTH - DONE.length)
52 // Everything dims while the keyboard is elsewhere, so it is clear where it is.
53 const dim = !hasKeyboard
54
55 return (
56 <Box flexDirection="column">
57 {!hasKeyboard && (
58 <Box flexDirection="column">
59 <Text dimColor>ctrl+x then tab to review here</Text>
60 <Text dimColor={dim}> </Text>
61 </Box>
62 )}
63 {now.view === 'picker' && (
64 <Box flexDirection="column">
65 {now.notice && (
66 <Box width={width} flexDirection="column">
67 <Text dimColor={dim}>{now.notice}</Text>
68 <Text dimColor={dim}> </Text>
69 </Box>
70 )}
71 {now.leech !== 'none' && (
72 <Box width={width} flexDirection="column">
73 <Text color="warning" dimColor={dim}>{LEECH_NOTICE[now.leech]}</Text>
74 <Text dimColor={dim}> </Text>
75 </Box>
76 )}
77 <Text bold dimColor={dim}>
78 {' '.repeat(KEY_WIDTH)}
79 {fit('Deck', nameWidth)}
80 {counts('New', 'Learn', 'Due')}
81 </Text>
82 {now.decks.length === 0 && <Text dimColor>No decks loaded.</Text>}
83 {now.decks.map((deck, i) => {
84 const row = `${fit(deck.name, nameWidth)}${counts(deck.newCount, deck.learnCount, deck.dueCount)}${isComplete(deck) ? DONE : ''}`
85 const hotkey = i < 9 ? String(i + 1) : undefined
86
87 return (
88 <Button
89 key={deck.name}
90 plain dimColor={dim}
91 hotkey={hotkey}
92 label={hotkey ? row : `${' '.repeat(KEY_WIDTH)}${row}`}
93 onPress={() => onPick(deck.name)}
94 />
95 )
96 })}
97 </Box>
98 )}
99 {(now.view === 'front' || now.view === 'back') && (
100 <Box width={width} flexDirection="column">
101 {now.leech !== 'none' && (
102 <Box flexDirection="column">
103 <Text color="warning" dimColor={dim}>{LEECH_NOTICE[now.leech]}</Text>
104 <Text dimColor={dim}> </Text>
105 </Box>
106 )}
107 <Text dimColor>{now.deck}</Text>
108 <Text dimColor={dim}> </Text>
109 {now.front === '' ? <Text dimColor>(this card has no front)</Text> : <Text dimColor={dim}>{now.front}</Text>}
110 {now.view === 'back' && (
111 <Box flexDirection="column">
112 <Text dimColor>{'─'.repeat(width)}</Text>
113 {now.back === '' ? <Text dimColor>(this card has no back)</Text> : <Text dimColor={dim}>{now.back}</Text>}
114 </Box>
115 )}
116 <Text dimColor={dim}> </Text>
117 {now.view === 'front' ? (
118 <Box>
119 <Button key="show-answer" plain dimColor={dim} hotkey="s" label="[S]how answer " onPress={onShowAnswer} />
120 <Button key="exit" plain dimColor={dim} hotkey="x" label="E[x]it" onPress={onExit} />
121 </Box>
122 ) : (
123 <Box>
124 {GRADES.map(grade => (
125 <Button key={grade.hotkey} plain dimColor={dim} hotkey={grade.hotkey} label={`${grade.label} `} onPress={() => onGrade(grade.ease)} />
126 ))}
127 </Box>
128 )}
129 </Box>
130 )}
131 {now.view === 'error' && now.error && (
132 <Box flexDirection="column">
133 <Text color="error" dimColor={dim}>{now.error.message}</Text>
134 <Text dimColor={dim}>{now.error.fix}</Text>
135 </Box>
136 )}
137 {/* Developer view of memory, on every view when switched on in /config.
138 It shows a card's answer early, so it is off by default. */}
139 {showDeveloperDetails && (
140 <Box flexDirection="column">
141 <Text dimColor={dim}> </Text>
142 {Object.entries(now).map(([name, value]) => (
143 <Text dimColor>{`${name}: ${JSON.stringify(value)}`.slice(0, width)}</Text>
144 ))}
145 </Box>
146 )}
147 </Box>
148 )
149}
150hooks/rules.ts 125 lines1// The rules: given what memory holds and what just happened, what memory
2// should hold next. They never talk to Anki or the engine themselves.
3import type { Card, Deck, Leech, Reply, Review } from '../types'
4import { htmlToText } from './text'
5
6export const START: Review = {
7 status: 'exited',
8 view: 'picker',
9 decks: [],
10 deck: null,
11 cardId: null,
12 front: '',
13 back: '',
14 leech: 'none',
15 error: null,
16 notice: null,
17}
18
19export function isComplete(deck: Deck): boolean {
20 return deck.newCount + deck.learnCount + deck.dueCount === 0
21}
22
23export function afterDeckList(now: Review, reply: Reply<Deck[]>): Review {
24 if (!reply.ok) {
25 return { ...now, view: 'error', error: reply.problem }
26 }
27
28 return { ...now, view: 'picker', decks: reply.value, error: null, notice: null, leech: 'none' }
29}
30
31export const NOTHING_DUE = 'Nothing is due in this deck. To study more, use Custom Study in Anki.'
32
33export function afterStart(now: Review, deckName: string, reply: Reply<Card | null>): Review {
34 if (!reply.ok) {
35 return { ...now, view: 'error', error: reply.problem }
36 }
37 if (reply.value === null) {
38 return { ...now, view: 'picker', notice: NOTHING_DUE, error: null }
39 }
40
41 return { ...afterNextCard(now, reply), deck: deckName, leech: 'none' }
42}
43
44export function afterShowAnswer(now: Review, reply: Reply<null>): Review {
45 if (!reply.ok) {
46 return { ...now, view: 'error', error: reply.problem }
47 }
48
49 // The leech notice has been read by the time the answer is asked for.
50 return { ...now, view: 'back', error: null, leech: 'none' }
51}
52
53export const DECK_FINISHED = 'You have finished this deck for now.'
54
55// Sync results go on the finished line: the reviews are already saved in
56// Anki, so a failed sync is out of date, not lost, and blocks nothing.
57export function afterSyncStart(now: Review): Review {
58 return { ...now, notice: `${DECK_FINISHED} Syncing with AnkiWeb…` }
59}
60
61export function afterSync(now: Review, reply: Reply<null>): Review {
62 const result = reply.ok ? 'Synced with AnkiWeb.' : `Could not sync: ${reply.problem.message}`
63
64 return { ...now, notice: `${DECK_FINISHED} ${result}` }
65}
66
67// The card-ID check: is Anki still showing the card memory holds?
68export function isSameCard(now: Review, current: Reply<Card | null>): boolean {
69 return current.ok && current.value !== null && current.value.cardId === now.cardId
70}
71
72// After grading, or when Anki turned out to be on another card.
73export function afterNextCard(now: Review, reply: Reply<Card | null>): Review {
74 if (!reply.ok) {
75 return { ...now, view: 'error', error: reply.problem }
76 }
77 if (reply.value === null) {
78 return { ...afterExit(now), notice: DECK_FINISHED }
79 }
80
81 return {
82 ...now,
83 status: 'active',
84 view: 'front',
85 cardId: reply.value.cardId,
86 front: htmlToText(reply.value.question),
87 back: htmlToText(reply.value.answer),
88 notice: null,
89 error: null,
90 }
91}
92
93// After a grade: the next card (or the end of the deck), with any leech notice.
94export function afterGrade(now: Review, next: Reply<Card | null>, leech: Leech): Review {
95 const moved = afterNextCard(now, next)
96
97 return moved.view === 'error' ? moved : { ...moved, leech }
98}
99
100export function afterExit(now: Review): Review {
101 return {
102 ...now,
103 status: 'exited',
104 view: 'picker',
105 deck: null,
106 cardId: null,
107 front: '',
108 back: '',
109 notice: null,
110 leech: 'none',
111 }
112}
113
114// Whether to ask for the keyboard back when a tool call ends. Claude Code
115// does not say when a permission prompt is up; the sign is the pane losing
116// the keyboard during a call it had the keyboard at the start of.
117export function shouldTakeKeyboardBack(when: {
118 isOn: boolean
119 isPaneOpen: boolean
120 hadKeyboardAtStart: boolean
121 lostKeyboardDuring: boolean
122}): boolean {
123 return when.isOn && when.isPaneOpen && when.hadKeyboardAtStart && when.lostKeyboardDuring
124}
125hooks/text.ts 33 lines1// Turns a card side's HTML into plain text the pane can print.
2
3const ENTITIES: Record<string, string> = {
4 ' ': ' ',
5 '&': '&',
6 '<': '<',
7 '>': '>',
8 '"': '"',
9 ''': "'",
10}
11
12export function htmlToText(html: string): string {
13 return (
14 html
15 // Style sheets and scripts are not card text; drop them whole.
16 .replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
17 // Media cannot be shown in a terminal, so each becomes a marker.
18 .replace(/<img\b[^>]*>/gi, '[image]')
19 .replace(/\[sound:[^\]]*\]/gi, '[sound]')
20 // Tags that start a new line in Anki become a line break.
21 .replace(/<br\s*\/?>/gi, '\n')
22 .replace(/<\/?(div|p|li|h[1-6])\b[^>]*>/gi, '\n')
23 // Every other tag is dropped, closed or not.
24 .replace(/<[^>]*>?/g, '')
25 .replace(/&[#a-z0-9]+;/gi, entity => ENTITIES[entity.toLowerCase()] ?? entity)
26 // Tidy spacing: no runs of spaces, no blank lines, no edges.
27 .split('\n')
28 .map(line => line.replace(/\s+/g, ' ').trim())
29 .filter(line => line !== '')
30 .join('\n')
31 )
32}
33types/index.d.ts 71 lines1export type Status = 'active' | 'paused' | 'exited'
2export type View = 'picker' | 'front' | 'back' | 'error'
3
4export type Deck = {
5 name: string
6 newCount: number
7 learnCount: number
8 dueCount: number
9}
10
11// A card as AnkiConnect describes it: the two sides are HTML.
12export type Card = {
13 cardId: number
14 question: string
15 answer: string
16}
17
18// Whether the card just graded became a leech, and if Anki suspended it.
19export type Leech = 'none' | 'leech' | 'suspended'
20
21// How well a card was remembered, as Anki numbers it: Again, Hard, Good, Easy.
22export type Ease = 1 | 2 | 3 | 4
23
24export type Problem = { message: string; fix: string }
25
26// What every ankiconnect function hands back: the value, or what went wrong.
27export type Reply<T> = { ok: true; value: T } | { ok: false; problem: Problem }
28
29// Everything the mod asks of Anki. The real connection and the stub both
30// provide exactly this, so the rest of the mod cannot tell them apart.
31export type Anki = {
32 listDecks: () => Promise<Reply<Deck[]>>
33 // The first card, or null when nothing is due.
34 startReview: (deckName: string) => Promise<Reply<Card | null>>
35 // The card Anki is showing, or null when the review is finished.
36 currentCard: () => Promise<Reply<Card | null>>
37 showAnswer: () => Promise<Reply<null>>
38 // Grades the card with this ID; says whether it just became a leech.
39 answerCard: (ease: Ease, cardId: number) => Promise<Reply<Leech>>
40 // Uploads and downloads reviews through AnkiWeb.
41 sync: () => Promise<Reply<null>>
42}
43
44// Developer switch: off uses real Anki; on uses the stub; down makes the
45// stub fail as if Anki were not running.
46export type StubMode = 'off' | 'on' | 'down'
47
48export type Review = {
49 status: Status
50 view: View
51 decks: Deck[]
52 deck: string | null
53 cardId: number | null
54 front: string
55 back: string
56 // Shown above the next card after a card becomes a leech.
57 leech: Leech
58 error: Problem | null
59 // A one-line message that is not an error, such as "nothing is due".
60 notice: string | null
61}
62
63declare module 'claude-code' {
64 interface PluginState {
65 'anki-review': {
66 review: Review
67 stubMode: StubMode
68 }
69 }
70}
71