@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/CHANGELOG.md +10 -0
- package/LICENSE +25 -0
- package/README.md +151 -0
- package/agents/mermaid-maker.md +61 -0
- package/agents/researcher.md +53 -0
- package/agents/svg-maker.md +65 -0
- package/commands/md_log.md +5 -0
- package/commands/md_unlog.md +5 -0
- package/dist/server.js +1181 -0
- package/dist/tui.js +1528 -0
- package/package.json +92 -0
- package/plugins/learn-tui.tsx +548 -0
- package/plugins/learn.ts +991 -0
- package/scripts/build-tui.ts +20 -0
- package/scripts/install.mjs +250 -0
- package/skills/marker-pdf-parser/README.md +38 -0
- package/skills/marker-pdf-parser/SKILL.md +130 -0
- package/skills/marker-pdf-parser/requirements.txt +1 -0
- package/skills/marker-pdf-parser/scripts/parse_pdf.py +150 -0
- package/skills/notebooklm-lecture-notes/SKILL.md +173 -0
- package/skills/notebooklm-lecture-notes/references/prompts.md +40 -0
- package/skills/teach/SKILL.md +150 -0
- package/skills/visualize/SKILL.md +82 -0
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 }
|