@bojackduy/opencode-learn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@bojackduy/opencode-learn",
4
+ "version": "0.1.0",
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
+ "type": "module",
7
+ "license": "MIT",
8
+ "private": false,
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/bojackduy/opencode-learn.git"
12
+ },
13
+ "homepage": "https://github.com/bojackduy/opencode-learn#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/bojackduy/opencode-learn/issues"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "learn",
21
+ "teach",
22
+ "quiz",
23
+ "md-log",
24
+ "obsidian",
25
+ "socratic",
26
+ "3blue1brown",
27
+ "mermaid",
28
+ "visual",
29
+ "tui",
30
+ "opentui"
31
+ ],
32
+ "bin": {
33
+ "opencode-learn": "./scripts/install.mjs"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "scripts",
38
+ "agents",
39
+ "skills",
40
+ "commands",
41
+ "plugins",
42
+ "README.md",
43
+ "LICENSE",
44
+ "CHANGELOG.md"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "engines": {
50
+ "bun": ">=1.1.0"
51
+ },
52
+ "exports": {
53
+ ".": {
54
+ "import": "./dist/server.js"
55
+ },
56
+ "./server": {
57
+ "import": "./dist/server.js"
58
+ },
59
+ "./tui": {
60
+ "import": "./dist/tui.js"
61
+ }
62
+ },
63
+ "scripts": {
64
+ "typecheck": "tsc --noEmit",
65
+ "build:server": "bun build plugins/learn.ts --outfile dist/server.js --target bun --external @opencode-ai/plugin --external @opencode-ai/plugin/tool",
66
+ "build:tui": "bun scripts/build-tui.ts",
67
+ "build": "bun run build:server && bun run build:tui",
68
+ "clean": "rm -rf dist",
69
+ "prepack": "bun run typecheck && bun run build",
70
+ "test": "echo \"no tests yet\" && exit 0",
71
+ "release:patch": "bun run typecheck && npm version patch -m \"chore: release %s\" && git push && git push --tags",
72
+ "release:minor": "bun run typecheck && npm version minor -m \"chore: release %s\" && git push && git push --tags",
73
+ "release:major": "bun run typecheck && npm version major -m \"chore: release %s\" && git push && git push --tags"
74
+ },
75
+ "peerDependencies": {
76
+ "@opencode-ai/plugin": ">=1.18.0 <2",
77
+ "@opentui/core": "*",
78
+ "@opentui/solid": "*",
79
+ "solid-js": "*"
80
+ },
81
+ "dependencies": {
82
+ "@mermaid-js/mermaid-cli": "^11.4.2",
83
+ "@opencode-ai/plugin": "1.18.25",
84
+ "@opentui/core": "^0.5.9",
85
+ "@opentui/solid": "^0.5.9",
86
+ "solid-js": "^1.9.15"
87
+ },
88
+ "devDependencies": {
89
+ "@types/node": "^26.4.0",
90
+ "typescript": "^5.7.0"
91
+ }
92
+ }
@@ -0,0 +1,548 @@
1
+ // @ts-nocheck
2
+ /** @jsxImportSource @opentui/solid */
3
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
4
+ import { createSignal, onCleanup, For, Show, createEffect } from "solid-js"
5
+ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
6
+ import * as fs from "node:fs"
7
+ import * as path from "node:path"
8
+ import { watch } from "node:fs"
9
+
10
+ const PENDING_DIR = ".opencode/learn-pending"
11
+ import { tmpdir } from "node:os"
12
+ const TUI_LOG = path.join(tmpdir(), "learn-tui.log")
13
+ 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
+ function ensureDir(dir: string) { try { fs.mkdirSync(dir, { recursive: true }) } catch {} }
15
+
16
+ type QuizPending = {
17
+ id: string
18
+ type: "quiz"
19
+ question: string
20
+ details?: string
21
+ options: Array<{ label: string; value: string; description?: string; index: number }>
22
+ correctIndices: number[]
23
+ explanation: string
24
+ multiSelect?: boolean
25
+ timestamp: number
26
+ }
27
+ type QuizBatchPending = {
28
+ id: string
29
+ type: "quiz_batch"
30
+ quizzes: Array<{ question: string; details?: string; options: Array<{ label: string; value: string; description?: string; index: number }>; correctIndices: number[]; explanation: string; multiSelect?: boolean }>
31
+ sessionID?: string
32
+ timestamp: number
33
+ }
34
+ type Pending = QuizPending | QuizBatchPending
35
+
36
+ function prevent(e: any) { try { e.preventDefault?.(); e.stopPropagation?.() } catch {} }
37
+
38
+ function QuizDialog(props: {
39
+ api: Parameters<TuiPlugin>[0]
40
+ request: QuizPending
41
+ onSubmit: (result: { answers: Array<{ label: string; value: string; index: number }>; dontKnow: boolean; note?: string }) => void
42
+ onCancel: () => void
43
+ }) {
44
+ const theme = () => props.api.theme.current
45
+ const dims = useTerminalDimensions()
46
+ const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 92))
47
+ const options = () => props.request.options
48
+ const correctSet = new Set(props.request.correctIndices)
49
+ const isMulti = () => !!props.request.multiSelect
50
+ const dontKnowIdx = () => options().length
51
+ const submitIdx = () => isMulti() ? options().length + 1 : -1
52
+
53
+ const [focused, setFocused] = createSignal<"options" | "note">("options")
54
+ const [optionIndex, setOptionIndex] = createSignal(0)
55
+ const [phase, setPhase] = createSignal<"select" | "feedback">("select")
56
+ const [note, setNote] = createSignal("")
57
+ const [dontKnow, setDontKnow] = createSignal(false)
58
+ const [selected, setSelected] = createSignal<Map<string, { label: string; value: string; index: number }>>(new Map())
59
+ const [feedback, setFeedback] = createSignal<{ correct: boolean; selectedIndices: number[] } | null>(null)
60
+
61
+ let noteInputEl: any
62
+
63
+ createEffect(() => {
64
+ if (focused() === "note" && noteInputEl) {
65
+ try { noteInputEl.focus() } catch {}
66
+ }
67
+ })
68
+
69
+ const toggleOption = (idx: number) => {
70
+ const opt = options()[idx]
71
+ if (!opt) return
72
+ const map = new Map(selected())
73
+ const key = `opt:${idx}`
74
+ if (dontKnow()) setDontKnow(false)
75
+ if (map.has(key)) map.delete(key)
76
+ else map.set(key, { label: opt.label, value: opt.value, index: idx + 1 })
77
+ setSelected(map)
78
+ }
79
+ const handleDontKnow = () => {
80
+ const willBe = !dontKnow()
81
+ setDontKnow(willBe)
82
+ if (willBe) setSelected(new Map())
83
+ else setSelected(new Map())
84
+ // For single-select, dontKnow is a final answer — submit immediately (no Submit button)
85
+ if (!isMulti() && willBe) setTimeout(() => submitSelect(), 0)
86
+ }
87
+ const submitSelect = () => {
88
+ const selMap = selected()
89
+ if (!isMulti() && selMap.size === 0 && !dontKnow()) return
90
+ if (isMulti() && selMap.size === 0 && !dontKnow()) return
91
+ if (dontKnow()) {
92
+ setFeedback({ correct: false, selectedIndices: [] })
93
+ setPhase("feedback")
94
+ return
95
+ }
96
+ const selectedIndices = Array.from(selMap.values()).map(v => v.index)
97
+ const correct = selectedIndices.length === props.request.correctIndices.length &&
98
+ selectedIndices.every(i => correctSet.has(i)) &&
99
+ props.request.correctIndices.every(i => selectedIndices.includes(i))
100
+ setFeedback({ correct, selectedIndices })
101
+ setPhase("feedback")
102
+ }
103
+ const confirmFeedback = () => {
104
+ const sel = Array.from(selected().values())
105
+ props.onSubmit({ answers: dontKnow() ? [] : sel, dontKnow: dontKnow(), note: note().trim() || undefined })
106
+ }
107
+
108
+ useKeyboard((evt: any) => {
109
+ const key = evt.name || evt.sequence || evt.raw || ""
110
+ const seq = evt.sequence || ""
111
+ // When in feedback, any Enter/Esc confirms
112
+ if (phase() === "feedback") {
113
+ if (key === "enter" || seq === "\r" || key === "escape" || key === "esc") {
114
+ prevent(evt)
115
+ confirmFeedback()
116
+ }
117
+ return
118
+ }
119
+ // Note focused: handle Tab/Esc/Enter to exit note, otherwise let input handle typing
120
+ if (focused() === "note") {
121
+ if (key === "tab" || seq === "\t") { prevent(evt); setFocused("options"); return }
122
+ if (key === "escape" || key === "esc") { prevent(evt); setFocused("options"); return }
123
+ if (key === "enter" && (evt.ctrl || evt.meta)) { prevent(evt); setFocused("options"); return }
124
+ // Allow typing to go to input; don't prevent
125
+ return
126
+ }
127
+ // Options focused
128
+ 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 }
130
+ if (key === "tab" || seq === "\t") { prevent(evt); setFocused("note"); return }
131
+ if (key === "escape" || key === "esc") { prevent(evt); props.onCancel(); return }
132
+ if (key === "space" || seq === " ") {
133
+ prevent(evt)
134
+ const idx = optionIndex()
135
+ if (idx === dontKnowIdx()) handleDontKnow()
136
+ else {
137
+ if (isMulti()) toggleOption(idx)
138
+ else {
139
+ const opt = options()[idx]
140
+ if (opt) { setSelected(new Map([[`opt:${idx}`, { label: opt.label, value: opt.value, index: idx + 1 }]])); setDontKnow(false); submitSelect() }
141
+ }
142
+ }
143
+ return
144
+ }
145
+ if (key === "enter" || seq === "\r") {
146
+ prevent(evt)
147
+ const idx = optionIndex()
148
+ if (idx === dontKnowIdx()) handleDontKnow()
149
+ else if (isMulti()) submitSelect()
150
+ else {
151
+ const opt = options()[idx]
152
+ if (opt) { setSelected(new Map([[`opt:${idx}`, { label: opt.label, value: opt.value, index: idx + 1 }]])); setDontKnow(false); submitSelect() }
153
+ }
154
+ return
155
+ }
156
+ if (seq === "ctrl+j" || (key === "enter" && (evt as any).ctrl)) {
157
+ prevent(evt); submitSelect(); return
158
+ }
159
+ })
160
+
161
+ 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}>
163
+ {/* Header */}
164
+ <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
+ <text fg={theme().background} bold>{phase() === "feedback" ? (feedback()?.correct ? "✓ CORRECT" : dontKnow() ? "○ I DON'T KNOW" : "✗ INCORRECT") : isMulti() ? "☑ QUIZ · MULTI-SELECT" : "● QUIZ · SINGLE" }</text>
166
+ <text fg={theme().background} dim>learn</text>
167
+ </box>
168
+
169
+ {/* Question */}
170
+ <box flexDirection="column" gap={1} paddingLeft={1} paddingRight={1} paddingTop={1}>
171
+ <text fg={theme().text} bold wrapMode="wrap">{props.request.question}</text>
172
+ <Show when={props.request.details}>
173
+ <text fg={theme().textMuted} wrapMode="wrap">{props.request.details}</text>
174
+ </Show>
175
+ </box>
176
+
177
+ <Show when={phase() === "select"}>
178
+ <box flexDirection="column" gap={0} padding={1} border={true} borderColor={theme().borderSubtle} backgroundColor={theme().background}>
179
+ <For each={options()}>
180
+ {(opt, i) => {
181
+ const idx = i()
182
+ const isFocused = () => focused() === "options" && optionIndex() === idx
183
+ const isSelected = () => selected().has(`opt:${idx}`)
184
+ return (
185
+ <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} paddingRight={1} backgroundColor={isFocused() ? theme().backgroundElement : undefined}>
186
+ <box width={2} alignItems="center"><text fg={isFocused() ? theme().accent : theme().textMuted}>{isFocused() ? "▸" : " "}</text></box>
187
+ <box width={2} alignItems="center"><text fg={isMulti() ? (isSelected() ? theme().success : theme().textMuted) : (isSelected() ? theme().accent : theme().textMuted)}>{isMulti() ? (isSelected() ? "☑" : "☐") : (isSelected() ? "⬢" : "○")}</text></box>
188
+ <box flexGrow={1}><text fg={isSelected() ? theme().text : theme().textMuted} bold={isFocused()} wrapMode="wrap">{idx + 1}. {opt.label}</text></box>
189
+ </box>
190
+ )
191
+ }}
192
+ </For>
193
+ <Show when={options().length > 0}><box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20, popupWidth() - 8))}</text></box></Show>
194
+ <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} paddingRight={1} backgroundColor={focused() === "options" && optionIndex() === dontKnowIdx() ? theme().backgroundElement : undefined}>
195
+ <box width={2} alignItems="center"><text fg={focused() === "options" && optionIndex() === dontKnowIdx() ? theme().accent : theme().textMuted}>{focused() === "options" && optionIndex() === dontKnowIdx() ? "▸" : " "}</text></box>
196
+ <box width={2} alignItems="center"><text fg={dontKnow() ? theme().warning : theme().textMuted}>{dontKnow() ? "☑" : "☐"}</text></box>
197
+ <box flexGrow={1}><text fg={dontKnow() ? theme().warning : theme().textMuted} italic wrapMode="wrap">I don't know — genuine gap, not a guess</text></box>
198
+ </box>
199
+
200
+ <box flexDirection="column" gap={0} paddingTop={1}>
201
+ <box flexDirection="row" alignItems="center" gap={1}>
202
+ <text fg={focused() === "note" ? theme().accent : theme().textMuted} bold={focused() === "note"}>✎ Note (optional)</text>
203
+ <Show when={focused() === "note"}><text fg={theme().accent}>● editing</text></Show>
204
+ </box>
205
+ <box border={true} borderColor={focused() === "note" ? theme().accent : theme().borderSubtle} backgroundColor={theme().backgroundElement} paddingLeft={1} paddingRight={1}>
206
+ <Show when={focused() === "note"} fallback={<text fg={note() ? theme().text : theme().textMuted} wrapMode="wrap">{note() || "Tab to edit · share what you were thinking"}</text>}>
207
+ <input
208
+ ref={(el: any) => noteInputEl = el}
209
+ value={note()}
210
+ 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?"
213
+ />
214
+ </Show>
215
+ </box>
216
+ </box>
217
+
218
+ <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>
221
+ </box>
222
+ <Show when={isMulti()}>
223
+ <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>
227
+ </box>
228
+ </box>
229
+ </Show>
230
+ </box>
231
+ </Show>
232
+
233
+ <Show when={phase() === "feedback"}>
234
+ <box flexDirection="column" gap={1} padding={1} border={true} borderColor={feedback()?.correct ? theme().success : theme().error} backgroundColor={theme().background}>
235
+ <For each={options()}>
236
+ {(opt, i) => {
237
+ const idx = i() + 1
238
+ const isSelected = () => feedback()?.selectedIndices.includes(idx) ?? false
239
+ const isCorrect = () => correctSet.has(idx)
240
+ let icon = " "
241
+ let fg = theme().textMuted
242
+ let bg: any = undefined
243
+ if (dontKnow()) { icon = isCorrect() ? "✓" : " "; fg = isCorrect() ? theme().background : theme().textMuted; bg = isCorrect() ? theme().success : undefined; }
244
+ else if (isSelected() && isCorrect()) { icon = "✓"; fg = theme().background; bg = theme().success; }
245
+ else if (isSelected() && !isCorrect()) { icon = "✗"; fg = theme().background; bg = theme().error; }
246
+ else if (!isSelected() && isCorrect()) { icon = "○"; fg = theme().background; bg = theme().warning; }
247
+ return (
248
+ <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} backgroundColor={bg}>
249
+ <box width={2} alignItems="center"><text fg={fg} bold>{icon}</text></box>
250
+ <box flexGrow={1}><text fg={fg} wrapMode="wrap">{idx}. {opt.label}</text></box>
251
+ </box>
252
+ )
253
+ }}
254
+ </For>
255
+ <box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20, popupWidth() - 12))}</text></box>
256
+ <Show when={dontKnow()}><text fg={theme().warning}>● You said: I don't know — genuine gap</text></Show>
257
+ <Show when={!dontKnow()}><text fg={feedback()?.correct ? theme().success : theme().error} bold>{feedback()?.correct ? "✓ Correct! Well located." : "✗ Incorrect — nice try, let's fix the edge."}</text></Show>
258
+ <text fg={theme().textMuted}>Correct: {props.request.correctIndices.map(i => `${i}. ${options()[i-1]?.label}`).join(", ")}</text>
259
+ <Show when={note()}><text fg={theme().textMuted}>Your note: {note()}</text></Show>
260
+ <box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}>
261
+ <text fg={theme().text} wrapMode="wrap">{props.request.explanation}</text>
262
+ </box>
263
+ <box justifyContent="center" paddingTop={1}><text fg={theme().textMuted}>↵ Enter / Esc to continue → next probe</text></box>
264
+ </box>
265
+ </Show>
266
+ </box>
267
+ )
268
+ }
269
+
270
+
271
+ function QuizBatchDialog(props: {
272
+ api: Parameters<TuiPlugin>[0]
273
+ request: QuizBatchPending
274
+ onSubmit: (result: { results: Array<{ answers: Array<{ label: string; value: string; index: number }>; dontKnow: boolean; note?: string; correct: boolean }> }) => void
275
+ onCancel: () => void
276
+ }) {
277
+ const theme = () => props.api.theme.current
278
+ const dims = useTerminalDimensions()
279
+ const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 96))
280
+ const [idx, setIdx] = createSignal(0)
281
+ // Guard: if no quizzes, cancel
282
+ if (!props.request.quizzes || props.request.quizzes.length === 0) {
283
+ tlog("QuizBatchDialog empty quizzes, cancelling", props.request.id)
284
+ setTimeout(() => props.onCancel(), 0)
285
+ return null as any
286
+ }
287
+ const cur = () => props.request.quizzes[idx()] ?? props.request.quizzes[0]
288
+ const [phase, setPhase] = createSignal<"select" | "feedback">("select")
289
+ const [feedback, setFeedback] = createSignal<{ correct: boolean; selectedIndices: number[] } | null>(null)
290
+ const [dontKnow, setDontKnow] = createSignal(false)
291
+ const [selected, setSelected] = createSignal<Map<string, any>>(new Map())
292
+ const [note, setNote] = createSignal("")
293
+ const [focused, setFocused] = createSignal<"options" | "note">("options")
294
+ const [optionIndex, setOptionIndex] = createSignal(0)
295
+ const [results, setResults] = createSignal<Array<{ answers: any[]; dontKnow: boolean; note?: string; correct: boolean }>>([])
296
+ const isMulti = () => !!cur().multiSelect
297
+ const dontKnowIdx = () => cur().options.length
298
+ const submitIdx = () => isMulti() ? cur().options.length + 1 : -1
299
+ let noteEl: any
300
+ createEffect(() => { if (focused()==="note" && noteEl) try{noteEl.focus()}catch(e){ tlog("note focus failed", String(e)) } })
301
+ const toggle = (i:number) => {
302
+ try {
303
+ const o = cur().options[i]; if(!o) return
304
+ const m = new Map(selected()); const k=`opt:${i}`
305
+ if (dontKnow()) setDontKnow(false)
306
+ if (m.has(k)) m.delete(k); else m.set(k,{label:o.label,value:o.value,index:i+1})
307
+ setSelected(m)
308
+ } catch(e){ tlog("toggle failed", String(e)) }
309
+ }
310
+ const goNext = () => {
311
+ try {
312
+ const sel = Array.from(selected().values())
313
+ const dk = dontKnow()
314
+ const correctSet = new Set(cur().correctIndices)
315
+ const si = sel.map((a:any)=>a.index)
316
+ const ok = !dk && si.length===cur().correctIndices.length && si.every((i:number)=>correctSet.has(i))
317
+ const entry = { answers: dk?[]:sel, dontKnow: dk, note: note().trim()||undefined, correct: ok }
318
+ const nextResults = [...results(), entry]
319
+ tlog("QuizBatchDialog goNext", idx(), ok, JSON.stringify(entry).slice(0,200))
320
+ setResults(nextResults)
321
+ if (idx() + 1 < props.request.quizzes.length) {
322
+ setIdx(i=>i+1); setSelected(new Map()); setDontKnow(false); setNote(""); setOptionIndex(0); setFocused("options"); setPhase("select"); setFeedback(null)
323
+ } else {
324
+ tlog("QuizBatchDialog done, submitting", nextResults.length)
325
+ props.onSubmit({ results: nextResults })
326
+ }
327
+ } catch(e){ tlog("goNext failed", String(e)); props.onCancel() }
328
+ }
329
+ const submitSelect = () => {
330
+ try {
331
+ const m = selected()
332
+ if (!isMulti() && m.size===0 && !dontKnow()) return
333
+ if (isMulti() && m.size===0 && !dontKnow()) return
334
+ const sel = Array.from(m.values())
335
+ const dk = dontKnow()
336
+ const correctSet = new Set(cur().correctIndices)
337
+ const si = sel.map((a:any)=>a.index)
338
+ const ok = !dk && si.length===cur().correctIndices.length && si.every((i:number)=>correctSet.has(i))
339
+ tlog("QuizBatchDialog submitSelect", idx(), sel.length, dk, ok)
340
+ setFeedback({ correct: ok, selectedIndices: si })
341
+ setPhase("feedback")
342
+ } catch(e){ tlog("submitSelect failed", String(e)) }
343
+ }
344
+ useKeyboard((evt:any)=>{
345
+ 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 }
348
+ if(focused()==="note"){ if(k==="tab"||seq==="\t"){prevent(evt); setFocused("options"); return} if(k==="escape"){prevent(evt); setFocused("options"); return} return }
349
+ if(k==="up"||k==="k"||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
350
+ if(k==="down"||k==="j"||seq==="\x1b[B"){prevent(evt); setOptionIndex(i=>Math.min(isMulti()?submitIdx():dontKnowIdx(),i+1)); return}
351
+ if(k==="tab"||seq==="\t"){prevent(evt); setFocused("note"); return}
352
+ if(k==="escape"||k==="esc"){prevent(evt); props.onCancel(); return}
353
+ if(k==="space"||seq===" "){prevent(evt); const i=optionIndex(); if(i===dontKnowIdx()){ const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); } else if(isMulti() && i===submitIdx()) submitSelect(); else if(isMulti()) toggle(i); else { const o=cur().options[i]; if(o){setSelected(new Map([[`opt:${i}`,{label:o.label,value:o.value,index:i+1}]])); setDontKnow(false); submitSelect()} } return}
354
+ if(k==="enter"||seq==="\r"){prevent(evt); const i=optionIndex(); if(i===dontKnowIdx()){ const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); } else if(isMulti()) submitSelect(); else { const o=cur().options[i]; if(o){setSelected(new Map([[`opt:${i}`,{label:o.label,value:o.value,index:i+1}]])); setDontKnow(false); submitSelect()} } return}
355
+ if(seq==="ctrl+j" || (k==="enter" && evt.ctrl)){prevent(evt); submitSelect(); return}
356
+ } catch(e){ tlog("useKeyboard batch failed", String(e)) }
357
+ })
358
+ 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}>
360
+ <box flexDirection="row" justifyContent="space-between" backgroundColor={theme().accent} paddingLeft={1} paddingRight={1} height={1}>
361
+ <text fg={theme().background} bold> decks.quiz batch {idx()+1}/{props.request.quizzes.length} {phase()==="feedback"?(feedback()?.correct?"✓":"✗"):""}</text>
362
+ <text fg={theme().background} dim>learn</text>
363
+ </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>
366
+ <Show when={phase()==="select"}>
367
+ <box flexDirection="column" gap={0} padding={1} border={true} borderColor={theme().borderSubtle} backgroundColor={theme().background}>
368
+ <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
+ <box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20,popupWidth()-8))}</text></box>
370
+ <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>
373
+ <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
+ </box>
375
+ </Show>
376
+ <Show when={phase()==="feedback"}>
377
+ <box flexDirection="column" gap={1} padding={1} border={true} borderColor={feedback()?.correct?theme().success:theme().error} backgroundColor={theme().background}>
378
+ <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
+ <text fg={feedback()?.correct?theme().success:theme().error} bold>{feedback()?.correct?"✓ Correct":"✗ Incorrect"}</text>
380
+ <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>
383
+ </box>
384
+ </Show>
385
+ </box>
386
+ )
387
+ }
388
+
389
+ export const tui: TuiPlugin = async (api) => {
390
+ const dir = api.state.path.directory || api.state.path.worktree || process.cwd()
391
+ const pendingDir = path.join(dir, PENDING_DIR)
392
+ ensureDir(pendingDir)
393
+ const heartbeatPath = path.join(pendingDir, ".tui-alive")
394
+ try { fs.writeFileSync(heartbeatPath, String(Date.now()), "utf8") } catch {}
395
+ const hbTimer = setInterval(() => { try { fs.writeFileSync(heartbeatPath, String(Date.now()), "utf8") } catch {} }, 2000)
396
+ api.lifecycle.onDispose(() => clearInterval(hbTimer))
397
+
398
+ const currentBySession = new Map<string, { id: string; type: string }>()
399
+ let watcher: ReturnType<typeof watch> | undefined
400
+ let pollTimer: ReturnType<typeof setInterval> | undefined
401
+
402
+ const getCurrentSessionID = (): string | null => {
403
+ try {
404
+ const cur = (api.route as any)?.current
405
+ if (cur?.name === "session" && cur?.params?.sessionID) return cur.params.sessionID as string
406
+ if (cur?.params?.id) return cur.params.id as string
407
+ } catch {}
408
+ return null
409
+ }
410
+
411
+ const processPending = () => {
412
+ const curSid = getCurrentSessionID()
413
+ if (!curSid) return
414
+ let current = currentBySession.get(curSid) as { id: string; type: string } | undefined
415
+ if (current) return
416
+ if (api.ui.dialog.open) return
417
+ let files: string[] = []
418
+ try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".")).sort() } catch { return }
419
+ // Session-distinct: only show pending for current session
420
+ 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
+ const pick = matching.find(x => x.j.sessionID === curSid) || matching.find(x => !x.j.sessionID)
422
+ if (!pick) return
423
+ const file = pick.f
424
+ const full = path.join(pendingDir, file)
425
+ let data: Pending | null = null
426
+ try { data = JSON.parse(fs.readFileSync(full, "utf8")) as Pending } catch { try { fs.unlinkSync(full) } catch {}; return }
427
+ if (!data || !data.id) { try { fs.unlinkSync(full) } catch {}; return }
428
+ // If pending was from a previous session that no longer exists, rebind to current session so inject still wakes you (like loop guardLoopOwnedUserMessage)
429
+ try {
430
+ const cur = (api.route as any)?.current
431
+ const curSid = cur?.params?.sessionID || cur?.sessionID
432
+ if (curSid && (data as any).sessionID && (data as any).sessionID !== curSid) {
433
+ // Check if old session still exists
434
+ const anyState: any = api.state as any
435
+ const exists = anyState.session?.get ? anyState.session.get((data as any).sessionID) : undefined
436
+ if (!exists) (data as any).sessionID = curSid
437
+ }
438
+ } catch {}
439
+ current = { id: data.id, type: data.type }
440
+ currentBySession.set(curSid, current)
441
+ const done = async (result: any) => {
442
+ const respPath = path.join(pendingDir, `response-${data!.id}.json`)
443
+ try { fs.writeFileSync(respPath, JSON.stringify({ id: data!.id, type: data!.type, result, sessionID: (data as any).sessionID, at: Date.now() }), "utf8") } catch {}
444
+ // Non-blocking wake: inject answer as new user prompt so agent continues (no timeout, no polling waste)
445
+ try {
446
+ const sessionID = (data as any).sessionID
447
+ // Build injected text with grading for quiz
448
+ let injectText = ""
449
+ if (data.type === "quiz") {
450
+ const qp = data as QuizPending
451
+ const r = result as { answers: Array<{ label: string; value: string; index: number }>; dontKnow: boolean; note?: string }
452
+ const dontKnow = !!r.dontKnow
453
+ const correctSet = new Set(qp.correctIndices)
454
+ const selectedIndices = (r.answers || []).map(a => a.index)
455
+ const selectedStr = dontKnow ? "I don't know" : (r.answers || []).map(a => `${a.index}. ${a.label}`).join(", ") || "(none)"
456
+ const correctStr = qp.correctIndices.map(i => `${i}. ${qp.options[i-1]?.label}`).join(", ")
457
+ const correct = dontKnow ? false : (selectedIndices.length === qp.correctIndices.length && selectedIndices.every(i => correctSet.has(i)) && qp.correctIndices.every(i => selectedIndices.includes(i)))
458
+ injectText = dontKnow
459
+ ? `[quiz answer] You selected "I don't know" for: "${qp.question}" — genuine gap. Correct: ${correctStr}. Explanation: ${qp.explanation}${r.note ? ` Note: ${r.note}` : ""}`
460
+ : `[quiz answer] Question: "${qp.question}" — You selected: ${selectedStr} — ${correct ? "Correct ✓" : "Incorrect ✗"}. Correct: ${correctStr}. Explanation: ${qp.explanation}${r.note ? ` Note: ${r.note}` : ""}`
461
+ } else if ((data as any).type === "quiz_batch") {
462
+ const batch = data as QuizBatchPending
463
+ const results = (result as any).results ?? []
464
+ const lines = batch.quizzes.map((qq, i) => {
465
+ const x = results[i] || {}
466
+ const cs = (qq.correctIndices||[]).map((idx:number)=>`${idx}. ${qq.options[idx-1]?.label}`).join(", ")
467
+ const sel = x?.dontKnow ? "I don't know" : (x?.answers||[]).map((a:any)=>`${a.index}. ${a.label}`).join(", ") || "(none)"
468
+ const ok = x?.correct ? "CORRECT" : x?.dontKnow ? "GAP" : "INCORRECT"
469
+ return `Q${i+1}: "${qq.question}" -> ${sel} = ${ok}. Correct: ${cs}`
470
+ }).join("\n")
471
+ injectText = `[quiz_batch answered] ${batch.quizzes.length} quizzes\n` + lines
472
+ }
473
+ // Try v2 SDK then v1 fallback
474
+ const anyClient = api.client as any
475
+ const sidToUse = "" // server (learn.ts watchAndInject) owns injection — loopd pattern
476
+ if (sidToUse && injectText) {
477
+ try { api.ui.toast({ message: `inject ${sidToUse.slice(0,6)}`, variant: "info", duration: 1200 }) } catch {}
478
+ try {
479
+ if (anyClient.session?.prompt) {
480
+ // Try v2 shape first: { path: { sessionID }, body: { prompt: { text } } }
481
+ try {
482
+ await anyClient.session.prompt({ path: { sessionID: sidToUse }, body: { prompt: { text: injectText } } })
483
+ } catch {
484
+ // Fallback v1 shape: { path: { sessionID }, body: { parts: [...] } }
485
+ await anyClient.session.prompt({ path: { sessionID: sidToUse }, body: { parts: [{ type: "text", text: injectText }] } as any })
486
+ }
487
+ } else if (anyClient.tui?.submitPrompt) {
488
+ await anyClient.tui.submitPrompt({ text: injectText })
489
+ }
490
+ // Fallback fetch
491
+ try {
492
+ const base = (api as any).serverUrl || "http://127.0.0.1:4096"
493
+ await fetch(`${String(base).replace(/\/$/, "")}/api/session/${sidToUse}/prompt`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: { text: injectText } }) })
494
+ } catch {}
495
+
496
+ } catch (e) {
497
+ // If injection fails, rely on response file for blocking fallback
498
+ try { console.error("learn-tui inject failed", e) } catch {}
499
+ }
500
+ }
501
+ } catch {}
502
+ try { fs.unlinkSync(full) } catch {}
503
+ api.ui.dialog.clear()
504
+ currentBySession.delete(curSid)
505
+ setTimeout(processPending, 150)
506
+ }
507
+ const cancel = async () => {
508
+ const respPath = path.join(pendingDir, `response-${data!.id}.json`)
509
+ try { fs.writeFileSync(respPath, JSON.stringify({ id: data!.id, type: data!.type, cancelled: true, sessionID: (data as any).sessionID, at: Date.now() }), "utf8") } catch {}
510
+ try {
511
+ const sid = (data as any).sessionID
512
+ if (sid) {
513
+ const anyClient = api.client as any
514
+ const injectText = (data as any).type === "quiz_batch"
515
+ ? `[quiz_batch cancelled] ${(data as QuizBatchPending).quizzes.length} quizzes — user cancelled`
516
+ : `[quiz cancelled] Question: "${(data as QuizPending).question}" — user cancelled`
517
+ try {
518
+ if (anyClient.session?.prompt) {
519
+ try { await anyClient.session.prompt({ path: { sessionID: sid }, body: { prompt: { text: injectText } } }) }
520
+ catch { await anyClient.session.prompt({ path: { sessionID: sid }, body: { parts: [{ type: "text", text: injectText }] } as any }) }
521
+ }
522
+ } catch {}
523
+ }
524
+ } catch {}
525
+ try { fs.unlinkSync(full) } catch {}
526
+ api.ui.dialog.clear()
527
+ currentBySession.delete(curSid)
528
+ setTimeout(processPending, 150)
529
+ }
530
+ if (data.type === "quiz") { tlog("processPending quiz", data.id); api.ui.dialog.replace(() => <QuizDialog api={api} request={data as QuizPending} onSubmit={done} onCancel={cancel} />) }
531
+ else if (data.type === "quiz_batch") { tlog("processPending quiz_batch", data.id, (data as QuizBatchPending).quizzes.length); api.ui.dialog.replace(() => <QuizBatchDialog api={api} request={data as QuizBatchPending} onSubmit={done} onCancel={cancel} />) }
532
+ else { tlog("processPending unknown", (data as any).type, data.id); try { fs.unlinkSync(full) } catch {}; currentBySession.delete(curSid); return }
533
+ try { api.ui.dialog.setSize("large") } catch {}
534
+ }
535
+
536
+ try { watcher = watch(pendingDir, () => setTimeout(processPending, 50)); api.lifecycle.onDispose(() => watcher?.close()) } catch {}
537
+ pollTimer = setInterval(processPending, 700)
538
+ api.lifecycle.onDispose(() => clearInterval(pollTimer!))
539
+ const off = api.event.on("session.status", () => setTimeout(processPending, 100))
540
+ api.lifecycle.onDispose(off)
541
+ setTimeout(processPending, 300)
542
+ api.ui.toast({ message: "learn TUI ready — beautiful quiz + question", variant: "info", duration: 2200 })
543
+ }
544
+
545
+ export default {
546
+ id: "learn-tui",
547
+ tui,
548
+ } satisfies TuiPluginModule & { id: string }