dsh-plugin-lookatstudy 0.19.0 → 0.20.1

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/lib/client.js CHANGED
@@ -152,8 +152,9 @@ window.__ModuleLoader__.load({
152
152
  .lks-ui .lks14-pill.on{background:rgb(var(--accent-rgb)/0.12);color:var(--accent-light)}
153
153
 
154
154
  /* ── notebook: teach prose + view tabs ── */
155
- .lks-ui .lks14-viewtab{color:var(--ink-muted);border-radius:999px}
156
- .lks-ui .lks14-viewtab.on{background:rgb(var(--ink-rgb)/0.08);color:var(--ink-strong)}
155
+ .lks-ui .lks14-viewtab{color:var(--ink-muted);border-radius:8px;transition:color .15s,background .15s}
156
+ .lks-ui .lks14-viewtab:hover{color:var(--ink)}
157
+ .lks-ui .lks14-viewtab.on{background:rgb(var(--brand-rgb)/0.16);color:var(--brand)}
157
158
  .lks-ui .lks14-prose{color:var(--ink);font-size:.9375rem;line-height:1.65}
158
159
  .lks-ui .lks14-prose h1,.lks-ui .lks-note-text h1{font-size:1.25rem;font-weight:800;color:var(--ink-strong)}
159
160
  .lks-ui .lks14-prose h2,.lks-ui .lks-note-text h2{font-size:1.1rem;font-weight:700;color:var(--ink-strong);border-bottom:1px solid var(--border);padding-bottom:.3em}
@@ -390,7 +391,7 @@ window.__ModuleLoader__.load({
390
391
  .lks-ui .lks14-emptycard-hint{font-size:.825rem;color:var(--ink-muted);line-height:1.6}
391
392
  /* B5: notebook tab capsule + 960px reading column */
392
393
  .lks-ui .lks14-notebody{margin:0 auto;max-width:960px;width:100%;padding:12px 20px 24px;box-sizing:border-box}
393
- .lks-ui .lks14-viewtabs{background:rgb(var(--ink-rgb)/0.08);border-radius:999px;padding:3px;align-self:flex-start}
394
+ .lks-ui .lks14-viewtabs{background:var(--surface-2);border-radius:10px;padding:3px;align-self:flex-start}
394
395
  .lks-ui .lks14-readbar{position:sticky;top:0;z-index:20;background:var(--surface-2);border-radius:10px}
395
396
 
396
397
  /* ══════════ C-track (P11a): interaction chrome ══════════ */
@@ -445,7 +446,7 @@ window.__ModuleLoader__.load({
445
446
  /* C6: zone head becomes the collapse toggle */
446
447
  .lks-ui .lks14-zoneh{display:flex;align-items:center;gap:8px;background:none;border:none;font:inherit;cursor:pointer;text-align:left;padding:0}
447
448
  .lks-ui .lks14-zonecount{font-size:10.5px;font-weight:700;color:var(--brand);background:rgb(var(--brand-rgb)/0.15);border-radius:999px;padding:1px 7px}
448
- .lks-ui .lks14-zonecaret{color:var(--ink-faint);font-size:10px}
449
+ .lks-ui .lks14-zonecaret{color:var(--ink-muted);font-size:11px;line-height:1}
449
450
  /* C6: note actions + pinned + edit */
450
451
  .lks-ui .lks-note-act{background:none;border:none;color:var(--ink-faint);cursor:pointer;padding:2px;display:inline-flex}
451
452
  .lks-ui .lks-note-act:hover{color:var(--ink)}
@@ -690,14 +691,9 @@ window.__ModuleLoader__.load({
690
691
  .lks-ui .lks14-palette-text{display:flex;flex-direction:column;gap:2px;min-width:0}
691
692
  .lks-ui .lks14-palette-sub{font-size:10.5px;color:var(--ink-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
692
693
  .lks-ui .lks14-palette-empty{padding:18px;text-align:center;color:var(--ink-faint);font-size:12px}
693
- /* E5: the thread pills under the lesson row */
694
+ /* E5: the lesson row carries the labeled thread chip (menu opens on click) */
694
695
  .lks-ui .lks14-lessonrow{display:flex;align-items:center;gap:8px;min-width:0}
695
696
  .lks-ui .lks14-lessonrow>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
696
- .lks-ui .lks14-threadpills{display:flex;gap:4px;margin-left:auto;overflow-x:auto;max-width:55%;scrollbar-width:none}
697
- .lks-ui .lks14-threadpill{flex:none;border:1px solid var(--border-faint);background:var(--surface-1);color:var(--ink-muted);font:inherit;font-size:10.5px;padding:2.5px 8px;border-radius:999px;cursor:pointer;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:all .15s}
698
- .lks-ui .lks14-threadpill:hover{color:var(--ink);border-color:var(--border)}
699
- .lks-ui .lks14-threadpill.on{background:color-mix(in srgb,var(--brand) 16%,transparent);border-color:rgb(var(--brand-rgb)/0.4);color:var(--brand)}
700
- .lks-ui .lks14-threadpill:focus-visible{box-shadow:0 0 0 2px var(--business-primary);outline:none}
701
697
  /* E7: the font-scale pair in the app header */
702
698
  .lks-ui .lks-hdr-zoom{display:flex;align-items:center;gap:4px;margin-left:4px}
703
699
  .lks-ui .lks-hdr-zoom-btn{border:1px solid var(--border-faint);background:var(--surface-1);color:var(--ink-muted);font:inherit;font-size:10px;font-weight:700;border-radius:6px;padding:2px 6px;cursor:pointer;transition:all .15s}
@@ -706,12 +702,30 @@ window.__ModuleLoader__.load({
706
702
  .lks-ui .lks-hdr-zoom-btn:focus-visible{box-shadow:0 0 0 2px var(--business-primary);outline:none}
707
703
  .lks-ui .lks-hdr-zoom-val{font-size:10px;color:var(--ink-faint);min-width:32px;text-align:center}
708
704
 
709
- /* E1: the context meter above the composer */
710
- .lks-ui .lks14-ctxmeter{display:flex;align-items:center;gap:8px;padding:0 4px 6px;font-size:10px;color:var(--ink-faint)}
711
- .lks-ui .lks14-ctxmeter-bar{flex:1;height:4px;border-radius:999px;background:rgb(var(--ink-rgb)/0.08);overflow:hidden}
712
- .lks-ui .lks14-ctxmeter-bar i{display:block;height:100%;border-radius:999px;background:var(--brand);transform-origin:left;transition:transform .3s var(--ease-out-expo)}
713
- .lks-ui .lks14-ctxmeter-bar i.hot{background:var(--warning)}
714
- .lks-ui .lks14-ctxmeter-label{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
705
+ /* E1: the context ring the host composer's occupancy meter (a 14px ring
706
+ beside send; hover reads, click opens the figures panel). */
707
+ .lks-ui .lks14-ctxring-wrap{position:relative;display:inline-flex;align-items:center}
708
+ .lks-ui .lks14-ctxring{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0;border:none;border-radius:50%;background:transparent;cursor:pointer;color:var(--ink-faint);transition:color .15s}
709
+ .lks-ui .lks14-ctxring:hover{color:var(--ink-muted)}
710
+ .lks-ui .lks14-ctxring:focus-visible{outline:none;box-shadow:0 0 0 2px var(--business-primary)}
711
+ .lks-ui .lks14-ctxring .track{fill:none;stroke:rgb(var(--ink-rgb)/0.18);stroke-width:2}
712
+ .lks-ui .lks14-ctxring .fill{fill:none;stroke:var(--brand);stroke-width:2;stroke-linecap:round;transition:stroke-dasharray .3s var(--ease-out-expo)}
713
+ .lks-ui .lks14-ctxring .fill.hot{stroke:var(--warning)}
714
+ .lks-ui .lks14-ctxpanel{position:absolute;bottom:calc(100% + 8px);right:0;z-index:30;width:240px;padding:10px 12px;border-radius:10px;background:var(--surface-2);border:1px solid var(--border-faint);box-shadow:0 8px 24px rgb(0 0 0/0.25);font-size:11px;color:var(--ink);text-align:left}
715
+ .lks-ui .lks14-ctxpanel-head{display:flex;align-items:baseline;gap:8px;margin-bottom:7px}
716
+ .lks-ui .lks14-ctxpanel-pct{font-size:15px;font-weight:700;color:var(--ink-strong)}
717
+ .lks-ui .lks14-ctxpanel-figs{margin-left:auto;color:var(--ink-muted);font-variant-numeric:tabular-nums}
718
+ .lks-ui .lks14-ctxpanel-bar{display:flex;height:4px;border-radius:999px;background:rgb(var(--ink-rgb)/0.1);overflow:hidden}
719
+ .lks-ui .lks14-ctxpanel-bar i{display:block;height:100%;background:var(--brand)}
720
+ .lks-ui .lks14-ctxpanel-bar i.tools{background:color-mix(in srgb,var(--brand) 55%,var(--warning))}
721
+ .lks-ui .lks14-ctxpanel-bar i.msgs{background:var(--warning)}
722
+ .lks-ui .lks14-ctxpanel-rows{margin-top:7px;display:flex;flex-direction:column;gap:3px}
723
+ .lks-ui .lks14-ctxpanel-row{display:flex;align-items:center;gap:6px;color:var(--ink-muted)}
724
+ .lks-ui .lks14-ctxpanel-row b{margin-left:auto;font-weight:600;color:var(--ink);font-variant-numeric:tabular-nums}
725
+ .lks-ui .lks14-ctxpanel-row .swatch{flex:none;width:7px;height:7px;border-radius:2px;background:var(--brand)}
726
+ .lks-ui .lks14-ctxpanel-row .swatch.tools{background:color-mix(in srgb,var(--brand) 55%,var(--warning))}
727
+ .lks-ui .lks14-ctxpanel-row .swatch.msgs{background:var(--warning)}
728
+ .lks-ui .lks14-ctxpanel-note{margin-top:6px;font-size:10px;color:var(--ink-faint)}
715
729
  /* E6: the model face chip + catalog popover */
716
730
  .lks-ui .lks-modelface{position:relative;display:flex}
717
731
  .lks-ui .lks-modelface-chip{display:flex;align-items:center;gap:4px;border:1px solid var(--border-faint);background:var(--surface-1);color:var(--ink-muted);font:inherit;font-size:10px;font-weight:700;border-radius:999px;padding:2.5px 8px;cursor:pointer;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:all .15s}
@@ -730,7 +744,6 @@ window.__ModuleLoader__.load({
730
744
  .lks-ui .lks-btn-attach:hover:not(:disabled){color:var(--ink);background:var(--surface-2)}
731
745
  .lks-ui .lks-btn-attach:disabled{opacity:.4;cursor:not-allowed}
732
746
  .lks-ui .lks-btn-attach:focus-visible{box-shadow:0 0 0 2px var(--business-primary);outline:none}
733
- .lks-ui .lks14-threadlabel{color:var(--ink-faint)}
734
747
  /* ── the distilled two-row head (0.19): identity+pct+delete over a hairline
735
748
  mastery bar, then search/review/worldswitch sharing one tools row ── */
736
749
  .lks-ui .lks14-railcard-row.main{min-width:0;max-width:100%;overflow:hidden}
@@ -747,6 +760,22 @@ window.__ModuleLoader__.load({
747
760
  .lks-ui .lks-audio-toggle{display:inline-flex;align-items:center;gap:4px}
748
761
  .lks-ui .lks-audio-toggle .lks-audio-label{display:none}
749
762
  .lks-ui .lks14-msgwrap:hover .lks-audio-toggle .lks-audio-label,.lks-ui .lks-audio-toggle:focus-visible .lks-audio-label{display:inline}
763
+ /* ── the thread switcher as one labeled chip + menu (0.19 owner note) ── */
764
+ .lks-ui .lks14-threadswitch{position:relative;margin-left:auto}
765
+ .lks-ui .lks14-threadchip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:none;border-radius:999px;background:rgb(var(--ink-rgb)/0.06);color:var(--ink-muted);font:inherit;font-size:11px;font-weight:700;cursor:pointer;transition:color .15s,background .15s;max-width:220px}
766
+ .lks-ui .lks14-threadchip:hover{background:rgb(var(--ink-rgb)/0.1);color:var(--ink)}
767
+ .lks-ui .lks14-threadchip-caret{font-size:9px}
768
+ .lks-ui .lks14-threadmenu{position:absolute;top:calc(100% + 6px);right:0;z-index:60;min-width:240px;max-width:300px;max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:1px;padding:4px;border-radius:10px;background:var(--surface-0);border:1px solid var(--border);box-shadow:0 12px 32px -8px rgb(var(--shadow-rgb)/0.5)}
769
+ .lks-ui .lks14-threadmenu-row{display:flex;align-items:center;gap:7px;width:100%;padding:6px 8px;border:none;border-radius:7px;background:none;font:inherit;font-size:12px;color:var(--ink);cursor:pointer;text-align:left}
770
+ .lks-ui .lks14-threadmenu-row:hover{background:rgb(var(--ink-rgb)/0.06)}
771
+ .lks-ui .lks14-threadmenu-row.on{background:rgb(var(--brand-rgb)/0.1)}
772
+ .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-title{color:var(--brand);font-weight:700}
773
+ .lks-ui .lks14-threadmenu-dot{flex:none;width:6px;height:6px;border-radius:999px;background:var(--ink-faint)}
774
+ .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-dot{background:var(--brand)}
775
+ .lks-ui .lks14-threadmenu-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
776
+ .lks-ui .lks14-threadmenu-now{flex:none;font-size:10px;font-weight:800;color:var(--brand)}
777
+
778
+
750
779
  /* ── light-mode chrome sweep: the rail-head glass family hardcoded dark for
751
780
  the map era; the list rail follows the theme, so the chrome reads
752
781
  ink-on-surface here too ── */
@@ -785,6 +814,8 @@ window.__ModuleLoader__.load({
785
814
  .lks-ui[data-lks-theme='light'] .lks14-importprog-step.pending .lks14-importprog-text{color:var(--ink-faint)}
786
815
  .lks-ui[data-lks-theme='light'] .lks14-importprog-step.pending .lks14-importprog-dots{border-color:var(--border)}
787
816
  .lks-ui[data-lks-theme='light'] .lks14-importprog-elapsed{color:var(--ink-muted)}
817
+ .lks-ui[data-lks-theme='light'] .lks14-threadmenu{background:rgb(255 255 255/0.98);border-color:var(--border-faint);box-shadow:0 12px 32px -8px rgb(0 0 0/0.18)}
818
+ .lks-ui[data-lks-theme='light'] .lks14-ctxpanel{background:rgb(255 255 255/0.98);box-shadow:0 8px 24px rgb(0 0 0/0.12)}
788
819
  /* ambient glyph motion sleeps under reduced-motion (A7 never had a guard) */
789
820
  @media (prefers-reduced-motion: reduce){
790
821
  .lks-ui .lks-lessorow.st-mastered .lks-lessorow-glyph svg,.lks-ui .lks-hdr-xp .lks-hdr-glyph,.lks-ui .lks-hdr-streak .lks-hdr-glyph{animation:none}
@@ -953,19 +984,6 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
953
984
 
954
985
 
955
986
 
956
- /* the companion creature (P7): bottom-right of the panel; gentle bob idle,
957
- bounce while talking, pop on celebration; reduced-motion = static (a11y). */
958
- .lks-companion{position:absolute;right:18px;bottom:14px;z-index:20;width:56px;height:56px;cursor:pointer;filter:drop-shadow(0 4px 10px rgba(0,0,0,.18));animation:lks-comp-bob 3.2s ease-in-out infinite}
959
- .lks-companion:focus-visible{outline:none;filter:drop-shadow(0 0 0 2px var(--dsw-alias-state-business-primary)) drop-shadow(0 4px 10px rgba(0,0,0,.18))}
960
- .lks-companion.mood-talking{animation:lks-comp-talk .6s ease-in-out infinite}
961
- .lks-companion.mood-celebrating{animation:lks-comp-pop .5s cubic-bezier(.34,1.56,.64,1) 3}
962
- .lks-companion.mood-encouraging{animation:lks-comp-nudge 1.2s ease-in-out 2}
963
- @keyframes lks-comp-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
964
- @keyframes lks-comp-talk{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-7px) scale(1.06)}}
965
- @keyframes lks-comp-pop{0%{transform:scale(1)}40%{transform:scale(1.25) rotate(-8deg)}100%{transform:scale(1)}}
966
- @keyframes lks-comp-nudge{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px) rotate(-4deg)}75%{transform:translateX(5px) rotate(4deg)}}
967
- @media (prefers-reduced-motion:reduce){.lks-companion{animation:none}}
968
-
969
987
  /* P5: proposal banner (ConfirmCard semantics) + thinking row + narrow switch */
970
988
  .lks-propbanner{flex:none;display:flex;align-items:center;gap:8px;margin:8px 12px 0;padding:8px 12px;border-radius:10px;background:var(--dsw-alias-state-business-tertiary);border:1px solid var(--dsw-alias-border-l2);font-size:12.5px;color:var(--dsw-alias-label-primary-bluish)}
971
989
  .lks-propbanner .lks-propbanner-why{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -1017,7 +1035,6 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1017
1035
 
1018
1036
  /* rail search results panel (P4, upstream CourseSearchPanel) */
1019
1037
  .lks14-searchpanel{margin:4px 0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
1020
- .lks14-threadlabel{flex:none;padding:8px 20px 0;font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1021
1038
  .lks14-searchrow{display:flex;flex-direction:column;align-items:flex-start;gap:1px;width:100%;text-align:left;font:inherit;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--dsw-alias-border-l1)}
1022
1039
  .lks14-searchrow:last-child{border-bottom:none}
1023
1040
  .lks14-searchrow:hover{background:var(--dsw-alias-interactive-bg-hover)}
@@ -2046,7 +2063,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2046
2063
  "note.src.learner": "我的笔记",
2047
2064
  "error.send": "消息没有送达——请稍后再试一次。",
2048
2065
  "error.action": "操作没有完成——请稍后再试。",
2049
- "thread.label": "「{title}」的对话",
2050
2066
  "thread.streamingBadge": "AI 正在输出",
2051
2067
  "map.search.label": "搜索",
2052
2068
  "map.review.label": "复习",
@@ -2063,15 +2079,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2063
2079
  "quiz.submit": "提交",
2064
2080
  "proposal.applied": "《{lesson}》已采纳提前毕业提议",
2065
2081
  "proposal.rejected": "《{lesson}》提议已忽略,继续保持练习",
2066
- "companion.poke": "戳一戳伴学伙伴",
2067
- "companion.poked": "伴学伙伴向你眨了眨眼",
2068
- "companion.form.ember": "小焰",
2069
- "companion.form.frost": "霜绒",
2070
- "companion.form.moss": "苔芽",
2071
- "companion.form.star": "星尘",
2072
- "companion.form.ink": "墨墨",
2073
- "settings.companion": "伴学伙伴",
2074
- "settings.companion.hint": "选择学习面板右下角的伴学生物形态。",
2075
2082
  "quiz.msg.explain-wrong": "我刚才有题答错了,帮我讲讲为什么错、正确的思路是什么。",
2076
2083
  "quiz.msg.retry": "再来一组类似的题巩固一下。",
2077
2084
  "quiz.msg.go-deeper": "这组我答得不错,帮我深入讲讲背后的原理和容易混淆的地方。",
@@ -2130,9 +2137,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2130
2137
  "palette.study": "开始学习",
2131
2138
  "palette.kind.action": "操作",
2132
2139
  "palette.empty": "没有匹配项",
2140
+ "threads.open": "{n} 条对话",
2141
+ "threads.now": "当前",
2133
2142
  "threads.label": "课时线程",
2134
2143
  "meter.label": "上下文用量",
2135
- "meter.estimate": " {n} tok(按事件字符估算)",
2144
+ "meter.aria": "上下文已用 {pct}%",
2145
+ "meter.brk.system": "系统提示",
2146
+ "meter.brk.tools": "工具",
2147
+ "meter.brk.messages": "对话",
2148
+ "meter.estimate.note": "按事件字符估算",
2136
2149
  "model.unknown": "当前模型",
2137
2150
  "model.switched": "已切换到 {model}",
2138
2151
  "model.switchFailed": "切换失败——请看宿主模型设置",
@@ -2198,7 +2211,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2198
2211
  "quiz.answer": "选 {letter}:{text}",
2199
2212
  "tutor.dormant": "学习模式已关闭 — 在下方对话区点「开始学习」即可唤醒导师。",
2200
2213
  "tutor.empty": "对话会出现在这里",
2201
- "tutor.empty.hint": "在下方输入框和导师说话",
2214
+ "tutor.empty.hint": "和导师聊聊这一课",
2202
2215
  "proposal.text": "导师提议你已掌握「{lesson}」:{rationale}",
2203
2216
  "proposal.accept": "接受",
2204
2217
  "proposal.decline": "再练练",
@@ -2393,7 +2406,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2393
2406
  "note.src.learner": "My note",
2394
2407
  "error.send": "The message did not go through — please try again.",
2395
2408
  "error.action": "The action did not complete — please try again.",
2396
- "thread.label": "Conversation · {title}",
2397
2409
  "thread.streamingBadge": "AI is responding",
2398
2410
  "map.search.label": "Search",
2399
2411
  "map.review.label": "Review",
@@ -2410,15 +2422,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2410
2422
  "quiz.submit": "Submit",
2411
2423
  "proposal.applied": "Graduation proposal for \"{lesson}\" accepted",
2412
2424
  "proposal.rejected": "Proposal for \"{lesson}\" declined — keep practicing",
2413
- "companion.poke": "Poke the companion",
2414
- "companion.poked": "The companion winks at you",
2415
- "companion.form.ember": "Ember",
2416
- "companion.form.frost": "Frost",
2417
- "companion.form.moss": "Moss",
2418
- "companion.form.star": "Stardust",
2419
- "companion.form.ink": "Ink",
2420
- "settings.companion": "Companion",
2421
- "settings.companion.hint": "Choose the companion creature in the study panel corner.",
2422
2425
  "quiz.msg.explain-wrong": "I got some questions wrong — walk me through why and the right line of thinking.",
2423
2426
  "quiz.msg.retry": "Give me another similar set to consolidate.",
2424
2427
  "quiz.msg.go-deeper": "I did well on this set — go deeper into the principles and common confusions.",
@@ -2478,8 +2481,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2478
2481
  "palette.kind.action": "Action",
2479
2482
  "palette.empty": "No matches",
2480
2483
  "threads.label": "Lesson threads",
2484
+ "threads.open": "{n} threads",
2485
+ "threads.now": "current",
2481
2486
  "meter.label": "Context usage",
2482
- "meter.estimate": "~{n} tok (char-volume estimate)",
2487
+ "meter.aria": "{pct}% of context used",
2488
+ "meter.brk.system": "System prompt",
2489
+ "meter.brk.tools": "Tools",
2490
+ "meter.brk.messages": "Messages",
2491
+ "meter.estimate.note": "Char-volume estimate",
2483
2492
  "model.unknown": "Current model",
2484
2493
  "model.switched": "Switched to {model}",
2485
2494
  "model.switchFailed": "Switch failed — check the host model settings",
@@ -3168,20 +3177,25 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3168
3177
  let node;
3169
3178
  while ((node = walker.nextNode()) !== null) if (findMathSpan(node.data) !== null && node.parentElement?.tagName !== "CODE") targets.push(node);
3170
3179
  for (const t of targets) {
3171
- const span = findMathSpan(t.data);
3172
- if (span === null) continue;
3173
- try {
3174
- const html = katex.renderToString(span.tex, {
3175
- displayMode: span.display,
3176
- throwOnError: false
3177
- });
3178
- const holder = document.createElement("span");
3179
- holder.innerHTML = html;
3180
- if (span.before !== "") t.before(document.createTextNode(span.before));
3181
- t.before(holder);
3182
- t.data = span.after;
3183
- replaced++;
3184
- } catch {}
3180
+ let guard = 0;
3181
+ while (guard++ < 200) {
3182
+ const span = findMathSpan(t.data);
3183
+ if (span === null) break;
3184
+ try {
3185
+ const html = katex.renderToString(span.tex, {
3186
+ displayMode: span.display,
3187
+ throwOnError: false
3188
+ });
3189
+ const holder = document.createElement("span");
3190
+ holder.innerHTML = html;
3191
+ if (span.before !== "") t.before(document.createTextNode(span.before));
3192
+ t.before(holder);
3193
+ t.data = span.after;
3194
+ replaced++;
3195
+ } catch {
3196
+ break;
3197
+ }
3198
+ }
3185
3199
  }
3186
3200
  return replaced;
3187
3201
  }
@@ -3191,6 +3205,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3191
3205
  */
3192
3206
  function attachCodeHeader(wrap, lang) {
3193
3207
  if (wrap.parentElement?.querySelector(".lks-codehead") !== null) return;
3208
+ wrap.closest(".lks-codeblock")?.querySelector(":scope > .lks-codeblock-head")?.remove();
3194
3209
  const head = document.createElement("div");
3195
3210
  head.className = "lks-codehead";
3196
3211
  const label = document.createElement("span");
@@ -3291,6 +3306,242 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3291
3306
  } catch {}
3292
3307
  }
3293
3308
  //#endregion
3309
+ //#region src/vendor/math-normalize.ts
3310
+ /**
3311
+ * math-normalize —— 数学记法归一(v0.19 数学渲染前置,纯函数)。
3312
+ *
3313
+ * `\(...\)` / `\[...\]` 是 LaTeX 的原生行内/行间记法,remark-math 只认 `$`/
3314
+ * `$$`。导入的内容(AI 讲解/转写稿/粘贴文本)两种记法都可能出现,渲染前统一
3315
+ * 归一到 `$` 记法。幂等;**围栏代码块内不动**(代码里的 `\(` 是字面量)。
3316
+ */
3317
+ function normalizeMathNotation(md) {
3318
+ const out = [];
3319
+ let inFence = false;
3320
+ for (const line of md.split("\n")) {
3321
+ if (/^[ \t]*(?:```|~~~)/.test(line)) {
3322
+ out.push(line);
3323
+ inFence = !inFence;
3324
+ continue;
3325
+ }
3326
+ out.push(inFence ? line : line.replace(/\\\[|\\\]|\\\(|\\\)/g, (tok) => tok === "\\[" || tok === "\\]" ? "$$" : "$"));
3327
+ }
3328
+ return out.join("\n");
3329
+ }
3330
+ //#endregion
3331
+ //#region src/client/views.tsx
3332
+ /**
3333
+ * Shared pure helpers for the study panel's pieces: chat-row shapes, tree
3334
+ * status copy, quiz-option extraction, and section default expansion. The
3335
+ * panel itself lives in `panel.tsx`; since 0.14.0 nothing here touches the
3336
+ * host composer or the conversation view.
3337
+ * @module dsh-plugin-lookatstudy/client/views
3338
+ */
3339
+ /** Small inline error surface for failed write actions (shared with settings). */
3340
+ function ActionError({ error }) {
3341
+ if (error === null) return null;
3342
+ return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
3343
+ }
3344
+ /**
3345
+ * Tooltip text for one course-tree status glyph. Pure (the translator is an
3346
+ * optional parameter defaulting to the active one, evaluated per call).
3347
+ */
3348
+ function statusTitle(kind, status, t = tr) {
3349
+ if (kind === "exam") return t("status.exam");
3350
+ switch (status) {
3351
+ case "mastered": return t("status.mastered");
3352
+ case "in_progress": return t("status.in_progress");
3353
+ case "available": return t("status.available");
3354
+ default: return t("status.locked");
3355
+ }
3356
+ }
3357
+ /**
3358
+ * Extract the last quiz option block (2–4 consecutive `A. …` `B. …` lines,
3359
+ * letters strictly consecutive from A) from an assistant reply — the raw text
3360
+ * of the tutor's question becomes the clickable answer surface. Pure.
3361
+ */
3362
+ function quizOptions(text) {
3363
+ const runs = [];
3364
+ let run = [];
3365
+ const flush = () => {
3366
+ if (run.length >= 2) runs.push(run);
3367
+ run = [];
3368
+ };
3369
+ for (const line of text.split("\n")) {
3370
+ const plain = /^([A-D])[.、:)]\s*(.+)$/.exec(line.trim());
3371
+ const table = /^\|\s*\*{0,2}([A-D])\*{0,2}\s*\|\s*(.+?)\s*\|$/.exec(line.trim());
3372
+ const match = plain ?? (table !== null ? [
3373
+ table[0],
3374
+ table[1],
3375
+ table[2].replaceAll("**", "").replaceAll("`", "")
3376
+ ] : null);
3377
+ if (match === null) {
3378
+ flush();
3379
+ continue;
3380
+ }
3381
+ const nextLetter = run.length === 0 ? "A" : String.fromCharCode(run[run.length - 1].letter.charCodeAt(0) + 1);
3382
+ if (match[1] === nextLetter) run.push({
3383
+ letter: match[1],
3384
+ text: match[2]
3385
+ });
3386
+ else {
3387
+ flush();
3388
+ if (match[1] === "A") run.push({
3389
+ letter: match[1],
3390
+ text: match[2]
3391
+ });
3392
+ }
3393
+ }
3394
+ flush();
3395
+ return runs.at(-1) ?? [];
3396
+ }
3397
+ /**
3398
+ * Merge the rail's two search tracks into the results panel: title hits
3399
+ * (lessons whose name matches — they are also filtered into the tree) first,
3400
+ * then full-text hits not already covered, snippets carried for the body
3401
+ * matches. Pure; empty queries yield no panel.
3402
+ */
3403
+ function mergeRailSearch(query, lessons, textHits) {
3404
+ const keys = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
3405
+ if (keys.length === 0) return [];
3406
+ const rows = [];
3407
+ const seen = /* @__PURE__ */ new Set();
3408
+ const matches = (title) => keys.every((k) => title.toLowerCase().includes(k));
3409
+ for (const lesson of lessons) {
3410
+ if (lesson.kind === "exam" || lesson.status === "locked" || !matches(lesson.title)) continue;
3411
+ rows.push({
3412
+ lessonId: lesson.id,
3413
+ title: lesson.title,
3414
+ snippet: "",
3415
+ courseTitle: "",
3416
+ fromTitle: true
3417
+ });
3418
+ seen.add(lesson.id);
3419
+ }
3420
+ for (const hit of textHits) {
3421
+ if (seen.has(hit.lessonId)) continue;
3422
+ rows.push({
3423
+ lessonId: hit.lessonId,
3424
+ title: hit.lessonTitle,
3425
+ snippet: hit.snippet,
3426
+ courseTitle: hit.courseTitle,
3427
+ fromTitle: false
3428
+ });
3429
+ seen.add(hit.lessonId);
3430
+ }
3431
+ return rows.slice(0, 12);
3432
+ }
3433
+ /**
3434
+ * A section's effective openness: the user's explicit toggle (persisted per
3435
+ * course) overrides the frontier-based default. Pure.
3436
+ */
3437
+ function effectiveOpen(sectionTitle, defaultOpen, overrides) {
3438
+ return overrides[sectionTitle] ?? defaultOpen;
3439
+ }
3440
+ /**
3441
+ * The narrow-mode pane picker: only valid ids, remembering the last choice
3442
+ * (upstream's narrow switcher semantics). Pure.
3443
+ */
3444
+ function pickNarrowPane(stored) {
3445
+ return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
3446
+ }
3447
+ /**
3448
+ * Default expansion for one rail section: collapsed when every study lesson is
3449
+ * done (mastered) or not yet reachable (locked). The focus lesson's section
3450
+ * stays open; exam nodes never force a section open. Pure.
3451
+ */
3452
+ function sectionDefaultOpen(section) {
3453
+ return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
3454
+ }
3455
+ /**
3456
+ * C1 sticky-follow: the stream follows new rows only while the reader is
3457
+ * within `tolerance` of the bottom (upstream's 80px rule — scroll up and the
3458
+ * feed stops chasing you). Pure.
3459
+ */
3460
+ function isStuck(scrollTop, scrollHeight, clientHeight, tolerance = 80) {
3461
+ return scrollHeight - scrollTop - clientHeight <= tolerance;
3462
+ }
3463
+ /**
3464
+ * C12 touch pane swipe: a horizontal flick (>50px, dominance >2x) switches the
3465
+ * narrow pane neighbor; anything else is not a pane gesture. Pure
3466
+ * (upstream swipeTarget semantics).
3467
+ */
3468
+ function swipePane(pane, dx, dy) {
3469
+ if (Math.abs(dx) < 50 || Math.abs(dx) <= 2 * Math.abs(dy)) return null;
3470
+ const order = [
3471
+ "rail",
3472
+ "chat",
3473
+ "note"
3474
+ ];
3475
+ const idx = order.indexOf(pane);
3476
+ if (idx < 0) return null;
3477
+ const next = dx < 0 ? idx + 1 : idx - 1;
3478
+ return next >= 0 && next < order.length ? order[next] : null;
3479
+ }
3480
+ /**
3481
+ * C12 selection-popover settle: coarse pointers need a longer quiet window
3482
+ * (upstream's 600/250 two-tier). Pure.
3483
+ */
3484
+ function settleMs(coarse) {
3485
+ return coarse ? 600 : 250;
3486
+ }
3487
+ /**
3488
+ * C9 interleaved review: pick one due lesson at random (Math.random; empty in,
3489
+ * null out). Pure.
3490
+ */
3491
+ function pickRandomDue(items) {
3492
+ if (items.length === 0) return null;
3493
+ return items[Math.floor(Math.random() * items.length)] ?? null;
3494
+ }
3495
+ /**
3496
+ * Display-side section-title humanizer (0.19 critique round): import-design
3497
+ * section ids arrive as repo-folder slugs ("0-course-setup", "1-Intro") —
3498
+ * strip the leading episode number, hyphen/underscore runs become spaces.
3499
+ * Keys, anchors, and ids stay raw; this is presentation only. Never returns
3500
+ * an empty string (a title that humanizes to nothing keeps its raw form).
3501
+ */
3502
+ function humanizeSectionTitle(raw) {
3503
+ const t = raw.trim().replace(/^\d+[.\-_ ]+/, "").replace(/[\-_]+/g, " ").replace(/\s+/g, " ").trim();
3504
+ return t === "" ? raw.trim() : t;
3505
+ }
3506
+ /**
3507
+ * Proportion the panel's bar through the host's heuristic composition, in the
3508
+ * segment order system → tools → messages; without a breakdown a single
3509
+ * brand segment carries the whole reading. A zero-width part is dropped.
3510
+ */
3511
+ function ctxSegments(pct, breakdown) {
3512
+ if (pct <= 0) return [];
3513
+ const total = breakdown === null ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens;
3514
+ if (total <= 0) return [{
3515
+ key: "total",
3516
+ cls: "",
3517
+ width: pct
3518
+ }];
3519
+ return [
3520
+ ["sys", breakdown.systemTokens],
3521
+ ["tools", breakdown.toolsTokens],
3522
+ ["msgs", breakdown.messageTokens]
3523
+ ].map(([key, tokens]) => ({
3524
+ key,
3525
+ cls: key,
3526
+ width: pct * tokens / total
3527
+ })).filter((s) => s.width > 0);
3528
+ }
3529
+ /** Compact token figure for the context panel (host formatTokens arrangement). */
3530
+ function fmtTokens(value) {
3531
+ if (value < 1e3) return String(value);
3532
+ if (value < 1e6) return `${String(Math.round(value / 100) / 10)}K`;
3533
+ return `${String(Math.round(value / 1e5) / 10)}M`;
3534
+ }
3535
+ /**
3536
+ * Panel-facing error text (0.19 critique round): raw err.message (host RPC
3537
+ * internals like "prompt rejected: …") never renders in the DOM — the user
3538
+ * gets one honest locale line, the raw error goes to the console.
3539
+ */
3540
+ function friendlyError(err, kind = "action", t = tr) {
3541
+ console.error("[lookatstudy]", err);
3542
+ return t(kind === "send" ? "error.send" : "error.action");
3543
+ }
3544
+ //#endregion
3294
3545
  //#region src/vendor/cmap-elk-layout.ts
3295
3546
  /** 字符宽度估算:CJK 全宽,ASCII 约 0.58em,其余(BBOP…)同 ASCII。 */
3296
3547
  function estTextWidth(text, fontSize = 13) {
@@ -5562,225 +5813,40 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5562
5813
  }
5563
5814
  }
5564
5815
  //#endregion
5565
- //#region src/client/companion.tsx
5816
+ //#region src/client/highlights.ts
5566
5817
  /**
5567
- * The companion creature the upstream 伴学生物's minimal honest DOM port
5568
- * (0.15.0 P7): a small being in the panel's bottom-right corner with five
5569
- * forms, a pure mood machine (idle / talking / celebrating / encouraging),
5570
- * and the a11y iron rule carried over reduced-motion renders it static.
5571
- * Upstream's Electron-native life (mic envelopes, typing squeeze, drag-throw)
5572
- * has no web-panel equivalent and stays unported by design.
5573
- * @module dsh-plugin-lookatstudy/client/companion
5818
+ * Locate a selection inside the model text: exact `selected` text search,
5819
+ * disambiguated by a fuzzy `surrounding` fingerprint (the same sentence
5820
+ * appearing twice resolves to the occurrence whose context matches).
5821
+ * @returns the global [start, end) in model.text, or null when absent.
5574
5822
  */
5575
- const COMPANION_FORMS = [
5576
- {
5577
- id: "ember",
5578
- labelKey: "companion.form.ember",
5579
- body: "#f59e0b",
5580
- accent: "#dc2626"
5581
- },
5582
- {
5583
- id: "frost",
5584
- labelKey: "companion.form.frost",
5585
- body: "#7dd3fc",
5586
- accent: "#0284c7"
5587
- },
5588
- {
5589
- id: "moss",
5590
- labelKey: "companion.form.moss",
5591
- body: "#86efac",
5592
- accent: "#16a34a"
5593
- },
5594
- {
5595
- id: "star",
5596
- labelKey: "companion.form.star",
5597
- body: "#c4b5fd",
5598
- accent: "#7c3aed"
5599
- },
5600
- {
5601
- id: "ink",
5602
- labelKey: "companion.form.ink",
5603
- body: "#cbd5e1",
5604
- accent: "#334155"
5823
+ function locateInModel(model, selected, surrounding) {
5824
+ const needle = selected.trim();
5825
+ if (needle.length < 2) return null;
5826
+ const hits = [];
5827
+ let from = 0;
5828
+ for (;;) {
5829
+ const hit = model.text.indexOf(needle, from);
5830
+ if (hit === -1) break;
5831
+ hits.push(hit);
5832
+ from = hit + 1;
5605
5833
  }
5606
- ];
5607
- /** Normalize a stored form id; junk falls back to 小焰 (upstream's default). */
5608
- function normalizeCompanionForm(stored) {
5609
- return COMPANION_FORMS.some((f) => f.id === stored) ? stored : "ember";
5610
- }
5611
- function storedCompanionForm() {
5612
- try {
5613
- return normalizeCompanionForm(localStorage.getItem("dsh-plugin-lookatstudy:companion-form"));
5614
- } catch {
5615
- return "ember";
5616
- }
5617
- }
5618
- function storeCompanionForm(id) {
5619
- try {
5620
- localStorage.setItem("dsh-plugin-lookatstudy:companion-form", id);
5621
- } catch {}
5622
- }
5623
- /**
5624
- * The mood machine — pure. `celebrate` and `encourage` are momentary (the
5625
- * view decays them back through `decay`); `talking` latches while read-aloud
5626
- * runs; poke is a self-echoing celebration.
5627
- */
5628
- function nextCompanionMood(current, event) {
5629
- switch (event) {
5630
- case "talk-start": return "talking";
5631
- case "talk-end": return current === "talking" ? "idle" : current;
5632
- case "celebrate":
5633
- case "poke": return "celebrating";
5634
- case "encourage": return "encouraging";
5635
- case "decay": return current === "celebrating" || current === "encouraging" ? "idle" : current;
5636
- default: return current;
5637
- }
5638
- }
5639
- /** One creature face — the shared skin every form wears. */
5640
- function CompanionFace({ form, mood }) {
5641
- const skin = COMPANION_FORMS.find((f) => f.id === form) ?? COMPANION_FORMS[0];
5642
- const eye = mood === "celebrating" ? "M -3.4 0 q 1.7 -2.6 3.4 0" : "M -3.4 -1.4 a 1.4 1.4 0 1 0 0.01 0";
5643
- const mouth = mood === "celebrating" ? "M 20 21 q 4 4 8 0" : mood === "encouraging" ? "M 21 22 q 3 -2.5 6 0" : "M 21 21 q 3 2.4 6 0";
5644
- return (0, react.createElement)("svg", {
5645
- width: 64,
5646
- height: 64,
5647
- viewBox: "0 0 64 64",
5648
- "aria-hidden": "true"
5649
- }, (0, react.createElement)("g", null, (0, react.createElement)("circle", {
5650
- cx: 32,
5651
- cy: 34,
5652
- r: 20,
5653
- fill: skin.body,
5654
- stroke: skin.accent,
5655
- strokeWidth: 2
5656
- }), form === "ember" ? (0, react.createElement)("path", {
5657
- d: "M32 4 q6 8 3 12 q8 -2 9 6 q4 -1 4 4 l-2 4 H18 l-2 -4 q0 -5 4 -4 q1 -8 9 -6 q-3 -4 3 -12z",
5658
- fill: skin.accent,
5659
- opacity: .85
5660
- }) : form === "star" ? (0, react.createElement)("path", {
5661
- d: "M32 2 l4 8 9 1 -6.5 6 1.5 9 -8.5 -4.5 -8 4.5 1.5 -9 -6.5 -6 9 -1z",
5662
- fill: skin.accent,
5663
- opacity: .85
5664
- }) : form === "moss" ? (0, react.createElement)("path", {
5665
- d: "M32 2 q-2 6 2 10 q-8 0 -8 6 H40 q0 -6 -8 -6 q4 -4 0 -10z",
5666
- fill: skin.accent,
5667
- opacity: .85
5668
- }) : form === "ink" ? (0, react.createElement)("path", {
5669
- d: "M32 2 q8 12 8 18 a8 8 0 1 1 -16 0 q0 -6 8 -18z",
5670
- fill: skin.accent,
5671
- opacity: .55
5672
- }) : (0, react.createElement)("path", {
5673
- d: "M18 12 l3 5 5 -1 -1 5 5 3 -5 3 1 5 -5 -1 -3 5 -3 -5 -5 1 1 -5 -5 -3 5 -3 -1 -5 5 1z",
5674
- fill: skin.accent,
5675
- opacity: .7
5676
- }), (0, react.createElement)("path", {
5677
- d: eye,
5678
- transform: "translate(25 30)",
5679
- stroke: skin.accent,
5680
- strokeWidth: 1.6,
5681
- fill: mood === "celebrating" ? "none" : skin.accent,
5682
- strokeLinecap: "round"
5683
- }), (0, react.createElement)("path", {
5684
- d: eye,
5685
- transform: "translate(14 30)",
5686
- stroke: skin.accent,
5687
- strokeWidth: 1.6,
5688
- fill: mood === "celebrating" ? "none" : skin.accent,
5689
- strokeLinecap: "round"
5690
- }), (0, react.createElement)("path", {
5691
- d: mouth,
5692
- stroke: skin.accent,
5693
- strokeWidth: 1.8,
5694
- fill: "none",
5695
- strokeLinecap: "round"
5696
- })));
5697
- }
5698
- /** The creature: fixed bottom-right inside the panel, mood-animated, pokeable. */
5699
- function Companion({ mood, onPoke }) {
5700
- const form = storedCompanionForm();
5701
- return (0, react.createElement)("div", {
5702
- className: `lks-companion mood-${mood}`,
5703
- "data-lks-companion": form,
5704
- title: tr("companion.poke"),
5705
- role: "button",
5706
- tabIndex: 0,
5707
- "aria-label": tr("companion.poke"),
5708
- onClick: () => {
5709
- onPoke?.();
5710
- },
5711
- onKeyDown: (e) => {
5712
- if (e.key === "Enter" || e.key === " ") {
5713
- e.preventDefault();
5714
- onPoke?.();
5715
- }
5716
- }
5717
- }, (0, react.createElement)(CompanionFace, {
5718
- form,
5719
- mood
5720
- }));
5721
- }
5722
- /** The hook driving one creature instance: event → mood, momentary decay. */
5723
- function useCompanionMood() {
5724
- const [mood, setMood] = (0, react.useState)("idle");
5725
- const moodRef = (0, react.useRef)(mood);
5726
- moodRef.current = mood;
5727
- const emit = (event) => {
5728
- const next = nextCompanionMood(moodRef.current, event);
5729
- if (next !== moodRef.current) {
5730
- moodRef.current = next;
5731
- setMood(next);
5732
- }
5733
- };
5734
- (0, react.useEffect)(() => {
5735
- if (mood !== "celebrating" && mood !== "encouraging") return;
5736
- const timer = setTimeout(() => {
5737
- moodRef.current = nextCompanionMood(moodRef.current, "decay");
5738
- setMood(moodRef.current);
5739
- }, 2400);
5740
- return () => {
5741
- clearTimeout(timer);
5742
- };
5743
- }, [mood]);
5744
- return {
5745
- mood,
5746
- emit
5747
- };
5748
- }
5749
- //#endregion
5750
- //#region src/client/highlights.ts
5751
- /**
5752
- * Locate a selection inside the model text: exact `selected` text search,
5753
- * disambiguated by a fuzzy `surrounding` fingerprint (the same sentence
5754
- * appearing twice resolves to the occurrence whose context matches).
5755
- * @returns the global [start, end) in model.text, or null when absent.
5756
- */
5757
- function locateInModel(model, selected, surrounding) {
5758
- const needle = selected.trim();
5759
- if (needle.length < 2) return null;
5760
- const hits = [];
5761
- let from = 0;
5762
- for (;;) {
5763
- const hit = model.text.indexOf(needle, from);
5764
- if (hit === -1) break;
5765
- hits.push(hit);
5766
- from = hit + 1;
5767
- }
5768
- if (hits.length === 0) return null;
5769
- if (hits.length === 1 || surrounding === void 0 || surrounding.trim() === "") return {
5770
- start: hits[0],
5771
- end: hits[0] + needle.length
5772
- };
5773
- const finger = surrounding.replace(/\s+/g, "");
5774
- let best = hits[0];
5775
- let bestScore = -1;
5776
- for (const hit of hits) {
5777
- const context = model.text.slice(Math.max(0, hit - 30), hit + needle.length + 30).replace(/\s+/g, "");
5778
- let score = 0;
5779
- for (let i = 0; i < finger.length; i += 4) if (context.includes(finger.slice(i, i + 4))) score += 1;
5780
- if (score > bestScore) {
5781
- bestScore = score;
5782
- best = hit;
5783
- }
5834
+ if (hits.length === 0) return null;
5835
+ if (hits.length === 1 || surrounding === void 0 || surrounding.trim() === "") return {
5836
+ start: hits[0],
5837
+ end: hits[0] + needle.length
5838
+ };
5839
+ const finger = surrounding.replace(/\s+/g, "");
5840
+ let best = hits[0];
5841
+ let bestScore = -1;
5842
+ for (const hit of hits) {
5843
+ const context = model.text.slice(Math.max(0, hit - 30), hit + needle.length + 30).replace(/\s+/g, "");
5844
+ let score = 0;
5845
+ for (let i = 0; i < finger.length; i += 4) if (context.includes(finger.slice(i, i + 4))) score += 1;
5846
+ if (score > bestScore) {
5847
+ bestScore = score;
5848
+ best = hit;
5849
+ }
5784
5850
  }
5785
5851
  return {
5786
5852
  start: best,
@@ -5874,191 +5940,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5874
5940
  return applied;
5875
5941
  }
5876
5942
  //#endregion
5877
- //#region src/client/views.tsx
5878
- /**
5879
- * Shared pure helpers for the study panel's pieces: chat-row shapes, tree
5880
- * status copy, quiz-option extraction, and section default expansion. The
5881
- * panel itself lives in `panel.tsx`; since 0.14.0 nothing here touches the
5882
- * host composer or the conversation view.
5883
- * @module dsh-plugin-lookatstudy/client/views
5884
- */
5885
- /** Small inline error surface for failed write actions (shared with settings). */
5886
- function ActionError({ error }) {
5887
- if (error === null) return null;
5888
- return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
5889
- }
5890
- /**
5891
- * Tooltip text for one course-tree status glyph. Pure (the translator is an
5892
- * optional parameter defaulting to the active one, evaluated per call).
5893
- */
5894
- function statusTitle(kind, status, t = tr) {
5895
- if (kind === "exam") return t("status.exam");
5896
- switch (status) {
5897
- case "mastered": return t("status.mastered");
5898
- case "in_progress": return t("status.in_progress");
5899
- case "available": return t("status.available");
5900
- default: return t("status.locked");
5901
- }
5902
- }
5903
- /**
5904
- * Extract the last quiz option block (2–4 consecutive `A. …` `B. …` lines,
5905
- * letters strictly consecutive from A) from an assistant reply — the raw text
5906
- * of the tutor's question becomes the clickable answer surface. Pure.
5907
- */
5908
- function quizOptions(text) {
5909
- const runs = [];
5910
- let run = [];
5911
- const flush = () => {
5912
- if (run.length >= 2) runs.push(run);
5913
- run = [];
5914
- };
5915
- for (const line of text.split("\n")) {
5916
- const plain = /^([A-D])[.、:)]\s*(.+)$/.exec(line.trim());
5917
- const table = /^\|\s*\*{0,2}([A-D])\*{0,2}\s*\|\s*(.+?)\s*\|$/.exec(line.trim());
5918
- const match = plain ?? (table !== null ? [
5919
- table[0],
5920
- table[1],
5921
- table[2].replaceAll("**", "").replaceAll("`", "")
5922
- ] : null);
5923
- if (match === null) {
5924
- flush();
5925
- continue;
5926
- }
5927
- const nextLetter = run.length === 0 ? "A" : String.fromCharCode(run[run.length - 1].letter.charCodeAt(0) + 1);
5928
- if (match[1] === nextLetter) run.push({
5929
- letter: match[1],
5930
- text: match[2]
5931
- });
5932
- else {
5933
- flush();
5934
- if (match[1] === "A") run.push({
5935
- letter: match[1],
5936
- text: match[2]
5937
- });
5938
- }
5939
- }
5940
- flush();
5941
- return runs.at(-1) ?? [];
5942
- }
5943
- /**
5944
- * Merge the rail's two search tracks into the results panel: title hits
5945
- * (lessons whose name matches — they are also filtered into the tree) first,
5946
- * then full-text hits not already covered, snippets carried for the body
5947
- * matches. Pure; empty queries yield no panel.
5948
- */
5949
- function mergeRailSearch(query, lessons, textHits) {
5950
- const keys = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
5951
- if (keys.length === 0) return [];
5952
- const rows = [];
5953
- const seen = /* @__PURE__ */ new Set();
5954
- const matches = (title) => keys.every((k) => title.toLowerCase().includes(k));
5955
- for (const lesson of lessons) {
5956
- if (lesson.kind === "exam" || lesson.status === "locked" || !matches(lesson.title)) continue;
5957
- rows.push({
5958
- lessonId: lesson.id,
5959
- title: lesson.title,
5960
- snippet: "",
5961
- courseTitle: "",
5962
- fromTitle: true
5963
- });
5964
- seen.add(lesson.id);
5965
- }
5966
- for (const hit of textHits) {
5967
- if (seen.has(hit.lessonId)) continue;
5968
- rows.push({
5969
- lessonId: hit.lessonId,
5970
- title: hit.lessonTitle,
5971
- snippet: hit.snippet,
5972
- courseTitle: hit.courseTitle,
5973
- fromTitle: false
5974
- });
5975
- seen.add(hit.lessonId);
5976
- }
5977
- return rows.slice(0, 12);
5978
- }
5979
- /**
5980
- * A section's effective openness: the user's explicit toggle (persisted per
5981
- * course) overrides the frontier-based default. Pure.
5982
- */
5983
- function effectiveOpen(sectionTitle, defaultOpen, overrides) {
5984
- return overrides[sectionTitle] ?? defaultOpen;
5985
- }
5986
- /**
5987
- * The narrow-mode pane picker: only valid ids, remembering the last choice
5988
- * (upstream's narrow switcher semantics). Pure.
5989
- */
5990
- function pickNarrowPane(stored) {
5991
- return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
5992
- }
5993
- /**
5994
- * Default expansion for one rail section: collapsed when every study lesson is
5995
- * done (mastered) or not yet reachable (locked). The focus lesson's section
5996
- * stays open; exam nodes never force a section open. Pure.
5997
- */
5998
- function sectionDefaultOpen(section) {
5999
- return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
6000
- }
6001
- /**
6002
- * C1 sticky-follow: the stream follows new rows only while the reader is
6003
- * within `tolerance` of the bottom (upstream's 80px rule — scroll up and the
6004
- * feed stops chasing you). Pure.
6005
- */
6006
- function isStuck(scrollTop, scrollHeight, clientHeight, tolerance = 80) {
6007
- return scrollHeight - scrollTop - clientHeight <= tolerance;
6008
- }
6009
- /**
6010
- * C12 touch pane swipe: a horizontal flick (>50px, dominance >2x) switches the
6011
- * narrow pane neighbor; anything else is not a pane gesture. Pure
6012
- * (upstream swipeTarget semantics).
6013
- */
6014
- function swipePane(pane, dx, dy) {
6015
- if (Math.abs(dx) < 50 || Math.abs(dx) <= 2 * Math.abs(dy)) return null;
6016
- const order = [
6017
- "rail",
6018
- "chat",
6019
- "note"
6020
- ];
6021
- const idx = order.indexOf(pane);
6022
- if (idx < 0) return null;
6023
- const next = dx < 0 ? idx + 1 : idx - 1;
6024
- return next >= 0 && next < order.length ? order[next] : null;
6025
- }
6026
- /**
6027
- * C12 selection-popover settle: coarse pointers need a longer quiet window
6028
- * (upstream's 600/250 two-tier). Pure.
6029
- */
6030
- function settleMs(coarse) {
6031
- return coarse ? 600 : 250;
6032
- }
6033
- /**
6034
- * C9 interleaved review: pick one due lesson at random (Math.random; empty in,
6035
- * null out). Pure.
6036
- */
6037
- function pickRandomDue(items) {
6038
- if (items.length === 0) return null;
6039
- return items[Math.floor(Math.random() * items.length)] ?? null;
6040
- }
6041
- /**
6042
- * Display-side section-title humanizer (0.19 critique round): import-design
6043
- * section ids arrive as repo-folder slugs ("0-course-setup", "1-Intro") —
6044
- * strip the leading episode number, hyphen/underscore runs become spaces.
6045
- * Keys, anchors, and ids stay raw; this is presentation only. Never returns
6046
- * an empty string (a title that humanizes to nothing keeps its raw form).
6047
- */
6048
- function humanizeSectionTitle(raw) {
6049
- const t = raw.trim().replace(/^\d+[.\-_ ]+/, "").replace(/[\-_]+/g, " ").replace(/\s+/g, " ").trim();
6050
- return t === "" ? raw.trim() : t;
6051
- }
6052
- /**
6053
- * Panel-facing error text (0.19 critique round): raw err.message (host RPC
6054
- * internals like "prompt rejected: …") never renders in the DOM — the user
6055
- * gets one honest locale line, the raw error goes to the console.
6056
- */
6057
- function friendlyError(err, kind = "action", t = tr) {
6058
- console.error("[lookatstudy]", err);
6059
- return t(kind === "send" ? "error.send" : "error.action");
6060
- }
6061
- //#endregion
6062
5943
  //#region src/client/maprail.tsx
6063
5944
  /**
6064
5945
  * RailList — the course rail as a quiet list (owner decision 2026-09-08: the
@@ -6173,6 +6054,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6173
6054
  y
6174
6055
  });
6175
6056
  };
6057
+ const panelZoom = () => {
6058
+ const z = parseFloat(document.querySelector(".lks-ui")?.style.zoom ?? "1");
6059
+ return Number.isFinite(z) && z > 0 ? z : 1;
6060
+ };
6176
6061
  const onMove = (e) => {
6177
6062
  if (coarse) return;
6178
6063
  const el = target(e);
@@ -6180,7 +6065,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6180
6065
  setTip(null);
6181
6066
  return;
6182
6067
  }
6183
- show(el, e.clientX + 12, e.clientY + 16);
6068
+ const z = panelZoom();
6069
+ show(el, (e.clientX + 10) / z, (e.clientY + 12) / z);
6184
6070
  };
6185
6071
  const onLeave = () => {
6186
6072
  setTip(null);
@@ -6197,7 +6083,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6197
6083
  pressTimer.current = setTimeout(() => {
6198
6084
  if (Math.hypot(e.clientX - start.x, e.clientY - start.y) < 10) {
6199
6085
  const r = el.getBoundingClientRect();
6200
- show(el, r.left + r.width / 2 - 80, r.top - 36);
6086
+ const z = panelZoom();
6087
+ show(el, (r.left + r.width / 2 - 80) / z, (r.top - 36) / z);
6201
6088
  }
6202
6089
  }, 500);
6203
6090
  };
@@ -6225,12 +6112,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6225
6112
  };
6226
6113
  }, []);
6227
6114
  if (tip === null) return null;
6115
+ const z = parseFloat(document.querySelector(".lks-ui")?.style.zoom ?? "1") || 1;
6116
+ const clamped = clampTip(tip.x, tip.y, 200, 40, window.innerWidth / z, window.innerHeight / z);
6228
6117
  return (0, react.createElement)("div", {
6229
6118
  className: "lks-tip",
6230
6119
  role: "tooltip",
6231
6120
  style: {
6232
- left: `${String(clampTip(tip.x, tip.y, 200, 40, window.innerWidth, window.innerHeight).x)}px`,
6233
- top: `${String(clampTip(tip.x, tip.y, 200, 40, window.innerWidth, window.innerHeight).y)}px`
6121
+ left: `${String(clamped.x)}px`,
6122
+ top: `${String(clamped.y)}px`
6234
6123
  }
6235
6124
  }, tip.text);
6236
6125
  }
@@ -6757,7 +6646,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6757
6646
  * transition here — the client bundle stays dependency-free.)
6758
6647
  * @module dsh-plugin-lookatstudy/client/celebration-layer
6759
6648
  */
6760
- /** prefers-reduced-motion, live-updating (the companion rides the same rule). */
6649
+ /** prefers-reduced-motion, live-updating. */
6761
6650
  function usePrefersReducedMotion() {
6762
6651
  const [reduced, setReduced] = (0, react.useState)(() => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
6763
6652
  (0, react.useEffect)(() => {
@@ -6987,7 +6876,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6987
6876
  const body = (0, react.createElement)(ErrorBoundary, { key: `b-${row.key}` }, (0, react.createElement)("div", {
6988
6877
  className: cls,
6989
6878
  "data-row-key": row.key,
6990
- dangerouslySetInnerHTML: { __html: renderMarkdown(row.text) }
6879
+ dangerouslySetInnerHTML: { __html: renderMarkdown(normalizeMathNotation(row.text)) }
6991
6880
  }));
6992
6881
  if (audio !== void 0 && row.role === "assistant") return (0, react.createElement)("div", {
6993
6882
  key: row.key,
@@ -7097,24 +6986,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7097
6986
  }, []);
7098
6987
  const [rows, setRows] = (0, react.useState)([]);
7099
6988
  const [pressure, setPressure] = (0, react.useState)(null);
6989
+ const [breakdown, setBreakdown] = (0, react.useState)(null);
7100
6990
  const importProgress = (0, react.useMemo)(() => importProgressOf(rows), [rows]);
7101
6991
  const contextMeter = (0, react.useMemo)(() => {
7102
6992
  if (pressure !== null && pressure.contextWindow !== void 0 && pressure.contextWindow > 0 && pressure.projectedTokens !== void 0) return {
7103
6993
  pct: Math.min(100, Math.round(pressure.projectedTokens / pressure.contextWindow * 100)),
7104
- label: `${String(pressure.projectedTokens)}/${String(pressure.contextWindow)}`,
7105
- estimated: false
6994
+ estimated: false,
6995
+ used: pressure.projectedTokens,
6996
+ window: pressure.contextWindow
6997
+ };
6998
+ const est = Math.round(rows.reduce((n, r) => n + r.text.length, 0) / 2.2);
6999
+ const window = data?.model?.contextWindow ?? null;
7000
+ if (window !== null && window > 0) return {
7001
+ pct: Math.min(100, Math.round(est / window * 100)),
7002
+ estimated: true,
7003
+ used: est,
7004
+ window
7106
7005
  };
7107
- const chars = rows.reduce((n, r) => n + r.text.length, 0);
7108
7006
  return {
7109
7007
  pct: null,
7110
- label: tr("meter.estimate", { n: String(Math.round(chars / 2.2)) }),
7111
- estimated: true
7008
+ estimated: true,
7009
+ used: est,
7010
+ window: null
7112
7011
  };
7113
- }, [pressure, rows]);
7012
+ }, [
7013
+ pressure,
7014
+ rows,
7015
+ data?.model
7016
+ ]);
7114
7017
  const [feedAttached, setFeedAttached] = (0, react.useState)(false);
7115
7018
  const [draft, setDraft] = (0, react.useState)("");
7116
7019
  const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
7117
7020
  const lesson = data?.lesson ?? null;
7021
+ const examLessonActive = lesson !== null && lesson.kind === "exam" && examOpen(data?.courses.flatMap((c) => c.sections).find((s) => s.lessons.some((l) => l.id === lesson.lessonId))?.lessons ?? []);
7118
7022
  const localBound = (0, react.useRef)(null);
7119
7023
  const streamEl = (0, react.useRef)(null);
7120
7024
  const touchStart = (0, react.useRef)(null);
@@ -7187,14 +7091,25 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7187
7091
  let off;
7188
7092
  try {
7189
7093
  const actx = ctx.sessions.scope(boundId);
7190
- const proj = (actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx))?.projections?.faceOf("contextPressure");
7094
+ const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
7095
+ const proj = face?.projections?.faceOf("contextPressure");
7096
+ const bproj = face?.projections?.faceOf("contextBreakdown");
7191
7097
  if (proj !== void 0) {
7192
7098
  const read = () => {
7099
+ if (disposed) return;
7193
7100
  const snap = proj.getSnapshot();
7194
- if (!disposed && snap !== void 0 && typeof snap === "object") setPressure({
7101
+ if (snap !== void 0 && typeof snap === "object") setPressure({
7195
7102
  projectedTokens: snap.projectedTokens,
7196
7103
  contextWindow: snap.contextWindow
7197
7104
  });
7105
+ if (bproj !== void 0) {
7106
+ const bsnap = bproj.getSnapshot();
7107
+ if (bsnap !== void 0 && typeof bsnap === "object") setBreakdown({
7108
+ systemTokens: bsnap.systemTokens ?? 0,
7109
+ toolsTokens: bsnap.toolsTokens ?? 0,
7110
+ messageTokens: bsnap.messageTokens ?? 0
7111
+ });
7112
+ }
7198
7113
  };
7199
7114
  read();
7200
7115
  off = proj.subscribe(read);
@@ -7361,8 +7276,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7361
7276
  window.removeEventListener("lookatstudy-exam-generate", onGenerate);
7362
7277
  };
7363
7278
  }, [send]);
7364
- const companion = useCompanionMood();
7365
- const setCompanionEvent = companion.emit;
7366
7279
  const progress = data?.progress ?? null;
7367
7280
  const body = (0, react.createElement)("div", {
7368
7281
  className: "lks14-body",
@@ -7392,9 +7305,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7392
7305
  onJumped: () => {
7393
7306
  setNarrowPane("chat");
7394
7307
  },
7395
- onBlankTap: () => {
7396
- setCompanionEvent("poke");
7397
- },
7398
7308
  streamingLessonId: feedGen && lesson !== null ? lesson.lessonId : null,
7399
7309
  turnActive: feedGen,
7400
7310
  importProgress,
@@ -7429,7 +7339,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7429
7339
  onClick: () => {
7430
7340
  setZoom(zoomTier + .1);
7431
7341
  }
7432
- }, "A+"))), (0, react.createElement)("div", { className: "lks14-row" }, (0, react.createElement)(ChatPane, {
7342
+ }, "A+"))), (0, react.createElement)("div", { className: "lks14-row" }, examLessonActive ? (0, react.createElement)("div", { className: "lks14-col lks14-chat" }, (0, react.createElement)(ExamView, {
7343
+ lessonId: lesson.lessonId,
7344
+ sectionTitle: lesson.sectionTitle,
7345
+ paused: examLeave !== null,
7346
+ onSessionChange: onExamSession
7347
+ })) : (0, react.createElement)(ChatPane, {
7433
7348
  data,
7434
7349
  lesson,
7435
7350
  rows,
@@ -7443,20 +7358,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7443
7358
  stop,
7444
7359
  setMode,
7445
7360
  narrowPane,
7446
- companionEvent: setCompanionEvent,
7447
7361
  onThreadJump: (id) => {
7448
7362
  setNarrowPane("chat");
7449
7363
  guardedSetFocus(id);
7450
7364
  },
7451
7365
  contextMeter,
7366
+ contextBreakdown: breakdown,
7452
7367
  uploadAttachment
7453
7368
  }), (0, react.createElement)(NotebookPane, {
7454
7369
  data,
7455
7370
  deleteNote,
7456
- send,
7457
- companionEvent: setCompanionEvent,
7458
- onExamSession,
7459
- examPaused: examLeave !== null
7371
+ send
7460
7372
  }))));
7461
7373
  return (0, react.createElement)("div", {
7462
7374
  className: "lks14 lks-ui",
@@ -7490,13 +7402,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7490
7402
  setNarrowPane("chat");
7491
7403
  send(tr("prompt.start"));
7492
7404
  }
7493
- }) : null, (0, react.createElement)(Companion, {
7494
- mood: companion.mood,
7495
- onPoke: () => {
7496
- setCompanionEvent("poke");
7497
- showStudyToast(tr("companion.poked"), { severity: "success" });
7498
- }
7499
- }), (0, react.createElement)("div", { className: "lks14-switch" }, ...[
7405
+ }) : null, (0, react.createElement)("div", { className: "lks14-switch" }, ...[
7500
7406
  "rail",
7501
7407
  "chat",
7502
7408
  "note"
@@ -7764,7 +7670,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7764
7670
  PANEL_SHELL.current = shell;
7765
7671
  }
7766
7672
  /** 左栏:course picker, tree, review box, import. */
7767
- function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, onBlankTap, streamingLessonId, turnActive, importProgress, stop, paletteBus }) {
7673
+ function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus }) {
7768
7674
  const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
7769
7675
  const [query, setQuery] = (0, react.useState)("");
7770
7676
  const [searchRows, setSearchRows] = (0, react.useState)([]);
@@ -7835,7 +7741,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7835
7741
  turnActive
7836
7742
  ]);
7837
7743
  const course = data?.courses.find((c) => c.courseId === courseId) ?? null;
7838
- const blankDown = (0, react.useRef)(null);
7839
7744
  (0, react.useEffect)(() => {
7840
7745
  if (optFocus !== null && data?.focusLessonId === optFocus) setOptFocus(null);
7841
7746
  }, [data?.focusLessonId, optFocus]);
@@ -7955,20 +7860,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7955
7860
  }, (0, react.createElement)(IconWrenchOutline16, { size: 13 }), tr("map.world.practice"))) : null)) : railtabs);
7956
7861
  const mapPane = data === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("loading")) : course === null ? null : (0, react.createElement)("div", {
7957
7862
  className: "lks14-railscroll",
7958
- ref: scrollEl,
7959
- onPointerDown: (e) => {
7960
- blankDown.current = {
7961
- x: e.clientX,
7962
- y: e.clientY
7963
- };
7964
- },
7965
- onClick: (e) => {
7966
- const d = blankDown.current;
7967
- blankDown.current = null;
7968
- if (d === null || Math.hypot(e.clientX - d.x, e.clientY - d.y) > 6) return;
7969
- if (e.target.closest("button, a, input, textarea, select") !== null) return;
7970
- onBlankTap();
7971
- }
7863
+ ref: scrollEl
7972
7864
  }, (0, react.createElement)("div", { className: "lks-raillist" }, query.trim() === "" && world === "practice" && !course.sections.some((s) => sectionWorldOf(s) === "practice") ? (0, react.createElement)("div", { className: "lks-empty-practice" }, tr("map.empty.practice")) : course.sections.filter((s) => query.trim() !== "" || sectionWorldOf(s) === world).flatMap((section) => {
7973
7865
  const examAllowed = examOpen(section.lessons);
7974
7866
  const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
@@ -8375,9 +8267,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8375
8267
  return cut <= 0 ? path : norm.slice(0, cut);
8376
8268
  }
8377
8269
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
8378
- function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, companionEvent, onThreadJump, contextMeter, uploadAttachment }) {
8270
+ function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadJump, contextMeter, contextBreakdown, uploadAttachment }) {
8379
8271
  const stuck = (0, react.useRef)(true);
8380
8272
  const [showFab, setShowFab] = (0, react.useState)(false);
8273
+ const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
8381
8274
  const onStreamScroll = () => {
8382
8275
  const el = streamEl.current;
8383
8276
  if (el === null) return;
@@ -8391,6 +8284,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8391
8284
  const el = streamEl.current;
8392
8285
  if (el !== null) el.scrollTop = el.scrollHeight;
8393
8286
  }, [rows.length, lastIsUser]);
8287
+ (0, react.useEffect)(() => {
8288
+ const root = streamEl.current;
8289
+ if (root === null) return;
8290
+ for (const el of root.querySelectorAll(".lks14-msg-assistant:not(.streaming)")) {
8291
+ if (el.dataset.lksFeedEnhanced === "1") continue;
8292
+ el.dataset.lksFeedEnhanced = "1";
8293
+ enhanceRendered(el).catch(() => {});
8294
+ }
8295
+ }, [rows]);
8394
8296
  const dormant = data?.active !== true;
8395
8297
  (0, react.useEffect)(() => {
8396
8298
  if (!busy) return;
@@ -8402,6 +8304,37 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8402
8304
  window.removeEventListener("keydown", onKey);
8403
8305
  };
8404
8306
  }, [busy, stop]);
8307
+ (0, react.useEffect)(() => {
8308
+ if (!threadsOpen) return;
8309
+ const onKey = (e) => {
8310
+ if (e.key === "Escape") setThreadsOpen(false);
8311
+ };
8312
+ const onDown = (e) => {
8313
+ if (e.target.closest(".lks14-threadswitch") === null) setThreadsOpen(false);
8314
+ };
8315
+ window.addEventListener("keydown", onKey);
8316
+ document.addEventListener("pointerdown", onDown);
8317
+ return () => {
8318
+ window.removeEventListener("keydown", onKey);
8319
+ document.removeEventListener("pointerdown", onDown);
8320
+ };
8321
+ }, [threadsOpen]);
8322
+ const [ctxOpen, setCtxOpen] = (0, react.useState)(false);
8323
+ (0, react.useEffect)(() => {
8324
+ if (!ctxOpen) return;
8325
+ const onKey = (e) => {
8326
+ if (e.key === "Escape") setCtxOpen(false);
8327
+ };
8328
+ const onDown = (e) => {
8329
+ if (e.target.closest(".lks14-ctxring-wrap") === null) setCtxOpen(false);
8330
+ };
8331
+ window.addEventListener("keydown", onKey);
8332
+ document.addEventListener("pointerdown", onDown);
8333
+ return () => {
8334
+ window.removeEventListener("keydown", onKey);
8335
+ document.removeEventListener("pointerdown", onDown);
8336
+ };
8337
+ }, [ctxOpen]);
8405
8338
  const { tts } = useStudy();
8406
8339
  const [msgAudio, setMsgAudio] = (0, react.useState)(null);
8407
8340
  const msgReadCtl = (0, react.useRef)(null);
@@ -8437,11 +8370,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8437
8370
  }
8438
8371
  };
8439
8372
  const playMessage = (key) => {
8440
- const el = rowElement(key);
8441
- if (el === null) return;
8442
- const sentences = speechSentencesOf((el.textContent ?? "").trim());
8373
+ if (rowElement(key) === null) return;
8374
+ const raw = rowsView.find((r) => r.key === key && r.role === "assistant")?.text;
8375
+ if (raw === void 0) return;
8376
+ const sentences = speechSentencesOf(raw);
8443
8377
  if (sentences.length === 0) return;
8444
- companionEvent("talk-start");
8445
8378
  msgReadCtl.current?.stop();
8446
8379
  const voice = storedTtsVoice();
8447
8380
  const prefetch = /* @__PURE__ */ new Map();
@@ -8454,7 +8387,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8454
8387
  }
8455
8388
  return tts(text, voice);
8456
8389
  };
8457
- const controller = new ReadAloudController(sentences, {
8390
+ const controller = new ReadAloudController(sentences.map((s) => speakMathInSentence(s)), {
8458
8391
  speak: (text) => fetchAudio(text).then((buf) => new Promise((resolve, reject) => {
8459
8392
  const blobUrl = URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" }));
8460
8393
  const audio = new Audio(blobUrl);
@@ -8522,7 +8455,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8522
8455
  });
8523
8456
  };
8524
8457
  const stopMessage = () => {
8525
- companionEvent("talk-end");
8526
8458
  msgReadCtl.current?.stop();
8527
8459
  setMsgAudio(null);
8528
8460
  document.querySelectorAll("mark.lks-reading").forEach((m) => {
@@ -8550,9 +8482,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8550
8482
  data: artifact.data,
8551
8483
  masteryPct: lesson.masteryPct,
8552
8484
  send,
8553
- onFinished: (allCorrect) => {
8554
- companionEvent(allCorrect ? "celebrate" : "encourage");
8555
- }
8485
+ onFinished: () => {}
8556
8486
  }) : (0, react.createElement)(ArtifactCard, {
8557
8487
  artifact,
8558
8488
  send
@@ -8650,20 +8580,29 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8650
8580
  }));
8651
8581
  send(tr("proposal.decline.msg", { lesson: proposal.lessonTitle }));
8652
8582
  }
8653
- }, tr("proposal.decline"))) : decidedBadge !== null ? (0, react.createElement)("div", { className: `lks-propbanner decided ${decidedBadge.kind}` }, decidedBadge.kind === "accepted" ? (0, react.createElement)(IconCrownFill16, { size: 14 }) : (0, react.createElement)(IconBookFill16, { size: 14 }), (0, react.createElement)("span", { className: "lks-propbanner-why" }, tr(decidedBadge.kind === "accepted" ? "proposal.applied" : "proposal.rejected", { lesson: decidedBadge.title }))) : null, lesson !== null || !dormant ? (0, react.createElement)("div", { className: "lks14-lessonrow" }, lesson !== null ? (0, react.createElement)("span", null, lesson.title) : (0, react.createElement)("span", null, tr("col.tutor")), threadPills.length > 0 ? (0, react.createElement)("span", {
8654
- className: "lks14-threadpills",
8655
- role: "tablist",
8656
- "aria-label": tr("threads.label")
8583
+ }, tr("proposal.decline"))) : decidedBadge !== null ? (0, react.createElement)("div", { className: `lks-propbanner decided ${decidedBadge.kind}` }, decidedBadge.kind === "accepted" ? (0, react.createElement)(IconCrownFill16, { size: 14 }) : (0, react.createElement)(IconBookFill16, { size: 14 }), (0, react.createElement)("span", { className: "lks-propbanner-why" }, tr(decidedBadge.kind === "accepted" ? "proposal.applied" : "proposal.rejected", { lesson: decidedBadge.title }))) : null, lesson !== null || !dormant ? (0, react.createElement)("div", { className: "lks14-lessonrow" }, lesson !== null ? (0, react.createElement)("span", null, lesson.title) : (0, react.createElement)("span", null, tr("col.tutor")), threadPills.length > 0 ? (0, react.createElement)("div", { className: "lks14-threadswitch" }, (0, react.createElement)("button", {
8584
+ className: "lks14-threadchip",
8585
+ "aria-expanded": String(threadsOpen),
8586
+ "data-tooltip": tr("threads.label"),
8587
+ onClick: () => {
8588
+ setThreadsOpen((v) => !v);
8589
+ }
8590
+ }, tr("threads.open", { n: String(threadPills.length) }), (0, react.createElement)("span", { className: "lks14-threadchip-caret" }, threadsOpen ? "▾" : "▸")), threadsOpen ? (0, react.createElement)("div", {
8591
+ className: "lks14-threadmenu",
8592
+ role: "menu"
8657
8593
  }, ...threadPills.map((p) => (0, react.createElement)("button", {
8658
8594
  key: p.id,
8659
- className: `lks14-threadpill${p.current ? " on" : ""}`,
8660
- role: "tab",
8661
- "aria-selected": String(p.current),
8662
- "data-tooltip": p.title,
8595
+ className: `lks14-threadmenu-row${p.current ? " on" : ""}`,
8596
+ role: "menuitem",
8597
+ "aria-current": p.current ? "true" : void 0,
8663
8598
  onClick: () => {
8599
+ setThreadsOpen(false);
8664
8600
  if (!p.current) onThreadJump(p.id);
8665
8601
  }
8666
- }, p.title))) : null) : null, bound && lesson !== null ? (0, react.createElement)("div", { className: "lks14-threadlabel" }, tr("thread.label", { title: lesson.title })) : null, (0, react.createElement)("div", {
8602
+ }, (0, react.createElement)("span", {
8603
+ className: "lks14-threadmenu-dot",
8604
+ "aria-hidden": "true"
8605
+ }), (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, p.title), p.current ? (0, react.createElement)("span", { className: "lks14-threadmenu-now" }, tr("threads.now")) : null))) : null) : null) : null, (0, react.createElement)("div", {
8667
8606
  className: "lks14-stream",
8668
8607
  onScroll: onStreamScroll,
8669
8608
  ref: streamEl,
@@ -8687,9 +8626,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8687
8626
  data: a.data,
8688
8627
  masteryPct: lesson.masteryPct,
8689
8628
  send,
8690
- onFinished: (allCorrect) => {
8691
- companionEvent(allCorrect ? "celebrate" : "encourage");
8692
- }
8629
+ onFinished: () => {}
8693
8630
  })), ...backlog.filter((a) => a.artifactType !== "quiz").map((a) => (0, react.createElement)(ArtifactCard, {
8694
8631
  key: a.id,
8695
8632
  artifact: a,
@@ -8703,14 +8640,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8703
8640
  if (!dormant) send(s.message);
8704
8641
  }
8705
8642
  }, s.label))) : null, (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", {
8706
- className: "lks14-ctxmeter",
8707
- "data-testid": "context-meter",
8708
- "data-estimated": String(contextMeter.estimated),
8709
- title: tr("meter.label")
8710
- }, (0, react.createElement)("span", { className: "lks14-ctxmeter-bar" }, (0, react.createElement)("i", {
8711
- style: { transform: `scaleX(${String((contextMeter.pct ?? 0) / 100)})` },
8712
- className: contextMeter.pct !== null && contextMeter.pct > 80 ? "hot" : void 0
8713
- })), (0, react.createElement)("span", { className: "lks14-ctxmeter-label" }, contextMeter.estimated ? contextMeter.label : `${String(contextMeter.pct ?? 0)}% · ${contextMeter.label}`)), (0, react.createElement)("div", {
8714
8643
  className: "lks14-composer-card",
8715
8644
  onDragOver: (e) => {
8716
8645
  if (e.dataTransfer?.types.includes("Files") === true) e.preventDefault();
@@ -8765,7 +8694,55 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8765
8694
  if (file !== void 0 && file !== null) attachFile(file);
8766
8695
  e.target.value = "";
8767
8696
  }
8768
- }), busy ? (0, react.createElement)("button", {
8697
+ }), contextMeter.pct !== null ? (0, react.createElement)("span", {
8698
+ className: "lks14-ctxring-wrap",
8699
+ key: "ctxring"
8700
+ }, (0, react.createElement)("button", {
8701
+ className: "lks14-ctxring",
8702
+ "data-testid": "context-meter",
8703
+ "data-estimated": String(contextMeter.estimated),
8704
+ "aria-label": tr("meter.aria", { pct: String(contextMeter.pct) }),
8705
+ "data-tooltip": tr("meter.aria", { pct: String(contextMeter.pct) }),
8706
+ "aria-haspopup": "dialog",
8707
+ "aria-expanded": String(ctxOpen),
8708
+ onClick: () => {
8709
+ setCtxOpen((v) => !v);
8710
+ }
8711
+ }, (0, react.createElement)("svg", {
8712
+ viewBox: "0 0 14 14",
8713
+ width: 14,
8714
+ height: 14,
8715
+ "aria-hidden": "true"
8716
+ }, (0, react.createElement)("circle", {
8717
+ className: "track",
8718
+ cx: 7,
8719
+ cy: 7,
8720
+ r: 5.5
8721
+ }), (0, react.createElement)("circle", {
8722
+ className: `fill${contextMeter.pct > 80 ? " hot" : ""}`,
8723
+ cx: 7,
8724
+ cy: 7,
8725
+ r: 5.5,
8726
+ strokeDasharray: `${String(2 * Math.PI * 5.5 * contextMeter.pct / 100)} ${String(2 * Math.PI * 5.5)}`,
8727
+ transform: "rotate(-90 7 7)"
8728
+ }))), ctxOpen ? (0, react.createElement)("div", {
8729
+ className: "lks14-ctxpanel",
8730
+ role: "dialog",
8731
+ "aria-label": tr("meter.label")
8732
+ }, (0, react.createElement)("div", { className: "lks14-ctxpanel-head" }, (0, react.createElement)("span", { className: "lks14-ctxpanel-pct" }, `${String(contextMeter.pct)}%`), (0, react.createElement)("span", { className: "lks14-ctxpanel-figs" }, `~${fmtTokens(contextMeter.used ?? 0)} / ${fmtTokens(contextMeter.window ?? 0)}`)), (0, react.createElement)("div", { className: "lks14-ctxpanel-bar" }, ...ctxSegments(contextMeter.pct, contextBreakdown).map((s) => (0, react.createElement)("i", {
8733
+ key: s.key,
8734
+ className: s.cls,
8735
+ style: { width: `${String(s.width)}%` }
8736
+ }))), contextBreakdown !== null ? (0, react.createElement)("div", { className: "lks14-ctxpanel-rows" }, (0, react.createElement)("div", { className: "lks14-ctxpanel-row" }, (0, react.createElement)("span", {
8737
+ className: "swatch sys",
8738
+ "aria-hidden": "true"
8739
+ }), tr("meter.brk.system"), (0, react.createElement)("b", null, `~${fmtTokens(contextBreakdown.systemTokens)}`)), (0, react.createElement)("div", { className: "lks14-ctxpanel-row" }, (0, react.createElement)("span", {
8740
+ className: "swatch tools",
8741
+ "aria-hidden": "true"
8742
+ }), tr("meter.brk.tools"), (0, react.createElement)("b", null, `~${fmtTokens(contextBreakdown.toolsTokens)}`)), (0, react.createElement)("div", { className: "lks14-ctxpanel-row" }, (0, react.createElement)("span", {
8743
+ className: "swatch msgs",
8744
+ "aria-hidden": "true"
8745
+ }), tr("meter.brk.messages"), (0, react.createElement)("b", null, `~${fmtTokens(contextBreakdown.messageTokens)}`))) : null, contextMeter.estimated ? (0, react.createElement)("div", { className: "lks14-ctxpanel-note" }, tr("meter.estimate.note")) : null) : null) : null, busy ? (0, react.createElement)("button", {
8769
8746
  className: "lks-btn-send stop",
8770
8747
  "aria-label": tr("chat.stop"),
8771
8748
  "data-tooltip": tr("chat.stop"),
@@ -8790,7 +8767,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8790
8767
  }, "▾") : null, sendError !== null || error !== null ? (0, react.createElement)("div", { className: "lks-propcard-err" }, sendError ?? error) : null));
8791
8768
  }
8792
8769
  /** 右栏:the notebook — 讲解/概念图/笔记 tabs (upstream NotebookPanel arrangement). */
8793
- function NotebookPane({ data, deleteNote, send, companionEvent, onExamSession, examPaused }) {
8770
+ function NotebookPane({ data, deleteNote, send }) {
8794
8771
  const lesson = data?.lesson ?? null;
8795
8772
  const [tab, setTab] = (0, react.useState)("teach");
8796
8773
  const [error, setError] = (0, react.useState)(null);
@@ -8824,18 +8801,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8824
8801
  setError(friendlyError(err, "action"));
8825
8802
  });
8826
8803
  };
8827
- const examContent = lesson !== null && lesson.kind === "exam" ? (() => {
8828
- const section = data?.courses.flatMap((c) => c.sections).find((s) => s.lessons.some((l) => l.id === lesson.lessonId));
8829
- return examOpen(section?.lessons ?? []) ? (0, react.createElement)(ExamView, {
8830
- lessonId: lesson.lessonId,
8831
- sectionTitle: lesson.sectionTitle,
8832
- paused: examPaused,
8833
- onSessionChange: onExamSession
8834
- }) : (0, react.createElement)("div", {
8835
- className: "lks14-empty",
8836
- style: { padding: "16px 0" }
8837
- }, tr("status.exam"));
8838
- })() : null;
8839
8804
  const artifacts = lesson?.artifacts ?? [];
8840
8805
  const [, setUnseenTick] = (0, react.useState)(0);
8841
8806
  const unseen = unseenArtifacts(lesson?.lessonId ?? "", artifacts);
@@ -8923,7 +8888,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8923
8888
  const [rateBusy, setRateBusy] = (0, react.useState)(false);
8924
8889
  const startReading = () => {
8925
8890
  if (lesson === null || lesson.speechText.trim() === "") return;
8926
- companionEvent("talk-start");
8927
8891
  readCtl.current?.stop();
8928
8892
  const sentences = speechSentencesOf(lesson.speechText);
8929
8893
  const voice = storedTtsVoice();
@@ -9004,7 +8968,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9004
8968
  });
9005
8969
  };
9006
8970
  const stopReading = () => {
9007
- companionEvent("talk-end");
9008
8971
  readCtl.current?.stop();
9009
8972
  setRead(null);
9010
8973
  };
@@ -9079,7 +9042,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9079
9042
  onClick: () => {
9080
9043
  setRateBusy(true);
9081
9044
  recordReview(lesson.lessonId, Number(q)).then(() => {
9082
- companionEvent(Number(q) >= 4 ? "celebrate" : "encourage");
9083
9045
  const rateEl = document.querySelector(`[data-lks-rate="${lesson.lessonId}"]`);
9084
9046
  const rr = Number(q) >= 4 && rateEl !== null ? rateEl.getBoundingClientRect() : null;
9085
9047
  celebrate(Number(q) >= 4 ? "correct" : "wrong", rr !== null ? { origin: {
@@ -9307,7 +9269,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9307
9269
  dangerouslySetInnerHTML: { __html: renderMarkdown(n.text) }
9308
9270
  })), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `'${n.quote}'`) : null)) : []);
9309
9271
  })));
9310
- return (0, react.createElement)("div", { className: "lks14-col lks14-note" }, examContent !== null ? (0, react.createElement)("div", { className: "lks14-notebody" }, examContent) : body, examContent === null && confirmNoteDel !== null ? (0, react.createElement)(ConfirmCard, {
9272
+ return (0, react.createElement)("div", { className: "lks14-col lks14-note" }, body, confirmNoteDel !== null ? (0, react.createElement)(ConfirmCard, {
9311
9273
  anchor: confirmNoteDel.rect,
9312
9274
  message: tr("note.delete.confirm"),
9313
9275
  danger: true,
@@ -9575,7 +9537,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9575
9537
  const { data, setMode, activate, setHistoryBudget } = useStudy();
9576
9538
  const [error, setError] = (0, react.useState)(null);
9577
9539
  const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
9578
- const [companion, setCompanion] = (0, react.useState)(storedCompanionForm);
9579
9540
  const fire = (action) => {
9580
9541
  action.then(() => {
9581
9542
  setError(null);
@@ -9602,17 +9563,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9602
9563
  onClick: () => {
9603
9564
  if (data !== null) fire(setHistoryBudget(!(data.historyBudget === true)));
9604
9565
  }
9605
- }, data?.historyBudget === true ? tr("settings.budget.off") : tr("settings.budget.on")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.companion")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.companion.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
9606
- className: "lks-set-select",
9607
- value: companion,
9608
- onChange: (e) => {
9609
- storeCompanionForm(normalizeCompanionForm(e.target.value));
9610
- setCompanion(normalizeCompanionForm(e.target.value));
9611
- }
9612
- }, ...COMPANION_FORMS.map((f) => (0, react.createElement)("option", {
9613
- key: f.id,
9614
- value: f.id
9615
- }, tr(f.labelKey)))))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
9566
+ }, data?.historyBudget === true ? tr("settings.budget.off") : tr("settings.budget.on")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
9616
9567
  className: "lks-set-select",
9617
9568
  value: voice,
9618
9569
  onChange: (e) => {