@huaqiu/dsh-tool-kicad-schematic-gen 0.6.3 → 0.6.5

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
@@ -84,6 +84,190 @@
84
84
  }
85
85
  //#endregion
86
86
  //#region ../dsh-sidebar-entry/lib/index.mjs
87
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
88
+ function sidebarRoot() {
89
+ const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
90
+ if (column === null) return void 0;
91
+ return column.querySelector("[class*=\"logoRow\"]")?.parentElement ?? column.firstElementChild;
92
+ }
93
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
94
+ function newSessionButton(root) {
95
+ const nested = root.querySelector("button[class*=\"newSession\"]");
96
+ if (nested !== null) return nested;
97
+ for (const child of root.children) if (child.tagName === "BUTTON") return child;
98
+ }
99
+ /** Build the entry row (a detached button; insert once the shell is up). */
100
+ function createEntry(options) {
101
+ const entry = document.createElement("button");
102
+ entry.type = "button";
103
+ entry.setAttribute(options.rowAttribute, "");
104
+ if (options.plugin !== void 0) {
105
+ entry.setAttribute("data-dsh-plugin", options.plugin);
106
+ entry.setAttribute("data-dsh-part", "sidebar-entry");
107
+ }
108
+ entry.className = options.css["entry"] ?? "";
109
+ const labelSpan = document.createElement("span");
110
+ labelSpan.className = options.css["entryLabel"] ?? "";
111
+ const iconSpan = document.createElement("span");
112
+ iconSpan.className = options.css["entryIcon"] ?? "";
113
+ iconSpan.innerHTML = options.icon;
114
+ entry.append(iconSpan, labelSpan);
115
+ let badgeSpan = null;
116
+ const applyBadge = () => {
117
+ if (options.badge === void 0) return;
118
+ const count = options.badge.count();
119
+ if (count <= 0) {
120
+ badgeSpan?.remove();
121
+ badgeSpan = null;
122
+ return;
123
+ }
124
+ if (badgeSpan === null) {
125
+ badgeSpan = document.createElement("span");
126
+ badgeSpan.className = options.css["entryBadge"] ?? "";
127
+ entry.append(badgeSpan);
128
+ }
129
+ badgeSpan.textContent = String(count);
130
+ entry.setAttribute("data-has-badge", "");
131
+ };
132
+ const applyLabel = () => {
133
+ entry.setAttribute("aria-label", options.label());
134
+ if (options.tooltip !== void 0) entry.setAttribute("title", options.tooltip());
135
+ labelSpan.textContent = options.label();
136
+ };
137
+ applyLabel();
138
+ applyBadge();
139
+ entry.addEventListener("click", options.onToggle);
140
+ return {
141
+ entry,
142
+ applyLabel,
143
+ applyBadge
144
+ };
145
+ }
146
+ /**
147
+ * Re-insert the entry after the New Session row, in the family's intended
148
+ * order.
149
+ *
150
+ * The `familySelectors` array order is the single source of truth for visual
151
+ * order, so the rendered order is stable regardless of which plugin mounts
152
+ * first or of observer callback order — a re-render can never swap two rows.
153
+ * (Earlier builds used the `position: 'before' | 'after'` convention, which
154
+ * clustered rows by mount order and let a late-mounting `before` row steal the
155
+ * lead. The array order supersedes it; `position` remains only as a fallback
156
+ * for a row whose selector is absent from the family list.)
157
+ */
158
+ function placeEntry(root, entry, options) {
159
+ const button = newSessionButton(root);
160
+ if (button === void 0) return false;
161
+ if (entry.parentElement !== root) {
162
+ const row = button.closest("[class*=\"logoRow\"]");
163
+ const base = row !== null && row.parentElement === root ? row : button;
164
+ const order = options.familySelectors;
165
+ const present = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(order.join(", "))).map((el) => ({
166
+ el,
167
+ idx: order.findIndex((sel) => el.matches(sel))
168
+ }));
169
+ const selfIndex = order.indexOf(options.rowSelector);
170
+ let anchor;
171
+ if (selfIndex === -1) anchor = options.position === "before" ? present.length > 0 ? present[0].el : base.nextElementSibling : present.length > 0 ? present[present.length - 1].el.nextElementSibling : base.nextElementSibling;
172
+ else {
173
+ const later = present.find((member) => member.idx > selfIndex);
174
+ if (later !== void 0) anchor = later.el;
175
+ else {
176
+ const earlier = [...present].reverse().find((member) => member.idx < selfIndex);
177
+ anchor = earlier !== void 0 ? earlier.el.nextElementSibling : base.nextElementSibling;
178
+ }
179
+ }
180
+ root.insertBefore(entry, anchor ?? null);
181
+ }
182
+ return true;
183
+ }
184
+ /**
185
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
186
+ * on later React re-renders.
187
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
188
+ * @returns disposer removing the entry and its observers.
189
+ */
190
+ function mountSidebarEntry(options) {
191
+ if (typeof document !== "undefined" && document.querySelector(options.rowSelector) !== null) return () => {};
192
+ const { entry, applyLabel, applyBadge } = createEntry(options);
193
+ let root;
194
+ let placed = false;
195
+ let unsubscribeRefresh;
196
+ if (options.refresh !== void 0) try {
197
+ unsubscribeRefresh = options.refresh.subscribe(applyLabel);
198
+ } catch {}
199
+ let unsubscribeBadge;
200
+ if (options.badge !== void 0) try {
201
+ unsubscribeBadge = options.badge.subscribe(applyBadge);
202
+ } catch {}
203
+ const tryPlace = () => {
204
+ if (root !== void 0 && !root.isConnected) {
205
+ rootObserver.disconnect();
206
+ root = void 0;
207
+ placed = false;
208
+ }
209
+ if (placed) {
210
+ if (document.body.contains(entry)) return;
211
+ rootObserver.disconnect();
212
+ root = void 0;
213
+ placed = false;
214
+ }
215
+ root ??= sidebarRoot();
216
+ if (root === void 0) return;
217
+ placed = placeEntry(root, entry, options);
218
+ if (placed) rootObserver.observe(root, {
219
+ childList: true,
220
+ subtree: true
221
+ });
222
+ };
223
+ const waitObserver = new MutationObserver(() => {
224
+ tryPlace();
225
+ });
226
+ waitObserver.observe(document.body, {
227
+ childList: true,
228
+ subtree: true
229
+ });
230
+ const rootObserver = new MutationObserver(() => {
231
+ if (root === void 0 || !root.isConnected) {
232
+ placed = false;
233
+ tryPlace();
234
+ return;
235
+ }
236
+ if (!root.contains(entry)) placed = placeEntry(root, entry, options);
237
+ });
238
+ options.active === void 0 || (() => {
239
+ const syncActive = () => {
240
+ if (options.active.isOpen()) entry.dataset.active = "true";
241
+ else delete entry.dataset.active;
242
+ };
243
+ const unsubscribe = options.active.subscribe(syncActive);
244
+ syncActive();
245
+ return unsubscribe;
246
+ })();
247
+ tryPlace();
248
+ return () => {
249
+ waitObserver.disconnect();
250
+ rootObserver.disconnect();
251
+ unsubscribeRefresh?.();
252
+ unsubscribeBadge?.();
253
+ entry.remove();
254
+ };
255
+ }
256
+ /**
257
+ * Every row selector in the Huaqiu sidebar family, in intended visual order.
258
+ *
259
+ * Family ordering is only stable when every participating plugin passes the
260
+ * SAME list to {@link mountSidebarEntry} — the core positions a row relative to
261
+ * the family block, never relative to transient shell geometry. Adding a new
262
+ * family row means adding its selector here (and to no plugin-local list).
263
+ */
264
+ const HUAQIU_SIDEBAR_ENTRY_FAMILY = [
265
+ "[data-hq-plugin-update-entry]",
266
+ "[data-hqcg-footprint-entry]",
267
+ "[data-hqcg-symbol-entry]",
268
+ "[data-hqcg-schematic-entry]",
269
+ "[data-hq-dummy-entry]"
270
+ ];
87
271
  /**
88
272
  * Read the host UI language from `<html lang>`.
89
273
  *
@@ -100,6 +284,44 @@
100
284
  const primary = tag.toLowerCase().split("-")[0];
101
285
  return primary === "zh" || primary === "en" ? primary : void 0;
102
286
  }
287
+ /**
288
+ * Watch `<html lang>` and report every resolved change.
289
+ *
290
+ * Idempotent-safe: repeated calls share one observer and each caller gets its
291
+ * own disposer; the observer disconnects when the last listener leaves.
292
+ * @param listener - called with the newly resolved language.
293
+ * @returns disposer removing this listener.
294
+ */
295
+ /** Shared observer/listener set backing {@link observeHostLocale}. */
296
+ const localeListeners = /* @__PURE__ */ new Set();
297
+ let localeObserver = null;
298
+ function observeHostLocale(listener) {
299
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined" || document.documentElement === null) return () => {};
300
+ let current = detectHostLocale();
301
+ const listeners = localeListeners;
302
+ listeners.add(listener);
303
+ if (localeObserver === null) {
304
+ localeObserver = new MutationObserver(() => {
305
+ const next = detectHostLocale();
306
+ if (next === current) return;
307
+ current = next;
308
+ for (const fn of [...listeners]) try {
309
+ fn(next);
310
+ } catch {}
311
+ });
312
+ localeObserver.observe(document.documentElement, {
313
+ attributes: true,
314
+ attributeFilter: ["lang"]
315
+ });
316
+ }
317
+ return () => {
318
+ listeners.delete(listener);
319
+ if (listeners.size === 0 && localeObserver !== null) {
320
+ localeObserver.disconnect();
321
+ localeObserver = null;
322
+ }
323
+ };
324
+ }
103
325
  //#endregion
104
326
  //#region src/client/schematic-ref.ts
105
327
  /**
@@ -109,10 +331,13 @@
109
331
  * SSE agents were removed with the package). The chip identity, the `@`
110
332
  * trigger, the clipboard projection and the menu row are preserved verbatim so
111
333
  * existing drafts and the composer UX keep working. The one deliberate change:
112
- * the chip's model text now pins the bundled `kicad-schematic-design` skill
113
- * instead of the deleted online tools (`generate_schematic_from_description` /
114
- * `generate_system_module_graph`), because the design skill is the new local
115
- * entry point the agent must follow.
334
+ * the chip's model text is now the bare clipboard token — the semantics that
335
+ * used to ride in the payload (pin the `kicad-schematic-design` skill instead
336
+ * of the deleted online tools `generate_schematic_from_description` /
337
+ * `generate_system_module_graph`, forbid hand-written KiCad files, defer to
338
+ * the user's words) moved to the node half's `huaqiu:schematic-gen`
339
+ * system-prompt section; see {@link SCHEMATIC_GEN_SERIALIZED} and
340
+ * `src/prompt.ts`.
116
341
  *
117
342
  * One module owns the identity of the chip, its menu row, and the model text
118
343
  * the chip expands to at submit time. Registering the source is MANDATORY, not
@@ -122,6 +347,12 @@
122
347
  * source"). A chip inserted without a registered source would therefore block
123
348
  * the user's message instead of degrading quietly.
124
349
  *
350
+ * The registration is also ORDER-sensitive: the trigger service is provided by
351
+ * a plugin whose own dependency chain is longer than ours, so reading it once
352
+ * at `apply()` time can find nothing. The entry therefore declares
353
+ * `inputTriggers` in its `inject` (cordis parks the plugin until the provider
354
+ * exists) and retries briefly if it still arrives late.
355
+ *
125
356
  * Registering it also buys the typed path for free: the user can type `@` and
126
357
  * pick *Generate schematic* (生成原理图 under a Chinese UI) from a group beside
127
358
  * *Files & folders* / *Sessions*.
@@ -145,11 +376,11 @@
145
376
  * Every line is `console.debug` and every helper is wrapped, so a host without
146
377
  * a console (or one that rejects the payload) can never break the plugin.
147
378
  */
148
- const TRACE = "[schematic-gen:ref]";
379
+ const TRACE$1 = "[schematic-gen:ref]";
149
380
  /** Emit one trace line. Never throws. */
150
- function trace(event, payload) {
381
+ function trace$1(event, payload) {
151
382
  try {
152
- console.debug(TRACE, event, payload ?? "");
383
+ console.debug(TRACE$1, event, payload ?? "");
153
384
  } catch {}
154
385
  }
155
386
  /**
@@ -162,7 +393,7 @@
162
393
  */
163
394
  function schematicGenLabel(lang = detectHostLocale()) {
164
395
  const label = lang === "en" ? "Generate schematic" : "生成原理图";
165
- trace("label", {
396
+ trace$1("label", {
166
397
  hostLang: lang ?? null,
167
398
  htmlLang: readHtmlLang(),
168
399
  label
@@ -185,20 +416,27 @@
185
416
  }
186
417
  /**
187
418
  * Model form of the chip: what the agent actually reads at the chip's position
188
- * in the user message.
189
- *
190
- * Deliberately an *intent*, never a fabricated design description — the user's
191
- * own words stay the only source of requirements, and the plugin being loaded
192
- * is never by itself a reason to generate.
193
- *
194
- * The retired plugin pinned the online tools here
195
- * (`generate_schematic_from_description` / `generate_system_module_graph`).
196
- * The migrated chip pins the bundled `kicad-schematic-design` skill instead:
197
- * the skill owns the module search / part import / editable-project flow, and
198
- * starts from the hardware design brief when no plan exists yet. Hand-written
199
- * KiCad files stay forbidden in both worlds.
419
+ * in the user message — the clipboard token itself, nothing more.
420
+ *
421
+ * This is the idiomatic mention pattern (DSH's own draft tests serialize a
422
+ * reference to its ref), and it is deliberate on two counts:
423
+ *
424
+ * 1. **Whatever `serialize` returns lands in the USER message** — the submit
425
+ * plane splices it over the chip's clipboard range — so a long payload is
426
+ * noise the user reads back in their own transcript on every send.
427
+ * 2. **The token has no semantics of its own**, so the semantics cannot live
428
+ * here; they live in the node half's `huaqiu:schematic-gen` system-prompt
429
+ * section (see `src/prompt.ts`), which defines the token, pins the
430
+ * `kicad-schematic-design` skill, and carries the guardrails.
431
+ *
432
+ * History: the chip first shipped a 6-line `<schematic-gen intent=…>` block,
433
+ * then a 3-line trimmed form, and now delegates entirely to the section. The
434
+ * retired plugin pinned deleted online tools here
435
+ * (`generate_schematic_from_description` / `generate_system_module_graph`);
436
+ * nothing in this constant may ever name a tool again — the skill pin is the
437
+ * section's job.
200
438
  */
201
- const SCHEMATIC_GEN_SERIALIZED = "<schematic-gen intent=\"kicad-schematic-design\">\nThe user explicitly chose Schematic Gen for this request. Produce the design with the `kicad-schematic-design` skill; when the hardware design brief is missing or incomplete, the skill starts by preparing it before the schematic work.\nNever hand-write .kicad_sch files or KiCad project files yourself — always go through the skill and its tools.\nUse the user's own words as the design description. If they have not described the circuit yet, ask for the requirements instead of assuming them.\n</schematic-gen>";
439
+ const SCHEMATIC_GEN_SERIALIZED = SCHEMATIC_GEN_CLIPBOARD;
202
440
  /**
203
441
  * The `ReferenceInsert` this source produces on pick (and on button click).
204
442
  *
@@ -212,7 +450,7 @@
212
450
  label: schematicGenLabel(lang),
213
451
  clipboardText: SCHEMATIC_GEN_CLIPBOARD
214
452
  };
215
- trace("insert", insert);
453
+ trace$1("insert", insert);
216
454
  return insert;
217
455
  }
218
456
  /**
@@ -239,12 +477,12 @@
239
477
  name: schematicGenLabel(lang),
240
478
  description: schematicGenDescription(lang)
241
479
  }];
242
- trace("candidates", rows);
480
+ trace$1("candidates", rows);
243
481
  return Promise.resolve(rows);
244
482
  },
245
483
  onPick: () => {
246
484
  const insert = schematicGenInsert(getLang());
247
- trace("onPick", insert);
485
+ trace$1("onPick", insert);
248
486
  return { insert };
249
487
  },
250
488
  codec: {
@@ -254,24 +492,569 @@
254
492
  };
255
493
  }
256
494
  /**
257
- * Register the source and return its disposer.
495
+ * One registration attempt, reported instead of swallowed.
496
+ *
497
+ * {@link registerSchematicGenSource} is the disposer-only view for callers that
498
+ * do not care; this is the same attempt with the outcome visible, which is what
499
+ * a caller needs to decide whether a chip may be inserted at all — an
500
+ * unregistered source makes every send fail (see the module header).
258
501
  *
259
- * Never throws: a missing service, a duplicate registration (a second live
260
- * copy of this plugin) or a throwing registry must not take the whole client
261
- * half down. Without a registered source the sidebar button degrades to
262
- * inserting nothing rather than producing a chip that would block submission.
263
502
  * @param registry - `ctx.get('inputTriggers')`, or undefined.
264
- * @returns disposer (a no-op when registration did not happen).
503
+ * @returns whether the registration landed, plus its disposer.
265
504
  */
266
- function registerSchematicGenSource(registry) {
267
- if (registry === void 0 || registry === null || typeof registry.registerSource !== "function") return () => {};
505
+ function registerSchematicGenSourceTracked(registry) {
506
+ if (registry === void 0 || registry === null || typeof registry.registerSource !== "function") return {
507
+ registered: false,
508
+ dispose: () => {}
509
+ };
268
510
  try {
269
511
  const dispose = registry.registerSource(createSchematicGenSource());
270
- return typeof dispose === "function" ? dispose : () => {};
512
+ return {
513
+ registered: true,
514
+ dispose: typeof dispose === "function" ? dispose : () => {}
515
+ };
271
516
  } catch {
272
- return () => {};
517
+ return {
518
+ registered: true,
519
+ dispose: () => {}
520
+ };
273
521
  }
274
522
  }
523
+ /**
524
+ * Host timer, or undefined where there is none.
525
+ *
526
+ * The retry is browser-only machinery, and the built bundle is also evaluated
527
+ * in bare hosts (the vm-based card test, a non-DOM worker) where `setInterval`
528
+ * does not exist. Those must degrade to "not registered yet" — a throw here
529
+ * would take the whole client half down, which is the failure mode this module
530
+ * exists to prevent.
531
+ */
532
+ function hostTimer() {
533
+ if (typeof setInterval !== "function" || typeof clearInterval !== "function") return void 0;
534
+ return {
535
+ set: (fn, ms) => setInterval(fn, ms),
536
+ clear: (handle) => clearInterval(handle)
537
+ };
538
+ }
539
+ /**
540
+ * Register the source, polling while the trigger service has not activated yet.
541
+ *
542
+ * Why this exists: activation is service-availability driven, and
543
+ * `inputTriggers` is provided by a plugin (`dsh-client-ui-input-trigger`) whose
544
+ * own dependency chain is far longer than ours. A plugin that reads the service
545
+ * exactly once at `apply()` time and finds it absent registers NOTHING —
546
+ * silently. The chip then lands in the composer (insertion only needs
547
+ * `conversation`) and the send dies on
548
+ * `slash: no serializer for reference source "schematic-gen"`, because the
549
+ * controller routes every chip to its owner's codec. Declaring `inputTriggers`
550
+ * in the plugin's `inject` is the primary fix (cordis then parks this plugin
551
+ * until the provider exists); this poll is the belt-and-braces path for a
552
+ * provider that arrives later than that declaration could promise.
553
+ *
554
+ * @param getRegistry - returns the current `ctx.get('inputTriggers')` value.
555
+ * @param hooks - scheduler/trace seams.
556
+ * @returns a live binding whose `registered()` the caller may consult.
557
+ */
558
+ function registerSchematicGenSourceWhenReady(getRegistry, hooks = {}) {
559
+ const intervalMs = hooks.intervalMs ?? 250;
560
+ const maxAttempts = hooks.maxAttempts ?? 80;
561
+ const traceFn = hooks.trace ?? trace$1;
562
+ const timer = hooks.setTimer !== void 0 && hooks.clearTimer !== void 0 ? {
563
+ set: hooks.setTimer,
564
+ clear: hooks.clearTimer
565
+ } : hostTimer();
566
+ let current = registerSchematicGenSourceTracked(getRegistry());
567
+ let handle;
568
+ let attempts = 0;
569
+ let stopped = false;
570
+ const stopTimer = () => {
571
+ if (handle === void 0 || timer === void 0) return;
572
+ timer.clear(handle);
573
+ handle = void 0;
574
+ };
575
+ const dispose = () => {
576
+ stopped = true;
577
+ stopTimer();
578
+ current.dispose();
579
+ };
580
+ if (current.registered) return {
581
+ registered: () => true,
582
+ dispose
583
+ };
584
+ if (timer === void 0) {
585
+ traceFn("no timer host — source registration not retried");
586
+ return {
587
+ registered: () => false,
588
+ dispose
589
+ };
590
+ }
591
+ traceFn("waiting for inputTriggers", {
592
+ intervalMs,
593
+ maxAttempts
594
+ });
595
+ handle = timer.set(() => {
596
+ if (stopped) return;
597
+ attempts += 1;
598
+ const next = registerSchematicGenSourceTracked(getRegistry());
599
+ if (next.registered) {
600
+ current = next;
601
+ stopTimer();
602
+ traceFn("registered (late)", { attempts });
603
+ return;
604
+ }
605
+ if (attempts >= maxAttempts) {
606
+ stopTimer();
607
+ traceFn("source registration gave up", { attempts });
608
+ }
609
+ }, intervalMs);
610
+ return {
611
+ registered: () => current.registered,
612
+ dispose
613
+ };
614
+ }
615
+ //#endregion
616
+ //#region src/client/sidebar-entry.ts
617
+ /**
618
+ * `@huaqiu/dsh-tool-kicad-schematic-gen` — the `Schematic Gen` sidebar toolbutton.
619
+ *
620
+ * Migrated from the retired `@huaqiu/dsh-tool-schematic-gen` plugin: the
621
+ * migration deleted the whole old package and this row went with it, which is
622
+ * why the sidebar showed only Plugin Updates / Footprint Gen / Symbol Gen while
623
+ * the plugin itself stayed installed and registered. The row, its ordering, its
624
+ * icon and its localization are restored verbatim.
625
+ *
626
+ * Mounts one row through the shared family helper
627
+ * (`@huaqiu/dsh-sidebar-entry`), in the same block and with the same look as
628
+ * the component-gen rows (封装生成 / Symbol 生成). Unlike those, clicking this
629
+ * row does NOT open a workspace: it inserts a `Generate schematic` reference
630
+ * chip into the current session's composer.
631
+ *
632
+ * The row is plain DOM; the chip it inserts is real DSH composer state.
633
+ *
634
+ * @module
635
+ */
636
+ /** Stable data attribute identifying the injected row. */
637
+ const SCHEMATIC_ENTRY_SELECTOR = "[data-hqcg-schematic-entry]";
638
+ /** Full attribute name (the helper's idempotency key). */
639
+ const SCHEMATIC_ENTRY_ATTRIBUTE = "data-hqcg-schematic-entry";
640
+ /** L2 semantic plugin id: rows carry data-dsh-plugin + data-dsh-part. */
641
+ const PLUGIN_ID = "huaqiu-schematic-gen";
642
+ /** Row class names (defined by {@link ENTRY_CSS_TEXT}). */
643
+ const ENTRY_CSS = {
644
+ entry: "hqcg-schematic-sidebar-entry",
645
+ entryIcon: "hqcg-schematic-sidebar-entry__icon",
646
+ entryLabel: "hqcg-schematic-sidebar-entry__label"
647
+ };
648
+ /** Localized row copy: zh is the fallback, matching the HIT card. */
649
+ const COPY = {
650
+ zh: {
651
+ label: "原理图生成",
652
+ tooltip: "在输入框插入「生成原理图」引用"
653
+ },
654
+ en: {
655
+ label: "Schematic Gen",
656
+ tooltip: "Insert a \"Generate schematic\" reference into the composer"
657
+ }
658
+ };
659
+ /**
660
+ * Resolve the row copy for one language.
661
+ * @param lang - resolved host language; undefined falls back to zh.
662
+ * @returns the label/tooltip pair.
663
+ */
664
+ function schematicEntryCopy(lang) {
665
+ return lang === "en" ? COPY.en : COPY.zh;
666
+ }
667
+ /**
668
+ * Inline schematic glyph — cropped to the artwork's ink, `currentColor` so it
669
+ * inherits the sidebar text/theme colour, 16×16 like the shell's nav glyphs
670
+ * (18×18 on the collapsed rail).
671
+ */
672
+ const SCHEMATIC_ICON = `
673
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
674
+ <!-- Schematic Sheet Frame -->
675
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
676
+
677
+ <!-- Circuit Wires & Connection Nodes -->
678
+ <path d="M7 8H10M14 8H17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
679
+ <path d="M10 16H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
680
+ <path d="M10 8V16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
681
+
682
+ <!-- Component Symbol (Resistor / Block representation) -->
683
+ <path d="M10 10L11 11L12 9L13 11L14 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
684
+
685
+ <!-- Junction Nodes / Pins -->
686
+ <circle cx="7" cy="8" r="1.5" fill="currentColor"/>
687
+ <circle cx="17" cy="8" r="1.5" fill="currentColor"/>
688
+ <circle cx="10" cy="16" r="1.5" fill="currentColor"/>
689
+ <circle cx="14" cy="16" r="1.5" fill="currentColor"/>
690
+ </svg>
691
+ `;
692
+ /**
693
+ * Row styles — matched to the shipped `sidebar.panellist` rows and to the
694
+ * component-gen rows, so the family block sits flush with the shell's own
695
+ * entries.
696
+ */
697
+ const ENTRY_CSS_TEXT = `
698
+ .hqcg-schematic-sidebar-entry {
699
+ box-sizing: border-box;
700
+ display: flex;
701
+ align-items: center;
702
+ gap: 8px;
703
+ width: 100%;
704
+ margin: 0 2px;
705
+ min-height: 36px;
706
+ padding: 7px 8px;
707
+ background: transparent;
708
+ border: none;
709
+ border-radius: 12px;
710
+ color: var(--dsw-alias-label-primary);
711
+ cursor: pointer;
712
+ font: inherit;
713
+ line-height: 22px;
714
+ text-align: left;
715
+ white-space: nowrap;
716
+ }
717
+ .hqcg-schematic-sidebar-entry:hover {
718
+ background: var(--dsw-alias-interactive-bg-hover);
719
+ }
720
+ .hqcg-schematic-sidebar-entry[data-active] {
721
+ background: var(--dsw-alias-interactive-bg-hover);
722
+ color: var(--dsw-alias-label-primary);
723
+ }
724
+ .hqcg-schematic-sidebar-entry__icon {
725
+ display: inline-flex;
726
+ align-items: center;
727
+ justify-content: center;
728
+ flex: none;
729
+ }
730
+ .hqcg-schematic-sidebar-entry__icon svg {
731
+ display: block;
732
+ width: 16px;
733
+ height: 16px;
734
+ }
735
+ .hqcg-schematic-sidebar-entry__label {
736
+ min-width: 0;
737
+ overflow: hidden;
738
+ text-overflow: ellipsis;
739
+ }
740
+ /* Collapsed rail: icon-only, centered — the same 36px square the shell uses. */
741
+ [data-dsh-frame][data-sidebar-collapsed] .hqcg-schematic-sidebar-entry,
742
+ [data-sidebar-collapsed] .hqcg-schematic-sidebar-entry {
743
+ justify-content: center;
744
+ width: 36px;
745
+ height: 36px;
746
+ margin: 0;
747
+ padding: 0;
748
+ }
749
+ [data-dsh-frame][data-sidebar-collapsed] .hqcg-schematic-sidebar-entry__icon svg,
750
+ [data-sidebar-collapsed] .hqcg-schematic-sidebar-entry__icon svg {
751
+ width: 18px;
752
+ height: 18px;
753
+ }
754
+ [data-dsh-frame][data-sidebar-collapsed] .hqcg-schematic-sidebar-entry__label,
755
+ [data-sidebar-collapsed] .hqcg-schematic-sidebar-entry__label {
756
+ display: none;
757
+ }
758
+ `;
759
+ /** Style tag id used for idempotent style injection. */
760
+ const STYLE_TAG_ID = "data-plugin-css=\"huaqiu-schematic-gen-sidebar\"";
761
+ /** Inject the row stylesheet once (idempotent). */
762
+ function injectSchematicEntryStyles() {
763
+ if (typeof document === "undefined") return;
764
+ if (document.querySelector(`style[${STYLE_TAG_ID}]`) !== null) return;
765
+ const tag = document.createElement("style");
766
+ tag.dataset.pluginCss = "huaqiu-schematic-gen-sidebar";
767
+ tag.textContent = ENTRY_CSS_TEXT;
768
+ document.head.appendChild(tag);
769
+ }
770
+ /**
771
+ * Mount the `Schematic Gen` sidebar row.
772
+ *
773
+ * Localization follows the host UI language through `<html lang>`
774
+ * (`dsh-client-locale` rewrites it on every language change and HQ Edge mirrors
775
+ * it), so the row re-labels itself without a `locale` service dependency.
776
+ *
777
+ * @param options - click action and family-block position.
778
+ * @returns disposer removing the row, its observers and its locale listener.
779
+ */
780
+ function mountSchematicGenSidebarEntry(options) {
781
+ if (typeof document === "undefined") return () => {};
782
+ injectSchematicEntryStyles();
783
+ const listeners = /* @__PURE__ */ new Set();
784
+ const announce = () => {
785
+ for (const fn of [...listeners]) try {
786
+ fn();
787
+ } catch {}
788
+ };
789
+ let lang = detectHostLocale();
790
+ const stopLocale = observeHostLocale((next) => {
791
+ lang = next;
792
+ announce();
793
+ });
794
+ const dispose = mountSidebarEntry({
795
+ rowAttribute: SCHEMATIC_ENTRY_ATTRIBUTE,
796
+ rowSelector: SCHEMATIC_ENTRY_SELECTOR,
797
+ plugin: PLUGIN_ID,
798
+ icon: SCHEMATIC_ICON,
799
+ css: ENTRY_CSS,
800
+ label: () => schematicEntryCopy(lang).label,
801
+ tooltip: () => schematicEntryCopy(lang).tooltip,
802
+ refresh: { subscribe: (fn) => {
803
+ listeners.add(fn);
804
+ return () => {
805
+ listeners.delete(fn);
806
+ };
807
+ } },
808
+ onToggle: options.onToggle,
809
+ position: options.position ?? "after",
810
+ familySelectors: HUAQIU_SIDEBAR_ENTRY_FAMILY
811
+ });
812
+ return () => {
813
+ dispose();
814
+ stopLocale();
815
+ listeners.clear();
816
+ };
817
+ }
818
+ //#endregion
819
+ //#region src/client/composer-chip.ts
820
+ /**
821
+ * `@huaqiu/dsh-tool-kicad-schematic-gen` — programmatic composer chip insertion.
822
+ *
823
+ * Migrated from the retired `@huaqiu/dsh-tool-schematic-gen` plugin, whose
824
+ * sidebar row was lost with the package. The row is restored together with the
825
+ * path that makes it do something: inserting the `@ Generate schematic` chip
826
+ * into the live composer.
827
+ *
828
+ * The public path is `ctx.conversation.input.for(actx).insertReference(ref,
829
+ * span)` — a verb on DSH's frozen `SessionInput` face, reached through two
830
+ * public services (`conversation`, `sessions`). No DOM forgery, no new public
831
+ * API, no DSH source change.
832
+ *
833
+ * Two coordinate systems are in play and mixing them silently misplaces the
834
+ * chip:
835
+ *
836
+ * - `InputState.draft` and `Occurrence.offset|length` are CLIPBOARD
837
+ * coordinates — a chip occupies its whole `clipboardText`.
838
+ * - `TokenSpan` is DETECT coordinates — a chip is exactly one `U+FFFC`.
839
+ *
840
+ * `insertReference` is span-CAS'd (`span.draftRev !== this.rev → false`) and
841
+ * refuses busy phases, so every call reads a fresh snapshot and retries once.
842
+ *
843
+ * @module
844
+ */
845
+ /** Browser-console tag for the insertion path (see schematic-ref.ts). */
846
+ const TRACE = "[schematic-gen:chip]";
847
+ /** Emit one trace line. Never throws. */
848
+ function trace(event, payload) {
849
+ try {
850
+ console.debug(TRACE, event, payload ?? "");
851
+ } catch {}
852
+ }
853
+ /**
854
+ * The Session whose composer the user is looking at.
855
+ *
856
+ * `SessionListState` has no `current` field — reading one silently yields
857
+ * undefined and the insertion aborts before touching the draft. The main view
858
+ * is whichever row `retainedBy.mainView` counts, so that is the primary
859
+ * answer; host list order and then any known row are the fallbacks.
860
+ *
861
+ * @param sessions - `ctx.get('sessions')`, or undefined.
862
+ * @returns the Session id, or undefined when the host has none yet.
863
+ */
864
+ function currentSessionId(sessions) {
865
+ let list;
866
+ try {
867
+ list = sessions?.list?.getSnapshot();
868
+ } catch {
869
+ list = void 0;
870
+ }
871
+ if (list === void 0 || list === null) return void 0;
872
+ const ids = Array.isArray(list.ids) ? list.ids : [];
873
+ for (const id of ids) if ((list.byId?.[id]?.retainedBy?.mainView ?? 0) > 0) return id;
874
+ if (ids.length > 0) return ids[0];
875
+ return Object.keys(list.byId ?? {})[0];
876
+ }
877
+ /** How many Sessions the host knows about — the only context a `no-session` trace needs. */
878
+ function countSessions(sessions) {
879
+ try {
880
+ const list = sessions?.list?.getSnapshot();
881
+ const ids = Array.isArray(list?.ids) ? list?.ids.length ?? 0 : 0;
882
+ return ids > 0 ? ids : Object.keys(list?.byId ?? {}).length;
883
+ } catch {
884
+ return 0;
885
+ }
886
+ }
887
+ /**
888
+ * Resolve one Session's scope context — the value `conversation.input.for()`
889
+ * needs.
890
+ *
891
+ * `binding(id).ctx` is the ONLY guard-safe path: DSH's dynamic-plugin facade
892
+ * rejects every service call whose result is a cordis Context
893
+ * (`sessions.scope()` → "service \"sessions\" returned a cordis Context, which
894
+ * the dynamic facade does not expose"), and that rejection THROWS. `binding()`
895
+ * returns a plain object, so its `ctx` field is reachable without going back
896
+ * through the facade. `scope()` stays as a last resort for a host whose
897
+ * binding carries no ctx.
898
+ *
899
+ * @param sessions - `ctx.get('sessions')`, or undefined.
900
+ * @param sessionId - target Session.
901
+ * @returns the scope context, or undefined.
902
+ */
903
+ function resolveSessionScope(sessions, sessionId) {
904
+ let binding;
905
+ try {
906
+ binding = sessions?.binding?.(sessionId);
907
+ } catch {
908
+ binding = void 0;
909
+ }
910
+ const ctx = binding?.ctx;
911
+ if (ctx !== void 0 && ctx !== null) return ctx;
912
+ try {
913
+ return sessions?.scope?.(sessionId);
914
+ } catch {
915
+ return;
916
+ }
917
+ }
918
+ /**
919
+ * Translate a clipboard-projected draft length into DETECT coordinates.
920
+ *
921
+ * Every existing chip shrinks from `length` clipboard characters to one detect
922
+ * character, so the detect length is the clipboard length minus the sum of
923
+ * `length - 1` over all occurrences. A draft with no chips is unchanged.
924
+ *
925
+ * @param state - the fresh input snapshot.
926
+ * @returns the detect-coordinate length of the whole draft.
927
+ */
928
+ function detectLengthOf(state) {
929
+ const draft = typeof state.draft === "string" ? state.draft : "";
930
+ const occurrences = Array.isArray(state.occurrences) ? state.occurrences : [];
931
+ let shrink = 0;
932
+ for (const occurrence of occurrences) {
933
+ const length = typeof occurrence.length === "number" ? occurrence.length : 0;
934
+ if (length > 1) shrink += length - 1;
935
+ }
936
+ return Math.max(0, draft.length - shrink);
937
+ }
938
+ /** Whether the draft already carries our chip. */
939
+ function hasSchematicChip(state) {
940
+ return (Array.isArray(state.occurrences) ? state.occurrences : []).some((o) => o.source === "schematic-gen" && o.ref === "generate");
941
+ }
942
+ /** Phases in which `insertReference` refuses to edit. */
943
+ const BUSY_PHASES = ["adjudicating", "submitting"];
944
+ /**
945
+ * Insert the `Generate schematic` chip at the end of the current draft.
946
+ *
947
+ * Never throws and never partially edits: every failure leaves the draft
948
+ * exactly as it was.
949
+ *
950
+ * @param deps - lazy service accessors (resolved per call, never captured).
951
+ * @param insert - the reference to insert (defaults to the schematic-gen one,
952
+ * whose label is resolved against the host language at call
953
+ * time — so the chip matches the UI language, not a frozen
954
+ * English literal).
955
+ * @returns whether a chip landed, and why not when it did not.
956
+ */
957
+ function insertSchematicChip(deps, insert = schematicGenInsert()) {
958
+ const sessions = deps.getSessions();
959
+ const conversation = deps.getConversation();
960
+ if (sessions === void 0 || conversation === void 0 || conversation.input === void 0) {
961
+ trace("unavailable", {
962
+ hasSessions: sessions !== void 0,
963
+ hasConversation: conversation !== void 0
964
+ });
965
+ return {
966
+ ok: false,
967
+ reason: "unavailable"
968
+ };
969
+ }
970
+ const sessionId = currentSessionId(sessions);
971
+ if (sessionId === void 0 || sessionId === null || sessionId === "") {
972
+ trace("no-session", { known: countSessions(sessions) });
973
+ return {
974
+ ok: false,
975
+ reason: "no-session"
976
+ };
977
+ }
978
+ const actx = resolveSessionScope(sessions, sessionId);
979
+ if (actx === void 0 || actx === null) {
980
+ trace("no-scope", { sessionId });
981
+ return {
982
+ ok: false,
983
+ reason: "no-session"
984
+ };
985
+ }
986
+ let input;
987
+ try {
988
+ input = conversation.input.for(actx);
989
+ } catch (err) {
990
+ trace("for() threw", err instanceof Error ? err.message : err);
991
+ input = void 0;
992
+ }
993
+ if (input === void 0 || input === null || typeof input.insertReference !== "function") {
994
+ trace("unavailable", { hasInput: input !== void 0 && input !== null });
995
+ return {
996
+ ok: false,
997
+ reason: "unavailable"
998
+ };
999
+ }
1000
+ trace("inserting", {
1001
+ label: insert.label,
1002
+ clipboardText: insert.clipboardText
1003
+ });
1004
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1005
+ const state = input.state?.getSnapshot() ?? {};
1006
+ if (hasSchematicChip(state)) {
1007
+ trace("already-present");
1008
+ return {
1009
+ ok: false,
1010
+ reason: "already-present"
1011
+ };
1012
+ }
1013
+ const phase = state.phase ?? "plain";
1014
+ if (BUSY_PHASES.includes(phase)) {
1015
+ trace("busy", { phase });
1016
+ return {
1017
+ ok: false,
1018
+ reason: "busy"
1019
+ };
1020
+ }
1021
+ const draftRev = typeof state.draftRev === "number" ? state.draftRev : 0;
1022
+ const end = detectLengthOf(state);
1023
+ let applied = false;
1024
+ try {
1025
+ applied = input.insertReference(insert, {
1026
+ start: end,
1027
+ end,
1028
+ draftRev
1029
+ });
1030
+ } catch (err) {
1031
+ trace("insertReference threw", err instanceof Error ? err.message : err);
1032
+ applied = false;
1033
+ }
1034
+ if (applied) {
1035
+ trace("inserted", {
1036
+ label: insert.label,
1037
+ span: {
1038
+ start: end,
1039
+ end,
1040
+ draftRev
1041
+ }
1042
+ });
1043
+ try {
1044
+ input.focus?.();
1045
+ } catch (err) {
1046
+ trace("focus threw", err instanceof Error ? err.message : err);
1047
+ }
1048
+ return { ok: true };
1049
+ }
1050
+ trace("cas-miss", { attempt });
1051
+ }
1052
+ trace("conflict");
1053
+ return {
1054
+ ok: false,
1055
+ reason: "conflict"
1056
+ };
1057
+ }
275
1058
  //#endregion
276
1059
  //#region src/client/index.ts
277
1060
  /** Harness compacts finished tool calls; keep only our delivery card visible. */
@@ -461,22 +1244,80 @@
461
1244
  }, busy ? "处理中…" : "放置到编辑器") : null, h("span", { style: { fontSize: 12 } }, `${result.fileCount} 个工程和库文件`)), result.status !== "ok" ? h("p", null, "存在未渲染页面或预览警告,请结合工具结果检查。") : null, message ? h("p", { role: "alert" }, message) : null);
462
1245
  }
463
1246
  return {
464
- inject: ["slots"],
1247
+ inject: ["slots", "inputTriggers"],
465
1248
  apply: (ctx) => {
466
1249
  getHost = () => ctx.get?.("hqEdge");
467
1250
  const stopVisibility = keepDeliveryVisible();
468
- const stopRefs = registerSchematicGenSource(ctx.get?.("inputTriggers"));
1251
+ const source = registerSchematicGenSourceWhenReady(() => ctx.get?.("inputTriggers"));
1252
+ try {
1253
+ console.debug("[schematic-gen:client] apply", {
1254
+ inputTriggers: source.registered(),
1255
+ slots: typeof ctx.slots?.inject === "function"
1256
+ });
1257
+ } catch {}
1258
+ const chipDeps = {
1259
+ getConversation: () => ctx.get?.("conversation"),
1260
+ getSessions: () => ctx.get?.("sessions")
1261
+ };
1262
+ const stopEntry = mountSchematicGenSidebarEntry({
1263
+ onToggle: () => {
1264
+ if (!source.registered()) {
1265
+ try {
1266
+ console.debug("[schematic-gen:chip] click", {
1267
+ ok: false,
1268
+ reason: "source-unregistered"
1269
+ });
1270
+ } catch {}
1271
+ notifyComposer(ctx, "source-unregistered");
1272
+ return;
1273
+ }
1274
+ const result = insertSchematicChip(chipDeps);
1275
+ try {
1276
+ console.debug("[schematic-gen:chip] click", result);
1277
+ } catch {}
1278
+ if (result.ok || result.reason === "already-present") return;
1279
+ notifyComposer(ctx, result.reason);
1280
+ },
1281
+ position: "after"
1282
+ });
1283
+ try {
1284
+ console.debug("[schematic-gen:client] sidebar row", { rows: document.querySelectorAll("[data-hqcg-schematic-entry]").length });
1285
+ } catch {}
469
1286
  const dispose = ctx.slots.inject("tool.call.toolview", () => ctx.slots.register({
470
1287
  name: "tool.call.toolview",
471
1288
  key: "render_kicad_module_preview"
472
1289
  }, Card));
473
1290
  ctx.effect?.(() => () => {
474
1291
  stopVisibility();
475
- stopRefs();
1292
+ source.dispose();
1293
+ stopEntry();
476
1294
  if (typeof dispose === "function") dispose();
477
1295
  });
478
1296
  }
479
1297
  };
1298
+ /** Surface why a chip insertion did not happen, through the composer's own notice channel. Best-effort by design. */
1299
+ function notifyComposer(context, reason) {
1300
+ try {
1301
+ const sessions = context.get?.("sessions");
1302
+ const conversation = context.get?.("conversation");
1303
+ if (sessions === void 0 || conversation?.input === void 0) return;
1304
+ const sessionId = currentSessionId(sessions);
1305
+ if (!sessionId) return;
1306
+ const actx = resolveSessionScope(sessions, sessionId);
1307
+ if (actx === void 0 || actx === null) return;
1308
+ conversation.input.for(actx)?.notify?.("info", noticeFor(reason));
1309
+ } catch {}
1310
+ }
1311
+ /** Human-readable notice text for one insertion failure reason. */
1312
+ function noticeFor(reason) {
1313
+ switch (reason) {
1314
+ case "no-session": return "Open a conversation first, then click Schematic Gen again.";
1315
+ case "busy": return "The composer is busy — try again once the current message settles.";
1316
+ case "conflict": return "The composer changed while inserting — click Schematic Gen again.";
1317
+ case "source-unregistered": return "Schematic Gen is still starting up — click again in a moment.";
1318
+ default: return "Schematic Gen is unavailable in this session.";
1319
+ }
1320
+ }
480
1321
  }
481
1322
  });
482
1323
  //#endregion