@bojackduy/opencode-learn 0.1.6 → 1.0.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/dist/server.js +45 -25
- package/dist/tui.js +1001 -478
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +167 -22
- package/plugins/learn.ts +42 -23
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.
|
|
4
|
+
"version": "1.0.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",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -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,6 +97,7 @@ 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
102
|
const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 92))
|
|
48
103
|
const popupHeight = () => Math.max(4, Math.min(24, dims().height - 2))
|
|
@@ -61,12 +116,38 @@ function QuizDialog(props: {
|
|
|
61
116
|
const [feedback, setFeedback] = createSignal<{ correct: boolean; selectedIndices: number[] } | null>(null)
|
|
62
117
|
|
|
63
118
|
let noteInputEl: any
|
|
64
|
-
|
|
119
|
+
let scrollRef: any
|
|
120
|
+
const [canScrollUp, setCanScrollUp] = createSignal(false)
|
|
121
|
+
const [canScrollDown, setCanScrollDown] = createSignal(false)
|
|
122
|
+
const updateScrollIndicators = () => {
|
|
123
|
+
try {
|
|
124
|
+
if (!scrollRef) { setCanScrollUp(false); setCanScrollDown(false); return }
|
|
125
|
+
const st = typeof scrollRef.scrollTop === "number" ? scrollRef.scrollTop : 0
|
|
126
|
+
const h = typeof scrollRef.height === "number" ? scrollRef.height : (scrollRef.viewportHeight ?? popupHeight())
|
|
127
|
+
const sh = typeof scrollRef.scrollHeight === "number" ? scrollRef.scrollHeight : 0
|
|
128
|
+
let effectiveSh = sh
|
|
129
|
+
if (!effectiveSh && typeof scrollRef.getChildren === "function") {
|
|
130
|
+
try { const kids = scrollRef.getChildren(); if (kids?.length) effectiveSh = Math.max(...kids.map((c:any)=> (c.y||0)+(c.height||0)), h) } catch {}
|
|
131
|
+
}
|
|
132
|
+
if (!effectiveSh || effectiveSh <= h + 1) { setCanScrollUp(false); setCanScrollDown(false); return }
|
|
133
|
+
setCanScrollUp(st > 0)
|
|
134
|
+
setCanScrollDown(st + h < effectiveSh - 1)
|
|
135
|
+
} catch { setCanScrollUp(false); setCanScrollDown(false) }
|
|
136
|
+
}
|
|
137
|
+
const scrollAmount = () => Math.max(1, Math.floor((scrollRef?.height ?? popupHeight()) / 3))
|
|
65
138
|
createEffect(() => {
|
|
66
139
|
if (focused() === "note" && noteInputEl) {
|
|
67
140
|
try { noteInputEl.focus() } catch {}
|
|
68
141
|
}
|
|
69
142
|
})
|
|
143
|
+
// Keep indicators in sync on phase/dims/feedback changes
|
|
144
|
+
createEffect(() => { phase(); feedback(); dims(); setTimeout(updateScrollIndicators, 40); setTimeout(updateScrollIndicators, 200) })
|
|
145
|
+
createEffect(() => { note(); setTimeout(updateScrollIndicators, 40) })
|
|
146
|
+
createEffect(() => {
|
|
147
|
+
if (phase() !== "feedback" && phase() !== "select") return
|
|
148
|
+
const id = setInterval(updateScrollIndicators, 200)
|
|
149
|
+
onCleanup(() => clearInterval(id))
|
|
150
|
+
})
|
|
70
151
|
|
|
71
152
|
const toggleOption = (idx: number) => {
|
|
72
153
|
const opt = options()[idx]
|
|
@@ -170,13 +251,28 @@ function QuizDialog(props: {
|
|
|
170
251
|
props.onSubmit({ answers: dontKnow() ? [] : sel, dontKnow: dontKnow(), note: note().trim() || undefined })
|
|
171
252
|
}
|
|
172
253
|
|
|
254
|
+
const isPlainKey = (evt:any, want:string) => {
|
|
255
|
+
try {
|
|
256
|
+
const n = String(evt.name||evt.sequence||"").toLowerCase()
|
|
257
|
+
if (n !== want.toLowerCase()) return false
|
|
258
|
+
if (evt.ctrl || evt.meta || evt.option || evt.alt) return false
|
|
259
|
+
return true
|
|
260
|
+
} catch { return false }
|
|
261
|
+
}
|
|
173
262
|
useKeyboard((evt: any) => {
|
|
174
263
|
const key = evt.name || evt.sequence || evt.raw || ""
|
|
175
264
|
const seq = evt.sequence || ""
|
|
265
|
+
const lower = String(key||"").toLowerCase()
|
|
176
266
|
if ((phase() as any) === "classifying") { prevent(evt); return }
|
|
177
|
-
// When in feedback,
|
|
267
|
+
// When in feedback, handle scroll first, then confirm
|
|
178
268
|
if (phase() === "feedback") {
|
|
179
|
-
if (
|
|
269
|
+
if (isPlainKey(evt,"d") || seq === "\x04") { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators, 30); setTimeout(updateScrollIndicators, 120) } catch {} return }
|
|
270
|
+
if (isPlainKey(evt,"u") || seq === "\x15") { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators, 30); setTimeout(updateScrollIndicators, 120) } catch {} return }
|
|
271
|
+
if (isPlainKey(evt,"j") || seq === "\x1b[B") { prevent(evt); try { scrollRef?.scrollBy(1); setTimeout(updateScrollIndicators, 30) } catch {} return }
|
|
272
|
+
if (isPlainKey(evt,"k") || seq === "\x1b[A") { prevent(evt); try { scrollRef?.scrollBy(-1); setTimeout(updateScrollIndicators, 30) } catch {} return }
|
|
273
|
+
if (key === "pageup" || seq === "\x1b[5~") { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators, 30) } catch {} return }
|
|
274
|
+
if (key === "pagedown" || seq === "\x1b[6~") { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators, 30) } catch {} return }
|
|
275
|
+
if ( lower === "enter" || seq === "\r" || lower === "escape" || lower === "esc") {
|
|
180
276
|
prevent(evt)
|
|
181
277
|
confirmFeedback()
|
|
182
278
|
}
|
|
@@ -190,6 +286,11 @@ function QuizDialog(props: {
|
|
|
190
286
|
// Allow typing to go to input; don't prevent
|
|
191
287
|
return
|
|
192
288
|
}
|
|
289
|
+
// d/u scroll works in both select and feedback — page scroll even before answer
|
|
290
|
+
if (phase() === "select" && (isPlainKey(evt,"d") || seq === "\x04")) { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators,30); setTimeout(updateScrollIndicators,120) } catch {} return }
|
|
291
|
+
if (phase() === "select" && (isPlainKey(evt,"u") || seq === "\x15")) { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators,30); setTimeout(updateScrollIndicators,120) } catch {} return }
|
|
292
|
+
if (phase() === "select" && (lower === "pageup" || seq === "\x1b[5~")) { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators,30) } catch {} return }
|
|
293
|
+
if (phase() === "select" && (lower === "pagedown" || seq === "\x1b[6~")) { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators,30) } catch {} return }
|
|
193
294
|
// Options focused — extra Submit for single note-only at dontKnowIdx+1
|
|
194
295
|
const maxIdx = () => {
|
|
195
296
|
if (isMulti()) return submitIdx()
|
|
@@ -239,12 +340,12 @@ function QuizDialog(props: {
|
|
|
239
340
|
<text fg={theme().background} dim>learn</text>
|
|
240
341
|
</box>
|
|
241
342
|
|
|
242
|
-
<scrollbox flexGrow={1}>
|
|
243
|
-
{/* Question */}
|
|
343
|
+
<scrollbox ref={(el:any)=> scrollRef = el} flexGrow={1} verticalScrollbarOptions={{ visible: true, trackOptions: { backgroundColor: theme().background, foregroundColor: theme().borderActive } }}>
|
|
344
|
+
{/* Question — use opencode markdown render so ```python blocks get syntax coloring like native messages */}
|
|
244
345
|
<box flexDirection="column" gap={1} paddingLeft={1} paddingRight={1} paddingTop={1}>
|
|
245
|
-
<
|
|
346
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.question)} fg={theme().text} bg={theme().backgroundPanel} />
|
|
246
347
|
<Show when={props.request.details}>
|
|
247
|
-
<
|
|
348
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.details)} fg={theme().textMuted} bg={theme().backgroundPanel} />
|
|
248
349
|
</Show>
|
|
249
350
|
</box>
|
|
250
351
|
|
|
@@ -293,9 +394,8 @@ function QuizDialog(props: {
|
|
|
293
394
|
</box>
|
|
294
395
|
|
|
295
396
|
<box flexDirection="row" justifyContent="space-between" paddingTop={1}>
|
|
296
|
-
<text fg={theme().textMuted}>{
|
|
297
|
-
<
|
|
298
|
-
<Show when={!isMulti() && note().trim() && !selected().size && !dontKnow()}><text fg={theme().warning}>note → classify on Enter</text></Show>
|
|
397
|
+
<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>
|
|
398
|
+
<text fg={theme().textMuted}>{focused() === "note" ? "Tab/Esc back" : ""}</text>
|
|
299
399
|
</box>
|
|
300
400
|
<Show when={isMulti()}>
|
|
301
401
|
<box justifyContent="center" paddingTop={1}>
|
|
@@ -351,14 +451,22 @@ function QuizDialog(props: {
|
|
|
351
451
|
<text fg={theme().textMuted}>Correct: {props.request.correctIndices.map(i => `${i}. ${options()[i-1]?.label}`).join(", ")}</text>
|
|
352
452
|
<Show when={note()}><text fg={theme().textMuted}>Your note: {note()}</text></Show>
|
|
353
453
|
<box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}>
|
|
354
|
-
<
|
|
454
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.explanation)} fg={theme().text} bg={theme().backgroundPanel} />
|
|
355
455
|
</box>
|
|
356
456
|
</box>
|
|
357
457
|
</Show>
|
|
358
458
|
</scrollbox>
|
|
359
459
|
<box height={1} justifyContent="center">
|
|
360
|
-
<text fg={theme().textMuted}>
|
|
361
|
-
{phase() === "feedback"
|
|
460
|
+
<text fg={theme().textMuted} wrapMode="wrap">
|
|
461
|
+
{phase() === "feedback"
|
|
462
|
+
? (canScrollUp() && canScrollDown() ? "▲ more above · ▼ more below — d/u to scroll · Enter to continue"
|
|
463
|
+
: canScrollDown() ? "▼ more below — d to scroll · Enter to continue"
|
|
464
|
+
: canScrollUp() ? "▲ more above — u to scroll · Enter to continue"
|
|
465
|
+
: "↵ Enter / Esc to continue → next probe")
|
|
466
|
+
: phase() === "classifying" ? "Classifying your note..."
|
|
467
|
+
: focused() === "note" ? "Enter submit note → classify · Tab/Esc back"
|
|
468
|
+
: (canScrollUp() || canScrollDown()) ? "j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel · d/u scroll"
|
|
469
|
+
: "j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel"}
|
|
362
470
|
</text>
|
|
363
471
|
</box>
|
|
364
472
|
</box>
|
|
@@ -373,6 +481,7 @@ function QuizBatchDialog(props: {
|
|
|
373
481
|
onCancel: () => void
|
|
374
482
|
}) {
|
|
375
483
|
const theme = () => props.api.theme.current
|
|
484
|
+
const syntax = () => syntaxStyle(theme())
|
|
376
485
|
const dims = useTerminalDimensions()
|
|
377
486
|
const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 96))
|
|
378
487
|
const popupHeight = () => Math.max(4, Math.min(24, dims().height - 2))
|
|
@@ -396,7 +505,33 @@ function QuizBatchDialog(props: {
|
|
|
396
505
|
const dontKnowIdx = () => cur().options.length
|
|
397
506
|
const submitIdx = () => isMulti() ? cur().options.length + 1 : -1
|
|
398
507
|
let noteEl: any
|
|
508
|
+
let scrollRefBatch: any
|
|
509
|
+
const [canScrollUpBatch, setCanScrollUpBatch] = createSignal(false)
|
|
510
|
+
const [canScrollDownBatch, setCanScrollDownBatch] = createSignal(false)
|
|
511
|
+
const updateScrollBatch = () => {
|
|
512
|
+
try {
|
|
513
|
+
if (!scrollRefBatch) { setCanScrollUpBatch(false); setCanScrollDownBatch(false); return }
|
|
514
|
+
const st = typeof scrollRefBatch.scrollTop === "number" ? scrollRefBatch.scrollTop : 0
|
|
515
|
+
const h = typeof scrollRefBatch.height === "number" ? scrollRefBatch.height : (scrollRefBatch.viewportHeight ?? popupHeight())
|
|
516
|
+
const sh = typeof scrollRefBatch.scrollHeight === "number" ? scrollRefBatch.scrollHeight : 0
|
|
517
|
+
let effectiveSh = sh
|
|
518
|
+
if (!effectiveSh && typeof scrollRefBatch.getChildren === "function") {
|
|
519
|
+
try { const kids = scrollRefBatch.getChildren(); if (kids?.length) effectiveSh = Math.max(...kids.map((c:any)=> (c.y||0)+(c.height||0)), h) } catch {}
|
|
520
|
+
}
|
|
521
|
+
if (!effectiveSh || effectiveSh <= h + 1) { setCanScrollUpBatch(false); setCanScrollDownBatch(false); return }
|
|
522
|
+
setCanScrollUpBatch(st > 0)
|
|
523
|
+
setCanScrollDownBatch(st + h < effectiveSh - 1)
|
|
524
|
+
} catch { setCanScrollUpBatch(false); setCanScrollDownBatch(false) }
|
|
525
|
+
}
|
|
526
|
+
const scrollAmountBatch = () => Math.max(1, Math.floor((scrollRefBatch?.height ?? popupHeight()) / 3))
|
|
399
527
|
createEffect(() => { if (focused()==="note" && noteEl) try{noteEl.focus()}catch(e){ tlog("note focus failed", String(e)) } })
|
|
528
|
+
createEffect(() => { phase(); feedback(); dims(); idx(); setTimeout(updateScrollBatch, 40); setTimeout(updateScrollBatch, 200) })
|
|
529
|
+
createEffect(() => { note(); setTimeout(updateScrollBatch, 40) })
|
|
530
|
+
createEffect(() => {
|
|
531
|
+
if (phase() !== "feedback" && phase() !== "select") return
|
|
532
|
+
const id = setInterval(updateScrollBatch, 200)
|
|
533
|
+
onCleanup(() => clearInterval(id))
|
|
534
|
+
})
|
|
400
535
|
const toggle = (i:number) => {
|
|
401
536
|
try {
|
|
402
537
|
const o = cur().options[i]; if(!o) return
|
|
@@ -490,12 +625,22 @@ function QuizBatchDialog(props: {
|
|
|
490
625
|
setPhase("feedback")
|
|
491
626
|
} catch(e){ tlog("submitSelect failed", String(e)) }
|
|
492
627
|
}
|
|
628
|
+
const isPlainKeyBatch = (evt:any, want:string) => {
|
|
629
|
+
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 }
|
|
630
|
+
}
|
|
493
631
|
useKeyboard((evt:any)=>{
|
|
494
632
|
try {
|
|
495
|
-
const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""
|
|
633
|
+
const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""; const lower=String(k||"").toLowerCase()
|
|
496
634
|
if((phase() as any)==="classifying"){ prevent(evt); return }
|
|
497
|
-
if(phase()==="feedback"){
|
|
635
|
+
if(phase()==="feedback"){
|
|
636
|
+
if (isPlainKeyBatch(evt,"d")||seq==="\x04"){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
637
|
+
if (isPlainKeyBatch(evt,"u")||seq==="\x15"){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
638
|
+
if (lower==="pageup"||seq==="\x1b[5~"){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
|
|
639
|
+
if (lower==="pagedown"||seq==="\x1b[6~"){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
|
|
640
|
+
if(lower==="enter"||seq==="\r"||lower==="escape"||lower==="esc"){ prevent(evt); goNext() } return }
|
|
498
641
|
if(focused()==="note"){ if(k==="tab"||seq==="\t"){prevent(evt); setFocused("options"); return} if(k==="escape"){prevent(evt); setFocused("options"); return} return }
|
|
642
|
+
if(phase()==="select" && (isPlainKeyBatch(evt,"d")||seq==="\x04")){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
643
|
+
if(phase()==="select" && (isPlainKeyBatch(evt,"u")||seq==="\x15")){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
499
644
|
if(k==="up"||k==="k"||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
|
|
500
645
|
if(k==="down"||k==="j"||seq==="\x1b[B"){prevent(evt); setOptionIndex(i=>Math.min(isMulti()?submitIdx():dontKnowIdx(),i+1)); return}
|
|
501
646
|
if(k==="tab"||seq==="\t"){prevent(evt); setFocused("note"); return}
|
|
@@ -511,16 +656,16 @@ function QuizBatchDialog(props: {
|
|
|
511
656
|
<text fg={theme().background} bold> decks.quiz batch {idx()+1}/{props.request.quizzes.length} {phase()==="feedback"?(feedback()?.correct?"✓":"✗"):""}</text>
|
|
512
657
|
<text fg={theme().background} dim>learn</text>
|
|
513
658
|
</box>
|
|
514
|
-
<scrollbox flexGrow={1}>
|
|
515
|
-
<
|
|
516
|
-
<Show when={cur().details}><
|
|
659
|
+
<scrollbox ref={(el:any)=> scrollRefBatch = el} flexGrow={1} verticalScrollbarOptions={{ visible: true, trackOptions: { backgroundColor: theme().background, foregroundColor: theme().borderActive } }}>
|
|
660
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(cur().question)} fg={theme().text} bg={theme().backgroundPanel} />
|
|
661
|
+
<Show when={cur().details}><markdown syntaxStyle={syntax()} content={decodeQuizText(cur().details)} fg={theme().textMuted} bg={theme().backgroundPanel} /></Show>
|
|
517
662
|
<Show when={phase()==="select"}>
|
|
518
663
|
<box flexDirection="column" gap={0} padding={1} border={true} borderColor={theme().borderSubtle} backgroundColor={theme().background}>
|
|
519
664
|
<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
665
|
<box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20,popupWidth()-8))}</text></box>
|
|
521
666
|
<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
667
|
<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}>{
|
|
668
|
+
<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
669
|
<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
670
|
</box>
|
|
526
671
|
</Show>
|
|
@@ -536,13 +681,13 @@ function QuizBatchDialog(props: {
|
|
|
536
681
|
<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
682
|
<text fg={feedback()?.correct?theme().success:theme().error} bold>{feedback()?.correct?"✓ Correct":"✗ Incorrect"}</text>
|
|
538
683
|
<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}><
|
|
684
|
+
<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
685
|
</box>
|
|
541
686
|
</Show>
|
|
542
687
|
</scrollbox>
|
|
543
688
|
<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..." : "
|
|
689
|
+
<text fg={theme().textMuted} wrapMode="wrap">
|
|
690
|
+
{phase()==="feedback" ? (canScrollUpBatch() && canScrollDownBatch() ? `▲ more above · ▼ more below — d/u to scroll · Enter → next (${idx()+1}/${props.request.quizzes.length})` : canScrollDownBatch() ? `▼ more below — d to scroll · Enter → next (${idx()+1}/${props.request.quizzes.length})` : canScrollUpBatch() ? `▲ more above — u to scroll · Enter → next (${idx()+1}/${props.request.quizzes.length})` : `Enter → next (${idx()+1}/${props.request.quizzes.length})`) : phase()==="classifying" ? "Classifying your note..." : focused()==="note" ? "Enter submit note → classify · Tab/Esc back" : (canScrollUpBatch() || canScrollDownBatch()) ? "j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel · d/u scroll" : "j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel"}
|
|
546
691
|
</text>
|
|
547
692
|
</box>
|
|
548
693
|
</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(
|
|
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:
|
|
812
|
-
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:
|
|
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: ${
|
|
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:
|
|
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] "${
|
|
843
|
-
: `[quiz answered] "${
|
|
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",
|
|
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: ${
|
|
867
|
-
if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [
|
|
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: ${
|
|
877
|
-
;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation:
|
|
878
|
-
if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${
|
|
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: ${
|
|
884
|
-
|
|
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): ${
|
|
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: "${
|
|
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: ${
|
|
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(
|
|
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 "${
|
|
932
|
-
if (opts.length < 2) return `quiz_batch error: need 2+ options in "${
|
|
933
|
-
normalized.push({ question:
|
|
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 {}
|