@bojackduy/opencode-learn 0.1.5 → 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.5",
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
@@ -22,6 +76,7 @@ type QuizPending = {
22
76
  correctIndices: number[]
23
77
  explanation: string
24
78
  multiSelect?: boolean
79
+ sessionID?: string
25
80
  timestamp: number
26
81
  }
27
82
  type QuizBatchPending = {
@@ -42,8 +97,10 @@ function QuizDialog(props: {
42
97
  onCancel: () => void
43
98
  }) {
44
99
  const theme = () => props.api.theme.current
100
+ const syntax = () => syntaxStyle(theme())
45
101
  const dims = useTerminalDimensions()
46
102
  const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 92))
103
+ const popupHeight = () => Math.max(4, Math.min(24, dims().height - 2))
47
104
  const options = () => props.request.options
48
105
  const correctSet = new Set(props.request.correctIndices)
49
106
  const isMulti = () => !!props.request.multiSelect
@@ -52,19 +109,45 @@ function QuizDialog(props: {
52
109
 
53
110
  const [focused, setFocused] = createSignal<"options" | "note">("options")
54
111
  const [optionIndex, setOptionIndex] = createSignal(0)
55
- const [phase, setPhase] = createSignal<"select" | "feedback">("select")
112
+ const [phase, setPhase] = createSignal<"select" | "feedback" | "classifying">("select")
56
113
  const [note, setNote] = createSignal("")
57
114
  const [dontKnow, setDontKnow] = createSignal(false)
58
115
  const [selected, setSelected] = createSignal<Map<string, { label: string; value: string; index: number }>>(new Map())
59
116
  const [feedback, setFeedback] = createSignal<{ correct: boolean; selectedIndices: number[] } | null>(null)
60
117
 
61
118
  let noteInputEl: any
62
-
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))
63
138
  createEffect(() => {
64
139
  if (focused() === "note" && noteInputEl) {
65
140
  try { noteInputEl.focus() } catch {}
66
141
  }
67
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
+ })
68
151
 
69
152
  const toggleOption = (idx: number) => {
70
153
  const opt = options()[idx]
@@ -86,8 +169,71 @@ function QuizDialog(props: {
86
169
  }
87
170
  const submitSelect = () => {
88
171
  const selMap = selected()
89
- if (!isMulti() && selMap.size === 0 && !dontKnow()) return
90
- if (isMulti() && selMap.size === 0 && !dontKnow()) return
172
+ if (!isMulti() && selMap.size === 0 && !dontKnow() && !note().trim()) return
173
+ if (isMulti() && selMap.size === 0 && !dontKnow() && !note().trim()) return
174
+ // If 0 selected but note present, trigger AI classify (async popup) — keep popup, show classifying
175
+ if (selMap.size === 0 && !dontKnow() && note().trim()) {
176
+ setPhase("classifying" as any)
177
+ try {
178
+ const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
179
+ const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
180
+ const pendingClassify = { id: props.request.id, type: "classify" as const, note: note().trim(), question: props.request.question, options: options().map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
181
+ fs.writeFileSync(path.join(pDir, `classify-${props.request.id}.json`), JSON.stringify(pendingClassify), "utf8")
182
+ tlog("QuizDialog classify request", props.request.id, note().trim().slice(0, 50))
183
+ // Poll for classify-response
184
+ const respPath = path.join(pDir, `classify-response-${props.request.id}.json`)
185
+ let attempts = 0
186
+ const poll = setInterval(() => {
187
+ attempts++
188
+ if (attempts > 60) { clearInterval(poll); setPhase("feedback"); setFeedback({ correct: false, selectedIndices: [] }); return }
189
+ try {
190
+ if (fs.existsSync(respPath)) {
191
+ clearInterval(poll)
192
+ const raw = fs.readFileSync(respPath, "utf8")
193
+ const data: any = JSON.parse(raw)
194
+ try { fs.unlinkSync(respPath); fs.unlinkSync(path.join(pDir, `classify-${props.request.id}.json`)) } catch {}
195
+ const inferred = data?.inferredIndices as number[] | undefined
196
+ const inferredValues = data?.inferredValues as string[] | undefined
197
+ const semanticCorrect = data?.semanticCorrect as boolean | undefined
198
+ const reason = data?.reason as string | undefined
199
+ const computeCorrect = (idxs: number[]) => {
200
+ if (typeof semanticCorrect === "boolean") return semanticCorrect
201
+ return idxs.length === props.request.correctIndices.length && idxs.every((v: number) => correctSet.has(v)) && props.request.correctIndices.every((v: number) => idxs.includes(v))
202
+ }
203
+ if (inferred && inferred.length) {
204
+ const m = new Map<string, { label: string; value: string; index: number }>()
205
+ for (let i = 0; i < inferred.length; i++) {
206
+ const idx = inferred[i]
207
+ const opt = options()[idx - 1]
208
+ if (opt) m.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx })
209
+ }
210
+ setSelected(m)
211
+ const correct = computeCorrect(inferred)
212
+ setFeedback({ correct, selectedIndices: inferred })
213
+ if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
214
+ tlog("QuizDialog classify done", inferred.join(","), correct, reason || "")
215
+ } else if (inferredValues && inferredValues.length) {
216
+ const byVal = new Map(options().map((o, i) => [o.value, i + 1]))
217
+ const idxs = inferredValues.map(v => byVal.get(v)).filter(Boolean) as number[]
218
+ const m = new Map<string, { label: string; value: string; index: number }>()
219
+ for (const idx of idxs) { const opt = options()[idx - 1]; if (opt) m.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx }) }
220
+ setSelected(m)
221
+ const correct = computeCorrect(idxs)
222
+ setFeedback({ correct, selectedIndices: idxs })
223
+ } else {
224
+ const correct = typeof semanticCorrect === "boolean" ? semanticCorrect : false
225
+ setFeedback({ correct, selectedIndices: [] })
226
+ if (reason) setNote(prev => prev ? `${prev} — ${reason}` : reason)
227
+ }
228
+ setPhase("feedback")
229
+ }
230
+ } catch {}
231
+ }, 500)
232
+ // Cleanup on dispose
233
+ onCleanup(() => clearInterval(poll))
234
+ } catch (e) { tlog("classify request failed", String(e)); setPhase("feedback"); setFeedback({ correct: false, selectedIndices: [] }) }
235
+ return
236
+ }
91
237
  if (dontKnow()) {
92
238
  setFeedback({ correct: false, selectedIndices: [] })
93
239
  setPhase("feedback")
@@ -105,12 +251,28 @@ function QuizDialog(props: {
105
251
  props.onSubmit({ answers: dontKnow() ? [] : sel, dontKnow: dontKnow(), note: note().trim() || undefined })
106
252
  }
107
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
+ }
108
262
  useKeyboard((evt: any) => {
109
263
  const key = evt.name || evt.sequence || evt.raw || ""
110
264
  const seq = evt.sequence || ""
111
- // When in feedback, any Enter/Esc confirms
265
+ const lower = String(key||"").toLowerCase()
266
+ if ((phase() as any) === "classifying") { prevent(evt); return }
267
+ // When in feedback, handle scroll first, then confirm
112
268
  if (phase() === "feedback") {
113
- 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") {
114
276
  prevent(evt)
115
277
  confirmFeedback()
116
278
  }
@@ -124,15 +286,26 @@ function QuizDialog(props: {
124
286
  // Allow typing to go to input; don't prevent
125
287
  return
126
288
  }
127
- // Options focused
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 }
294
+ // Options focused — extra Submit for single note-only at dontKnowIdx+1
295
+ const maxIdx = () => {
296
+ if (isMulti()) return submitIdx()
297
+ if (note().trim() && !selected().size && !dontKnow()) return dontKnowIdx() + 1
298
+ return dontKnowIdx()
299
+ }
128
300
  if (key === "up" || key === "k" || seq === "\x1b[A") { prevent(evt); setOptionIndex(i => Math.max(0, i - 1)); return }
129
- if (key === "down" || key === "j" || seq === "\x1b[B") { prevent(evt); setOptionIndex(i => Math.min(isMulti() ? submitIdx() : dontKnowIdx(), i + 1)); return }
301
+ if (key === "down" || key === "j" || seq === "\x1b[B") { prevent(evt); setOptionIndex(i => Math.min(maxIdx(), i + 1)); return }
130
302
  if (key === "tab" || seq === "\t") { prevent(evt); setFocused("note"); return }
131
303
  if (key === "escape" || key === "esc") { prevent(evt); props.onCancel(); return }
132
304
  if (key === "space" || seq === " ") {
133
305
  prevent(evt)
134
306
  const idx = optionIndex()
135
307
  if (idx === dontKnowIdx()) handleDontKnow()
308
+ else if (!isMulti() && idx === dontKnowIdx() + 1 && note().trim() && !selected().size && !dontKnow()) submitSelect()
136
309
  else {
137
310
  if (isMulti()) toggleOption(idx)
138
311
  else {
@@ -146,6 +319,7 @@ function QuizDialog(props: {
146
319
  prevent(evt)
147
320
  const idx = optionIndex()
148
321
  if (idx === dontKnowIdx()) handleDontKnow()
322
+ else if (!isMulti() && idx === dontKnowIdx() + 1 && note().trim() && !selected().size && !dontKnow()) submitSelect()
149
323
  else if (isMulti()) submitSelect()
150
324
  else {
151
325
  const opt = options()[idx]
@@ -159,18 +333,19 @@ function QuizDialog(props: {
159
333
  })
160
334
 
161
335
  return (
162
- <box flexDirection="column" width={popupWidth()} border={true} borderColor={phase() === "feedback" ? (feedback()?.correct ? theme().success : theme().error) : theme().accent} backgroundColor={theme().backgroundPanel} padding={1} gap={1}>
336
+ <box flexDirection="column" width={popupWidth()} height={popupHeight()} border={true} borderColor={phase() === "feedback" ? (feedback()?.correct ? theme().success : theme().error) : theme().accent} backgroundColor={theme().backgroundPanel} padding={1} gap={1}>
163
337
  {/* Header */}
164
338
  <box flexDirection="row" justifyContent="space-between" alignItems="center" backgroundColor={phase() === "feedback" ? (feedback()?.correct ? theme().success : theme().error) : theme().accent} paddingLeft={1} paddingRight={1} height={1}>
165
339
  <text fg={theme().background} bold>{phase() === "feedback" ? (feedback()?.correct ? "✓ CORRECT" : dontKnow() ? "○ I DON'T KNOW" : "✗ INCORRECT") : isMulti() ? "☑ QUIZ · MULTI-SELECT" : "● QUIZ · SINGLE" }</text>
166
340
  <text fg={theme().background} dim>learn</text>
167
341
  </box>
168
342
 
169
- {/* 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 */}
170
345
  <box flexDirection="column" gap={1} paddingLeft={1} paddingRight={1} paddingTop={1}>
171
- <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} />
172
347
  <Show when={props.request.details}>
173
- <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} />
174
349
  </Show>
175
350
  </box>
176
351
 
@@ -208,25 +383,43 @@ function QuizDialog(props: {
208
383
  ref={(el: any) => noteInputEl = el}
209
384
  value={note()}
210
385
  onInput={(value: any) => setNote(typeof value === "string" ? value : value?.target?.value ?? value?.value ?? String(value ?? ""))}
211
- onSubmit={() => setFocused("options")}
212
- placeholder="what was on your mind?"
386
+ onSubmit={() => {
387
+ if (!selected().size && !dontKnow() && note().trim()) submitSelect()
388
+ else setFocused("options")
389
+ }}
390
+ placeholder="what was on your mind? (Enter to submit note → classify)"
213
391
  />
214
392
  </Show>
215
393
  </box>
216
394
  </box>
217
395
 
218
396
  <box flexDirection="row" justifyContent="space-between" paddingTop={1}>
219
- <text fg={theme().textMuted}>↑↓/j k · Space toggle · ↓ to Submit Enter · Tab note · Esc cancel</text>
220
- <Show when={isMulti()}><text fg={selected().size > 0 || dontKnow() ? theme().success : theme().warning}>{selected().size} selected {dontKnow() ? "· I don't know" : ""}</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>
221
399
  </box>
222
400
  <Show when={isMulti()}>
223
401
  <box justifyContent="center" paddingTop={1}>
224
- <box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() ? theme().success : theme().borderSubtle)} backgroundColor={focused() === "options" && optionIndex() === submitIdx() ? theme().backgroundElement : (selected().size > 0 || dontKnow() ? theme().success : theme().background)} paddingLeft={2} paddingRight={2}>
225
- <text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() ? theme().background : theme().textMuted)}>{focused() === "options" && optionIndex() === submitIdx() ? "▸" : " "}</text>
226
- <text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() ? theme().background : theme().textMuted)} bold>↳ Submit</text>
402
+ <box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().success : theme().borderSubtle)} backgroundColor={focused() === "options" && optionIndex() === submitIdx() ? theme().backgroundElement : (selected().size > 0 || dontKnow() || note().trim() ? theme().success : theme().background)} paddingLeft={2} paddingRight={2}>
403
+ <text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().background : theme().textMuted)}>{focused() === "options" && optionIndex() === submitIdx() ? "▸" : " "}</text>
404
+ <text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().background : theme().textMuted)} bold>↳ Submit{note().trim() && !selected().size && !dontKnow() ? " note" : ""}</text>
227
405
  </box>
228
406
  </box>
229
407
  </Show>
408
+ <Show when={!isMulti() && note().trim() && !selected().size && !dontKnow()}>
409
+ <box justifyContent="center" paddingTop={1}>
410
+ <box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().accent : theme().success} backgroundColor={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().backgroundElement : theme().success} paddingLeft={2} paddingRight={2}>
411
+ <text fg={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().accent : theme().background} bold>↳ Submit note → classify</text>
412
+ </box>
413
+ </box>
414
+ </Show>
415
+ </box>
416
+ </Show>
417
+
418
+ <Show when={(phase() as any) === "classifying"}>
419
+ <box flexDirection="column" gap={1} padding={1} border={true} borderColor={theme().accent} backgroundColor={theme().background} alignItems="center">
420
+ <text fg={theme().accent} bold>◐ Classifying your note...</text>
421
+ <text fg={theme().textMuted} wrapMode="wrap">"{note()}"</text>
422
+ <text fg={theme().textMuted}>Mapping to options — please wait</text>
230
423
  </box>
231
424
  </Show>
232
425
 
@@ -258,11 +451,24 @@ function QuizDialog(props: {
258
451
  <text fg={theme().textMuted}>Correct: {props.request.correctIndices.map(i => `${i}. ${options()[i-1]?.label}`).join(", ")}</text>
259
452
  <Show when={note()}><text fg={theme().textMuted}>Your note: {note()}</text></Show>
260
453
  <box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}>
261
- <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} />
262
455
  </box>
263
- <box justifyContent="center" paddingTop={1}><text fg={theme().textMuted}>↵ Enter / Esc to continue → next probe</text></box>
264
456
  </box>
265
457
  </Show>
458
+ </scrollbox>
459
+ <box height={1} justifyContent="center">
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"}
470
+ </text>
471
+ </box>
266
472
  </box>
267
473
  )
268
474
  }
@@ -275,8 +481,10 @@ function QuizBatchDialog(props: {
275
481
  onCancel: () => void
276
482
  }) {
277
483
  const theme = () => props.api.theme.current
484
+ const syntax = () => syntaxStyle(theme())
278
485
  const dims = useTerminalDimensions()
279
486
  const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 96))
487
+ const popupHeight = () => Math.max(4, Math.min(24, dims().height - 2))
280
488
  const [idx, setIdx] = createSignal(0)
281
489
  // Guard: if no quizzes, cancel
282
490
  if (!props.request.quizzes || props.request.quizzes.length === 0) {
@@ -285,7 +493,7 @@ function QuizBatchDialog(props: {
285
493
  return null as any
286
494
  }
287
495
  const cur = () => props.request.quizzes[idx()] ?? props.request.quizzes[0]
288
- const [phase, setPhase] = createSignal<"select" | "feedback">("select")
496
+ const [phase, setPhase] = createSignal<"select" | "feedback" | "classifying">("select")
289
497
  const [feedback, setFeedback] = createSignal<{ correct: boolean; selectedIndices: number[] } | null>(null)
290
498
  const [dontKnow, setDontKnow] = createSignal(false)
291
499
  const [selected, setSelected] = createSignal<Map<string, any>>(new Map())
@@ -297,7 +505,33 @@ function QuizBatchDialog(props: {
297
505
  const dontKnowIdx = () => cur().options.length
298
506
  const submitIdx = () => isMulti() ? cur().options.length + 1 : -1
299
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))
300
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
+ })
301
535
  const toggle = (i:number) => {
302
536
  try {
303
537
  const o = cur().options[i]; if(!o) return
@@ -329,8 +563,58 @@ function QuizBatchDialog(props: {
329
563
  const submitSelect = () => {
330
564
  try {
331
565
  const m = selected()
332
- if (!isMulti() && m.size===0 && !dontKnow()) return
333
- if (isMulti() && m.size===0 && !dontKnow()) return
566
+ if (!isMulti() && m.size===0 && !dontKnow() && !note().trim()) return
567
+ if (isMulti() && m.size===0 && !dontKnow() && !note().trim()) return
568
+ // 0 selected + note -> AI classify, keep popup
569
+ if (m.size===0 && !dontKnow() && note().trim()) {
570
+ setPhase("classifying" as any)
571
+ try {
572
+ const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
573
+ const cid = `${props.request.id}-${idx()}`
574
+ const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
575
+ const pendingClassify = { id: cid, type: "classify" as const, note: note().trim(), question: cur().question, options: cur().options.map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
576
+ fs.writeFileSync(path.join(pDir, `classify-${cid}.json`), JSON.stringify(pendingClassify), "utf8")
577
+ tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50))
578
+ const respPath = path.join(pDir, `classify-response-${cid}.json`)
579
+ let attempts = 0
580
+ const poll = setInterval(() => {
581
+ attempts++
582
+ if (attempts > 60) { clearInterval(poll); setFeedback({ correct: false, selectedIndices: [] }); setPhase("feedback"); return }
583
+ try {
584
+ if (fs.existsSync(respPath)) {
585
+ clearInterval(poll)
586
+ const raw = fs.readFileSync(respPath, "utf8")
587
+ const data: any = JSON.parse(raw)
588
+ try { fs.unlinkSync(respPath); fs.unlinkSync(path.join(pDir, `classify-${cid}.json`)) } catch {}
589
+ const inferred = data?.inferredIndices as number[] | undefined
590
+ const semanticCorrect = data?.semanticCorrect as boolean | undefined
591
+ const reason = data?.reason as string | undefined
592
+ const computeOk2 = (idxs: number[]) => {
593
+ if (typeof semanticCorrect === "boolean") return semanticCorrect
594
+ const correctSet2 = new Set(cur().correctIndices)
595
+ return idxs.length === cur().correctIndices.length && idxs.every(v => correctSet2.has(v)) && cur().correctIndices.every(v => idxs.includes(v))
596
+ }
597
+ if (inferred && inferred.length) {
598
+ const mm = new Map<string, any>()
599
+ for (const idx of inferred) { const opt = cur().options[idx - 1]; if (opt) mm.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx }) }
600
+ setSelected(mm)
601
+ const ok2 = computeOk2(inferred)
602
+ setFeedback({ correct: ok2, selectedIndices: inferred })
603
+ if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
604
+ tlog("QuizBatchDialog classify done", inferred.join(","), ok2, reason || "")
605
+ } else {
606
+ const ok2 = typeof semanticCorrect === "boolean" ? semanticCorrect : false
607
+ setFeedback({ correct: ok2, selectedIndices: [] })
608
+ if (reason) setNote(prev => prev ? `${prev} — ${reason}` : reason)
609
+ }
610
+ setPhase("feedback")
611
+ }
612
+ } catch {}
613
+ }, 500)
614
+ onCleanup(() => clearInterval(poll))
615
+ } catch (e) { tlog("classify batch failed", String(e)); setFeedback({ correct: false, selectedIndices: [] }); setPhase("feedback") }
616
+ return
617
+ }
334
618
  const sel = Array.from(m.values())
335
619
  const dk = dontKnow()
336
620
  const correctSet = new Set(cur().correctIndices)
@@ -341,11 +625,22 @@ function QuizBatchDialog(props: {
341
625
  setPhase("feedback")
342
626
  } catch(e){ tlog("submitSelect failed", String(e)) }
343
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
+ }
344
631
  useKeyboard((evt:any)=>{
345
632
  try {
346
- const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""
347
- if(phase()==="feedback"){ if(k==="enter"||seq==="\r"||k==="escape"||k==="esc"){ prevent(evt); goNext() } return }
633
+ const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""; const lower=String(k||"").toLowerCase()
634
+ if((phase() as any)==="classifying"){ prevent(evt); 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 }
348
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 }
349
644
  if(k==="up"||k==="k"||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
350
645
  if(k==="down"||k==="j"||seq==="\x1b[B"){prevent(evt); setOptionIndex(i=>Math.min(isMulti()?submitIdx():dontKnowIdx(),i+1)); return}
351
646
  if(k==="tab"||seq==="\t"){prevent(evt); setFocused("note"); return}
@@ -356,32 +651,45 @@ function QuizBatchDialog(props: {
356
651
  } catch(e){ tlog("useKeyboard batch failed", String(e)) }
357
652
  })
358
653
  return (
359
- <box flexDirection="column" width={popupWidth()} border={true} borderColor={phase()==="feedback"?(feedback()?.correct?theme().success:theme().error):theme().accent} backgroundColor={theme().backgroundPanel} padding={1} gap={1}>
654
+ <box flexDirection="column" width={popupWidth()} height={popupHeight()} border={true} borderColor={phase()==="feedback"?(feedback()?.correct?theme().success:theme().error):theme().accent} backgroundColor={theme().backgroundPanel} padding={1} gap={1}>
360
655
  <box flexDirection="row" justifyContent="space-between" backgroundColor={theme().accent} paddingLeft={1} paddingRight={1} height={1}>
361
656
  <text fg={theme().background} bold> decks.quiz batch {idx()+1}/{props.request.quizzes.length} {phase()==="feedback"?(feedback()?.correct?"✓":"✗"):""}</text>
362
657
  <text fg={theme().background} dim>learn</text>
363
658
  </box>
364
- <text fg={theme().text} bold wrapMode="wrap">{cur().question}</text>
365
- <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>
366
662
  <Show when={phase()==="select"}>
367
663
  <box flexDirection="column" gap={0} padding={1} border={true} borderColor={theme().borderSubtle} backgroundColor={theme().background}>
368
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>
369
665
  <box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20,popupWidth()-8))}</text></box>
370
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>
371
- <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"}</text>}><input ref={(el:any)=>noteEl=el} value={note()} onInput={(v:any)=>setNote(typeof v==="string"?v:v?.target?.value??"")} onSubmit={()=>setFocused("options")} placeholder="note" /></Show></box></box>
372
- <box flexDirection="row" justifyContent="space-between" paddingTop={1}><text fg={theme().textMuted}>Space toggle · to Submit Enter · Tab note · Ctrl+Enter submit</text><text fg={theme().textMuted}>{idx()+1}/{props.request.quizzes.length}</text></box>
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>
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>
373
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>
374
670
  </box>
375
671
  </Show>
672
+ <Show when={(phase() as any)==="classifying"}>
673
+ <box flexDirection="column" gap={1} padding={1} border={true} borderColor={theme().accent} backgroundColor={theme().background} alignItems="center">
674
+ <text fg={theme().accent} bold>◐ Classifying your note...</text>
675
+ <text fg={theme().textMuted} wrapMode="wrap">"{note()}"</text>
676
+ <text fg={theme().textMuted}>Mapping to options — please wait</text>
677
+ </box>
678
+ </Show>
376
679
  <Show when={phase()==="feedback"}>
377
680
  <box flexDirection="column" gap={1} padding={1} border={true} borderColor={feedback()?.correct?theme().success:theme().error} backgroundColor={theme().background}>
378
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>
379
682
  <text fg={feedback()?.correct?theme().success:theme().error} bold>{feedback()?.correct?"✓ Correct":"✗ Incorrect"}</text>
380
683
  <text fg={theme().textMuted}>Correct: {cur().correctIndices.map((i:number)=>`${i}. ${cur().options[i-1]?.label}`).join(", ")}</text>
381
- <box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}><text fg={theme().text} wrapMode="wrap">{cur().explanation}</text></box>
382
- <box justifyContent="center"><text fg={theme().textMuted}>Enter → next ({idx()+1}/{props.request.quizzes.length})</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>
383
685
  </box>
384
686
  </Show>
687
+ </scrollbox>
688
+ <box height={1} justifyContent="center">
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"}
691
+ </text>
692
+ </box>
385
693
  </box>
386
694
  )
387
695
  }
@@ -389,6 +697,7 @@ function QuizBatchDialog(props: {
389
697
  export const tui: TuiPlugin = async (api) => {
390
698
  const dir = api.state.path.directory || api.state.path.worktree || process.cwd()
391
699
  const pendingDir = path.join(dir, PENDING_DIR)
700
+ ;(globalThis as any).__learnPendingDir = pendingDir
392
701
  ensureDir(pendingDir)
393
702
  const heartbeatPath = path.join(pendingDir, ".tui-alive")
394
703
  try { fs.writeFileSync(heartbeatPath, String(Date.now()), "utf8") } catch {}
@@ -415,7 +724,7 @@ export const tui: TuiPlugin = async (api) => {
415
724
  if (current) return
416
725
  if (api.ui.dialog.open) return
417
726
  let files: string[] = []
418
- try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".")).sort() } catch { return }
727
+ try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
419
728
  // Session-distinct: only show pending for current session
420
729
  const matching = files.map(f => { try { const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any; return { f, j } } catch { return null } }).filter(Boolean) as Array<{f: string, j: any}>
421
730
  const pick = matching.find(x => x.j.sessionID === curSid) || matching.find(x => !x.j.sessionID)
@@ -425,6 +734,7 @@ export const tui: TuiPlugin = async (api) => {
425
734
  let data: Pending | null = null
426
735
  try { data = JSON.parse(fs.readFileSync(full, "utf8")) as Pending } catch { try { fs.unlinkSync(full) } catch {}; return }
427
736
  if (!data || !data.id) { try { fs.unlinkSync(full) } catch {}; return }
737
+ if (!(data as any).sessionID) (data as any).sessionID = curSid
428
738
  // If pending was from a previous session that no longer exists, rebind to current session so inject still wakes you (like loop guardLoopOwnedUserMessage)
429
739
  try {
430
740
  const cur = (api.route as any)?.current