dsh-focus-overlay 0.11.3 → 0.12.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/README.en.md CHANGED
@@ -25,6 +25,7 @@
25
25
  - **Auto-focus & reminders** — after a reply completes normally, optionally auto-enter focus at your question; while in focus, get a toast for a new reply or when the AI is waiting on you
26
26
  - **Width handles** — draggable strips beside the content column (official WidthHandle replica) that resize the reading column live and persist
27
27
  - **Hotkey F** — press `F` anywhere to enter focus mode instantly (toggleable in settings); typing in an input never triggers it
28
+ - **Focus counter** — the settings card keeps a running total of your focus-mode entries; past 100 entries a GitHub Star nudge appears, is remembered after the first use, and then becomes a direct "open the project page" button
28
29
 
29
30
  These features are designed to give **small screens** more content space: tucking away the persistent header/composer and tool steps lets the conversation use as much of the screen as possible.
30
31
 
@@ -71,6 +72,7 @@ Click the line to expand that turn's full work detail (per-category one-line sum
71
72
  | Auto-enter focus | After a reply **completes normally**, auto-open focus at your question; abnormal endings (stop / error / max-tokens / interrupt) never fire — the judgement waits for a stable snapshot (streaming tail drained) first |
72
73
  | Reply / waiting reminders | In focus, a one-shot "New reply ready + View" toast on completion (auto-dismissed after 6s); a question/approval raises "AI is waiting for your reply + Answer", whose button expands the in-place answer card — auto-cleared once answered, never leaving focus |
73
74
  | F hotkey | Press `F` anywhere to enter focus mode instantly (default on, toggleable in settings); never fires while typing in an input or editable element, modifiers and auto-repeat ignored |
75
+ | Focus counter | A row at the bottom of the settings card totals your focus-mode entries (auto-entries included), persisted to `localStorage`; past 100 entries a Star nudge appears — after the first use it is remembered and replaced by a direct "open the project page" button |
74
76
  | Esc peels layer by layer | Answer card → input bar → focus mode, one layer per press; Esc mid-IME-composition cancels the composition first |
75
77
  | i18n | Chinese / English copy registered under the `focus` namespace, follows the UI language |
76
78
  | Plugin configuration card | A collapsible card under "Settings → Plugins → Plugin configuration", preferences persisted to `localStorage`; the Node half registers the same settings namespace (schemastery schema) so the tab dispatches it |
package/README.md CHANGED
@@ -23,6 +23,7 @@
23
23
  - **自动专注与提醒** —— 回复正常完成后可自动进入专注并定位到你的提问;专注中收到新回复、或 AI 等待你回答时弹出提醒
24
24
  - **宽度调节** —— 正文两侧的拖拽把手(官方 WidthHandle 复刻),实时拖动阅读列宽并持久化
25
25
  - **快捷键 F** —— 任意界面按 `F` 一键进入专注模式,可在设置中关闭;输入框内打字不会误触发
26
+ - **专注计数** —— 设置卡片最底部累计展示你进入专注模式的次数;超过 100 次后出现 GitHub Star 引导,点过一次即被记住,之后提供「前往 GitHub 项目页面」直达按钮
26
27
 
27
28
  这些功能专为**小屏幕**提供更多内容呈现空间:收起常驻的标题 / 输入区与工具步骤,让有限的屏幕尽可能多地展示对话。
28
29
 
@@ -69,6 +70,7 @@
69
70
  | 自动进入专注 | 回复**正常完成**后自动打开专注并定位到本轮你的提问;异常结束(停止 / 报错 / 超 token / 打断)不触发——判定要求快照稳定(流式尾部排空)后才下结论 |
70
71
  | 回复 / 等待提醒 | 专注中回复完成弹「新回复已生成 + 查看」(6 秒自动消失,一次性);AI 提问 / 审批等待时弹「AI 正在等待你的回复 + 去回答」,按钮原地展开答题卡,作答后自动消失——全程无需退出专注 |
71
72
  | F 键快捷键 | 任意界面按 `F` 立即进入专注(默认开,可在设置中关闭);输入框 / 可编辑元素内打字不触发,忽略修饰键与长按自动重复 |
73
+ | 专注计数 | 设置卡片最底部一行累计进入专注模式的次数(含自动进入),持久化到 `localStorage`;超过 100 次出现 Star 引导按钮,点击前往仓库页面并记住状态,之后改为「前往 GitHub 项目页面」直达按钮 |
72
74
  | Esc 逐层退出 | 答题卡 → 输入条 → 专注模式,一层一层退;输入法组词中的 Esc 优先取消组词 |
73
75
  | i18n | 中 / 英文案,注册到 `focus` 命名空间,跟随界面语言 |
74
76
  | 插件配置卡片 | 「设置 → 插件 → 插件配置」中的可折叠卡片,偏好持久化到 `localStorage`;Node 半区以 schemastery schema 注册同名设置 namespace 供其分发 |
package/lib/client.js CHANGED
@@ -519,6 +519,39 @@ window.__ModuleLoader__.load({
519
519
  function onboardingSeen(stored, version) {
520
520
  return stored === version;
521
521
  }
522
+ /** Parse the persisted focus-entry counter (`{"entries":n}` JSON). Anything
523
+ * unreadable — null, broken JSON, a non-number or a negative — reads as 0,
524
+ * so one corrupted write can never make the settings page show a bogus
525
+ * count; fractions floor (a counter is always whole). */
526
+ function readEntryCount(raw) {
527
+ try {
528
+ if (!raw) return 0;
529
+ const parsed = JSON.parse(raw);
530
+ if (!parsed || typeof parsed !== "object") return 0;
531
+ const n = parsed.entries;
532
+ if (typeof n !== "number" || !Number.isFinite(n)) return 0;
533
+ return Math.max(0, Math.floor(n));
534
+ } catch {
535
+ return 0;
536
+ }
537
+ }
538
+ /** Whether the settings card should show the Star nudge for this entry count. */
539
+ function shouldShowStar(entries) {
540
+ return entries > 100;
541
+ }
542
+ /** Whether the persisted stats mark the Star button as already used — the
543
+ * card then offers a plain "open the project page" button instead of asking
544
+ * for a star again. Anything unreadable reads as not-starred. */
545
+ function readStarred(raw) {
546
+ try {
547
+ if (!raw) return false;
548
+ const parsed = JSON.parse(raw);
549
+ if (!parsed || typeof parsed !== "object") return false;
550
+ return parsed.starred === true;
551
+ } catch {
552
+ return false;
553
+ }
554
+ }
522
555
  /** Whether a keydown should enter focus mode via the F hotkey. Pure decision so
523
556
  * the guard rails are unit-testable: the pref must be enabled, focus must not
524
557
  * already be open, the press must not land in an editable element (composer /
@@ -557,6 +590,14 @@ window.__ModuleLoader__.load({
557
590
  if (o.engaged || o.focused || o.draftEmpty && o.inZone) return "bar";
558
591
  return o.draftEmpty ? "tobottom" : "pill";
559
592
  }
593
+ /** Whether the bottom dock should render the OFFICIAL composer (CSS-lifted
594
+ * above the overlay) as its bar form: the user wants the borrow, the
595
+ * composer DOM probed available, and the dock currently wants the bar.
596
+ * Pure so the decision is unit-testable at the seam the lift effect
597
+ * drives — the e2e loop exercises the DOM consequences. */
598
+ function shouldLiftOfficialComposer(o) {
599
+ return o.wanted && o.available && o.form === "bar";
600
+ }
560
601
  /**
561
602
  * Bottom-zone membership with hysteresis: entering requires the viewport edge
562
603
  * within {@link BOTTOM_ENTER_PX} of the scroll end, leaving requires drifting
@@ -693,7 +734,8 @@ window.__ModuleLoader__.load({
693
734
  scroll: "preserve",
694
735
  width: 760,
695
736
  autoFocus: false,
696
- hotkey: true
737
+ hotkey: true,
738
+ borrow: true
697
739
  };
698
740
  function load() {
699
741
  try {
@@ -748,6 +790,64 @@ window.__ModuleLoader__.load({
748
790
  } catch {}
749
791
  }
750
792
  };
793
+ const STATS_KEY = "dsh-focus-overlay:stats";
794
+ function loadStats() {
795
+ try {
796
+ const raw = localStorage.getItem(STATS_KEY);
797
+ return {
798
+ entries: readEntryCount(raw),
799
+ starred: readStarred(raw)
800
+ };
801
+ } catch {
802
+ return {
803
+ entries: 0,
804
+ starred: false
805
+ };
806
+ }
807
+ }
808
+ let stats = loadStats();
809
+ const statsListeners = [];
810
+ function persistStats(next) {
811
+ stats = next;
812
+ try {
813
+ localStorage.setItem(STATS_KEY, JSON.stringify(next));
814
+ } catch {}
815
+ for (const l of statsListeners) l();
816
+ }
817
+ const statsStore = {
818
+ getEntries: () => stats.entries,
819
+ isStarred: () => stats.starred,
820
+ /** One focus-mode open. Writes through to localStorage (best effort); when
821
+ * storage is unavailable the in-memory value still counts for the session. */
822
+ bumpEntries: () => persistStats({
823
+ ...stats,
824
+ entries: stats.entries + 1
825
+ }),
826
+ /** Mark the Star button as used (idempotent). */
827
+ markStarred: () => {
828
+ if (!stats.starred) persistStats({
829
+ ...stats,
830
+ starred: true
831
+ });
832
+ },
833
+ subscribe: (l) => {
834
+ statsListeners.push(l);
835
+ return () => {
836
+ const i = statsListeners.indexOf(l);
837
+ if (i >= 0) statsListeners.splice(i, 1);
838
+ };
839
+ }
840
+ };
841
+ function useEntryCount() {
842
+ const [n, setN] = (0, react.useState)(statsStore.getEntries);
843
+ (0, react.useEffect)(() => statsStore.subscribe(() => setN(statsStore.getEntries)), []);
844
+ return n;
845
+ }
846
+ function useStarred() {
847
+ const [s, setS] = (0, react.useState)(statsStore.isStarred);
848
+ (0, react.useEffect)(() => statsStore.subscribe(() => setS(statsStore.isStarred)), []);
849
+ return s;
850
+ }
751
851
  //#endregion
752
852
  //#region src/client/Composer.tsx
753
853
  /**
@@ -1147,8 +1247,9 @@ window.__ModuleLoader__.load({
1147
1247
  });
1148
1248
  }
1149
1249
  function FocusBottomDock(props) {
1150
- const { t, width, form, wait, onCardClose, draft, queueCount, occCount, errorLine, textareaRef, onFocusChange, onDraftChange, onSend, onExpand, onJumpBottom } = props;
1250
+ const { t, width, form, borrowed, wait, onCardClose, draft, queueCount, occCount, errorLine, textareaRef, onFocusChange, onDraftChange, onSend, onExpand, onJumpBottom } = props;
1151
1251
  if (form === "toast") return null;
1252
+ if (form === "bar" && borrowed) return null;
1152
1253
  if (form === "card") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnswerCard, {
1153
1254
  t,
1154
1255
  width,
@@ -1188,6 +1289,172 @@ window.__ModuleLoader__.load({
1188
1289
  });
1189
1290
  }
1190
1291
  //#endregion
1292
+ //#region src/client/celebrate.ts
1293
+ /**
1294
+ * Firework for the Star button: a short, self-contained canvas animation
1295
+ * appended to document.body with pointer-events:none and removed when done,
1296
+ * so it can never intercept clicks. No dependencies, no stylesheet injection:
1297
+ * every style is inline, so cleanup is simply removing the element.
1298
+ *
1299
+ * Five staggered bursts of ~90 trailing particles each; the GitHub page opens
1300
+ * only after the show ends (celebrateThenOpen).
1301
+ */
1302
+ const BURSTS = 5;
1303
+ const BURST_GAP_MS = 280;
1304
+ const PARTICLES_PER_BURST = 90;
1305
+ const GRAVITY = 300;
1306
+ const MAX_LIFE_S = 2;
1307
+ const COLORS = [
1308
+ "#f43f5e",
1309
+ "#fb923c",
1310
+ "#facc15",
1311
+ "#4ade80",
1312
+ "#38bdf8",
1313
+ "#a78bfa",
1314
+ "#f472b6"
1315
+ ];
1316
+ const FIREWORKS_TOTAL_MS = 4e3;
1317
+ /** Play the fireworks, then open `url` in a new tab once the sky is clear.
1318
+ * Browsers honor the click's transient activation for a few seconds, so the
1319
+ * delayed open is not popup-blocked in Chrome; stricter browsers fall back
1320
+ * on the repo button beside the counter, which opens the page directly. */
1321
+ function celebrateThenOpen(url) {
1322
+ const launched = launchFireworks();
1323
+ setTimeout(() => {
1324
+ try {
1325
+ window.open(url, "_blank", "noopener");
1326
+ } catch {}
1327
+ }, launched ? FIREWORKS_TOTAL_MS : 400);
1328
+ }
1329
+ /** Launch the bursts. Returns false when nothing was animated (reduced-motion
1330
+ * preference, an existing show, or an unavailable 2D context). */
1331
+ function launchFireworks() {
1332
+ try {
1333
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
1334
+ if (document.querySelector("[data-fm-fireworks]")) return false;
1335
+ const canvas = document.createElement("canvas");
1336
+ canvas.setAttribute("data-fm-fireworks", "");
1337
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
1338
+ const w = window.innerWidth;
1339
+ const h = window.innerHeight;
1340
+ canvas.width = Math.max(1, Math.floor(w * dpr));
1341
+ canvas.height = Math.max(1, Math.floor(h * dpr));
1342
+ canvas.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483000";
1343
+ document.body.appendChild(canvas);
1344
+ const ctx = canvas.getContext("2d");
1345
+ if (!ctx) {
1346
+ canvas.remove();
1347
+ return false;
1348
+ }
1349
+ ctx.scale(dpr, dpr);
1350
+ const rand = (a, b) => a + Math.random() * (b - a);
1351
+ const rockets = [];
1352
+ const particles = [];
1353
+ const spawnRocket = (i) => {
1354
+ rockets.push({
1355
+ x: w * (.22 + .14 * i) + rand(-30, 30),
1356
+ y: h + 8,
1357
+ vy: -h * rand(1.05, 1.25),
1358
+ targetY: h * rand(.14, .36),
1359
+ color: COLORS[i % COLORS.length]
1360
+ });
1361
+ };
1362
+ const start = performance.now();
1363
+ let spawned = 0;
1364
+ const explode = (r) => {
1365
+ for (let k = 0; k < PARTICLES_PER_BURST; k++) {
1366
+ const angle = Math.PI * 2 * k / PARTICLES_PER_BURST + rand(-.07, .07);
1367
+ const speed = rand(80, 430) * (k % 4 === 0 ? .55 : 1);
1368
+ const maxLife = rand(1.2, MAX_LIFE_S);
1369
+ particles.push({
1370
+ x: r.x,
1371
+ y: r.y,
1372
+ px: r.x,
1373
+ py: r.y,
1374
+ vx: Math.cos(angle) * speed,
1375
+ vy: Math.sin(angle) * speed,
1376
+ life: maxLife,
1377
+ maxLife,
1378
+ color: Math.random() < .25 ? "#ffffff" : r.color
1379
+ });
1380
+ }
1381
+ };
1382
+ let prev = performance.now();
1383
+ let raf = 0;
1384
+ const step = (now) => {
1385
+ const dt = Math.min((now - prev) / 1e3, .05);
1386
+ prev = now;
1387
+ ctx.clearRect(0, 0, w, h);
1388
+ ctx.globalCompositeOperation = "lighter";
1389
+ const elapsed = now - start;
1390
+ while (spawned < BURSTS && elapsed >= spawned * BURST_GAP_MS) spawnRocket(spawned++);
1391
+ for (let i = rockets.length - 1; i >= 0; i--) {
1392
+ const r = rockets[i];
1393
+ r.y += r.vy * dt;
1394
+ ctx.globalAlpha = .9;
1395
+ ctx.strokeStyle = r.color;
1396
+ ctx.lineWidth = 2.4;
1397
+ ctx.beginPath();
1398
+ ctx.moveTo(r.x, r.y + 9);
1399
+ ctx.lineTo(r.x, r.y);
1400
+ ctx.stroke();
1401
+ if (Math.random() < .6) particles.push({
1402
+ x: r.x + rand(-2, 2),
1403
+ y: r.y + rand(0, 6),
1404
+ px: r.x,
1405
+ py: r.y,
1406
+ vx: rand(-30, 30),
1407
+ vy: rand(10, 70),
1408
+ life: rand(.25, .5),
1409
+ maxLife: .5,
1410
+ color: r.color
1411
+ });
1412
+ if (r.y <= r.targetY) {
1413
+ explode(r);
1414
+ rockets.splice(i, 1);
1415
+ }
1416
+ }
1417
+ ctx.lineWidth = 1.8;
1418
+ for (let i = particles.length - 1; i >= 0; i--) {
1419
+ const p = particles[i];
1420
+ p.life -= dt;
1421
+ if (p.life <= 0) {
1422
+ particles.splice(i, 1);
1423
+ continue;
1424
+ }
1425
+ p.px = p.x;
1426
+ p.py = p.y;
1427
+ p.vy += GRAVITY * dt;
1428
+ p.vx *= 1 - 1.1 * dt;
1429
+ p.vy *= 1 - .4 * dt;
1430
+ p.x += p.vx * dt;
1431
+ p.y += p.vy * dt;
1432
+ let a = p.life / p.maxLife;
1433
+ if (p.life < p.maxLife * .4 && Math.random() < .35) a *= .3;
1434
+ ctx.globalAlpha = Math.max(0, a);
1435
+ ctx.strokeStyle = p.color;
1436
+ ctx.beginPath();
1437
+ ctx.moveTo(p.px, p.py);
1438
+ ctx.lineTo(p.x, p.y);
1439
+ ctx.stroke();
1440
+ }
1441
+ ctx.globalAlpha = 1;
1442
+ if (elapsed < FIREWORKS_TOTAL_MS || rockets.length > 0 || particles.length > 0) {
1443
+ raf = requestAnimationFrame(step);
1444
+ return;
1445
+ }
1446
+ canvas.remove();
1447
+ };
1448
+ raf = requestAnimationFrame(step);
1449
+ setTimeout(() => {
1450
+ cancelAnimationFrame(raf);
1451
+ canvas.remove();
1452
+ }, 7e3);
1453
+ return true;
1454
+ } catch {}
1455
+ return false;
1456
+ }
1457
+ //#endregion
1191
1458
  //#region src/client/styles.ts
1192
1459
  /** Package-owned stylesheet text (injected by the client apply, cleaned up on unload). */
1193
1460
  const FOCUS_CSS = `
@@ -1195,7 +1462,7 @@ window.__ModuleLoader__.load({
1195
1462
  .fm-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));flex:0 0 auto}
1196
1463
  .fm-title{font-size:calc(14px + var(--dsh-content-font-delta,0px));font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1197
1464
  .fm-body-wrap{position:relative;flex:1 1 auto;display:flex;min-height:0}
1198
- .fm-body{flex:1 1 auto;min-width:0;overflow-y:auto;padding:28px 28px 110px}
1465
+ .fm-body{flex:1 1 auto;min-width:0;overflow-y:auto;padding:28px 28px 160px}
1199
1466
  .fm-inner{max-width:760px;margin:0 auto}
1200
1467
  .fm-msg{margin:0}
1201
1468
  .fm-user-msg{margin:0}
@@ -1248,6 +1515,31 @@ window.__ModuleLoader__.load({
1248
1515
  .fm-reply-toast{position:absolute;left:50%;transform:translateX(-50%);bottom:84px;z-index:2;display:flex;align-items:center;gap:10px;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:100px;padding:8px 8px 8px 16px;box-shadow:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));font-size:var(--dsh-content-font-size-secondary,13px)}
1249
1516
  .fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
1250
1517
  .fm-reply-toast-text{white-space:nowrap}
1518
+ /* ---- official-composer borrow: the lifted input bar ----
1519
+ The main view's composer seat ([data-composer-seat], normally in the
1520
+ conversation scroll flow UNDER this overlay) is re-anchored above the
1521
+ overlay while the dock wants the bar form. Its ancestor chain has no
1522
+ transform/filter/positioned-z traps (verified against the 0.1.2 build),
1523
+ so position:fixed escapes to the viewport; the z-index sits just above
1524
+ .fm-overlay's 2147483000 and below the int32 ceiling. The slash/@ menus
1525
+ are descendants of the composer card, so they lift with it.
1526
+ The seat itself must paint NOTHING: it is a layout box larger than the
1527
+ composer card (hero variant stacks brand/workspace rows above it), so a
1528
+ seat-level background or shadow would draw a translucent SQUARE behind
1529
+ the rounded card. Only the card's own skin shows; the seat's empty area
1530
+ is click-transparent so the reading surface beneath stays interactive. */
1531
+ .fm-lift{position:fixed!important;left:50%!important;right:auto!important;top:auto!important;bottom:20px!important;transform:translateX(-50%)!important;width:min(var(--fm-lift-width,680px),calc(100vw - 48px))!important;max-width:calc(100vw - 48px)!important;z-index:2147483200!important;margin:0!important;box-shadow:none!important;background:0 0!important;pointer-events:none}
1532
+ /* The composer's dock row (queue/references rail) is hidden while lifted on
1533
+ purpose: focus mode's dock is the plugin's own bottom-center region, and a
1534
+ second rail inside the floating bar would duplicate it. Noteboard's canvas
1535
+ borrow does the same. This drops that row's affordances IN FOCUS MODE only
1536
+ (the main view keeps them); revisit if a focus-mode user needs them. */
1537
+ .fm-lift [data-slot="conversation.composer.dock"]{display:none!important}
1538
+ .fm-lift [data-composer-card]{pointer-events:auto}
1539
+ /* Cap the lifted editor's scroll area: a long multi-line draft must not grow
1540
+ the floating card over half the reading surface (the main view allows the
1541
+ full composer column height, which focus mode cannot afford). */
1542
+ .fm-lift [data-input-scroll]{max-height:180px!important;overscroll-behavior:contain}
1251
1543
  /* ---- bottom dock: bar / pill / jump-to-bottom / answer card ----
1252
1544
  Visual language mirrors the official composer cards token-for-token
1253
1545
  (ui-conversation InputBar/QuestionComposer, ui-user-questions
@@ -1322,9 +1614,13 @@ window.__ModuleLoader__.load({
1322
1614
  .fm-plugin-card-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
1323
1615
  .fm-plugin-card-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}
1324
1616
  .fm-plugin-card-chevron-open{transform:rotate(180deg)}
1325
- .fm-plugin-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}
1617
+ .fm-plugin-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:16px}
1326
1618
  .fm-plugin-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}
1327
1619
  .fm-plugin-field+.fm-plugin-field{border-top:1px solid var(--dsw-alias-border-l2)}
1620
+ /* The stats row ends the card body, so it carries NO bottom padding of its
1621
+ own — the body's 16px padding-bottom is the entire below-gap, matching the
1622
+ row's 16px above-gap after the divider (symmetric, one rhythm). */
1623
+ .fm-plugin-field-stat{padding:16px 0 0}
1328
1624
  .fm-plugin-field-head{align-items:center;gap:8px;display:flex}
1329
1625
  .fm-plugin-field-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
1330
1626
  .fm-plugin-field-value{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
@@ -1713,7 +2009,9 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1713
2009
  const focusStore = {
1714
2010
  get: () => focusOn,
1715
2011
  set: (v) => {
1716
- focusOn = !!v;
2012
+ const next = !!v;
2013
+ if (next && !focusOn) statsStore.bumpEntries();
2014
+ focusOn = next;
1717
2015
  notify();
1718
2016
  },
1719
2017
  subscribe: (l) => {
@@ -1922,6 +2220,20 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1922
2220
  }, i))]
1923
2221
  });
1924
2222
  }
2223
+ /** The main view's composer seat — the DOM node the borrow path CSS-lifts
2224
+ * above the overlay. ONE selector shared by the probe, the lift, the focus
2225
+ * bridge, and the Esc peel, so they can never disagree on WHAT they lift. */
2226
+ function composerSeat() {
2227
+ return document.querySelector("[data-composer-seat]");
2228
+ }
2229
+ /** The official editor's editable surface inside the seat. The SAME node the
2230
+ * availability probe verifies and the pill→bar handoff focuses: probe
2231
+ * success therefore implies a focusable editor. If a dsh update moves either
2232
+ * surface, the probe fails → the dock degrades to the built-in textarea bar
2233
+ * — never to a silently un-focusable lifted composer. */
2234
+ function composerEditable(seat) {
2235
+ return seat ? seat.querySelector("[contenteditable=\"true\"]") : null;
2236
+ }
1925
2237
  function FocusContent(props) {
1926
2238
  const { useSessions, sessions, workspaces, conversation, chatFileMentions, uiSession, uiConversation, settingsScope, t } = props;
1927
2239
  const prefs = usePrefs();
@@ -2195,7 +2507,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2195
2507
  const idx = findSeqIndex(items, seq);
2196
2508
  const el = idx >= 0 ? anchors["fm-" + idx] : void 0;
2197
2509
  if (el && bodyEl) {
2198
- const limit = bodyEl.getBoundingClientRect().bottom - 110;
2510
+ const limit = bodyEl.getBoundingClientRect().bottom - 160;
2199
2511
  const hidden = el.getBoundingClientRect().bottom - limit;
2200
2512
  if (hidden > 0) bodyEl.scrollBy({
2201
2513
  top: hidden,
@@ -2218,6 +2530,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2218
2530
  const onKey = (e) => {
2219
2531
  if (e.key !== "Escape") return;
2220
2532
  if (e.isComposing || e.keyCode === 229) return;
2533
+ if (composerSeat()?.querySelector("[role=\"listbox\"]")) return;
2221
2534
  e.preventDefault();
2222
2535
  e.stopPropagation();
2223
2536
  if (cardOpen) {
@@ -2302,7 +2615,10 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2302
2615
  };
2303
2616
  const hideBar = () => {
2304
2617
  try {
2305
- taRef.current && taRef.current.blur();
2618
+ if (taRef.current) taRef.current.blur();
2619
+ const seat = composerSeat();
2620
+ const active = document.activeElement;
2621
+ if (seat && active && seat.contains(active)) active.blur();
2306
2622
  } catch {}
2307
2623
  setEngaged(false);
2308
2624
  setDockFocused(false);
@@ -2327,6 +2643,87 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2327
2643
  focused: dockFocused,
2328
2644
  engaged
2329
2645
  });
2646
+ const [borrowAvailable, setBorrowAvailable] = (0, react.useState)(false);
2647
+ const borrowWanted = prefs.borrow && !!currentId && snap !== null;
2648
+ const borrowedBar = shouldLiftOfficialComposer({
2649
+ wanted: borrowWanted,
2650
+ available: borrowAvailable,
2651
+ form
2652
+ });
2653
+ (0, react.useEffect)(() => {
2654
+ if (!borrowWanted) {
2655
+ setBorrowAvailable(false);
2656
+ return;
2657
+ }
2658
+ let tries = 0;
2659
+ let timer = null;
2660
+ const probe = () => {
2661
+ if (composerEditable(composerSeat())) {
2662
+ setBorrowAvailable(true);
2663
+ return;
2664
+ }
2665
+ if (++tries > 30) return;
2666
+ timer = setTimeout(probe, 100);
2667
+ };
2668
+ probe();
2669
+ return () => {
2670
+ if (timer) clearTimeout(timer);
2671
+ };
2672
+ }, [borrowWanted]);
2673
+ (0, react.useEffect)(() => {
2674
+ if (!borrowedBar) return;
2675
+ const seat = composerSeat();
2676
+ if (!seat) return;
2677
+ const inSeat = (t) => t instanceof Node && seat.contains(t);
2678
+ const onFocusIn = (e) => {
2679
+ if (inSeat(e.target)) onDockFocusChange(true);
2680
+ };
2681
+ const onFocusOut = (e) => {
2682
+ if (inSeat(e.relatedTarget)) return;
2683
+ setTimeout(() => {
2684
+ if (!inSeat(document.activeElement)) onDockFocusChange(false);
2685
+ }, 0);
2686
+ };
2687
+ seat.addEventListener("focusin", onFocusIn);
2688
+ seat.addEventListener("focusout", onFocusOut);
2689
+ return () => {
2690
+ seat.removeEventListener("focusin", onFocusIn);
2691
+ seat.removeEventListener("focusout", onFocusOut);
2692
+ onDockFocusChange(false);
2693
+ };
2694
+ }, [borrowedBar]);
2695
+ (0, react.useEffect)(() => {
2696
+ const seat = composerSeat();
2697
+ if (!seat) return;
2698
+ if (!borrowedBar) return;
2699
+ seat.style.setProperty("--fm-lift-width", `${Math.min(prefs.width, 680)}px`);
2700
+ seat.classList.add("fm-lift");
2701
+ if (focusBarOnce.current) {
2702
+ const editable = composerEditable(seat);
2703
+ if (!editable) {
2704
+ setBorrowAvailable(false);
2705
+ setDockFocused(false);
2706
+ } else {
2707
+ focusBarOnce.current = false;
2708
+ let landed = false;
2709
+ try {
2710
+ editable.focus();
2711
+ landed = seat.contains(document.activeElement);
2712
+ } catch {}
2713
+ onDockFocusChange(landed);
2714
+ setEngaged(false);
2715
+ }
2716
+ }
2717
+ return () => {
2718
+ seat.classList.remove("fm-lift");
2719
+ seat.style.removeProperty("--fm-lift-width");
2720
+ };
2721
+ }, [borrowedBar, prefs.width]);
2722
+ const prevDraftRef = (0, react.useRef)(draftValue);
2723
+ (0, react.useEffect)(() => {
2724
+ if (prevDraftRef.current !== "" && draftValue === "" && zone) revealArmed.current = true;
2725
+ prevDraftRef.current = draftValue;
2726
+ }, [draftValue, zone]);
2330
2727
  (0, react.useEffect)(() => {
2331
2728
  if (!pending) {
2332
2729
  if (cardOpen) setCardOpen(false);
@@ -2337,14 +2734,14 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2337
2734
  setEngaged(false);
2338
2735
  }, [pending, cardOpen]);
2339
2736
  (0, react.useEffect)(() => {
2340
- if (form === "bar" && focusBarOnce.current) {
2737
+ if (form === "bar" && !borrowedBar && focusBarOnce.current) {
2341
2738
  focusBarOnce.current = false;
2342
2739
  try {
2343
2740
  taRef.current && taRef.current.focus();
2344
2741
  } catch {}
2345
2742
  setEngaged(false);
2346
2743
  }
2347
- }, [form]);
2744
+ }, [form, borrowedBar]);
2348
2745
  let title = "Focus Mode";
2349
2746
  if (currentId && listState && listState.byId && listState.byId[currentId]) {
2350
2747
  const row = listState.byId[currentId];
@@ -2588,6 +2985,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2588
2985
  t,
2589
2986
  width: prefs.width,
2590
2987
  form,
2988
+ borrowed: borrowedBar,
2591
2989
  wait,
2592
2990
  onCardClose: () => setCardOpen(false),
2593
2991
  draft: draftValue,
@@ -2687,6 +3085,23 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2687
3085
  children: t("settings.hotkey.hint")
2688
3086
  })]
2689
3087
  }),
3088
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3089
+ className: "fm-plugin-field",
3090
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3091
+ className: "fm-plugin-check",
3092
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3093
+ type: "checkbox",
3094
+ checked: prefs.borrow,
3095
+ onChange: (e) => prefsStore.update({ borrow: e.target.checked })
3096
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3097
+ className: "fm-plugin-check-label",
3098
+ children: t("settings.borrow")
3099
+ })]
3100
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3101
+ className: "fm-plugin-field-hint",
3102
+ children: t("settings.borrow.hint")
3103
+ })]
3104
+ }),
2690
3105
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2691
3106
  className: "fm-plugin-field",
2692
3107
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -2723,6 +3138,41 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2723
3138
  })
2724
3139
  ] });
2725
3140
  }
3141
+ /** The settings card's bottom row: the persisted focus-entry counter, and —
3142
+ * once the count passes the threshold — a nudge to star the repo. */
3143
+ const REPO_URL = "https://github.com/boogoo619/dsh-focus-overlay";
3144
+ function FocusEntryStat({ t }) {
3145
+ const entries = useEntryCount();
3146
+ const starred = useStarred();
3147
+ const star = shouldShowStar(entries);
3148
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3149
+ className: "fm-plugin-field fm-plugin-field-stat",
3150
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3151
+ className: "fm-plugin-field-head",
3152
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3153
+ className: "fm-plugin-field-label",
3154
+ children: t(star ? "settings.entries.cheer" : "settings.entries", { n: entries })
3155
+ }), star ? starred ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3156
+ variant: "outline",
3157
+ size: "sm",
3158
+ onClick: () => {
3159
+ try {
3160
+ window.open(REPO_URL, "_blank", "noopener");
3161
+ } catch {}
3162
+ },
3163
+ children: t("settings.entries.repo")
3164
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3165
+ variant: "primary",
3166
+ size: "sm",
3167
+ onClick: () => {
3168
+ statsStore.markStarred();
3169
+ celebrateThenOpen(REPO_URL);
3170
+ },
3171
+ children: t("settings.entries.star")
3172
+ }) : null]
3173
+ })
3174
+ });
3175
+ }
2726
3176
  function FocusSettingsCard({ t }) {
2727
3177
  const [open, setOpen] = (0, react.useState)(false);
2728
3178
  const title = t("settings.label");
@@ -2744,9 +3194,9 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2744
3194
  children: t("settings.description")
2745
3195
  })]
2746
3196
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: "fm-plugin-card-chevron" + (open ? " fm-plugin-card-chevron-open" : "") })]
2747
- }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3197
+ }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2748
3198
  className: "fm-plugin-card-body",
2749
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusPrefsFields, { t })
3199
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusPrefsFields, { t }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusEntryStat, { t })]
2750
3200
  }) : null]
2751
3201
  });
2752
3202
  }
@@ -2864,6 +3314,12 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2864
3314
  "settings.autoFocus.hint": "AI 正常完成回复后自动进入并定位到你的提问",
2865
3315
  "settings.hotkey": "按 F 键进入专注模式",
2866
3316
  "settings.hotkey.hint": "在任意界面按下 F 立即进入专注模式;在输入框内打字不会触发",
3317
+ "settings.borrow": "使用官方输入框",
3318
+ "settings.borrow.hint": "专注模式直接借用主界面的官方输入框(支持 / 指令、@ 引用文件与会话);关闭时使用插件内置输入框。官方输入框不可用时自动回退。",
3319
+ "settings.entries": "您已进入专注模式 {n} 次",
3320
+ "settings.entries.cheer": "🎉 您已进入专注模式 {n} 次 ✨",
3321
+ "settings.entries.star": "为这个项目点一个 GitHub Star ⭐",
3322
+ "settings.entries.repo": "前往 GitHub 项目页面",
2867
3323
  "process.toolCalls": "{n} 次工具调用",
2868
3324
  "process.messages": "{n} 条消息",
2869
3325
  "process.subagents": "{n} 个 subagent",
@@ -2943,6 +3399,12 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2943
3399
  "settings.autoFocus.hint": "Auto-open at your question after a normal reply",
2944
3400
  "settings.hotkey": "Press F to enter focus mode",
2945
3401
  "settings.hotkey.hint": "Press F anywhere to enter focus mode instantly; typing in an input never triggers it",
3402
+ "settings.borrow": "Use the official composer",
3403
+ "settings.borrow.hint": "Focus mode borrows the main view's official composer (slash commands, @ file/session references included); turn off to use the built-in input bar. Falls back automatically when the official composer is unavailable.",
3404
+ "settings.entries": "You have entered focus mode {n} times",
3405
+ "settings.entries.cheer": "🎉 You have entered focus mode {n} times ✨",
3406
+ "settings.entries.star": "Give this project a GitHub Star ⭐",
3407
+ "settings.entries.repo": "Open the GitHub project page",
2946
3408
  "process.toolCalls": "{n} tool calls",
2947
3409
  "process.messages": "{n} messages",
2948
3410
  "process.subagents": "{n} subagents",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-focus-overlay",
3
- "version": "0.11.3",
3
+ "version": "0.12.1",
4
4
  "description": "Focus Mode for the dsh web GUI: a full-screen reading overlay that hides chrome and folds the AI tool-call flow into summaries, reusing the official Markdown / image primitives.",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",