Side pane for LessonFolk learners: the current course, its lessons, the lesson's key ideas and what comes next

<h1 align="center"> <img src="assets/brand/logo.svg" alt="LessonFolk" width="320"> </h1>
LessonFolk is a collection of open-source AI courses designed to be taught by an AI. Connect your AI chat (Claude Code, Codex, Claude Desktop, Cursor…) to LessonFolk, say "let's start", and it guides you through lessons in a conversation: explaining, asking questions, adapting to your level, and saving your progress.
LessonFolk is a website and an MCP server (Model Context Protocol, a standard way for AI apps to use tools). Your AI chat connects to it: it gets the courses and the tutor's instructions, and saves your progress there. The website shows your courses, your path and your progress.
The tutor asks about your level and interests, recommends a path of courses, and teaches one short lesson at a time. Courses are grouped by theme: Understanding AI, Using AI tools, Building with AI and AI and society. The full list, in the recommended order, is courses/en/index.yaml, or ask your tutor "what can I learn?".
Pick one of two ways:
Hosted (coming soon): sign in on the hosted LessonFolk with GitHub or Google, open its Connect page and add LessonFolk to your AI chat. Your progress is saved with your account.
Self-hosted: private, on your computer, no data sent to us. With Docker running:
git clone https://github.com/CGSeb/lessonfolk.git
cd lessonfolk
docker compose up -d --build
Open http://127.0.0.1:4321 to see the dashboard. Then start your AI chat in this folder:
claude and approve the lessonfolk MCP server when asked.codex mcp add lessonfolk --url http://localhost:4321/mcp once, then codex.Then say:
Let's start learning AI.
The learner guide has the details.
LessonFolk is developed by CG Seb.
hooks/register.tsx 381 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Choices, IdeaMarks, LessonState, View } from '../types'
5import {
6 changes,
7 DEFAULT_INSTANCE,
8 instanceUrl,
9 load,
10 markIdea,
11 parseChoices,
12 replyOf,
13 SERVER,
14 TOOL_PREFIX,
15 togglePick,
16} from './companion'
17
18const PANE = 'course-companion'
19const TITLE = 'Course companion'
20const TOOL = 'mcp__course-companion__key_idea'
21const CHOICES_TOOL = 'mcp__course-companion__choices'
22
23const NO_IDEAS: IdeaMarks = { covered: [] }
24
25const isActive = atom({ plugin: 'course-companion', key: 'isActive' } as const, false)
26const choices = atom({ plugin: 'course-companion', key: 'choices' } as const, null as Choices | null)
27const view = atom({ plugin: 'course-companion', key: 'view' } as const, { state: 'no-progress' } as View)
28const snapshot = atom({ plugin: 'course-companion', key: 'snapshot' } as const, null)
29const ideas = atom({ plugin: 'course-companion', key: 'ideas' } as const, NO_IDEAS)
30// The LessonFolk instance the Dashboard button opens (local or hosted).
31const instance = atom({ plugin: 'course-companion', key: 'instance' } as const, DEFAULT_INSTANCE)
32
33// The learner commands of AGENTS.md, offered as buttons above the prompt.
34const COMMANDS = [
35 { key: 'continue', label: 'Continue', text: 'continue' },
36 { key: 'quiz', label: 'Quiz me', text: 'quiz me' },
37 { key: 'skip', label: 'Skip', text: 'skip this' },
38 { key: 'progress', label: 'My progress', text: 'show my progress' },
39]
40
41const isSkill = (name: string | undefined, skill: string) => name === skill || name?.endsWith(`:${skill}`)
42
43// The server's name as this session knows it: `lessonfolk` from `.mcp.json`, or the name of the
44// connector a hosted instance was added under, learned from the tutor's first call to it.
45let server = SERVER
46
47const isLessonfolkTool = (tool: string) =>
48 tool.startsWith(TOOL_PREFIX) || (/^mcp__[^_].*lessonfolk.*?__/i.test(tool) && !tool.startsWith('mcp__course-'))
49
50// Calls a tool of the LessonFolk MCP server with the session's own connection (and sign-in),
51// so it reads the same progress as the tutor, from a local or a hosted instance.
52const callServer = ($: EngineInterface) => async (tool: string, args?: Record<string, unknown>) => {
53 try {
54 const { content, isError } = await $.mcp.call(server, tool, args)
55 if (isError) return undefined
56
57 return content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('\n')
58 } catch {
59 return undefined
60 }
61}
62
63// Re-reads the progress and the courses from the server; toasts what changed since the last read.
64async function refresh($: EngineInterface, isQuiet = false) {
65 // A relative path would resolve against the mod's own folder: read from the session's.
66 const root = (await $.session.cwd()).replace(/\\/g, '/')
67 const config = await $.fs.read(`${root}/.mcp.json`).then(text => text, () => undefined)
68 await update($, instance, () => instanceUrl(config))
69
70 const loaded = await load(callServer($))
71 const before = await read($, snapshot)
72 const previous = before ? { snapshot: before, view: await read($, view) } : undefined
73 await update($, view, () => loaded.view)
74 await update($, snapshot, () => loaded.snapshot)
75 await update($, ideas, marks =>
76 marks.lessonId && marks.lessonId === loaded.view.current?.id ? marks : NO_IDEAS,
77 )
78 if (!isQuiet) for (const text of changes(previous, loaded)) $.ui.toast(text)
79}
80
81// Whether this run of the app opened the pane yet. `isActive` is kept with the session and
82// survives a restart, but the pane does not, so the pane opens once per run of the module.
83let hasOpened = false
84
85// Opens the pane; when the app keeps it waiting undrawn, says why instead of failing silently.
86async function openPane($: EngineInterface) {
87 hasOpened = true
88 const opened = await $.ui.open({ id: PANE, title: TITLE }).catch((error: unknown) => ({
89 isPlaced: false as const,
90 reason: error instanceof Error ? error.message : String(error),
91 }))
92 if (!opened.isPlaced) $.ui.toast(`${TITLE} waits: ${opened.reason}`)
93
94 return opened
95}
96
97// Learning has started: the commands band shows and the pane opens, once per run.
98async function activate($: EngineInterface) {
99 await update($, isActive, () => true)
100 if (!hasOpened) void openPane($)
101}
102
103export const register: Register = on => {
104 on('session.start', async ($, e, next) => {
105 await $.command.register({
106 name: 'course-companion',
107 description: 'Show the LessonFolk course companion pane',
108 })
109 await $.tool.register({
110 name: 'key_idea',
111 description:
112 'Updates the Course companion pane while you teach a LessonFolk lesson. Call it when ' +
113 'you start teaching a key idea of the current lesson (status "active") and when the ' +
114 'learner has understood it (status "done"). Key ideas are numbered from 1 in the ' +
115 'order of the lesson\'s "## Key ideas" section. Lesson progress comes from ' +
116 'the LessonFolk server on its own; this tool only marks key ideas.',
117 inputSchema: {
118 type: 'object',
119 properties: {
120 lesson: { type: 'string', description: 'Lesson id, e.g. ai-foundations/02-how-machines-learn' },
121 keyIdea: { type: 'integer', minimum: 1, description: 'Key idea number, from 1' },
122 status: {
123 type: 'string',
124 enum: ['active', 'done'],
125 description: 'Started teaching it, or understood (default active)',
126 },
127 },
128 required: ['lesson', 'keyIdea'],
129 },
130 })
131 await $.tool.register({
132 name: 'choices',
133 description:
134 'Shows the answers to the question you just asked the LessonFolk learner as buttons ' +
135 'above the prompt; a press sends that answer as the learner\'s reply. Call it after ' +
136 'asking a question with a fixed set of answers you listed: onboarding (experience, ' +
137 'goal, themes to explore), accepting a path, "continue now or stop here?", yes/no. ' +
138 'Never call it for "Check your understanding", review or level check questions: the ' +
139 'learner answers those in their own words. The learner can always type instead.',
140 inputSchema: {
141 type: 'object',
142 properties: {
143 options: {
144 type: 'array',
145 minItems: 2,
146 maxItems: 8,
147 description: 'The answers, in the order you listed them',
148 items: {
149 type: 'object',
150 properties: {
151 label: { type: 'string', description: 'Short button text (1-4 words)' },
152 reply: { type: 'string', description: 'What a press sends, when not the label' },
153 },
154 required: ['label'],
155 },
156 },
157 multiSelect: {
158 type: 'boolean',
159 description: 'True when the learner may pick several (then a Send button sends them)',
160 },
161 },
162 required: ['options'],
163 },
164 })
165 await refresh($, true)
166 // A resumed session where the learner was already learning gets its pane back.
167 if (await read($, isActive)) void openPane($)
168
169 return next(e)
170 })
171
172 on('command.run', { command: 'course-companion' }, async $ => {
173 await refresh($, true)
174 const opened = await openPane($)
175
176 return {
177 text: opened.isPlaced
178 ? 'Course companion pane opened.'
179 : `Course companion pane could not be shown: ${opened.reason}`,
180 }
181 })
182
183 // The learn skill starting is what opens the pane; review and progress keep it current.
184 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
185 const ran = await next(e)
186 if (ran.deny !== undefined || ran.isError) return ran
187 if (!['learn', 'review', 'progress'].some(skill => isSkill(e.skill, skill))) return ran
188
189 await refresh($, true)
190 if (!isSkill(e.skill, 'learn')) return ran
191
192 await activate($)
193
194 return {
195 ...ran,
196 context: [
197 ...(ran.context ?? []),
198 `A "${TITLE}" pane shows the learner their course, lessons and the current lesson's ` +
199 `key ideas. It follows the LessonFolk server on its own. While you teach, call ` +
200 `the ${TOOL} tool as you start each key idea (status "active") and once the learner ` +
201 `has it (status "done"). After asking a question with a fixed set of answers ` +
202 `(onboarding, path, continue or stop), call ${CHOICES_TOOL} so the learner can ` +
203 `answer with a button; never for check-your-understanding, review or level check questions.`,
204 ],
205 }
206 })
207
208 on('tool.call', { tool: TOOL }, async ($, e) => {
209 const input = (e as unknown as { input?: Record<string, unknown> }).input ??
210 (e as unknown as Record<string, unknown>)
211 const lesson = typeof input.lesson === 'string' ? input.lesson : undefined
212 const keyIdea = Number(input.keyIdea)
213 const status = input.status === 'done' ? 'done' : 'active'
214 if (!lesson || !Number.isInteger(keyIdea) || keyIdea < 1) {
215 return { result: 'Nothing updated: give the lesson id and a key idea number from 1.' }
216 }
217
218 const current = (await read($, view)).current
219 if (current?.id !== lesson) await refresh($, true)
220 await update($, ideas, marks => markIdea(marks, lesson, keyIdea, status))
221 await activate($)
222
223 return { result: 'Course companion pane updated.' }
224 })
225
226 on('tool.call', { tool: CHOICES_TOOL }, async ($, e) => {
227 const input = (e as unknown as { input?: Record<string, unknown> }).input ??
228 (e as unknown as Record<string, unknown>)
229 const offered = parseChoices(input)
230 if (!offered) return { result: 'Nothing shown: give 2 to 8 options, each with a label.' }
231
232 await update($, choices, () => offered)
233 // The tutor asking the learner something means a learning session: the band stays once
234 // the question is answered, even before any progress exists (onboarding).
235 await update($, isActive, () => true)
236
237 return { result: 'The answers are shown as buttons above the prompt.' }
238 })
239
240 // Any reply, pressed or typed, answers the question: its buttons go.
241 on('prompt.submit', async ($, e, next) => {
242 await update($, choices, () => null)
243
244 return next(e)
245 })
246
247 // Any call of the tutor to the LessonFolk server (a save, or reading the progress) is the
248 // moment to re-read it: that is the source of truth for lessons and courses.
249 let isRefreshing = false
250 on('tool.call', async ($, e, next) => {
251 if (!isLessonfolkTool(e.tool)) return next(e)
252 const ran = await next(e)
253 if (isRefreshing || ran.deny !== undefined || ran.isError) return ran
254
255 server = e.tool.split('__')[1] ?? server
256 isRefreshing = true
257 try {
258 await refresh($)
259 await activate($)
260 } finally {
261 isRefreshing = false
262 }
263
264 return ran
265 })
266
267 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
268 if (e.props.hasSurvey) return next(e)
269 const offered = await read($, choices)
270 if (!offered && !(await read($, isActive))) return next(e)
271
272 const { Box, Button, Link, Text } = $.ui.resolve(e)
273
274 // Always last: the pane, and the dashboard of the instance the tutor works with.
275 const tools = [
276 <Text key="sep" dimColor>·</Text>,
277 <Button key="pane" label="Companion" onPress={() => void openPane($)} />,
278 <Link key="dashboard" href={await read($, instance)} label="Dashboard ↗" />,
279 ]
280
281 // The tutor's question takes the band until it is answered; then the commands come back.
282 if (offered) {
283 const send = (text: string) => {
284 void update($, choices, () => null)
285 void $.prompt.submit({ text, asUser: true })
286 }
287
288 return (
289 <Box flexWrap="wrap" gap={1}>
290 <Text dimColor>Answer:</Text>
291 {offered.options.map((o, i) => (
292 <Button
293 key={`choice-${i}`}
294 label={offered.isMulti && offered.picked.includes(i) ? `✓ ${o.label}` : o.label}
295 onPress={() =>
296 offered.isMulti ? void update($, choices, c => (c ? togglePick(c, i) : c)) : send(o.reply)
297 }
298 />
299 ))}
300 {offered.isMulti && offered.picked.length > 0 && (
301 <Button key="send" label="Send" onPress={() => send(replyOf(offered))} />
302 )}
303 {tools}
304 </Box>
305 )
306 }
307
308 return (
309 <Box gap={1}>
310 <Text dimColor>Say:</Text>
311 {COMMANDS.map(c => (
312 <Button key={c.key} label={c.label} onPress={() => void $.prompt.submit({ text: c.text, asUser: true })} />
313 ))}
314 {tools}
315 </Box>
316 )
317 })
318
319 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
320 const { Box, Text } = $.ui.resolve(e)
321 const v = await read($, view)
322 const marks = await read($, ideas)
323
324 if (v.state === 'no-progress' || v.state === 'offline' || v.state === 'all-done') {
325 const lines = {
326 'no-progress': ['No course started yet.', 'Say "start" to begin.'],
327 offline: ['Your progress could not be read.', 'Check that the LessonFolk server is connected (/mcp).'],
328 'all-done': ['You have finished every course. Well done!', 'Ask the tutor what to explore next.'],
329 }[v.state]
330
331 return (
332 <Box flexDirection="column">
333 {lines.map(line => (
334 <Text dimColor>{line}</Text>
335 ))}
336 </Box>
337 )
338 }
339
340 const lessons = v.lessons ?? []
341 const finished = lessons.filter(l => l.status === 'done' || l.status === 'skipped').length
342 const mark = (s: LessonState['status']) =>
343 ({ done: '✓', skipped: '↷', current: '●', todo: '○' })[s]
344 const ideaMark = (i: number) =>
345 marks.covered.includes(i) ? '✓' : marks.active === i ? '●' : '○'
346
347 return (
348 <Box flexDirection="column">
349 <Text bold>{v.courseTitle}</Text>
350 {(v.themeTitle || v.level) && (
351 <Text dimColor>{[v.themeTitle, v.level].filter(Boolean).join(' · ')}</Text>
352 )}
353 <Text> </Text>
354 <Text bold>
355 Lessons {finished}/{lessons.length}
356 </Text>
357 {lessons.map(l => (
358 <Text dimColor={l.status !== 'current'} bold={l.status === 'current'}>
359 {mark(l.status)} {l.title}
360 {l.status === 'skipped' ? ' (skipped)' : ''}
361 </Text>
362 ))}
363 {v.current && v.current.ideas.length > 0 && <Text> </Text>}
364 {v.current && v.current.ideas.length > 0 && <Text bold>Key ideas</Text>}
365 {v.current?.ideas.map((idea, i) => (
366 <Text dimColor={ideaMark(i + 1) === '✓'} bold={ideaMark(i + 1) === '●'}>
367 {ideaMark(i + 1)} {i + 1}. {idea}
368 </Text>
369 ))}
370 {v.next && <Text> </Text>}
371 {v.next && (
372 <Text>
373 Next: {v.next.title}
374 {v.next.courseTitle ? ` (${v.next.courseTitle})` : ''}
375 </Text>
376 )}
377 </Box>
378 )
379 })
380}
381hooks/companion.ts 211 lines1// Pure logic of the Course companion: reads the learner's progress and the courses from the
2// LessonFolk MCP server through a `call` function, so it runs the same in the mod and in its
3// tests, with a local or a hosted instance.
4
5import type { Choices, IdeaMarks, LessonState, Snapshot, View } from '../types'
6
7// Calls a tool of the LessonFolk MCP server: the tool's text result, or undefined when it
8// failed or the server could not be reached.
9export type Caller = (tool: string, args?: Record<string, unknown>) => Promise<string | undefined>
10
11// The name of the server in `.mcp.json` (what `claude mcp add lessonfolk …` registers).
12export const SERVER = 'lessonfolk'
13export const TOOL_PREFIX = `mcp__${SERVER}__`
14
15type Progress = {
16 profile?: { level?: string }
17 path?: string[]
18 current?: string | null
19 lessons?: Record<string, { status?: string }>
20}
21
22type CourseSummary = {
23 id: string
24 title: string
25 level?: string
26 themeTitle?: string
27 lessons: { id: string; title: string; prerequisites?: string[]; status?: string }[]
28}
29
30// The `## Key ideas` items: their bold lead when they have one, else their first words.
31export const keyIdeas = (lesson: string): string[] => {
32 const section = /^## Key ideas\s*$([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(lesson)?.[1] ?? ''
33
34 return [...section.matchAll(/^\d+\.\s+(.+)$/gm)].map(m => {
35 const text = m[1] ?? ''
36 const bold = /^\*\*(.+?)\*\*/.exec(text)?.[1]
37 const label = (bold ?? text).replace(/\*\*|\*|`/g, '').replace(/[.:]\s*$/, '').trim()
38
39 return label.length > 70 ? `${label.slice(0, 67).trimEnd()}…` : label
40 })
41}
42
43// `list_courses` statuses: not_started, in_progress, done, skipped, skipped_after_level_check.
44const isFinished = (status?: string) =>
45 status === 'done' || status === 'skipped' || status === 'skipped_after_level_check'
46
47const courseOf = (lessonId: string) => lessonId.split('/')[0] ?? lessonId
48
49const parseJson = <T>(text?: string): T | undefined => {
50 if (text === undefined) return undefined
51 try {
52 const parsed = JSON.parse(text) as T
53
54 return parsed && typeof parsed === 'object' ? parsed : undefined
55 } catch {
56 return undefined
57 }
58}
59
60export const statusesOf = (progress?: Progress): Snapshot['statuses'] =>
61 Object.fromEntries(
62 Object.entries(progress?.lessons ?? {}).map(([id, l]) => [id, l?.status ?? 'in_progress']),
63 )
64
65export type Loaded = { view: View; snapshot: Snapshot }
66
67// Builds the pane's view: the course in focus (the current lesson's, else the next lesson's),
68// its lessons, the current lesson's key ideas and the next lesson, following the order of
69// "Start or resume" (the learner's path first, then the catalog order of list_courses).
70export const load = async (call: Caller): Promise<Loaded> => {
71 const progress = parseJson<Progress>(await call('get_progress'))
72 const catalog = parseJson<CourseSummary[]>(await call('list_courses'))
73 const statuses = statusesOf(progress)
74 const snapshot: Snapshot = { current: progress?.current ?? undefined, statuses }
75
76 if (!progress || !Array.isArray(catalog)) return { view: { state: 'offline' }, snapshot }
77 if (!progress.profile?.level && Object.keys(statuses).length === 0) {
78 return { view: { state: 'no-progress' }, snapshot }
79 }
80
81 const byId = new Map(catalog.map(course => [course.id, course]))
82 const order = [...(progress.path ?? []).filter(id => byId.has(id))]
83 for (const course of catalog) if (!order.includes(course.id)) order.push(course.id)
84
85 const lessonTitle = (id: string) =>
86 catalog.flatMap(course => course.lessons).find(lesson => lesson.id === id)?.title ?? id
87
88 // The first unfinished lesson whose prerequisites are finished; `current` counts as finished.
89 const done = (id: string) => isFinished(statuses[id]) || id === progress.current
90 const next = order
91 .flatMap(id => byId.get(id)?.lessons ?? [])
92 .find(lesson => !done(lesson.id) && (lesson.prerequisites ?? []).every(done))?.id
93
94 const current = progress.current && statuses[progress.current] !== undefined &&
95 !isFinished(statuses[progress.current])
96 ? progress.current
97 : undefined
98 const focus = current ?? next
99 if (!focus) return { view: { state: 'all-done' }, snapshot }
100
101 const courseId = courseOf(focus)
102 const course = byId.get(courseId)
103 const lessons: LessonState[] = (course?.lessons ?? []).map(lesson => ({
104 id: lesson.id,
105 title: lesson.title,
106 status: lesson.id === current
107 ? 'current'
108 : isFinished(statuses[lesson.id]) ? (statuses[lesson.id] === 'done' ? 'done' : 'skipped') : 'todo',
109 }))
110 const ideas = current ? keyIdeas((await call('get_lesson', { lessonId: current })) ?? '') : []
111
112 return {
113 view: {
114 state: current ? 'lesson' : 'between',
115 courseId,
116 courseTitle: course?.title ?? courseId,
117 themeTitle: course?.themeTitle,
118 level: course?.level,
119 lessons,
120 current: current ? { id: current, title: lessonTitle(current), ideas } : undefined,
121 next: next
122 ? {
123 id: next,
124 title: lessonTitle(next),
125 courseTitle: courseOf(next) === courseId ? undefined : byId.get(courseOf(next))?.title ?? courseOf(next),
126 }
127 : undefined,
128 },
129 snapshot,
130 }
131}
132
133// The LessonFolk instance the Dashboard button opens: the origin of the `lessonfolk` url in the
134// project's `.mcp.json` (the MCP endpoint is `<instance>/mcp`), the local default otherwise.
135export const DEFAULT_INSTANCE = 'http://localhost:4321/'
136
137export const instanceUrl = (mcpJson?: string): string => {
138 try {
139 const config = JSON.parse(mcpJson ?? '{}') as { mcpServers?: Record<string, { url?: string }> }
140 const url = new URL(config.mcpServers?.[SERVER]?.url ?? '')
141
142 return url.protocol === 'http:' || url.protocol === 'https:' ? `${url.origin}/` : DEFAULT_INSTANCE
143 } catch {
144 return DEFAULT_INSTANCE
145 }
146}
147
148// What changed between two reads of the progress, worth a toast.
149export const changes = (previous: Loaded | undefined, loaded: Loaded): string[] => {
150 if (!previous) return []
151 const { snapshot: before } = previous
152 const { snapshot: after, view } = loaded
153 const toasts: string[] = []
154 const titleOf = (id: string) =>
155 [...(view.lessons ?? []), ...(previous.view.lessons ?? [])].find(l => l.id === id)?.title ?? id
156
157 for (const [id, status] of Object.entries(after.statuses)) {
158 if (status === 'done' && before.statuses[id] !== 'done') toasts.push(`Lesson complete: ${titleOf(id)}`)
159 }
160
161 const course = after.current ? courseOf(after.current) : undefined
162 const hadStarted = Object.entries(before.statuses).some(
163 ([id, status]) => courseOf(id) === course && (isFinished(status) || id === before.current),
164 )
165 if (course && after.current !== before.current && !hadStarted && view.courseId === course) {
166 toasts.push(`New course: ${view.courseTitle}${view.themeTitle ? ` (${view.themeTitle})` : ''}`)
167 }
168
169 return toasts
170}
171
172// The tutor's report on a key idea: `active` starts it, `done` finishes it; earlier ones are covered.
173export const markIdea = (marks: IdeaMarks, lessonId: string, keyIdea: number, status: 'active' | 'done'): IdeaMarks => {
174 const covered = marks.lessonId === lessonId ? marks.covered : []
175 const upTo = status === 'done' ? keyIdea : keyIdea - 1
176 const all = new Set(covered)
177 for (let i = 1; i <= upTo; i++) all.add(i)
178
179 return {
180 lessonId,
181 covered: [...all].sort((a, b) => a - b),
182 active: status === 'active' ? keyIdea : undefined,
183 }
184}
185
186// The tutor's offered answers, from the choices tool's input: 2 to 8 options with a label.
187export const parseChoices = (input: Record<string, unknown>): Choices | undefined => {
188 const raw = Array.isArray(input.options) ? (input.options as unknown[]) : []
189 const options = raw.flatMap(option => {
190 const o = (typeof option === 'string' ? { label: option } : option ?? {}) as Record<string, unknown>
191 const label = typeof o.label === 'string' ? o.label.trim() : ''
192 const reply = typeof o.reply === 'string' && o.reply.trim() ? o.reply.trim() : label
193
194 return label ? [{ label, reply }] : []
195 })
196 if (options.length < 2 || options.length > 8) return undefined
197
198 return { options, isMulti: input.multiSelect === true, picked: [] }
199}
200
201// What the learner sends: the picked options' replies, in the order the tutor listed them.
202export const replyOf = (choices: Choices) =>
203 choices.options.filter((_, i) => choices.picked.includes(i)).map(o => o.reply).join(', ')
204
205export const togglePick =(choices: Choices, index: number): Choices => ({
206 ...choices,
207 picked: choices.picked.includes(index)
208 ? choices.picked.filter(i => i !== index)
209 : [...choices.picked, index].sort((a, b) => a - b),
210})
211types/index.d.ts 44 lines1export type LessonState = {
2 id: string
3 title: string
4 status: 'done' | 'skipped' | 'current' | 'todo'
5}
6
7export type View = {
8 state: 'no-progress' | 'offline' | 'all-done' | 'lesson' | 'between'
9 courseId?: string
10 courseTitle?: string
11 themeTitle?: string
12 level?: string
13 lessons?: LessonState[]
14 current?: { id: string; title: string; ideas: string[] }
15 next?: { id: string; title: string; courseTitle?: string }
16}
17
18// Lesson statuses as last read from the server, to spot what a save changed.
19export type Snapshot = { current?: string; statuses: Record<string, string> }
20
21// Key ideas of `lessonId` the tutor reported: 1-based numbers.
22export type IdeaMarks = { lessonId?: string; covered: number[]; active?: number }
23
24// The answers the tutor offered for the question it just asked, shown as buttons above the prompt.
25export type Choices = {
26 options: { label: string; reply: string }[]
27 isMulti: boolean
28 picked: number[]
29}
30
31
32declare module 'claude-code' {
33 interface PluginState {
34 'course-companion': {
35 isActive: boolean
36 instance: string
37 choices: Choices | null
38 view: View
39 snapshot: Snapshot | null
40 ideas: IdeaMarks
41 }
42 }
43}
44