@bojackduy/opencode-learn 0.1.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-learn",
4
- "version": "0.1.6",
4
+ "version": "1.1.0",
5
5
  "description": "Pi learn system for OpenCode — Socratic teaching, graded quiz, Obsidian md_log, and visual makers. Port of amosblomqvist/learn (video: How I Use AI to Learn Things) to OpenCode.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -6,12 +6,66 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
6
6
  import * as fs from "node:fs"
7
7
  import * as path from "node:path"
8
8
  import { watch } from "node:fs"
9
+ import { SyntaxStyle } from "@opentui/core"
10
+ function syntaxStyle(theme:any){
11
+ return SyntaxStyle.fromTheme([
12
+ { scope: ["default"], style: { foreground: theme.text } },
13
+ { scope: ["comment", "comment.documentation"], style: { foreground: theme.syntaxComment, italic: true } },
14
+ { scope: ["string", "symbol", "character", "character.special"], style: { foreground: theme.syntaxString } },
15
+ { scope: ["number", "boolean", "float", "constant"], style: { foreground: theme.syntaxNumber } },
16
+ { scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine", "keyword", "keyword.directive", "keyword.modifier", "keyword.exception"], style: { foreground: theme.syntaxKeyword, italic: true } },
17
+ { scope: ["keyword.type"], style: { foreground: theme.syntaxType, bold: true, italic: true } },
18
+ { scope: ["keyword.import", "keyword.export", "tag.attribute"], style: { foreground: theme.syntaxKeyword } },
19
+ { scope: ["keyword.function", "function.method", "variable.member", "function", "constructor"], style: { foreground: theme.syntaxFunction } },
20
+ { scope: ["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary", "punctuation.special", "tag.delimiter"], style: { foreground: theme.syntaxOperator } },
21
+ { scope: ["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"], style: { foreground: theme.syntaxVariable } },
22
+ { scope: ["type", "module", "class", "namespace"], style: { foreground: theme.syntaxType } },
23
+ { scope: ["punctuation", "punctuation.bracket"], style: { foreground: theme.syntaxPunctuation } },
24
+ { scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super", "tag"], style: { foreground: theme.error } },
25
+ { scope: ["string.escape", "string.regexp"], style: { foreground: theme.syntaxKeyword } },
26
+ { scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },
27
+ { scope: ["markup.heading.1"], style: { foreground: theme.markdownHeading, bold: true, underline: true } },
28
+ { scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
29
+ { scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
30
+ { scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
31
+ { scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
32
+ { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode } },
33
+ { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.background } },
34
+ { scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"], style: { foreground: theme.markdownLink, underline: true } },
35
+ { scope: ["markup.link.label", "label"], style: { foreground: theme.markdownLinkText, underline: true } },
36
+ { scope: ["spell", "nospell", "markup.underline"], style: { foreground: theme.text } },
37
+ { scope: ["conceal", "markup.strikethrough", "markup.list.unchecked", "debug"], style: { foreground: theme.textMuted } },
38
+ { scope: ["comment.error", "error"], style: { foreground: theme.error, italic: true, bold: true } },
39
+ { scope: ["comment.warning", "warning"], style: { foreground: theme.warning, italic: true, bold: true } },
40
+ { scope: ["comment.todo", "comment.note"], style: { foreground: theme.info, italic: true, bold: true } },
41
+ { scope: ["type.definition"], style: { foreground: theme.syntaxType, bold: true } },
42
+ { scope: ["attribute", "annotation"], style: { foreground: theme.warning } },
43
+ { scope: ["markup.list.checked"], style: { foreground: theme.success } },
44
+ { scope: ["diff.plus"], style: { foreground: theme.diffAdded, background: theme.diffAddedBg } },
45
+ { scope: ["diff.minus"], style: { foreground: theme.diffRemoved, background: theme.diffRemovedBg } },
46
+ { scope: ["diff.delta"], style: { foreground: theme.diffContext, background: theme.diffContextBg } },
47
+ { scope: ["info"], style: { foreground: theme.info } },
48
+ ])
49
+ }
9
50
 
10
51
  const PENDING_DIR = ".opencode/learn-pending"
11
52
  import { tmpdir } from "node:os"
12
53
  const TUI_LOG = path.join(tmpdir(), "learn-tui.log")
13
54
  function tlog(...a: any[]) { try { fs.appendFileSync(TUI_LOG, `[${new Date().toISOString()}] ${a.map(x=> typeof x==="string"? x : JSON.stringify(x)).join(" ")}\n`) } catch {} }
14
55
  function ensureDir(dir: string) { try { fs.mkdirSync(dir, { recursive: true }) } catch {} }
56
+ function decodeQuizText(s: string): string {
57
+ if (!s || typeof s !== "string") return s
58
+ if (!s.includes("\\")) return s
59
+ // Convert literal \n / \r\n / \t escapes to real whitespace. Handles both single and double-escaped payloads (e.g. file contains \\n after JSON round-trip).
60
+ // Only touches backslash sequences, leaves actual newlines intact.
61
+ let out = s.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t")
62
+ // Decode escaped quotes/backslashes that may survive double-escaping: \" -> ", \' -> ', \\ -> \
63
+ // Do this after newline handling to avoid re-introducing \n.
64
+ out = out.replace(/\\"/g, '"').replace(/\\'/g, "'")
65
+ // Collapse double-escaped backslashes that are not part of \n already handled
66
+ out = out.replace(/\\\\/g, "\\")
67
+ return out
68
+ }
15
69
 
16
70
  type QuizPending = {
17
71
  id: string
@@ -43,9 +97,16 @@ function QuizDialog(props: {
43
97
  onCancel: () => void
44
98
  }) {
45
99
  const theme = () => props.api.theme.current
100
+ const syntax = () => syntaxStyle(theme())
46
101
  const dims = useTerminalDimensions()
47
- const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 92))
48
- const popupHeight = () => Math.max(4, Math.min(24, dims().height - 2))
102
+ const popupWidth = () => {
103
+ const w = dims().width
104
+ return Math.max(62, Math.min(w - 8, Math.floor(w * 0.80), 92))
105
+ }
106
+ const popupHeight = () => {
107
+ const h = dims().height
108
+ return Math.max(14, Math.min(h - 6, Math.floor(h * 0.62), 26))
109
+ }
49
110
  const options = () => props.request.options
50
111
  const correctSet = new Set(props.request.correctIndices)
51
112
  const isMulti = () => !!props.request.multiSelect
@@ -61,12 +122,38 @@ function QuizDialog(props: {
61
122
  const [feedback, setFeedback] = createSignal<{ correct: boolean; selectedIndices: number[] } | null>(null)
62
123
 
63
124
  let noteInputEl: any
64
-
125
+ let scrollRef: any
126
+ const [canScrollUp, setCanScrollUp] = createSignal(false)
127
+ const [canScrollDown, setCanScrollDown] = createSignal(false)
128
+ const updateScrollIndicators = () => {
129
+ try {
130
+ if (!scrollRef) { setCanScrollUp(false); setCanScrollDown(false); return }
131
+ const st = typeof scrollRef.scrollTop === "number" ? scrollRef.scrollTop : 0
132
+ const h = typeof scrollRef.height === "number" ? scrollRef.height : (scrollRef.viewportHeight ?? popupHeight())
133
+ const sh = typeof scrollRef.scrollHeight === "number" ? scrollRef.scrollHeight : 0
134
+ let effectiveSh = sh
135
+ if (!effectiveSh && typeof scrollRef.getChildren === "function") {
136
+ try { const kids = scrollRef.getChildren(); if (kids?.length) effectiveSh = Math.max(...kids.map((c:any)=> (c.y||0)+(c.height||0)), h) } catch {}
137
+ }
138
+ if (!effectiveSh || effectiveSh <= h + 1) { setCanScrollUp(false); setCanScrollDown(false); return }
139
+ setCanScrollUp(st > 0)
140
+ setCanScrollDown(st + h < effectiveSh - 1)
141
+ } catch { setCanScrollUp(false); setCanScrollDown(false) }
142
+ }
143
+ const scrollAmount = () => Math.max(1, Math.floor((scrollRef?.height ?? popupHeight()) / 3))
65
144
  createEffect(() => {
66
145
  if (focused() === "note" && noteInputEl) {
67
146
  try { noteInputEl.focus() } catch {}
68
147
  }
69
148
  })
149
+ // Keep indicators in sync on phase/dims/feedback changes
150
+ createEffect(() => { phase(); feedback(); dims(); setTimeout(updateScrollIndicators, 40); setTimeout(updateScrollIndicators, 200) })
151
+ createEffect(() => { note(); setTimeout(updateScrollIndicators, 40) })
152
+ createEffect(() => {
153
+ if (phase() !== "feedback" && phase() !== "select") return
154
+ const id = setInterval(updateScrollIndicators, 200)
155
+ onCleanup(() => clearInterval(id))
156
+ })
70
157
 
71
158
  const toggleOption = (idx: number) => {
72
159
  const opt = options()[idx]
@@ -170,13 +257,28 @@ function QuizDialog(props: {
170
257
  props.onSubmit({ answers: dontKnow() ? [] : sel, dontKnow: dontKnow(), note: note().trim() || undefined })
171
258
  }
172
259
 
260
+ const isPlainKey = (evt:any, want:string) => {
261
+ try {
262
+ const n = String(evt.name||evt.sequence||"").toLowerCase()
263
+ if (n !== want.toLowerCase()) return false
264
+ if (evt.ctrl || evt.meta || evt.option || evt.alt) return false
265
+ return true
266
+ } catch { return false }
267
+ }
173
268
  useKeyboard((evt: any) => {
174
269
  const key = evt.name || evt.sequence || evt.raw || ""
175
270
  const seq = evt.sequence || ""
271
+ const lower = String(key||"").toLowerCase()
176
272
  if ((phase() as any) === "classifying") { prevent(evt); return }
177
- // When in feedback, any Enter/Esc confirms
273
+ // When in feedback, handle scroll first, then confirm
178
274
  if (phase() === "feedback") {
179
- if (key === "enter" || seq === "\r" || key === "escape" || key === "esc") {
275
+ if (isPlainKey(evt,"d") || seq === "\x04") { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators, 30); setTimeout(updateScrollIndicators, 120) } catch {} return }
276
+ if (isPlainKey(evt,"u") || seq === "\x15") { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators, 30); setTimeout(updateScrollIndicators, 120) } catch {} return }
277
+ if (isPlainKey(evt,"j") || seq === "\x1b[B") { prevent(evt); try { scrollRef?.scrollBy(1); setTimeout(updateScrollIndicators, 30) } catch {} return }
278
+ if (isPlainKey(evt,"k") || seq === "\x1b[A") { prevent(evt); try { scrollRef?.scrollBy(-1); setTimeout(updateScrollIndicators, 30) } catch {} return }
279
+ if (key === "pageup" || seq === "\x1b[5~") { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators, 30) } catch {} return }
280
+ if (key === "pagedown" || seq === "\x1b[6~") { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators, 30) } catch {} return }
281
+ if ( lower === "enter" || seq === "\r" || lower === "escape" || lower === "esc") {
180
282
  prevent(evt)
181
283
  confirmFeedback()
182
284
  }
@@ -190,6 +292,11 @@ function QuizDialog(props: {
190
292
  // Allow typing to go to input; don't prevent
191
293
  return
192
294
  }
295
+ // d/u scroll works in both select and feedback — page scroll even before answer
296
+ if (phase() === "select" && (isPlainKey(evt,"d") || seq === "\x04")) { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators,30); setTimeout(updateScrollIndicators,120) } catch {} return }
297
+ if (phase() === "select" && (isPlainKey(evt,"u") || seq === "\x15")) { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators,30); setTimeout(updateScrollIndicators,120) } catch {} return }
298
+ if (phase() === "select" && (lower === "pageup" || seq === "\x1b[5~")) { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators,30) } catch {} return }
299
+ if (phase() === "select" && (lower === "pagedown" || seq === "\x1b[6~")) { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators,30) } catch {} return }
193
300
  // Options focused — extra Submit for single note-only at dontKnowIdx+1
194
301
  const maxIdx = () => {
195
302
  if (isMulti()) return submitIdx()
@@ -238,13 +345,21 @@ function QuizDialog(props: {
238
345
  <text fg={theme().background} bold>{phase() === "feedback" ? (feedback()?.correct ? "✓ CORRECT" : dontKnow() ? "○ I DON'T KNOW" : "✗ INCORRECT") : isMulti() ? "☑ QUIZ · MULTI-SELECT" : "● QUIZ · SINGLE" }</text>
239
346
  <text fg={theme().background} dim>learn</text>
240
347
  </box>
348
+ {/* Pinned scroll cue — header-anchored, high contrast so user instantly knows explanation is below */}
349
+ <Show when={phase()==="feedback" && (canScrollUp() || canScrollDown())}>
350
+ <box justifyContent="center" height={1} backgroundColor={canScrollDown() ? theme().warning : theme().accent} paddingLeft={1} paddingRight={1}>
351
+ <text fg={theme().background} bold>
352
+ {canScrollUp() && canScrollDown() ? "▲ more above · ▼ more below — d / u to scroll" : canScrollDown() ? "▼ more below — press d to see explanation" : "▲ more above — press u to scroll up"}
353
+ </text>
354
+ </box>
355
+ </Show>
241
356
 
242
- <scrollbox flexGrow={1}>
243
- {/* Question */}
357
+ <scrollbox ref={(el:any)=> scrollRef = el} flexGrow={1} verticalScrollbarOptions={{ visible: true, trackOptions: { backgroundColor: theme().background, foregroundColor: theme().borderActive } }}>
358
+ {/* Question — use opencode markdown render so ```python blocks get syntax coloring like native messages */}
244
359
  <box flexDirection="column" gap={1} paddingLeft={1} paddingRight={1} paddingTop={1}>
245
- <text fg={theme().text} bold wrapMode="wrap">{props.request.question}</text>
360
+ <markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.question)} fg={theme().text} bg={theme().backgroundPanel} />
246
361
  <Show when={props.request.details}>
247
- <text fg={theme().textMuted} wrapMode="wrap">{props.request.details}</text>
362
+ <markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.details)} fg={theme().textMuted} bg={theme().backgroundPanel} />
248
363
  </Show>
249
364
  </box>
250
365
 
@@ -293,9 +408,8 @@ function QuizDialog(props: {
293
408
  </box>
294
409
 
295
410
  <box flexDirection="row" justifyContent="space-between" paddingTop={1}>
296
- <text fg={theme().textMuted}>{focused() === "note" ? "Enter submit note classify · Tab back · Esc back" : "↑↓/j k · Space toggle · ↓ to Submit Enter · Tab note · Esc cancel"}</text>
297
- <Show when={isMulti()}><text fg={selected().size > 0 || dontKnow() ? theme().success : note().trim() ? theme().warning : theme().warning}>{selected().size > 0 ? `${selected().size} selected` : note().trim() ? "note → classify" : "0 selected"} {dontKnow() ? "· I don't know" : ""}</text></Show>
298
- <Show when={!isMulti() && note().trim() && !selected().size && !dontKnow()}><text fg={theme().warning}>note → classify on Enter</text></Show>
411
+ <text fg={theme().textMuted}>{isMulti() ? `${selected().size} selected${dontKnow() ? " · I don't know" : ""}` : note().trim() && !selected().size && !dontKnow() ? "note classify" : dontKnow() ? "I don't know" : ""}</text>
412
+ <text fg={theme().textMuted}>{focused() === "note" ? "Tab/Esc back" : ""}</text>
299
413
  </box>
300
414
  <Show when={isMulti()}>
301
415
  <box justifyContent="center" paddingTop={1}>
@@ -351,14 +465,18 @@ function QuizDialog(props: {
351
465
  <text fg={theme().textMuted}>Correct: {props.request.correctIndices.map(i => `${i}. ${options()[i-1]?.label}`).join(", ")}</text>
352
466
  <Show when={note()}><text fg={theme().textMuted}>Your note: {note()}</text></Show>
353
467
  <box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}>
354
- <text fg={theme().text} wrapMode="wrap">{props.request.explanation}</text>
468
+ <markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.explanation)} fg={theme().text} bg={theme().backgroundPanel} />
355
469
  </box>
356
470
  </box>
357
471
  </Show>
358
472
  </scrollbox>
359
473
  <box height={1} justifyContent="center">
360
- <text fg={theme().textMuted}>
361
- {phase() === "feedback" ? "↵ Enter / Esc to continue → next probe" : phase() === "classifying" ? "Classifying your note..." : "Navigate options · Tab note · Esc cancel"}
474
+ <text fg={theme().textMuted} wrapMode="wrap">
475
+ {phase() === "feedback"
476
+ ? (canScrollUp() && canScrollDown() ? <><span style={{fg: theme.warning, bold: true}}>▲ more above · ▼ more below</span><span style={{fg: theme().textMuted}}> — d/u to scroll · Enter to continue</span></> : canScrollDown() ? <><span style={{fg: theme.warning, bold: true}}>▼ more below</span><span style={{fg: theme().textMuted}}> — d to scroll · Enter to continue</span></> : canScrollUp() ? <><span style={{fg: theme.accent, bold: true}}>▲ more above</span><span style={{fg: theme().textMuted}}> — u to scroll · Enter to continue</span></> : "↵ Enter / Esc to continue → next probe")
477
+ : phase() === "classifying" ? "Classifying your note..."
478
+ : focused() === "note" ? "Enter submit note → classify · Tab/Esc back"
479
+ : (canScrollUp() || canScrollDown()) ? <><span style={{fg: theme().textMuted}}>j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel</span><span style={{fg: theme.warning, bold: true}}> · d/u scroll</span></> : "j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel"}
362
480
  </text>
363
481
  </box>
364
482
  </box>
@@ -373,9 +491,16 @@ function QuizBatchDialog(props: {
373
491
  onCancel: () => void
374
492
  }) {
375
493
  const theme = () => props.api.theme.current
494
+ const syntax = () => syntaxStyle(theme())
376
495
  const dims = useTerminalDimensions()
377
- const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 96))
378
- const popupHeight = () => Math.max(4, Math.min(24, dims().height - 2))
496
+ const popupWidth = () => {
497
+ const w = dims().width
498
+ return Math.max(64, Math.min(w - 8, Math.floor(w * 0.82), 96))
499
+ }
500
+ const popupHeight = () => {
501
+ const h = dims().height
502
+ return Math.max(14, Math.min(h - 6, Math.floor(h * 0.64), 28))
503
+ }
379
504
  const [idx, setIdx] = createSignal(0)
380
505
  // Guard: if no quizzes, cancel
381
506
  if (!props.request.quizzes || props.request.quizzes.length === 0) {
@@ -396,7 +521,33 @@ function QuizBatchDialog(props: {
396
521
  const dontKnowIdx = () => cur().options.length
397
522
  const submitIdx = () => isMulti() ? cur().options.length + 1 : -1
398
523
  let noteEl: any
524
+ let scrollRefBatch: any
525
+ const [canScrollUpBatch, setCanScrollUpBatch] = createSignal(false)
526
+ const [canScrollDownBatch, setCanScrollDownBatch] = createSignal(false)
527
+ const updateScrollBatch = () => {
528
+ try {
529
+ if (!scrollRefBatch) { setCanScrollUpBatch(false); setCanScrollDownBatch(false); return }
530
+ const st = typeof scrollRefBatch.scrollTop === "number" ? scrollRefBatch.scrollTop : 0
531
+ const h = typeof scrollRefBatch.height === "number" ? scrollRefBatch.height : (scrollRefBatch.viewportHeight ?? popupHeight())
532
+ const sh = typeof scrollRefBatch.scrollHeight === "number" ? scrollRefBatch.scrollHeight : 0
533
+ let effectiveSh = sh
534
+ if (!effectiveSh && typeof scrollRefBatch.getChildren === "function") {
535
+ try { const kids = scrollRefBatch.getChildren(); if (kids?.length) effectiveSh = Math.max(...kids.map((c:any)=> (c.y||0)+(c.height||0)), h) } catch {}
536
+ }
537
+ if (!effectiveSh || effectiveSh <= h + 1) { setCanScrollUpBatch(false); setCanScrollDownBatch(false); return }
538
+ setCanScrollUpBatch(st > 0)
539
+ setCanScrollDownBatch(st + h < effectiveSh - 1)
540
+ } catch { setCanScrollUpBatch(false); setCanScrollDownBatch(false) }
541
+ }
542
+ const scrollAmountBatch = () => Math.max(1, Math.floor((scrollRefBatch?.height ?? popupHeight()) / 3))
399
543
  createEffect(() => { if (focused()==="note" && noteEl) try{noteEl.focus()}catch(e){ tlog("note focus failed", String(e)) } })
544
+ createEffect(() => { phase(); feedback(); dims(); idx(); setTimeout(updateScrollBatch, 40); setTimeout(updateScrollBatch, 200) })
545
+ createEffect(() => { note(); setTimeout(updateScrollBatch, 40) })
546
+ createEffect(() => {
547
+ if (phase() !== "feedback" && phase() !== "select") return
548
+ const id = setInterval(updateScrollBatch, 200)
549
+ onCleanup(() => clearInterval(id))
550
+ })
400
551
  const toggle = (i:number) => {
401
552
  try {
402
553
  const o = cur().options[i]; if(!o) return
@@ -490,12 +641,22 @@ function QuizBatchDialog(props: {
490
641
  setPhase("feedback")
491
642
  } catch(e){ tlog("submitSelect failed", String(e)) }
492
643
  }
644
+ const isPlainKeyBatch = (evt:any, want:string) => {
645
+ try { const n=String(evt.name||evt.sequence||"").toLowerCase(); if(n!==want.toLowerCase()) return false; if(evt.ctrl||evt.meta||evt.option||evt.alt) return false; return true } catch { return false }
646
+ }
493
647
  useKeyboard((evt:any)=>{
494
648
  try {
495
- const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""
649
+ const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""; const lower=String(k||"").toLowerCase()
496
650
  if((phase() as any)==="classifying"){ prevent(evt); return }
497
- if(phase()==="feedback"){ if(k==="enter"||seq==="\r"||k==="escape"||k==="esc"){ prevent(evt); goNext() } return }
651
+ if(phase()==="feedback"){
652
+ if (isPlainKeyBatch(evt,"d")||seq==="\x04"){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
653
+ if (isPlainKeyBatch(evt,"u")||seq==="\x15"){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
654
+ if (lower==="pageup"||seq==="\x1b[5~"){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
655
+ if (lower==="pagedown"||seq==="\x1b[6~"){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
656
+ if(lower==="enter"||seq==="\r"||lower==="escape"||lower==="esc"){ prevent(evt); goNext() } return }
498
657
  if(focused()==="note"){ if(k==="tab"||seq==="\t"){prevent(evt); setFocused("options"); return} if(k==="escape"){prevent(evt); setFocused("options"); return} return }
658
+ if(phase()==="select" && (isPlainKeyBatch(evt,"d")||seq==="\x04")){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
659
+ if(phase()==="select" && (isPlainKeyBatch(evt,"u")||seq==="\x15")){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
499
660
  if(k==="up"||k==="k"||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
500
661
  if(k==="down"||k==="j"||seq==="\x1b[B"){prevent(evt); setOptionIndex(i=>Math.min(isMulti()?submitIdx():dontKnowIdx(),i+1)); return}
501
662
  if(k==="tab"||seq==="\t"){prevent(evt); setFocused("note"); return}
@@ -511,16 +672,24 @@ function QuizBatchDialog(props: {
511
672
  <text fg={theme().background} bold> decks.quiz batch {idx()+1}/{props.request.quizzes.length} {phase()==="feedback"?(feedback()?.correct?"✓":"✗"):""}</text>
512
673
  <text fg={theme().background} dim>learn</text>
513
674
  </box>
514
- <scrollbox flexGrow={1}>
515
- <text fg={theme().text} bold wrapMode="wrap">{cur().question}</text>
516
- <Show when={cur().details}><text fg={theme().textMuted} wrapMode="wrap">{cur().details}</text></Show>
675
+ {/* Pinned scroll cue — header-anchored, high contrast */}
676
+ <Show when={phase()==="feedback" && (canScrollUpBatch() || canScrollDownBatch())}>
677
+ <box justifyContent="center" height={1} backgroundColor={canScrollDownBatch() ? theme().warning : theme().accent} paddingLeft={1} paddingRight={1}>
678
+ <text fg={theme().background} bold>
679
+ {canScrollUpBatch() && canScrollDownBatch() ? "▲ more above · ▼ more below — d / u to scroll" : canScrollDownBatch() ? "▼ more below — press d to see explanation" : "▲ more above — press u to scroll up"}
680
+ </text>
681
+ </box>
682
+ </Show>
683
+ <scrollbox ref={(el:any)=> scrollRefBatch = el} flexGrow={1} verticalScrollbarOptions={{ visible: true, trackOptions: { backgroundColor: theme().background, foregroundColor: theme().borderActive } }}>
684
+ <markdown syntaxStyle={syntax()} content={decodeQuizText(cur().question)} fg={theme().text} bg={theme().backgroundPanel} />
685
+ <Show when={cur().details}><markdown syntaxStyle={syntax()} content={decodeQuizText(cur().details)} fg={theme().textMuted} bg={theme().backgroundPanel} /></Show>
517
686
  <Show when={phase()==="select"}>
518
687
  <box flexDirection="column" gap={0} padding={1} border={true} borderColor={theme().borderSubtle} backgroundColor={theme().background}>
519
688
  <For each={cur().options}>{(opt:any,i:any)=>{const id=i(); const foc=()=>focused()==="options"&&optionIndex()===id; const sel=()=>selected().has(`opt:${id}`); return <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} backgroundColor={foc()?theme().backgroundElement:undefined}><box width={2}><text fg={foc()?theme().accent:theme().textMuted}>{foc()?"▸":" "}</text></box><box width={2}><text fg={isMulti()?(sel()?theme().success:theme().textMuted):(sel()?theme().accent:theme().textMuted)}>{isMulti()?(sel()?"☑":"☐"):(sel()?"⬢":"○")}</text></box><box flexGrow={1}><text fg={sel()?theme().text:theme().textMuted} bold={foc()} wrapMode="wrap">{id+1}. {opt.label}</text></box></box>}}</For>
520
689
  <box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20,popupWidth()-8))}</text></box>
521
690
  <box flexDirection="row" gap={1} paddingLeft={1} backgroundColor={focused()==="options"&&optionIndex()===dontKnowIdx()?theme().backgroundElement:undefined}><box width={2}><text fg={focused()==="options"&&optionIndex()===dontKnowIdx()?theme().accent:theme().textMuted}>{focused()==="options"&&optionIndex()===dontKnowIdx()?"▸":" "}</text></box><box width={2}><text fg={dontKnow()?theme().warning:theme().textMuted}>{dontKnow()?"☑":"☐"}</text></box><box flexGrow={1}><text fg={dontKnow()?theme().warning:theme().textMuted} italic>I don't know</text></box></box>
522
691
  <box flexDirection="column" paddingTop={1}><text fg={focused()==="note"?theme().accent:theme().textMuted}>✎ Note</text><box border={true} borderColor={focused()==="note"?theme().accent:theme().borderSubtle} backgroundColor={theme().backgroundElement} paddingLeft={1} paddingRight={1}><Show when={focused()==="note"} fallback={<text fg={theme().textMuted}>{note()||"Tab to edit · share what you were thinking"}</text>}><input ref={(el:any)=>noteEl=el} value={note()} onInput={(v:any)=>setNote(typeof v==="string"?v:v?.target?.value??"")} onSubmit={()=>{ if (!selected().size && !dontKnow() && note().trim()) submitSelect(); else setFocused("options") }} placeholder="note (Enter to submit note → classify)" /></Show></box></box>
523
- <box flexDirection="row" justifyContent="space-between" paddingTop={1}><text fg={theme().textMuted}>{focused()==="note" ? "Enter submit note classify · Tab back · Esc back" : "Space toggle · ↓ to Submit → Enter · Tab note · Ctrl+Enter submit"}</text><text fg={theme().textMuted}>{idx()+1}/{props.request.quizzes.length}</text></box>
692
+ <box flexDirection="row" justifyContent="space-between" paddingTop={1}><text fg={theme().textMuted}>{isMulti() ? `${selected().size} selected` : note().trim() && !selected().size ? "note classify" : ""}</text><text fg={theme().textMuted}>{idx()+1}/{props.request.quizzes.length}</text></box>
524
693
  <Show when={isMulti()}><box justifyContent="center" paddingTop={1}><box flexDirection="row" gap={1} border={true} borderColor={focused()==="options"&&optionIndex()===submitIdx()?theme().accent:theme().borderSubtle} backgroundColor={focused()==="options"&&optionIndex()===submitIdx()?theme().backgroundElement:theme().background} paddingLeft={2} paddingRight={2}><text fg={focused()==="options"&&optionIndex()===submitIdx()?theme().accent:theme().textMuted}>{focused()==="options"&&optionIndex()===submitIdx()?"▸":" "}</text><text bold>↳ Submit</text></box></box></Show>
525
694
  </box>
526
695
  </Show>
@@ -536,13 +705,13 @@ function QuizBatchDialog(props: {
536
705
  <For each={cur().options}>{(opt:any,i:any)=>{const id=i()+1; const sel=()=>feedback()?.selectedIndices.includes(id)??false; const ok=()=>new Set(cur().correctIndices).has(id); let ic=" "; let fg=theme().textMuted; let bg:any=undefined; if(dontKnow()){ic=ok()?"✓":" "; fg=ok()?theme().background:theme().textMuted; bg=ok()?theme().success:undefined} else if(sel()&&ok()){ic="✓"; fg=theme().background; bg=theme().success} else if(sel()&&!ok()){ic="✗"; fg=theme().background; bg=theme().error} else if(!sel()&&ok()){ic="○"; fg=theme().background; bg=theme().warning} return <box flexDirection="row" gap={1} paddingLeft={1} backgroundColor={bg}><box width={2}><text fg={fg} bold>{ic}</text></box><box flexGrow={1}><text fg={fg} wrapMode="wrap">{id}. {opt.label}</text></box></box>}}</For>
537
706
  <text fg={feedback()?.correct?theme().success:theme().error} bold>{feedback()?.correct?"✓ Correct":"✗ Incorrect"}</text>
538
707
  <text fg={theme().textMuted}>Correct: {cur().correctIndices.map((i:number)=>`${i}. ${cur().options[i-1]?.label}`).join(", ")}</text>
539
- <box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}><text fg={theme().text} wrapMode="wrap">{cur().explanation}</text></box>
708
+ <box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}><markdown syntaxStyle={syntax()} content={decodeQuizText(cur().explanation)} fg={theme().text} bg={theme().backgroundPanel} /></box>
540
709
  </box>
541
710
  </Show>
542
711
  </scrollbox>
543
712
  <box height={1} justifyContent="center">
544
- <text fg={theme().textMuted}>
545
- {phase()==="feedback" ? `Enter → next (${idx()+1}/${props.request.quizzes.length})` : phase()==="classifying" ? "Classifying your note..." : "Navigate options · Tab note · Esc cancel"}
713
+ <text fg={theme().textMuted} wrapMode="wrap">
714
+ {phase()==="feedback" ? (canScrollUpBatch() && canScrollDownBatch() ? <><span style={{fg: theme.warning, bold: true}}>▲ more above · ▼ more below</span><span style={{fg: theme().textMuted}}> — d/u to scroll · Enter → next ({idx()+1}/{props.request.quizzes.length})</span></> : canScrollDownBatch() ? <><span style={{fg: theme.warning, bold: true}}>▼ more below</span><span style={{fg: theme().textMuted}}> — d to scroll · Enter → next ({idx()+1}/{props.request.quizzes.length})</span></> : canScrollUpBatch() ? <><span style={{fg: theme.accent, bold: true}}>▲ more above</span><span style={{fg: theme().textMuted}}> — u to scroll · Enter → next ({idx()+1}/{props.request.quizzes.length})</span></> : `Enter → next (${idx()+1}/${props.request.quizzes.length})`) : phase()==="classifying" ? "Classifying your note..." : focused()==="note" ? "Enter submit note → classify · Tab/Esc back" : (canScrollUpBatch() || canScrollDownBatch()) ? <><span style={{fg: theme().textMuted}}>j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel</span><span style={{fg: theme.warning, bold: true}}> · d/u scroll</span></> : "j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel"}
546
715
  </text>
547
716
  </box>
548
717
  </box>
package/plugins/learn.ts CHANGED
@@ -125,6 +125,15 @@ function resolveCorrect(correctAnswer: string | string[] | undefined, options: A
125
125
  return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) as number[] }
126
126
  }
127
127
 
128
+ function decodeQuizText(s: string | undefined): string | undefined {
129
+ if (!s || typeof s !== "string") return s
130
+ if (!s.includes("\\")) return s
131
+ let out = s.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t")
132
+ out = out.replace(/\\"/g, '"').replace(/\\'/g, "'")
133
+ out = out.replace(/\\\\/g, "\\")
134
+ return out
135
+ }
136
+
128
137
  // ────────────────────────────────────────────────────────────────────────────
129
138
  // md-log helpers (ported from .pi/extensions/md-log.ts)
130
139
  // ────────────────────────────────────────────────────────────────────────────
@@ -788,8 +797,14 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
788
797
  shuffle: tool.schema.boolean().optional().describe("Default true: shuffle before display. False only if order matters."),
789
798
  },
790
799
  async execute(args, ctx) {
800
+ // Fix double-escaped \n coming from LLM JSON (e.g. "\\n" literal instead of newline)
801
+ const qFixed = (decodeQuizText(args.question) ?? args.question) as string
802
+ const dFixed = decodeQuizText(args.details) as string | undefined
803
+ const eFixed = (decodeQuizText(args.explanation) ?? args.explanation) as string
804
+ // Also decode option labels in case they contain code
805
+ const optsDecoded = (args.options as any[] | undefined)?.map((o: any) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description })) as any
791
806
  let options: Array<{ label: string; value: string; description?: string }>
792
- try { options = normalizeQuizOptions(args.options as any) } catch (e) { return `quiz error: ${(e as Error).message}` }
807
+ try { options = normalizeQuizOptions(optsDecoded) } catch (e) { return `quiz error: ${(e as Error).message}` }
793
808
  if (args.shuffle !== false) options = shuffleOptions(options)
794
809
  const { indices: correctIndices, error: correctError } = resolveCorrect(args.correctAnswer as any, options)
795
810
  if (correctError) return `quiz error: ${correctError}`
@@ -808,17 +823,17 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
808
823
  const payload = {
809
824
  id,
810
825
  type: "quiz" as const,
811
- question: args.question,
812
- details: args.details,
826
+ question: qFixed,
827
+ details: dFixed,
813
828
  options: options.map((o, i) => ({ label: o.label, value: o.value, description: o.description, index: i + 1 })),
814
829
  correctIndices,
815
- explanation: args.explanation,
830
+ explanation: eFixed,
816
831
  multiSelect: !!args.multiSelect,
817
832
  sessionID: (ctx as any).sessionID,
818
833
  timestamp: Date.now(),
819
834
  }
820
835
  try { fs.writeFileSync(pendingPath, JSON.stringify(payload), "utf8"); slog("quiz wrote durably", pendingPath, "alive", tuiAlive) } catch (e) { slog("quiz write failed", String(e)) }
821
- try { await (ctx as any).metadata?.({ title: `Quiz: ${args.question.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
836
+ try { await (ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
822
837
  watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
823
838
  const dk = !!r?.dontKnow
824
839
  const sel = (r?.answers || []).map((a: any) => `${a.index}. ${a.label}`).join(", ") || "(none)"
@@ -832,19 +847,19 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
832
847
  answers: r?.answers || [],
833
848
  correct: ok,
834
849
  correctIndices,
835
- explanation: args.explanation,
850
+ explanation: eFixed,
836
851
  dontKnow: dk,
837
852
  note: r?.note,
838
853
  }
839
854
  void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
840
855
  }
841
856
  return dk
842
- ? `[quiz answered] "${args.question}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${args.explanation}${note}`
843
- : `[quiz answered] "${args.question}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${args.explanation}${note}`
857
+ ? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
858
+ : `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
844
859
  })
845
860
  // Always mirror question with TRUE shuffled order (pi: tool_execution_update)
846
861
  if (mdLogFile) {
847
- try { await withMdLock(() => appendToMdLog(questionCallout("Quiz", args.question, args.details?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
862
+ try { await withMdLock(() => appendToMdLog(questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
848
863
  }
849
864
  if (tuiAlive) {
850
865
  return `[quiz displayed in TUI — waiting for your answer in the popup. I'll continue once you respond.]`
@@ -863,8 +878,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
863
878
  if (raw === null) return "User cancelled the quiz"
864
879
  const trimmed = (raw as string).trim()
865
880
  if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
866
- const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${args.explanation}`
867
- if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [args.question, trimmed, `Correct: ${correctStr}`, args.explanation])))
881
+ const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
882
+ if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])))
868
883
  return msg
869
884
  }
870
885
  const nums = trimmed.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 1 && n <= options.length)
@@ -873,28 +888,28 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
873
888
  const correct = selectedSet.size === correctSet.size && [...selectedSet].every(n => correctSet.has(n))
874
889
  const selectedStr = nums.map(n => `${n}. ${options[n - 1].label}`).join(", ") || "(none)"
875
890
  const verdict = correct ? "correctly" : "incorrectly"
876
- const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${args.explanation}`
877
- ;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation: args.explanation } })
878
- if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${args.question}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, args.explanation])))
891
+ const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
892
+ ;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation: eFixed } })
893
+ if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])))
879
894
  return result
880
895
  }
881
896
  const instruction = [
882
897
  `[quiz ready — awaiting user answer via \`question\` tool]`,
883
- `Question: ${args.question}`,
884
- args.details ? `Details: ${args.details}` : null,
898
+ `Question: ${qFixed}`,
899
+ dFixed ? `Details: ${dFixed}` : null,
885
900
  `Options (display order, already shuffled):`,
886
901
  ...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""} (value="${o.value}")`),
887
902
  `Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
888
- `Explanation (reveal AFTER answer): ${args.explanation}`,
903
+ `Explanation (reveal AFTER answer): ${eFixed}`,
889
904
  `Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
890
905
  ``,
891
906
  `INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
892
907
  ` header: "Quiz"`,
893
- ` question: "${args.question.replace(/"/g, '\\"')}"`,
908
+ ` question: "${qFixed.replace(/"/g, '\\"')}"`,
894
909
  ` options: [${options.map(o => `{label:"${o.label.replace(/"/g, '\\"')}", description:"${(o.description ?? "").replace(/"/g, '\\"')}"}`).join(", ")}]`,
895
910
  `Then compare the user's selected labels to correct indices [${correctIndices.join(", ")}]. Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show ✓/✗, reveal Correct: ${correctStr}, and Explanation. An 'I don't know' maps to dontKnow (genuine gap).`,
896
911
  ].filter(Boolean).join("\n")
897
- ;(ctx as any).metadata?.({ title: `Quiz: ${args.question.slice(0, 40)}`, metadata: { correctIndices, explanation: args.explanation, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } })
912
+ ;(ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { correctIndices, explanation: eFixed, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } })
898
913
  return instruction
899
914
  },
900
915
  }),
@@ -924,13 +939,17 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
924
939
  slog("quiz_batch isAlive", isAlive)
925
940
  const normalized: any[] = []
926
941
  for (const q of (args.quizzes as any[])) {
942
+ const qFixed = (decodeQuizText(q.question) ?? q.question) as string
943
+ const dFixed = decodeQuizText(q.details) as string | undefined
944
+ const eFixed = (decodeQuizText(q.explanation) ?? q.explanation) as string
945
+ const optsDecoded = (q.options as any[] | undefined)?.map((o: any) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description })) as any
927
946
  let opts: any
928
- try { opts = normalizeQuizOptions(q.options) } catch (e) { slog("quiz_batch normalize error", (e as Error).message); return `quiz_batch error: ${(e as Error).message} in "${q.question}"` }
947
+ try { opts = normalizeQuizOptions(optsDecoded) } catch (e) { slog("quiz_batch normalize error", (e as Error).message); return `quiz_batch error: ${(e as Error).message} in "${qFixed}"` }
929
948
  if (q.shuffle !== false) opts = shuffleOptions(opts)
930
949
  const { indices, error } = resolveCorrect(q.correctAnswer as any, opts)
931
- if (error) { slog("quiz_batch resolveCorrect error", error); return `quiz_batch error: ${error} in "${q.question}"` }
932
- if (opts.length < 2) return `quiz_batch error: need 2+ options in "${q.question}"`
933
- normalized.push({ question: q.question, details: q.details, options: opts, correctIndices: indices, explanation: q.explanation, multiSelect: !!q.multiSelect })
950
+ if (error) { slog("quiz_batch resolveCorrect error", error); return `quiz_batch error: ${error} in "${qFixed}"` }
951
+ if (opts.length < 2) return `quiz_batch error: need 2+ options in "${qFixed}"`
952
+ normalized.push({ question: qFixed, details: dFixed, options: opts, correctIndices: indices, explanation: eFixed, multiSelect: !!q.multiSelect })
934
953
  }
935
954
  slog("quiz_batch normalized", normalized.length)
936
955
  try { fs.mkdirSync(pendingDirPath, { recursive: true }) } catch {}