SLOPSHOPPER

anki-review

Review Anki cards in a pane while Claude works

newpaneguardcommandnetworktimer
v1.0.0MITupdated 2026-10-14wickedurge/Claude-Mod-Anki-Review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · anki-review
│ ┃ Anki review ✕ › fix the failing auth test and add an audit log call │ ┃ ctrl+x then tab to review here │ ┃ ⏺ Read(src/auth.ts) │ ┃ AnkiConnect answered with HTTP 0. ⎿ Read 6 lines │ ┃ Check the AnkiConnect address in /config. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /anki │ ⎿ anki-review: Anki review pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Anki review
ctrl+x then tab to review here AnkiConnect answered with HTTP 0. Check the AnkiConnect address in /config.
README

anki-review

Checks

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

Requirements

  • Anki desktop, running while you review. Get it from apps.ankiweb.net.
  • The AnkiConnect add-on. In Anki, choose Tools, Add-ons, Get Add-ons, and enter the code 2055492159. Restart Anki afterwards.
  • Claude Code with mod support. Tested on version 2.1.287.
  • An AnkiWeb account only if you want the optional sync.

Install

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.

Use

  1. Start Anki.
  2. In Claude Code, type /anki. A pane opens with your decks and their New, Learn and Due counts, as Anki shows them.
  3. Press a deck's number, or move with the arrow keys and press Enter.
  4. Review with these keys:
OnKeyDoes
A card's frontsShow the answer
A card's frontxExit to the deck list
A card's backaAgain
A card's backhHard
A card's backgGood
A card's backeEasy

When a deck is finished, the pane returns to the deck list.

Moving between the pane and the prompt

The pane and Claude's prompt box share one keyboard.

  • Esc sends the keyboard back to the prompt, so you can talk to Claude.
  • 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.

What the pane tells you

  • Leeches. When pressing Again turns a card into a leech, the next card says so, and adds that the card was suspended if your deck suspends leeches.
  • Nothing due. Picking a finished deck says so. To study more, use Custom Study in Anki; the session deck it creates then appears in the list.
  • Errors. If Anki is not running or AnkiConnect answers with an error, the pane says what went wrong and what to do. Run /anki again once it is fixed.

Settings

Open /config and find anki-review:

SettingDefaultWhat it does
AnkiConnect addresshttp://127.0.0.1:8765Where the AnkiConnect add-on listens.
AnkiConnect API keyemptyOnly needed if you set an apiKey in AnkiConnect's config. Stored securely.
Sync with AnkiWeb when a deck is finishedoffAfter a deck's last card, asks Anki to sync so your reviews reach your other devices.
Take the keyboard back after a permission promptoffIf a permission prompt takes the keyboard from the pane mid-review, gives it back once you answer the prompt.
Show developer detailsoffPrints 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.

How it works

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.

Development

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.

Licence

MIT. See LICENSE.

Source 7 files
hooks/register.tsx 194 lines
1// 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}
194
hooks/ankiconnect.ts 278 lines
1// 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}
278
hooks/ankiconnect-stub.ts 117 lines
1// 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">&amp; ir',
43    answer: 'tengo &lt;irregular&gt;',
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}
117
hooks/pane.tsx 150 lines
1// 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}
150
hooks/rules.ts 125 lines
1// 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}
125
hooks/text.ts 33 lines
1// Turns a card side's HTML into plain text the pane can print.
2
3const ENTITIES: Record<string, string> = {
4  '&nbsp;': ' ',
5  '&amp;': '&',
6  '&lt;': '<',
7  '&gt;': '>',
8  '&quot;': '"',
9  '&#39;': "'",
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}
33
types/index.d.ts 71 lines
1export 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