pi-studio 0.9.2 → 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 = "";
@@ -1679,7 +1705,6 @@
1679
1705
  let lineNumbersEnabled = false;
1680
1706
  let lineNumbersRenderRaf = null;
1681
1707
  let annotationsEnabled = true;
1682
- const STUDIO_UI_REFRESH_STORAGE_KEY = "piStudio.uiRefresh";
1683
1708
  const STUDIO_ZEN_MODE_STORAGE_KEY = "piStudio.zenMode";
1684
1709
  const studioUiRefreshEnabled = readStudioUiRefreshEnabled();
1685
1710
  const EDITOR_FONT_SIZE_OPTIONS = [10, 11, 12, 13, 14, 15, 16, 18];
@@ -1724,16 +1749,8 @@
1724
1749
  const isFalsey = (value) => ["0", "false", "no", "off", "classic"].indexOf(normalize(value)) !== -1;
1725
1750
  if (queryValue !== null) {
1726
1751
  const normalizedQuery = normalize(queryValue);
1727
- const enabled = isTruthy(queryValue) || (!isFalsey(queryValue) && normalizedQuery !== "");
1728
- try {
1729
- window.localStorage && window.localStorage.setItem(STUDIO_UI_REFRESH_STORAGE_KEY, enabled ? "1" : "0");
1730
- } catch {}
1731
- return enabled;
1752
+ return isTruthy(queryValue) || (!isFalsey(queryValue) && normalizedQuery !== "");
1732
1753
  }
1733
- try {
1734
- const stored = window.localStorage ? window.localStorage.getItem(STUDIO_UI_REFRESH_STORAGE_KEY) : null;
1735
- if (stored !== null) return stored !== "0" && !isFalsey(stored);
1736
- } catch {}
1737
1754
  return true;
1738
1755
  }
1739
1756
 
@@ -1762,7 +1779,7 @@
1762
1779
  function syncStudioZenModeUi() {
1763
1780
  if (document.body) document.body.classList.toggle("studio-zen-mode", studioZenModeEnabled);
1764
1781
  if (!zenModeBtn) return;
1765
- zenModeBtn.textContent = studioZenModeEnabled ? "Exit Zen" : "Zen";
1782
+ zenModeBtn.textContent = studioZenModeEnabled ? "Exit Zen" : "Zen";
1766
1783
  zenModeBtn.title = studioZenModeEnabled ? "Show full Studio controls." : "Hide secondary Studio controls.";
1767
1784
  zenModeBtn.setAttribute("aria-pressed", studioZenModeEnabled ? "true" : "false");
1768
1785
  }
@@ -2001,37 +2018,6 @@
2001
2018
  return { name, anchor: anchorEl, button: buttonEl, menu: menuEl };
2002
2019
  }
2003
2020
 
2004
- function setStudioUiRefreshPreference(enabled) {
2005
- try {
2006
- window.localStorage && window.localStorage.setItem(STUDIO_UI_REFRESH_STORAGE_KEY, enabled ? "1" : "0");
2007
- } catch {}
2008
- try {
2009
- const url = new URL(window.location.href);
2010
- url.searchParams.set("uiRefresh", enabled ? "1" : "0");
2011
- window.location.assign(url.toString());
2012
- } catch {
2013
- window.location.reload();
2014
- }
2015
- }
2016
-
2017
- function setupStudioUiRefreshToggleButton() {
2018
- if (!footerMetaEl || document.getElementById("studioUiRefreshToggleBtn")) return;
2019
- const button = makeStudioUiRefreshElement("button", "footer-compact-btn studio-ui-refresh-toggle", studioUiRefreshEnabled ? "UI: Fresh" : "UI: Classic");
2020
- button.id = "studioUiRefreshToggleBtn";
2021
- button.type = "button";
2022
- button.title = studioUiRefreshEnabled
2023
- ? "Switch Studio to the classic layout."
2024
- : "Switch Studio to the refreshed layout.";
2025
- button.addEventListener("click", () => {
2026
- setStudioUiRefreshPreference(!studioUiRefreshEnabled);
2027
- });
2028
- if (compactBtn && compactBtn.parentNode === footerMetaEl) {
2029
- compactBtn.insertAdjacentElement("afterend", button);
2030
- } else {
2031
- footerMetaEl.appendChild(button);
2032
- }
2033
- }
2034
-
2035
2021
  function setupStudioUiRefreshPrototype() {
2036
2022
  if (!studioUiRefreshEnabled || studioUiRefreshUi) return;
2037
2023
  const leftHeaderEl = document.getElementById("leftSectionHeader");
@@ -2042,7 +2028,7 @@
2042
2028
  if (!isEditorOnlyMode && critiqueBtn && lensSelect) {
2043
2029
  const reviewButton = makeStudioUiRefreshElement("button", "studio-refresh-tool-tab studio-refresh-review-btn", "Review");
2044
2030
  reviewMenu = makeStudioUiRefreshMenu(reviewButton, "review", "studio-refresh-review-anchor");
2045
- appendStudioUiRefreshMenuSection(reviewMenu.menu, "Action", [critiqueBtn]);
2031
+ appendStudioUiRefreshMenuSection(reviewMenu.menu, "Action", [critiqueBtn, quizBtn]);
2046
2032
  appendStudioUiRefreshMenuSection(reviewMenu.menu, "Setting", [lensSelect]);
2047
2033
  }
2048
2034
 
@@ -2161,7 +2147,6 @@
2161
2147
  syncStudioUiRefreshSummaries();
2162
2148
  }
2163
2149
 
2164
- setupStudioUiRefreshToggleButton();
2165
2150
  setupStudioUiRefreshPrototype();
2166
2151
  syncStudioZenModeUi();
2167
2152
  const annotationHelpers = globalThis.PiStudioAnnotationHelpers;
@@ -3051,6 +3036,18 @@
3051
3036
  && typeof studioPdfFocusDialogEl.contains === "function"
3052
3037
  && studioPdfFocusDialogEl.contains(event.target)
3053
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
+ }
3054
3051
 
3055
3052
  if (isStudioPdfFocusOpen() && plainEscape) {
3056
3053
  event.preventDefault();
@@ -3076,7 +3073,7 @@
3076
3073
  return;
3077
3074
  }
3078
3075
 
3079
- if (scratchpadOwnsEvent || reviewNotesOwnsEvent || outlineOwnsEvent || pdfFocusOwnsEvent) {
3076
+ if (scratchpadOwnsEvent || reviewNotesOwnsEvent || outlineOwnsEvent || pdfFocusOwnsEvent || quizOwnsEvent) {
3080
3077
  return;
3081
3078
  }
3082
3079
 
@@ -7313,6 +7310,645 @@
7313
7310
  return "draft_" + makeRequestId();
7314
7311
  }
7315
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
+
7316
7952
  function escapeHtml(text) {
7317
7953
  return text
7318
7954
  .replace(/&/g, "&amp;")
@@ -12444,6 +13080,10 @@
12444
13080
  critiqueBtn.disabled = true;
12445
13081
  critiqueBtn.title = "Critique is unavailable in editor-only mode.";
12446
13082
  }
13083
+ if (quizBtn) {
13084
+ quizBtn.disabled = true;
13085
+ quizBtn.title = "Quiz is unavailable in editor-only mode.";
13086
+ }
12447
13087
  syncStudioUiRefreshReviewTrigger();
12448
13088
  return;
12449
13089
  }
@@ -12504,6 +13144,15 @@
12504
13144
  ? "Critique text as-is (includes [an: ...] markers)."
12505
13145
  : "Critique text with [an: ...] markers stripped."));
12506
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
+ }
12507
13156
  syncStudioUiRefreshReviewTrigger();
12508
13157
  }
12509
13158
 
@@ -12647,6 +13296,16 @@
12647
13296
  updateFooterMeta();
12648
13297
  }
12649
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
+
12650
13309
  if (message.type === "debug_event") {
12651
13310
  debugTrace("server_debug_event", summarizeServerMessage(message));
12652
13311
  return;
@@ -13974,6 +14633,16 @@
13974
14633
  }
13975
14634
  });
13976
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
+
13977
14646
  loadResponseBtn.addEventListener("click", () => {
13978
14647
  if (!latestResponseMarkdown.trim()) {
13979
14648
  setStatus("No response available yet.", "warning");