@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/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.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",
@@ -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, any Enter/Esc confirms
267
+ // When in feedback, handle scroll first, then confirm
178
268
  if (phase() === "feedback") {
179
- if (key === "enter" || seq === "\r" || key === "escape" || key === "esc") {
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
- <text fg={theme().text} bold wrapMode="wrap">{props.request.question}</text>
346
+ <markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.question)} fg={theme().text} bg={theme().backgroundPanel} />
246
347
  <Show when={props.request.details}>
247
- <text fg={theme().textMuted} wrapMode="wrap">{props.request.details}</text>
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}>{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>
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
- <text fg={theme().text} wrapMode="wrap">{props.request.explanation}</text>
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" ? "↵ Enter / Esc to continue → next probe" : phase() === "classifying" ? "Classifying your note..." : "Navigate options · Tab note · Esc cancel"}
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"){ if(k==="enter"||seq==="\r"||k==="escape"||k==="esc"){ prevent(evt); goNext() } return }
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
- <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>
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}>{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>
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}><text fg={theme().text} wrapMode="wrap">{cur().explanation}</text></box>
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..." : "Navigate options · Tab note · Esc cancel"}
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(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 {}