@huaqiu/dsh-eda-selection 0.0.0-stage → 0.5.7

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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `@huaqiu/dsh-eda-selection` — browser half.
3
+ *
4
+ * Contributes ONE entry to the public `conversation.input.dock` slot: an
5
+ * ambient `Current selection · N items (editor)` chip that appears above the
6
+ * composer whenever the FOCUSED KiCad editor has a selection, and disappears
7
+ * the moment it does not.
8
+ *
9
+ * ── Where the state comes from ──────────────────────────────────────────────
10
+ * The `hqEdge` host service provided by `@hqedge/dsh-edge-bridge`. Its
11
+ * `selection` face is fed by the bridge's OWN `/runtime/dsh` poll, so this
12
+ * plugin adds NO second polling loop and never talks to HQ Edge itself.
13
+ *
14
+ * `inject` declares `hqEdge`, so DSH holds this plugin's activation until the
15
+ * bridge has provided it — `ctx.get('hqEdge')` inside `apply()` therefore
16
+ * resolves. The accessor is still built lazily (a closure, not a captured
17
+ * value) because the service object is created once but its `selection` face
18
+ * is read on every subscription.
19
+ *
20
+ * The bridge's `onChanged` replays the current state immediately when one is
21
+ * already known, so a chip mounted after the first summary does not wait for a
22
+ * change; and it returns a disposer, which is what the slot's own lifecycle
23
+ * expects.
24
+ *
25
+ * ── Unknown vs known-empty ──────────────────────────────────────────────────
26
+ * `hqEdge.selection.getCurrent()` returns `null` when the state is UNKNOWN
27
+ * (nothing published yet, or the sync failed). That is NOT a selection, and
28
+ * the chip renders nothing for it — a chip must never appear on screen that
29
+ * the host does not currently back.
30
+ *
31
+ * ── No second composer ──────────────────────────────────────────────────────
32
+ * This entry never touches the draft, the input state, or the reference
33
+ * pipeline. It is a read-only projection of host state; the agent reaches the
34
+ * real selection through the existing EDA selection tools.
35
+ *
36
+ * @module @huaqiu/dsh-eda-selection/client
37
+ */
38
+ import { createElement, useEffect, useState, type ReactElement } from 'react'
39
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
40
+ import { SelectionChip } from './SelectionChip.js'
41
+ import { en, zh, type EdaSelectionKey } from './locales.js'
42
+ import {
43
+ normalizeSelectionSnapshot,
44
+ type HqEdgeLike,
45
+ type SelectionSnapshot,
46
+ } from './hq-edge.js'
47
+
48
+ export const inject = ['slots', 'locale', 'hqEdge'] as const
49
+
50
+ /** This plugin's dictionary namespace. */
51
+ export const EDA_SELECTION_NS = 'huaqiu:eda-selection'
52
+
53
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
54
+ interface LocaleNamespaceMap {
55
+ 'huaqiu:eda-selection': EdaSelectionKey
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Minimal structural client context (the DSH client runtime provides these
61
+ * faces). Declared structurally, never imported, so the package stays
62
+ * independently installable — the same pattern as
63
+ * `@huaqiu/dsh-tool-schematic-gen` and `@huaqiu/dsh-huaqiu-about`.
64
+ */
65
+ export interface ClientContext {
66
+ slots?: {
67
+ inject(key: string, callback: () => () => void): () => void
68
+ register(
69
+ spec: {
70
+ name: string
71
+ id?: string
72
+ order?: number
73
+ label?: () => string
74
+ locale?: string
75
+ },
76
+ component: unknown,
77
+ ): () => void
78
+ }
79
+ locale?: {
80
+ register(namespace: string, dictionaries: unknown): unknown
81
+ bind(namespace: string): (key: string, params?: Record<string, unknown>) => string
82
+ }
83
+ get<T = unknown>(name: string): T | undefined
84
+ effect?(fn: () => (() => void) | void, label?: string): void
85
+ }
86
+
87
+ /** Translation function shape (the locale service's `bind` result). */
88
+ export type Translate = (key: string, params?: Record<string, unknown>) => string
89
+
90
+ /**
91
+ * Resolve the `hqEdge` service defensively.
92
+ *
93
+ * `inject` already guarantees it exists by `apply()` time; the try/catch and
94
+ * the shape checks keep a standalone DSH (no edge-bridge) from taking the
95
+ * plugin down. In that case the chip simply never renders.
96
+ */
97
+ function getHqEdge(ctx: ClientContext): HqEdgeLike | undefined {
98
+ try {
99
+ const edge = ctx.get<HqEdgeLike | undefined>('hqEdge')
100
+ if (!edge || !edge.selection || typeof edge.selection.getCurrent !== 'function') return undefined
101
+ return edge
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Read and normalize the current snapshot from the bridge.
109
+ *
110
+ * The bridge already normalizes; re-normalizing here keeps this plugin honest
111
+ * when pointed at an older bridge (or a test double) that does not.
112
+ */
113
+ function readSnapshot(edge: HqEdgeLike | undefined): SelectionSnapshot | null {
114
+ if (!edge) return null
115
+ try {
116
+ return normalizeSelectionSnapshot(edge.selection?.getCurrent())
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ export interface SelectionDockProps {
123
+ /** Bound dictionary, injected by the client entry. */
124
+ t: Translate
125
+ /** Lazy bridge accessor — a closure over `ctx`, never a captured value. */
126
+ getEdge: () => HqEdgeLike | undefined
127
+ }
128
+
129
+ /**
130
+ * The dock component: subscribes to the bridge and renders the chip.
131
+ *
132
+ * A hook rather than a plain read, because the snapshot arrives asynchronously
133
+ * (the bridge's first poll lands after this component mounts).
134
+ */
135
+ export function SelectionDock({ t, getEdge }: SelectionDockProps): ReactElement | null {
136
+ const [snapshot, setSnapshot] = useState<SelectionSnapshot | null>(null)
137
+
138
+ useEffect(() => {
139
+ const edge = getEdge()
140
+
141
+ // No bridge (standalone DSH) or a bridge without the selection face
142
+ // (older edge-bridge): render nothing, permanently. Not an error.
143
+ if (!edge || typeof edge.selection?.onChanged !== 'function') {
144
+ setSnapshot(null)
145
+ return
146
+ }
147
+
148
+ // `onChanged` replays the current state immediately when one is known, so
149
+ // this read is only needed for the unknown-at-mount case.
150
+ setSnapshot(readSnapshot(edge))
151
+
152
+ let alive = true
153
+ const dispose = edge.selection.onChanged((next) => {
154
+ if (!alive) return
155
+ // Normalize on the way in too: a listener must never be handed a shape
156
+ // it did not expect, whatever the bridge did.
157
+ setSnapshot(normalizeSelectionSnapshot(next))
158
+ })
159
+
160
+ return () => {
161
+ alive = false
162
+ dispose()
163
+ }
164
+ }, [getEdge])
165
+
166
+ return createElement(SelectionChip, { snapshot, t })
167
+ }
168
+
169
+ /**
170
+ * Install the dock entry on the running client context.
171
+ *
172
+ * Never throws: a deployment without the slot service, the locale service, or
173
+ * the `hqEdge` bridge must still load — it simply shows no chip.
174
+ */
175
+ export function apply(ctx: ClientContext): void {
176
+ // Dictionaries first, so the first render already has localized text. The
177
+ // untyped register form is used because this namespace is declared by this
178
+ // package (not by DSH), which keeps DSH core untouched.
179
+ const locale = ctx.locale
180
+ if (locale && typeof locale.register === 'function') {
181
+ try {
182
+ locale.register(EDA_SELECTION_NS, { zh, en })
183
+ } catch {
184
+ // A duplicate registration (hot reload, double mount) is not fatal —
185
+ // the first registration's dictionaries are already live.
186
+ }
187
+ }
188
+
189
+ const t: Translate =
190
+ locale && typeof locale.bind === 'function'
191
+ ? locale.bind(EDA_SELECTION_NS)
192
+ : (key: string) => key
193
+
194
+ const getEdge = (): HqEdgeLike | undefined => getHqEdge(ctx)
195
+
196
+ const slots = ctx.slots
197
+ if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {
198
+ return
199
+ }
200
+
201
+ ctx.effect?.(
202
+ () =>
203
+ slots.inject('conversation.input.dock', () =>
204
+ slots.register(
205
+ {
206
+ name: 'conversation.input.dock',
207
+ // Our own id: a fresh id is added BESIDE the shipped entries
208
+ // (queue / todo / goal) instead of replacing one of them.
209
+ id: 'eda-selection',
210
+ // Ascending among the dock entries. `queue` is 20 and `todo` is 0,
211
+ // so 5 puts the ambient chip near the top — closest to the composer
212
+ // it describes — without displacing the todo dock at 0.
213
+ order: 5,
214
+ label: () => t('dock.label'),
215
+ locale: EDA_SELECTION_NS,
216
+ },
217
+ () => createElement(SelectionDock, { t, getEdge }),
218
+ ),
219
+ ),
220
+ 'huaqiu eda-selection: composer dock chip',
221
+ )
222
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * zh / en copy for the ambient `Current selection` dock entry.
3
+ *
4
+ * Chinese is the source of truth: `EdaSelectionKey` is derived from it and
5
+ * `en` is typed against it, so a key added to one language without the other
6
+ * is a COMPILE error rather than a silent Chinese fallback at runtime.
7
+ *
8
+ * Kept in its own module (rather than inlined in the component) so the copy is
9
+ * reviewable on its own and the test file can assert bilingual balance
10
+ * directly, mirroring `@huaqiu/dsh-auth` / `@huaqiu/dsh-tool-schematic-gen`.
11
+ */
12
+
13
+ export const zh = {
14
+ /** Dock-entry label shown wherever the owner projects one. */
15
+ 'dock.label': '当前选中',
16
+ /** The chip itself: "Current selection · 3 items (PCB)". */
17
+ 'chip.label': '当前选中',
18
+ 'chip.sep': '·',
19
+ /** Editor names, spelled for a human reader. */
20
+ 'editor.pcb': 'PCB',
21
+ 'editor.schematic': '原理图',
22
+ 'editor.unspecified': '编辑器',
23
+ /** "{count} items" / "{count} 项". */
24
+ 'chip.items': '{count} 项',
25
+ /** One item — English needs the singular form. */
26
+ 'chip.item': '1 项',
27
+ /** Screen-reader label for the chip. */
28
+ 'chip.aria': '当前编辑器选中 {count} 项',
29
+ /** Bounded label list, shown after the count when the host supplied any. */
30
+ 'chip.labels': ':{labels}',
31
+ } as const
32
+
33
+ export type EdaSelectionKey = keyof typeof zh
34
+
35
+ export const en: Record<EdaSelectionKey, string> = {
36
+ 'dock.label': 'Current selection',
37
+ 'chip.label': 'Current selection',
38
+ 'chip.sep': '·',
39
+ 'editor.pcb': 'PCB',
40
+ 'editor.schematic': 'Schematic',
41
+ 'editor.unspecified': 'editor',
42
+ 'chip.items': '{count} items',
43
+ 'chip.item': '1 item',
44
+ 'chip.aria': 'Current editor selection: {count} items',
45
+ 'chip.labels': ': {labels}',
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `@huaqiu/dsh-eda-selection` — node half.
3
+ *
4
+ * Registers ONE dynamic system-prompt section, `eda:selection`, that reports
5
+ * the focused editor's selection to the agent. Nothing else: no tools, no
6
+ * routes, no I/O.
7
+ *
8
+ * ── Why a section and not a context ─────────────────────────────────────────
9
+ * A *section* is part of the system prompt (stable policy text); a *context* is
10
+ * materialized as a durable user-role snapshot. The selection summary is
11
+ * neither — it is ambient state that changes between turns and must not be
12
+ * persisted into the conversation history as if the user had said it. A
13
+ * section whose provider re-evaluates at each assembly is exactly that.
14
+ *
15
+ * ── Why the provider performs no I/O ────────────────────────────────────────
16
+ * The provider reads `hqEdge.selection.getCurrent()` — an in-memory value the
17
+ * bridge keeps fresh from its own `/runtime/dsh` poll. Prompt assembly happens
18
+ * on the agent's hot path, so an HTTP call here would add latency to every
19
+ * turn and could fail; a stale-but-free read is strictly better than a fresh
20
+ * blocking one. When the value is unknown the section contributes NOTHING.
21
+ *
22
+ * ── What the section must not do ────────────────────────────────────────────
23
+ * It must not imply that selection CONTENT was inserted into the conversation.
24
+ * The summary is a count, an editor identity and a bounded label list — enough
25
+ * to decide whether the existing selection tools are worth calling, never a
26
+ * substitute for calling them.
27
+ *
28
+ * @module @huaqiu/dsh-eda-selection
29
+ */
30
+ import {
31
+ EDA_SELECTION_SECTION,
32
+ EDA_SELECTION_SECTION_ORDER,
33
+ edaSelectionSectionText,
34
+ registerEdaSelectionSection,
35
+ type SystemPromptLike,
36
+ } from './prompt.js'
37
+ import { normalizeSelectionSnapshot, type HqEdgeLike } from './client/hq-edge.js'
38
+ import { getLogger } from '@huaqiu/dsh-plugin-log'
39
+
40
+ /** Plugin id — matches package.json. */
41
+ export const name = '@huaqiu/dsh-eda-selection'
42
+
43
+ /**
44
+ * Cordis services this half depends on.
45
+ *
46
+ * `hqEdge` is the bridge's node-side twin. It is declared here so DSH holds
47
+ * this plugin until the bridge has provided it, and every read is still
48
+ * guarded: a standalone DSH deployment without the bridge must not fail.
49
+ *
50
+ * `systemPrompt` carries the section. Declared for the same reason.
51
+ */
52
+ export const inject = ['hqEdge', 'systemPrompt'] as const
53
+
54
+ /** Shared component name for the unified DSH-plugin log. */
55
+ const COMPONENT = 'dsh-eda-selection'
56
+ const log = getLogger(COMPONENT)
57
+
58
+ /** Host plugin body — register the dynamic selection section. */
59
+ export function apply(ctx: {
60
+ hqEdge?: HqEdgeLike
61
+ systemPrompt?: SystemPromptLike
62
+ }): () => void {
63
+ // Lazy, never captured once: the bridge's `selection` face is a live object,
64
+ // but reading it lazily keeps this plugin working if the service is swapped.
65
+ const readSelection = (): ReturnType<typeof normalizeSelectionSnapshot> => {
66
+ try {
67
+ const edge = ctx.hqEdge
68
+ if (!edge || !edge.selection || typeof edge.selection.getCurrent !== 'function') return null
69
+ return normalizeSelectionSnapshot(edge.selection.getCurrent())
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ const dispose = registerEdaSelectionSection(ctx.systemPrompt, readSelection)
76
+
77
+ if (dispose === undefined) {
78
+ log.warn('systemPrompt service unavailable — the selection prompt section was not registered')
79
+ }
80
+
81
+ return () => {
82
+ dispose?.()
83
+ }
84
+ }
85
+
86
+ export {
87
+ EDA_SELECTION_SECTION,
88
+ EDA_SELECTION_SECTION_ORDER,
89
+ edaSelectionSectionText,
90
+ registerEdaSelectionSection,
91
+ type SystemPromptLike,
92
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The `eda:selection` system-prompt section.
3
+ *
4
+ * Split out from `src/index.ts` so the text builder is unit-testable without a
5
+ * cordis context, a bridge, or a prompt registry — the same call
6
+ * `@huaqiu/dsh-tool-schematic-gen` makes for its `prompt.ts`.
7
+ *
8
+ * @module
9
+ */
10
+ import type { SelectionSnapshot } from './client/hq-edge.js'
11
+
12
+ /** Unique prompt-section name. */
13
+ export const EDA_SELECTION_SECTION = 'eda:selection'
14
+
15
+ /**
16
+ * Centrally allocated placement: 590, between DSH's `PLAN_POLICY` (500) and
17
+ * `TEAM_POLICY` (600).
18
+ *
19
+ * That is where the sibling Huaqiu tool-policy section
20
+ * (`huaqiu:schematic-gen`) already sits, so both Huaqiu policy blocks are
21
+ * adjacent and both precede the tool-list sections (`TOOL_*` at 1000+) they
22
+ * refer to. `ctx.systemPrompt.getSectionOrder()` only resolves DSH's own named
23
+ * positions, so the number is pinned here and asserted by a test.
24
+ */
25
+ export const EDA_SELECTION_SECTION_ORDER = 590
26
+
27
+ /**
28
+ * Structural view of `ctx.systemPrompt` — only the face we use.
29
+ *
30
+ * `text` accepts a provider, which the registry re-evaluates at every
31
+ * assembly with that assembly's `AssembleContext`. Returning `''` contributes
32
+ * nothing: `renderPrompt` filters empty sections out before joining.
33
+ */
34
+ export interface SystemPromptLike {
35
+ section(section: {
36
+ name: string
37
+ order: number
38
+ text: string | ((context: unknown) => string)
39
+ }): () => void
40
+ }
41
+
42
+ /** Editor display name for the prompt (English: the prompt is model-facing). */
43
+ function editorName(editor: SelectionSnapshot['editor']): string {
44
+ switch (editor) {
45
+ case 'PCB':
46
+ return 'PCB (pcbnew)'
47
+ case 'SCHEMATIC':
48
+ return 'schematic (eeschema)'
49
+ default:
50
+ return 'an unidentified editor'
51
+ }
52
+ }
53
+
54
+ /** How many labels the section may quote. The host already bounds the list. */
55
+ export const MAX_SECTION_LABELS = 5
56
+
57
+ /**
58
+ * Build the section text for one snapshot.
59
+ *
60
+ * Returns `''` for every "contribute nothing" case — an unknown state
61
+ * (`null`) or a known-empty selection. One gate for both, so the section and
62
+ * the browser chip can never disagree about when the selection exists.
63
+ *
64
+ * @param snapshot - the current summary, or `null` when unknown.
65
+ * @returns the section text, or `''` when it must contribute nothing.
66
+ */
67
+ export function edaSelectionSectionText(snapshot: SelectionSnapshot | null): string {
68
+ if (snapshot === null || !snapshot.hasSelection || snapshot.itemCount <= 0) return ''
69
+
70
+ const lines: string[] = [
71
+ 'Current EDA selection (Huaqiu EDA host):',
72
+ `- The focused editor is ${editorName(snapshot.editor)}.`,
73
+ `- It currently has ${snapshot.itemCount} selected item(s).`,
74
+ ]
75
+
76
+ const labels = snapshot.labels.slice(0, MAX_SECTION_LABELS)
77
+ if (labels.length > 0) {
78
+ lines.push(`- Selected labels (partial, host-truncated): ${labels.join(', ')}.`)
79
+ }
80
+
81
+ lines.push(
82
+ '- This is a SUMMARY only. The selected items themselves are NOT in this ' +
83
+ 'conversation — call the existing selection tools (`get_pcb_selection`, ' +
84
+ '`get_selection_netlist`) when you need the actual entities, nets or ' +
85
+ 'netlist. Never claim to know the selected content without calling them.',
86
+ )
87
+
88
+ return lines.join('\n')
89
+ }
90
+
91
+ /**
92
+ * Register the dynamic selection section.
93
+ *
94
+ * Never throws: a DSH deployment without the prompt service, or one where a
95
+ * duplicate registration races, must still load. A duplicate is the expected
96
+ * hot-reload case, so it is swallowed rather than surfaced.
97
+ *
98
+ * @param systemPrompt - `ctx.systemPrompt`, or undefined.
99
+ * @param readSelection - reads the current summary. Must not perform I/O.
100
+ * @returns the section's disposer, or `undefined` when nothing was registered.
101
+ */
102
+ export function registerEdaSelectionSection(
103
+ systemPrompt: unknown,
104
+ readSelection: () => SelectionSnapshot | null,
105
+ ): (() => void) | undefined {
106
+ if (
107
+ systemPrompt === undefined || systemPrompt === null ||
108
+ typeof (systemPrompt as SystemPromptLike).section !== 'function'
109
+ ) {
110
+ return undefined
111
+ }
112
+
113
+ try {
114
+ const dispose = (systemPrompt as SystemPromptLike).section({
115
+ name: EDA_SELECTION_SECTION,
116
+ order: EDA_SELECTION_SECTION_ORDER,
117
+ // A provider, not a constant: the selection changes between turns and the
118
+ // registry re-evaluates this at every assembly. It performs NO I/O — it
119
+ // reads the bridge's in-memory cache.
120
+ text: () => edaSelectionSectionText(readSelection()),
121
+ })
122
+ return typeof dispose === 'function' ? dispose : undefined
123
+ } catch {
124
+ // Duplicate registration (hot reload) — the first one is still live.
125
+ return undefined
126
+ }
127
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.