pi-studio 0.9.3 → 0.9.4

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.
@@ -77,6 +77,7 @@
77
77
  const pullLatestBtn = document.getElementById("pullLatestBtn");
78
78
  const insertHeaderBtn = document.getElementById("insertHeaderBtn");
79
79
  const critiqueBtn = document.getElementById("critiqueBtn");
80
+ const quizBtn = document.getElementById("quizBtn");
80
81
  const lensSelect = document.getElementById("lensSelect");
81
82
  const fileInput = document.getElementById("fileInput");
82
83
  const resourceDirBtn = document.getElementById("resourceDirBtn");
@@ -235,6 +236,31 @@
235
236
  const PDF_EXPORT_FETCH_TIMEOUT_MS = 180_000;
236
237
  const HTML_EXPORT_FETCH_TIMEOUT_MS = 180_000;
237
238
  const EDITOR_TAB_TEXT = " ";
239
+ const QUIZ_DEFAULT_COUNT = 5;
240
+ const QUIZ_ANGLES = ["general", "scientist", "mathematician", "statistician", "developer", "reviewer"];
241
+ const QUIZ_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
242
+ let quizOverlayEl = null;
243
+ let quizDialogEl = null;
244
+ let quizPreviewRenderNonce = 0;
245
+ const quizMarkdownRenderCache = new Map();
246
+ let quizState = {
247
+ open: false,
248
+ requestId: null,
249
+ pending: false,
250
+ sourceText: "",
251
+ sourceLabel: "Studio editor",
252
+ scope: "editor",
253
+ angle: "general",
254
+ thinking: "minimal",
255
+ questionCount: QUIZ_DEFAULT_COUNT,
256
+ cards: [],
257
+ index: 0,
258
+ answer: "",
259
+ feedback: null,
260
+ discussion: [],
261
+ status: "",
262
+ error: "",
263
+ };
238
264
  let replTmuxAvailable = null;
239
265
  let replSessions = [];
240
266
  let replActiveSessionName = "";
@@ -2002,7 +2028,7 @@
2002
2028
  if (!isEditorOnlyMode && critiqueBtn && lensSelect) {
2003
2029
  const reviewButton = makeStudioUiRefreshElement("button", "studio-refresh-tool-tab studio-refresh-review-btn", "Review");
2004
2030
  reviewMenu = makeStudioUiRefreshMenu(reviewButton, "review", "studio-refresh-review-anchor");
2005
- appendStudioUiRefreshMenuSection(reviewMenu.menu, "Action", [critiqueBtn]);
2031
+ appendStudioUiRefreshMenuSection(reviewMenu.menu, "Action", [critiqueBtn, quizBtn]);
2006
2032
  appendStudioUiRefreshMenuSection(reviewMenu.menu, "Setting", [lensSelect]);
2007
2033
  }
2008
2034
 
@@ -3010,6 +3036,18 @@
3010
3036
  && typeof studioPdfFocusDialogEl.contains === "function"
3011
3037
  && studioPdfFocusDialogEl.contains(event.target)
3012
3038
  );
3039
+ const quizOwnsEvent = Boolean(
3040
+ quizDialogEl
3041
+ && event.target
3042
+ && typeof quizDialogEl.contains === "function"
3043
+ && quizDialogEl.contains(event.target)
3044
+ );
3045
+
3046
+ if (isQuizOpen() && plainEscape) {
3047
+ event.preventDefault();
3048
+ minimizeQuizOverlay();
3049
+ return;
3050
+ }
3013
3051
 
3014
3052
  if (isStudioPdfFocusOpen() && plainEscape) {
3015
3053
  event.preventDefault();
@@ -3035,7 +3073,7 @@
3035
3073
  return;
3036
3074
  }
3037
3075
 
3038
- if (scratchpadOwnsEvent || reviewNotesOwnsEvent || outlineOwnsEvent || pdfFocusOwnsEvent) {
3076
+ if (scratchpadOwnsEvent || reviewNotesOwnsEvent || outlineOwnsEvent || pdfFocusOwnsEvent || quizOwnsEvent) {
3039
3077
  return;
3040
3078
  }
3041
3079
 
@@ -7272,6 +7310,645 @@
7272
7310
  return "draft_" + makeRequestId();
7273
7311
  }
7274
7312
 
7313
+ function normalizeQuizAngle(angle) {
7314
+ const value = String(angle || "").trim().toLowerCase();
7315
+ return QUIZ_ANGLES.includes(value) ? value : "general";
7316
+ }
7317
+
7318
+ function getQuizAngleLabel(angle) {
7319
+ switch (normalizeQuizAngle(angle)) {
7320
+ case "scientist": return "Scientist";
7321
+ case "mathematician": return "Mathematician";
7322
+ case "statistician": return "Statistician";
7323
+ case "developer": return "Developer";
7324
+ case "reviewer": return "Reviewer";
7325
+ default: return "General";
7326
+ }
7327
+ }
7328
+
7329
+ function normalizeQuizThinking(thinking) {
7330
+ const value = String(thinking || "").trim().toLowerCase();
7331
+ return QUIZ_THINKING_LEVELS.includes(value) ? value : "minimal";
7332
+ }
7333
+
7334
+ function getQuizThinkingLabel(thinking) {
7335
+ switch (normalizeQuizThinking(thinking)) {
7336
+ case "off": return "Off";
7337
+ case "low": return "Low";
7338
+ case "medium": return "Medium";
7339
+ case "high": return "High";
7340
+ default: return "Minimal";
7341
+ }
7342
+ }
7343
+
7344
+ function getQuizKindLabel(kind) {
7345
+ const value = String(kind || "").trim().toLowerCase();
7346
+ if (!value) return "";
7347
+ return value
7348
+ .split(/[-_\s]+/)
7349
+ .filter(Boolean)
7350
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
7351
+ .join(" ");
7352
+ }
7353
+
7354
+ function getQuizModelLabel() {
7355
+ const label = String(modelLabel || "").trim();
7356
+ const withoutThinking = label.replace(/\s*\((?:off|minimal|low|medium|high|xhigh)\)\s*$/i, "").trim();
7357
+ return withoutThinking || label || "current Pi model";
7358
+ }
7359
+
7360
+ function shouldRenderQuizMarkdownPreview() {
7361
+ const lang = normalizeFenceLanguage(editorLanguage || "");
7362
+ return !lang || lang === "markdown" || lang === "latex";
7363
+ }
7364
+
7365
+ function renderQuizMarkdownBlockHtml(markdown, className) {
7366
+ const source = String(markdown || "");
7367
+ return "<div class='studio-quiz-markdown-body rendered-markdown " + escapeHtml(className || "") + "' data-quiz-markdown='" + escapeHtml(source) + "'>"
7368
+ + "<div class='studio-quiz-markdown-fallback'>" + escapeHtml(source) + "</div>"
7369
+ + "</div>";
7370
+ }
7371
+
7372
+ function restoreQuizScrollTop(scrollTop) {
7373
+ const scrollEl = getQuizScrollContainer();
7374
+ if (!scrollEl) return;
7375
+ scrollEl.scrollTop = Math.max(0, Number(scrollTop) || 0);
7376
+ }
7377
+
7378
+ function restoreQuizScrollTopSoon(scrollTop) {
7379
+ restoreQuizScrollTop(scrollTop);
7380
+ window.requestAnimationFrame(() => restoreQuizScrollTop(scrollTop));
7381
+ }
7382
+
7383
+ function isQuizScrollNearBottom(scrollEl) {
7384
+ if (!scrollEl) return false;
7385
+ return (scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 80;
7386
+ }
7387
+
7388
+ function scrollQuizToBottom() {
7389
+ const scrollEl = getQuizScrollContainer();
7390
+ if (!scrollEl) return;
7391
+ scrollEl.scrollTop = scrollEl.scrollHeight;
7392
+ }
7393
+
7394
+ function revealQuizTarget(selector) {
7395
+ if (!quizDialogEl || !selector) return false;
7396
+ const scrollEl = getQuizScrollContainer();
7397
+ const target = quizDialogEl.querySelector(selector);
7398
+ if (!scrollEl || !(target instanceof HTMLElement)) return false;
7399
+ const targetTop = target.offsetTop;
7400
+ const targetBottom = targetTop + target.offsetHeight;
7401
+ const visibleTop = scrollEl.scrollTop;
7402
+ const visibleBottom = visibleTop + scrollEl.clientHeight;
7403
+ if (targetTop >= visibleTop + 12 && targetBottom <= visibleBottom - 12) return true;
7404
+ scrollEl.scrollTop = Math.max(0, targetTop - 18);
7405
+ return true;
7406
+ }
7407
+
7408
+ function applyQuizScrollIntent(options, fallbackScrollTop, wasNearBottom) {
7409
+ const opts = options && typeof options === "object" ? options : {};
7410
+ if (opts.scrollToBottom || (opts.followBottomIfNearBottom && wasNearBottom)) {
7411
+ scrollQuizToBottom();
7412
+ return;
7413
+ }
7414
+ if (opts.revealSelector && revealQuizTarget(opts.revealSelector)) {
7415
+ return;
7416
+ }
7417
+ if (opts.preserveScroll) restoreQuizScrollTop(fallbackScrollTop);
7418
+ }
7419
+
7420
+ function applyQuizScrollIntentSoon(options, fallbackScrollTop, wasNearBottom) {
7421
+ applyQuizScrollIntent(options, fallbackScrollTop, wasNearBottom);
7422
+ window.requestAnimationFrame(() => applyQuizScrollIntent(options, fallbackScrollTop, wasNearBottom));
7423
+ }
7424
+
7425
+ function trimQuizMarkdownRenderCache() {
7426
+ while (quizMarkdownRenderCache.size > 80) {
7427
+ const firstKey = quizMarkdownRenderCache.keys().next().value;
7428
+ if (!firstKey) break;
7429
+ quizMarkdownRenderCache.delete(firstKey);
7430
+ }
7431
+ }
7432
+
7433
+ async function renderQuizMarkdownToHtml(markdown) {
7434
+ const source = String(markdown || "");
7435
+ const cacheKey = String(editorLanguage || "markdown") + "\n" + source;
7436
+ if (quizMarkdownRenderCache.has(cacheKey)) return quizMarkdownRenderCache.get(cacheKey);
7437
+ const renderedHtml = await renderMarkdownWithPandoc(source, { includeEditorLanguage: true });
7438
+ const sanitized = sanitizeRenderedHtml(renderedHtml, source, { stripMarkdownHtmlComments: editorLanguage !== "latex" });
7439
+ quizMarkdownRenderCache.set(cacheKey, sanitized);
7440
+ trimQuizMarkdownRenderCache();
7441
+ return sanitized;
7442
+ }
7443
+
7444
+ async function renderQuizMarkdownFields(nonce, options) {
7445
+ const opts = options && typeof options === "object" ? options : {};
7446
+ const fallbackScrollTop = Number(opts.fallbackScrollTop) || 0;
7447
+ const wasNearBottom = Boolean(opts.wasNearBottom);
7448
+ if (!quizDialogEl || !shouldRenderQuizMarkdownPreview()) {
7449
+ applyQuizScrollIntentSoon(opts, fallbackScrollTop, wasNearBottom);
7450
+ return;
7451
+ }
7452
+ const targets = Array.from(quizDialogEl.querySelectorAll("[data-quiz-markdown]")).filter((target) => target instanceof HTMLElement);
7453
+ const preserveScroll = Boolean(opts.preserveScroll || opts.revealSelector || opts.scrollToBottom || opts.followBottomIfNearBottom);
7454
+ for (const target of targets) {
7455
+ const markdown = target.getAttribute("data-quiz-markdown") || "";
7456
+ if (!markdown.trim()) continue;
7457
+ const scrollEl = preserveScroll ? getQuizScrollContainer() : null;
7458
+ const scrollTop = scrollEl ? scrollEl.scrollTop : fallbackScrollTop;
7459
+ try {
7460
+ const html = await renderQuizMarkdownToHtml(markdown);
7461
+ if (nonce !== quizPreviewRenderNonce || !quizDialogEl || !quizDialogEl.contains(target)) return;
7462
+ target.innerHTML = html;
7463
+ await renderAnnotationMathInElement(target);
7464
+ decoratePdfEmbeds(target);
7465
+ await renderPdfPreviewsInElement(target);
7466
+ await renderMermaidInElement(target);
7467
+ await renderMathFallbackInElement(target);
7468
+ decorateCopyablePreviewBlocks(target);
7469
+ if (preserveScroll) restoreQuizScrollTopSoon(scrollTop);
7470
+ } catch (error) {
7471
+ console.error("Quiz markdown preview render failed:", error);
7472
+ target.classList.add("studio-quiz-markdown-render-failed");
7473
+ }
7474
+ }
7475
+ applyQuizScrollIntentSoon(opts, fallbackScrollTop, wasNearBottom);
7476
+ }
7477
+
7478
+ function isQuizOpen() {
7479
+ return Boolean(quizOverlayEl && !quizOverlayEl.hidden);
7480
+ }
7481
+
7482
+ function getQuizCurrentCard() {
7483
+ if (!Array.isArray(quizState.cards) || quizState.cards.length === 0) return null;
7484
+ const index = Math.max(0, Math.min(quizState.index || 0, quizState.cards.length - 1));
7485
+ return quizState.cards[index] || null;
7486
+ }
7487
+
7488
+ function getQuizSourceLabel(scope) {
7489
+ const base = sourceState && sourceState.label ? sourceState.label : "Studio editor";
7490
+ return scope === "selection" ? base + " selection" : base;
7491
+ }
7492
+
7493
+ function ensureQuizOverlay() {
7494
+ if (quizOverlayEl && quizDialogEl) return quizOverlayEl;
7495
+ quizOverlayEl = document.createElement("div");
7496
+ quizOverlayEl.className = "studio-quiz-overlay";
7497
+ quizOverlayEl.setAttribute("role", "presentation");
7498
+ quizOverlayEl.hidden = true;
7499
+ quizOverlayEl.innerHTML = "<div class='studio-quiz-dialog' role='dialog' aria-modal='true' aria-label='Studio quiz'></div>";
7500
+ document.body.appendChild(quizOverlayEl);
7501
+ quizDialogEl = quizOverlayEl.querySelector(".studio-quiz-dialog");
7502
+ quizOverlayEl.addEventListener("click", (event) => {
7503
+ if (event.target === quizOverlayEl) closeQuizOverlay();
7504
+ });
7505
+ quizDialogEl.addEventListener("input", (event) => {
7506
+ const target = event.target;
7507
+ if (!(target instanceof HTMLElement)) return;
7508
+ if (target.matches("[data-quiz-input='answer']")) {
7509
+ const card = getQuizCurrentCard();
7510
+ if (card) card.answer = target.value;
7511
+ quizState.answer = target.value;
7512
+ }
7513
+ });
7514
+ quizDialogEl.addEventListener("click", (event) => {
7515
+ const target = event.target instanceof Element ? event.target.closest("[data-quiz-action]") : null;
7516
+ if (!target) return;
7517
+ event.preventDefault();
7518
+ handleQuizAction(target.getAttribute("data-quiz-action") || "");
7519
+ });
7520
+ quizDialogEl.addEventListener("keydown", handleQuizKeydown);
7521
+ return quizOverlayEl;
7522
+ }
7523
+
7524
+ function resetQuizStateFromEditor() {
7525
+ const previousAngle = normalizeQuizAngle(quizState.angle);
7526
+ const previousThinking = normalizeQuizThinking(quizState.thinking);
7527
+ const previousCount = quizState.questionCount || QUIZ_DEFAULT_COUNT;
7528
+ const selection = getEditorSelectionRange();
7529
+ const hasSelection = Boolean(selection.selected && selection.selected.trim());
7530
+ const scope = hasSelection ? "selection" : "editor";
7531
+ quizState = {
7532
+ open: true,
7533
+ requestId: null,
7534
+ pending: false,
7535
+ sourceText: hasSelection ? selection.selected : selection.raw,
7536
+ sourceLabel: getQuizSourceLabel(scope),
7537
+ scope,
7538
+ angle: previousAngle,
7539
+ thinking: previousThinking,
7540
+ questionCount: previousCount,
7541
+ cards: [],
7542
+ index: 0,
7543
+ answer: "",
7544
+ feedback: null,
7545
+ discussion: [],
7546
+ status: "",
7547
+ error: "",
7548
+ };
7549
+ }
7550
+
7551
+ function hasResumableQuiz() {
7552
+ return Boolean(
7553
+ quizState.pending ||
7554
+ (Array.isArray(quizState.cards) && quizState.cards.length > 0) ||
7555
+ (quizState.sourceText && (quizState.status || quizState.error))
7556
+ );
7557
+ }
7558
+
7559
+ function openQuizOverlay() {
7560
+ ensureQuizOverlay();
7561
+ if (!hasResumableQuiz()) {
7562
+ resetQuizStateFromEditor();
7563
+ } else {
7564
+ quizState.open = true;
7565
+ }
7566
+ quizOverlayEl.hidden = false;
7567
+ document.body.classList.add("studio-quiz-open");
7568
+ renderQuizOverlay();
7569
+ }
7570
+
7571
+ function closeQuizOverlay() {
7572
+ if (!quizOverlayEl) return;
7573
+ quizOverlayEl.hidden = true;
7574
+ document.body.classList.remove("studio-quiz-open");
7575
+ quizState.open = false;
7576
+ syncActionButtons();
7577
+ }
7578
+
7579
+ function minimizeQuizOverlay() {
7580
+ closeQuizOverlay();
7581
+ setStatus("Quiz minimized — use Review → Quiz me to resume.", "success");
7582
+ }
7583
+
7584
+ function handleQuizKeydown(event) {
7585
+ if (!event) return;
7586
+ const key = typeof event.key === "string" ? event.key : "";
7587
+ const plainEscape = key === "Escape" && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
7588
+ const submitShortcut = key === "Enter" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
7589
+ if (plainEscape) {
7590
+ event.preventDefault();
7591
+ event.stopPropagation();
7592
+ minimizeQuizOverlay();
7593
+ return;
7594
+ }
7595
+ if (submitShortcut) {
7596
+ event.preventDefault();
7597
+ event.stopPropagation();
7598
+ const card = getQuizCurrentCard();
7599
+ if (!card) {
7600
+ startQuizRequest();
7601
+ return;
7602
+ }
7603
+ if (!card.feedback) {
7604
+ checkQuizAnswer();
7605
+ return;
7606
+ }
7607
+ const promptEl = quizDialogEl ? quizDialogEl.querySelector("[data-quiz-field='discussion']") : null;
7608
+ const prompt = promptEl ? String(promptEl.value || "").trim() : "";
7609
+ if (prompt) {
7610
+ discussQuizCard();
7611
+ } else if (quizState.index < quizState.cards.length - 1) {
7612
+ quizState.index = Math.min(quizState.cards.length - 1, (quizState.index || 0) + 1);
7613
+ quizState.error = "";
7614
+ quizState.status = "";
7615
+ renderQuizOverlay();
7616
+ }
7617
+ return;
7618
+ }
7619
+ event.stopPropagation();
7620
+ }
7621
+
7622
+ function renderQuizOption(value, selected, label) {
7623
+ return "<option value='" + escapeHtml(value) + "'" + (value === selected ? " selected" : "") + ">" + escapeHtml(label) + "</option>";
7624
+ }
7625
+
7626
+ function renderQuizSetupHtml() {
7627
+ const scope = quizState.scope === "selection" ? "selection" : "editor";
7628
+ const angle = normalizeQuizAngle(quizState.angle);
7629
+ const thinking = normalizeQuizThinking(quizState.thinking);
7630
+ const count = Math.max(1, Math.min(8, Math.floor(Number(quizState.questionCount) || QUIZ_DEFAULT_COUNT)));
7631
+ const selection = getEditorSelectionRange();
7632
+ const hasSelection = Boolean(selection.selected && selection.selected.trim());
7633
+ return "<div class='studio-quiz-setup'>"
7634
+ + "<p class='studio-quiz-copy'>A short active-recall loop: answer one question, check it, ask about the card if useful, then move on.</p>"
7635
+ + "<div class='studio-quiz-fields'>"
7636
+ + "<label>Scope<select data-quiz-field='scope'>"
7637
+ + renderQuizOption("editor", scope, "Editor")
7638
+ + (hasSelection ? renderQuizOption("selection", scope, "Selection") : "")
7639
+ + "</select></label>"
7640
+ + "<label>Angle<select data-quiz-field='angle'>"
7641
+ + QUIZ_ANGLES.map((candidate) => renderQuizOption(candidate, angle, getQuizAngleLabel(candidate))).join("")
7642
+ + "</select></label>"
7643
+ + "<label>Thinking<select data-quiz-field='thinking'>"
7644
+ + QUIZ_THINKING_LEVELS.map((candidate) => renderQuizOption(candidate, thinking, getQuizThinkingLabel(candidate))).join("")
7645
+ + "</select></label>"
7646
+ + "<label>Questions<input data-quiz-field='count' type='number' min='1' max='8' value='" + String(count) + "'></label>"
7647
+ + "</div>"
7648
+ + "<div class='studio-quiz-source-note'>Source: " + escapeHtml(getQuizSourceLabel(scope)) + " · " + escapeHtml(String((scope === "selection" ? selection.selected : selection.raw).trim().length)) + " chars · Studio model: " + escapeHtml(getQuizModelLabel()) + "</div>"
7649
+ + (quizState.error ? "<div class='studio-quiz-error'>" + escapeHtml(quizState.error) + "</div>" : "")
7650
+ + (quizState.status ? "<div class='studio-quiz-status'>" + escapeHtml(quizState.status) + "</div>" : "")
7651
+ + "<div class='studio-quiz-actions'><button data-quiz-action='start' type='button'" + (quizState.pending ? " disabled" : "") + ">" + (quizState.pending ? "Generating…" : "Start quiz") + "</button></div>"
7652
+ + "</div>";
7653
+ }
7654
+
7655
+ function getQuizScrollContainer() {
7656
+ if (!quizDialogEl) return null;
7657
+ return quizDialogEl.querySelector(".studio-quiz-card, .studio-quiz-setup");
7658
+ }
7659
+
7660
+ function renderQuizCardHtml() {
7661
+ const card = getQuizCurrentCard();
7662
+ if (!card) return renderQuizSetupHtml();
7663
+ const total = quizState.cards.length;
7664
+ const index = Math.max(0, Math.min(quizState.index || 0, total - 1));
7665
+ const feedback = card.feedback || null;
7666
+ const answer = typeof card.answer === "string" ? card.answer : "";
7667
+ const discussion = Array.isArray(card.discussion) ? card.discussion : [];
7668
+ const scoreClass = feedback && feedback.score ? String(feedback.score).toLowerCase().replace(/[^a-z0-9_-]/g, "") : "";
7669
+ const idealAnswer = feedback && feedback.idealAnswer ? feedback.idealAnswer : (card.idealAnswer || "");
7670
+ const kindLabel = getQuizKindLabel(card.kind);
7671
+ const cardMeta = [kindLabel, getQuizAngleLabel(quizState.angle), quizState.sourceLabel || "Studio editor"].filter(Boolean).join(" · ");
7672
+ const renderMarkdown = shouldRenderQuizMarkdownPreview();
7673
+ return "<div class='studio-quiz-card'>"
7674
+ + "<div class='studio-quiz-meta'><span>Question " + String(index + 1) + " of " + String(total) + "</span><span>" + escapeHtml(cardMeta) + "</span></div>"
7675
+ + (card.snippet ? (renderMarkdown ? renderQuizMarkdownBlockHtml(card.snippet, "studio-quiz-snippet") : "<pre class='studio-quiz-snippet'><code>" + escapeHtml(card.snippet) + "</code></pre>") : "")
7676
+ + (renderMarkdown ? renderQuizMarkdownBlockHtml(card.question || "", "studio-quiz-question") : "<div class='studio-quiz-question'>" + escapeHtml(card.question || "") + "</div>")
7677
+ + "<label class='studio-quiz-answer-label'>Your answer<textarea data-quiz-input='answer' rows='6' placeholder='Explain it in your own words…'" + (feedback ? " disabled" : "") + ">" + escapeHtml(answer) + "</textarea></label>"
7678
+ + (feedback ? "<div class='studio-quiz-feedback studio-quiz-score-" + escapeHtml(scoreClass) + "'>"
7679
+ + "<div class='studio-quiz-feedback-title'>" + escapeHtml(feedback.score || "feedback") + "</div>"
7680
+ + (feedback.feedback ? renderQuizMarkdownBlockHtml(feedback.feedback, "studio-quiz-feedback-text") : "")
7681
+ + (idealAnswer ? "<div class='studio-quiz-ideal'><strong>Stronger answer</strong>" + renderQuizMarkdownBlockHtml(idealAnswer, "studio-quiz-feedback-text") + "</div>" : "")
7682
+ + (feedback.followUp ? "<div class='studio-quiz-follow-up'><strong>Suggested stretch question</strong>" + renderQuizMarkdownBlockHtml(feedback.followUp, "studio-quiz-feedback-text") + "</div>" : "")
7683
+ + "</div>" : "")
7684
+ + (discussion.length ? "<div class='studio-quiz-discussion'>" + discussion.map((entry) => "<div class='studio-quiz-discussion-entry studio-quiz-discussion-" + escapeHtml(entry.role || "assistant") + "'><strong>" + escapeHtml(entry.role === "user" ? "You" : "Tutor") + "</strong><p>" + escapeHtml(entry.text || "") + "</p></div>").join("") + "</div>" : "")
7685
+ + (feedback ? "<div class='studio-quiz-discuss-row'><textarea data-quiz-field='discussion' rows='2' placeholder='Ask the tutor about this card…'></textarea><button data-quiz-action='discuss' type='button'" + (quizState.pending ? " disabled" : "") + ">Ask</button></div>" : "")
7686
+ + (quizState.error ? "<div class='studio-quiz-error'>" + escapeHtml(quizState.error) + "</div>" : "")
7687
+ + (quizState.status ? "<div class='studio-quiz-status'>" + escapeHtml(quizState.status) + "</div>" : "")
7688
+ + "<div class='studio-quiz-actions studio-quiz-card-actions'>"
7689
+ + "<button data-quiz-action='previous' type='button'" + (index <= 0 ? " disabled" : "") + ">Previous</button>"
7690
+ + (feedback ? "<button data-quiz-action='next' type='button'" + (index >= total - 1 ? " disabled" : "") + ">Next</button>" : "<button data-quiz-action='check' type='button'" + (quizState.pending ? " disabled" : "") + ">" + (quizState.pending ? "Checking…" : "Check answer") + "</button>")
7691
+ + "<button data-quiz-action='restart' type='button'>New quiz</button>"
7692
+ + "</div>"
7693
+ + "</div>";
7694
+ }
7695
+
7696
+ function renderQuizOverlay(options) {
7697
+ if (!quizDialogEl) return;
7698
+ const scrollOptions = options && typeof options === "object" ? options : {};
7699
+ const preserveScroll = Boolean(scrollOptions.preserveScroll || scrollOptions.revealSelector || scrollOptions.scrollToBottom || scrollOptions.followBottomIfNearBottom);
7700
+ const previousScrollEl = getQuizScrollContainer();
7701
+ const previousScrollTop = previousScrollEl ? previousScrollEl.scrollTop : 0;
7702
+ const wasNearBottom = isQuizScrollNearBottom(previousScrollEl);
7703
+ const bodyHtml = quizState.cards && quizState.cards.length ? renderQuizCardHtml() : renderQuizSetupHtml();
7704
+ quizDialogEl.innerHTML = "<div class='studio-quiz-header'>"
7705
+ + "<div><div class='studio-quiz-eyebrow'>Review</div><h2>Quiz me</h2></div>"
7706
+ + "<div class='studio-quiz-header-actions'>"
7707
+ + "<button class='studio-quiz-minimize' data-quiz-action='minimize' type='button'>Minimize</button>"
7708
+ + "<button class='studio-quiz-close' data-quiz-action='close' type='button' aria-label='Close quiz'>Close</button>"
7709
+ + "</div>"
7710
+ + "</div>"
7711
+ + bodyHtml;
7712
+ if (preserveScroll) {
7713
+ const nextScrollEl = getQuizScrollContainer();
7714
+ if (nextScrollEl) {
7715
+ nextScrollEl.scrollTop = previousScrollTop;
7716
+ window.requestAnimationFrame(() => {
7717
+ const rafScrollEl = getQuizScrollContainer();
7718
+ if (rafScrollEl) rafScrollEl.scrollTop = previousScrollTop;
7719
+ });
7720
+ }
7721
+ }
7722
+ applyQuizScrollIntentSoon(scrollOptions, previousScrollTop, wasNearBottom);
7723
+ const renderNonce = ++quizPreviewRenderNonce;
7724
+ void renderQuizMarkdownFields(renderNonce, {
7725
+ ...scrollOptions,
7726
+ preserveScroll,
7727
+ fallbackScrollTop: previousScrollTop,
7728
+ wasNearBottom,
7729
+ });
7730
+ }
7731
+
7732
+ function readQuizSetupFields() {
7733
+ if (!quizDialogEl) return;
7734
+ const scopeEl = quizDialogEl.querySelector("[data-quiz-field='scope']");
7735
+ const angleEl = quizDialogEl.querySelector("[data-quiz-field='angle']");
7736
+ const thinkingEl = quizDialogEl.querySelector("[data-quiz-field='thinking']");
7737
+ const countEl = quizDialogEl.querySelector("[data-quiz-field='count']");
7738
+ const selection = getEditorSelectionRange();
7739
+ const scope = scopeEl && scopeEl.value === "selection" && selection.selected.trim() ? "selection" : "editor";
7740
+ quizState.scope = scope;
7741
+ quizState.angle = normalizeQuizAngle(angleEl ? angleEl.value : quizState.angle);
7742
+ quizState.thinking = normalizeQuizThinking(thinkingEl ? thinkingEl.value : quizState.thinking);
7743
+ quizState.questionCount = Math.max(1, Math.min(8, Math.floor(Number(countEl ? countEl.value : quizState.questionCount) || QUIZ_DEFAULT_COUNT)));
7744
+ quizState.sourceText = scope === "selection" ? selection.selected : selection.raw;
7745
+ quizState.sourceLabel = getQuizSourceLabel(scope);
7746
+ }
7747
+
7748
+ function startQuizRequest() {
7749
+ readQuizSetupFields();
7750
+ const sourceText = String(quizState.sourceText || "").trim();
7751
+ if (!sourceText) {
7752
+ quizState.error = "Quiz source is empty.";
7753
+ renderQuizOverlay({ preserveScroll: true });
7754
+ return;
7755
+ }
7756
+ const requestId = makeRequestId();
7757
+ quizState.requestId = requestId;
7758
+ quizState.pending = true;
7759
+ quizState.error = "";
7760
+ quizState.status = "Generating quiz…";
7761
+ renderQuizOverlay({ preserveScroll: true });
7762
+ if (!sendMessage({
7763
+ type: "quiz_generate_request",
7764
+ requestId,
7765
+ sourceText,
7766
+ sourceLabel: quizState.sourceLabel,
7767
+ scope: quizState.scope,
7768
+ angle: quizState.angle,
7769
+ thinking: quizState.thinking,
7770
+ questionCount: quizState.questionCount,
7771
+ })) {
7772
+ quizState.pending = false;
7773
+ quizState.status = "";
7774
+ quizState.error = "Not connected to Studio server.";
7775
+ renderQuizOverlay({ preserveScroll: true });
7776
+ }
7777
+ }
7778
+
7779
+ function checkQuizAnswer() {
7780
+ const card = getQuizCurrentCard();
7781
+ if (!card) return;
7782
+ const answer = String(card.answer || quizState.answer || "").trim();
7783
+ if (!answer) {
7784
+ quizState.error = "Write an answer first.";
7785
+ renderQuizOverlay({ preserveScroll: true });
7786
+ return;
7787
+ }
7788
+ const requestId = makeRequestId();
7789
+ quizState.requestId = requestId;
7790
+ quizState.pending = true;
7791
+ quizState.error = "";
7792
+ quizState.status = "Checking answer…";
7793
+ renderQuizOverlay({ preserveScroll: true });
7794
+ if (!sendMessage({
7795
+ type: "quiz_answer_request",
7796
+ requestId,
7797
+ question: card.question || "",
7798
+ snippet: card.snippet || "",
7799
+ answer,
7800
+ idealAnswer: card.idealAnswer || "",
7801
+ angle: quizState.angle,
7802
+ thinking: quizState.thinking,
7803
+ sourceLabel: quizState.sourceLabel,
7804
+ })) {
7805
+ quizState.pending = false;
7806
+ quizState.status = "";
7807
+ quizState.error = "Not connected to Studio server.";
7808
+ renderQuizOverlay({ preserveScroll: true });
7809
+ }
7810
+ }
7811
+
7812
+ function discussQuizCard() {
7813
+ const card = getQuizCurrentCard();
7814
+ if (!card || !quizDialogEl) return;
7815
+ const promptEl = quizDialogEl.querySelector("[data-quiz-field='discussion']");
7816
+ const prompt = promptEl ? String(promptEl.value || "").trim() : "";
7817
+ if (!prompt) {
7818
+ quizState.error = "Write a follow-up question first.";
7819
+ renderQuizOverlay({ preserveScroll: true });
7820
+ return;
7821
+ }
7822
+ const requestId = makeRequestId();
7823
+ quizState.requestId = requestId;
7824
+ quizState.pending = true;
7825
+ quizState.error = "";
7826
+ quizState.status = "Discussing…";
7827
+ card.discussion = Array.isArray(card.discussion) ? card.discussion.concat([{ role: "user", text: prompt }]) : [{ role: "user", text: prompt }];
7828
+ renderQuizOverlay({ preserveScroll: true });
7829
+ if (!sendMessage({
7830
+ type: "quiz_discuss_request",
7831
+ requestId,
7832
+ question: card.question || "",
7833
+ snippet: card.snippet || "",
7834
+ answer: card.answer || "",
7835
+ feedback: card.feedback && card.feedback.feedback ? card.feedback.feedback : "",
7836
+ prompt,
7837
+ angle: quizState.angle,
7838
+ thinking: quizState.thinking,
7839
+ sourceLabel: quizState.sourceLabel,
7840
+ })) {
7841
+ quizState.pending = false;
7842
+ quizState.status = "";
7843
+ quizState.error = "Not connected to Studio server.";
7844
+ renderQuizOverlay({ preserveScroll: true });
7845
+ }
7846
+ }
7847
+
7848
+ function handleQuizAction(action) {
7849
+ if (action === "close") {
7850
+ closeQuizOverlay();
7851
+ return;
7852
+ }
7853
+ if (action === "minimize") {
7854
+ minimizeQuizOverlay();
7855
+ return;
7856
+ }
7857
+ if (action === "start") {
7858
+ startQuizRequest();
7859
+ return;
7860
+ }
7861
+ if (action === "check") {
7862
+ checkQuizAnswer();
7863
+ return;
7864
+ }
7865
+ if (action === "discuss") {
7866
+ discussQuizCard();
7867
+ return;
7868
+ }
7869
+ if (action === "previous") {
7870
+ quizState.index = Math.max(0, (quizState.index || 0) - 1);
7871
+ quizState.error = "";
7872
+ quizState.status = "";
7873
+ renderQuizOverlay();
7874
+ return;
7875
+ }
7876
+ if (action === "next") {
7877
+ quizState.index = Math.min(Math.max(0, quizState.cards.length - 1), (quizState.index || 0) + 1);
7878
+ quizState.error = "";
7879
+ quizState.status = "";
7880
+ renderQuizOverlay();
7881
+ return;
7882
+ }
7883
+ if (action === "restart") {
7884
+ resetQuizStateFromEditor();
7885
+ renderQuizOverlay();
7886
+ }
7887
+ }
7888
+
7889
+ function handleQuizServerMessage(message) {
7890
+ if (!quizState.requestId || typeof message.requestId !== "string" || message.requestId !== quizState.requestId) return false;
7891
+ if (message.type === "quiz_progress") {
7892
+ quizState.pending = true;
7893
+ quizState.status = typeof message.message === "string" ? message.message : "Working…";
7894
+ quizState.error = "";
7895
+ renderQuizOverlay({ preserveScroll: true });
7896
+ return true;
7897
+ }
7898
+ if (message.type === "quiz_error") {
7899
+ quizState.pending = false;
7900
+ quizState.status = "";
7901
+ quizState.error = typeof message.message === "string" ? message.message : "Quiz request failed.";
7902
+ renderQuizOverlay({ preserveScroll: true });
7903
+ return true;
7904
+ }
7905
+ if (message.type === "quiz_generated") {
7906
+ const cards = Array.isArray(message.cards) ? message.cards : [];
7907
+ quizState.pending = false;
7908
+ quizState.status = "";
7909
+ quizState.error = "";
7910
+ quizState.cards = cards.map((card, index) => ({
7911
+ id: typeof card.id === "string" ? card.id : "q" + String(index + 1),
7912
+ kind: typeof card.kind === "string" ? card.kind : "",
7913
+ snippet: typeof card.snippet === "string" ? card.snippet : "",
7914
+ question: typeof card.question === "string" ? card.question : "",
7915
+ idealAnswer: typeof card.idealAnswer === "string" ? card.idealAnswer : "",
7916
+ answer: "",
7917
+ feedback: null,
7918
+ discussion: [],
7919
+ })).filter((card) => card.question);
7920
+ quizState.index = 0;
7921
+ quizState.angle = normalizeQuizAngle(message.angle || quizState.angle);
7922
+ quizState.thinking = normalizeQuizThinking(message.thinking || quizState.thinking);
7923
+ quizState.sourceLabel = typeof message.sourceLabel === "string" ? message.sourceLabel : quizState.sourceLabel;
7924
+ if (!quizState.cards.length) quizState.error = "No quiz questions were generated.";
7925
+ renderQuizOverlay();
7926
+ return true;
7927
+ }
7928
+ if (message.type === "quiz_feedback") {
7929
+ const card = getQuizCurrentCard();
7930
+ quizState.pending = false;
7931
+ quizState.status = "";
7932
+ quizState.error = "";
7933
+ if (card) card.feedback = message.feedback || null;
7934
+ renderQuizOverlay({ revealSelector: ".studio-quiz-feedback", followBottomIfNearBottom: true });
7935
+ return true;
7936
+ }
7937
+ if (message.type === "quiz_discussion") {
7938
+ const card = getQuizCurrentCard();
7939
+ quizState.pending = false;
7940
+ quizState.status = "";
7941
+ quizState.error = "";
7942
+ if (card) {
7943
+ const answer = typeof message.answer === "string" ? message.answer : "";
7944
+ card.discussion = Array.isArray(card.discussion) ? card.discussion.concat([{ role: "assistant", text: answer }]) : [{ role: "assistant", text: answer }];
7945
+ }
7946
+ renderQuizOverlay({ scrollToBottom: true });
7947
+ return true;
7948
+ }
7949
+ return false;
7950
+ }
7951
+
7275
7952
  function escapeHtml(text) {
7276
7953
  return text
7277
7954
  .replace(/&/g, "&amp;")
@@ -12403,6 +13080,10 @@
12403
13080
  critiqueBtn.disabled = true;
12404
13081
  critiqueBtn.title = "Critique is unavailable in editor-only mode.";
12405
13082
  }
13083
+ if (quizBtn) {
13084
+ quizBtn.disabled = true;
13085
+ quizBtn.title = "Quiz is unavailable in editor-only mode.";
13086
+ }
12406
13087
  syncStudioUiRefreshReviewTrigger();
12407
13088
  return;
12408
13089
  }
@@ -12463,6 +13144,15 @@
12463
13144
  ? "Critique text as-is (includes [an: ...] markers)."
12464
13145
  : "Critique text with [an: ...] markers stripped."));
12465
13146
  }
13147
+ if (quizBtn) {
13148
+ quizBtn.textContent = hasResumableQuiz() ? "Resume quiz" : "Quiz me";
13149
+ quizBtn.disabled = wsState === "Disconnected" || uiBusy || canQueueSteering;
13150
+ quizBtn.title = canQueueSteering
13151
+ ? "Quiz is unavailable while Run editor text is active."
13152
+ : (hasResumableQuiz()
13153
+ ? "Resume the current Studio quiz."
13154
+ : "Open an active quiz for the current editor selection or document.");
13155
+ }
12466
13156
  syncStudioUiRefreshReviewTrigger();
12467
13157
  }
12468
13158
 
@@ -12606,6 +13296,16 @@
12606
13296
  updateFooterMeta();
12607
13297
  }
12608
13298
 
13299
+ if (
13300
+ message.type === "quiz_progress" ||
13301
+ message.type === "quiz_generated" ||
13302
+ message.type === "quiz_feedback" ||
13303
+ message.type === "quiz_discussion" ||
13304
+ message.type === "quiz_error"
13305
+ ) {
13306
+ if (handleQuizServerMessage(message)) return;
13307
+ }
13308
+
12609
13309
  if (message.type === "debug_event") {
12610
13310
  debugTrace("server_debug_event", summarizeServerMessage(message));
12611
13311
  return;
@@ -13933,6 +14633,16 @@
13933
14633
  }
13934
14634
  });
13935
14635
 
14636
+ if (quizBtn) {
14637
+ quizBtn.addEventListener("click", () => {
14638
+ if (!hasResumableQuiz() && !String(sourceTextEl.value || "").trim()) {
14639
+ setStatus("Add editor text before starting a quiz.", "warning");
14640
+ return;
14641
+ }
14642
+ openQuizOverlay();
14643
+ });
14644
+ }
14645
+
13936
14646
  loadResponseBtn.addEventListener("click", () => {
13937
14647
  if (!latestResponseMarkdown.trim()) {
13938
14648
  setStatus("No response available yet.", "warning");