@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/agents/classify.md +17 -0
- package/dist/server.js +220 -27
- package/dist/tui.js +1394 -490
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +344 -34
- package/plugins/learn.ts +197 -25
- package/scripts/install.mjs +3 -3
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
|
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
<
|
|
346
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.question)} fg={theme().text} bg={theme().backgroundPanel} />
|
|
172
347
|
<Show when={props.request.details}>
|
|
173
|
-
<
|
|
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={() =>
|
|
212
|
-
|
|
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}
|
|
220
|
-
<
|
|
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
|
-
<
|
|
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()
|
|
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
|
-
<
|
|
365
|
-
<
|
|
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}>
|
|
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}><
|
|
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
|