@huaqiu/dsh-eda-selection 0.0.0-stage → 0.5.6
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/LICENSE +21 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +357 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +137 -0
- package/lib/index.mjs +171 -0
- package/package.json +58 -3
- package/src/client/SelectionChip.tsx +95 -0
- package/src/client/chip-text.ts +111 -0
- package/src/client/hq-edge.ts +129 -0
- package/src/client/index.ts +222 -0
- package/src/client/locales.ts +46 -0
- package/src/index.ts +92 -0
- package/src/prompt.ts +127 -0
- package/README.md +0 -3
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { getLogger } from "@huaqiu/dsh-plugin-log";
|
|
2
|
+
//#region src/prompt.ts
|
|
3
|
+
/** Unique prompt-section name. */
|
|
4
|
+
const EDA_SELECTION_SECTION = "eda:selection";
|
|
5
|
+
/**
|
|
6
|
+
* Centrally allocated placement: 590, between DSH's `PLAN_POLICY` (500) and
|
|
7
|
+
* `TEAM_POLICY` (600).
|
|
8
|
+
*
|
|
9
|
+
* That is where the sibling Huaqiu tool-policy section
|
|
10
|
+
* (`huaqiu:schematic-gen`) already sits, so both Huaqiu policy blocks are
|
|
11
|
+
* adjacent and both precede the tool-list sections (`TOOL_*` at 1000+) they
|
|
12
|
+
* refer to. `ctx.systemPrompt.getSectionOrder()` only resolves DSH's own named
|
|
13
|
+
* positions, so the number is pinned here and asserted by a test.
|
|
14
|
+
*/
|
|
15
|
+
const EDA_SELECTION_SECTION_ORDER = 590;
|
|
16
|
+
/** Editor display name for the prompt (English: the prompt is model-facing). */
|
|
17
|
+
function editorName(editor) {
|
|
18
|
+
switch (editor) {
|
|
19
|
+
case "PCB": return "PCB (pcbnew)";
|
|
20
|
+
case "SCHEMATIC": return "schematic (eeschema)";
|
|
21
|
+
default: return "an unidentified editor";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the section text for one snapshot.
|
|
26
|
+
*
|
|
27
|
+
* Returns `''` for every "contribute nothing" case — an unknown state
|
|
28
|
+
* (`null`) or a known-empty selection. One gate for both, so the section and
|
|
29
|
+
* the browser chip can never disagree about when the selection exists.
|
|
30
|
+
*
|
|
31
|
+
* @param snapshot - the current summary, or `null` when unknown.
|
|
32
|
+
* @returns the section text, or `''` when it must contribute nothing.
|
|
33
|
+
*/
|
|
34
|
+
function edaSelectionSectionText(snapshot) {
|
|
35
|
+
if (snapshot === null || !snapshot.hasSelection || snapshot.itemCount <= 0) return "";
|
|
36
|
+
const lines = [
|
|
37
|
+
"Current EDA selection (Huaqiu EDA host):",
|
|
38
|
+
`- The focused editor is ${editorName(snapshot.editor)}.`,
|
|
39
|
+
`- It currently has ${snapshot.itemCount} selected item(s).`
|
|
40
|
+
];
|
|
41
|
+
const labels = snapshot.labels.slice(0, 5);
|
|
42
|
+
if (labels.length > 0) lines.push(`- Selected labels (partial, host-truncated): ${labels.join(", ")}.`);
|
|
43
|
+
lines.push("- This is a SUMMARY only. The selected items themselves are NOT in this conversation — call the existing selection tools (`get_pcb_selection`, `get_selection_netlist`) when you need the actual entities, nets or netlist. Never claim to know the selected content without calling them.");
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Register the dynamic selection section.
|
|
48
|
+
*
|
|
49
|
+
* Never throws: a DSH deployment without the prompt service, or one where a
|
|
50
|
+
* duplicate registration races, must still load. A duplicate is the expected
|
|
51
|
+
* hot-reload case, so it is swallowed rather than surfaced.
|
|
52
|
+
*
|
|
53
|
+
* @param systemPrompt - `ctx.systemPrompt`, or undefined.
|
|
54
|
+
* @param readSelection - reads the current summary. Must not perform I/O.
|
|
55
|
+
* @returns the section's disposer, or `undefined` when nothing was registered.
|
|
56
|
+
*/
|
|
57
|
+
function registerEdaSelectionSection(systemPrompt, readSelection) {
|
|
58
|
+
if (systemPrompt === void 0 || systemPrompt === null || typeof systemPrompt.section !== "function") return;
|
|
59
|
+
try {
|
|
60
|
+
const dispose = systemPrompt.section({
|
|
61
|
+
name: EDA_SELECTION_SECTION,
|
|
62
|
+
order: 590,
|
|
63
|
+
text: () => edaSelectionSectionText(readSelection())
|
|
64
|
+
});
|
|
65
|
+
return typeof dispose === "function" ? dispose : void 0;
|
|
66
|
+
} catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/client/hq-edge.ts
|
|
72
|
+
/**
|
|
73
|
+
* Normalize one raw summary into {@link SelectionSnapshot}, or `null` when it
|
|
74
|
+
* carries no usable state.
|
|
75
|
+
*
|
|
76
|
+
* The bridge already normalizes, but this plugin can be pointed at a host
|
|
77
|
+
* service that predates the selection face (or at a hand-rolled test double),
|
|
78
|
+
* so the same defensive rules are applied again here:
|
|
79
|
+
*
|
|
80
|
+
* - null / undefined / non-object → `null` (unknown)
|
|
81
|
+
* - `hasSelection` not a boolean → `null` (cannot tell selected from not)
|
|
82
|
+
* - unknown editor name → `UNSPECIFIED`
|
|
83
|
+
* - non-string labels are dropped; the array is copied
|
|
84
|
+
*
|
|
85
|
+
* A well-formed `hasSelection: false` is KEPT: that is a known-empty state,
|
|
86
|
+
* and dropping it would erase the distinction the whole design rests on.
|
|
87
|
+
*
|
|
88
|
+
* @param raw - value read from `hqEdge.selection.getCurrent()`.
|
|
89
|
+
* @returns the normalized snapshot, or `null` for unknown/malformed.
|
|
90
|
+
*/
|
|
91
|
+
function normalizeSelectionSnapshot(raw) {
|
|
92
|
+
if (raw === null || raw === void 0) return null;
|
|
93
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
94
|
+
const record = raw;
|
|
95
|
+
if (typeof record.hasSelection !== "boolean") return null;
|
|
96
|
+
const itemCount = typeof record.itemCount === "number" && Number.isFinite(record.itemCount) && record.itemCount > 0 ? Math.floor(record.itemCount) : 0;
|
|
97
|
+
const editor = record.editor;
|
|
98
|
+
const knownEditor = editor === "SCHEMATIC" || editor === "PCB" ? editor : "UNSPECIFIED";
|
|
99
|
+
const labels = Array.isArray(record.labels) ? record.labels.filter((label) => typeof label === "string") : [];
|
|
100
|
+
const revision = typeof record.revision === "string" ? record.revision : typeof record.revision === "number" && Number.isFinite(record.revision) ? String(record.revision) : "";
|
|
101
|
+
return {
|
|
102
|
+
hasSelection: record.hasSelection,
|
|
103
|
+
itemCount,
|
|
104
|
+
editor: knownEditor,
|
|
105
|
+
labels,
|
|
106
|
+
revision
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/index.ts
|
|
111
|
+
/**
|
|
112
|
+
* `@huaqiu/dsh-eda-selection` — node half.
|
|
113
|
+
*
|
|
114
|
+
* Registers ONE dynamic system-prompt section, `eda:selection`, that reports
|
|
115
|
+
* the focused editor's selection to the agent. Nothing else: no tools, no
|
|
116
|
+
* routes, no I/O.
|
|
117
|
+
*
|
|
118
|
+
* ── Why a section and not a context ─────────────────────────────────────────
|
|
119
|
+
* A *section* is part of the system prompt (stable policy text); a *context* is
|
|
120
|
+
* materialized as a durable user-role snapshot. The selection summary is
|
|
121
|
+
* neither — it is ambient state that changes between turns and must not be
|
|
122
|
+
* persisted into the conversation history as if the user had said it. A
|
|
123
|
+
* section whose provider re-evaluates at each assembly is exactly that.
|
|
124
|
+
*
|
|
125
|
+
* ── Why the provider performs no I/O ────────────────────────────────────────
|
|
126
|
+
* The provider reads `hqEdge.selection.getCurrent()` — an in-memory value the
|
|
127
|
+
* bridge keeps fresh from its own `/runtime/dsh` poll. Prompt assembly happens
|
|
128
|
+
* on the agent's hot path, so an HTTP call here would add latency to every
|
|
129
|
+
* turn and could fail; a stale-but-free read is strictly better than a fresh
|
|
130
|
+
* blocking one. When the value is unknown the section contributes NOTHING.
|
|
131
|
+
*
|
|
132
|
+
* ── What the section must not do ────────────────────────────────────────────
|
|
133
|
+
* It must not imply that selection CONTENT was inserted into the conversation.
|
|
134
|
+
* The summary is a count, an editor identity and a bounded label list — enough
|
|
135
|
+
* to decide whether the existing selection tools are worth calling, never a
|
|
136
|
+
* substitute for calling them.
|
|
137
|
+
*
|
|
138
|
+
* @module @huaqiu/dsh-eda-selection
|
|
139
|
+
*/
|
|
140
|
+
/** Plugin id — matches package.json. */
|
|
141
|
+
const name = "@huaqiu/dsh-eda-selection";
|
|
142
|
+
/**
|
|
143
|
+
* Cordis services this half depends on.
|
|
144
|
+
*
|
|
145
|
+
* `hqEdge` is the bridge's node-side twin. It is declared here so DSH holds
|
|
146
|
+
* this plugin until the bridge has provided it, and every read is still
|
|
147
|
+
* guarded: a standalone DSH deployment without the bridge must not fail.
|
|
148
|
+
*
|
|
149
|
+
* `systemPrompt` carries the section. Declared for the same reason.
|
|
150
|
+
*/
|
|
151
|
+
const inject = ["hqEdge", "systemPrompt"];
|
|
152
|
+
const log = getLogger("dsh-eda-selection");
|
|
153
|
+
/** Host plugin body — register the dynamic selection section. */
|
|
154
|
+
function apply(ctx) {
|
|
155
|
+
const readSelection = () => {
|
|
156
|
+
try {
|
|
157
|
+
const edge = ctx.hqEdge;
|
|
158
|
+
if (!edge || !edge.selection || typeof edge.selection.getCurrent !== "function") return null;
|
|
159
|
+
return normalizeSelectionSnapshot(edge.selection.getCurrent());
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const dispose = registerEdaSelectionSection(ctx.systemPrompt, readSelection);
|
|
165
|
+
if (dispose === void 0) log.warn("systemPrompt service unavailable — the selection prompt section was not registered");
|
|
166
|
+
return () => {
|
|
167
|
+
dispose?.();
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
export { EDA_SELECTION_SECTION, EDA_SELECTION_SECTION_ORDER, apply, edaSelectionSectionText, inject, name, registerEdaSelectionSection };
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huaqiu/dsh-eda-selection",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.6",
|
|
4
|
+
"description": "Ambient 'Current Selection' chip above the DSH composer plus a dynamic system-prompt section describing the focused EDA editor's selection.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.mjs",
|
|
7
|
+
"types": "./lib/index.d.mts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.mts",
|
|
11
|
+
"default": "./lib/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"default": "./lib/client.js"
|
|
15
|
+
},
|
|
16
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"platform": "web",
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-locale",
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
33
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.0"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"lib",
|
|
37
|
+
"src",
|
|
38
|
+
"cordis.patch.yml"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@huaqiu/dsh-plugin-log": "0.5.6"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
48
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
|
|
50
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.0",
|
|
51
|
+
"@types/react": "~18.3.1",
|
|
52
|
+
"@types/react-dom": "^18.3.5",
|
|
53
|
+
"react": "^18.3.1",
|
|
54
|
+
"react-dom": "^18.3.1"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"build": "tsdown",
|
|
59
|
+
"test": "vitest run"
|
|
60
|
+
}
|
|
6
61
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ambient `Current selection` chip — the browser half's only visible
|
|
3
|
+
* surface.
|
|
4
|
+
*
|
|
5
|
+
* It is a passive indicator: it renders what the host published and nothing
|
|
6
|
+
* else. There is no click action (the agent reaches the real selection through
|
|
7
|
+
* the existing EDA tools), no expand affordance, and no per-item type claim.
|
|
8
|
+
*
|
|
9
|
+
* ── When it renders ─────────────────────────────────────────────────────────
|
|
10
|
+
* Only for a snapshot with `hasSelection && itemCount > 0`. Both UNKNOWN
|
|
11
|
+
* (`null` — nothing published yet, or the sync failed) and KNOWN-EMPTY
|
|
12
|
+
* (`hasSelection: false` with a real revision) render NOTHING. Rendering
|
|
13
|
+
* either of them would put a chip on screen that the host does not back.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import { createElement, type CSSProperties, type ReactElement } from 'react'
|
|
18
|
+
import type { SelectionSnapshot } from './hq-edge.js'
|
|
19
|
+
import {
|
|
20
|
+
formatSelectionChip,
|
|
21
|
+
selectionChipAriaLabel,
|
|
22
|
+
type Translate,
|
|
23
|
+
} from './chip-text.js'
|
|
24
|
+
|
|
25
|
+
const ROOT: CSSProperties = {
|
|
26
|
+
display: 'inline-flex',
|
|
27
|
+
alignItems: 'center',
|
|
28
|
+
gap: 6,
|
|
29
|
+
// The dock is full-width; the chip itself is only as wide as its text so it
|
|
30
|
+
// reads as an ambient tag rather than a toolbar.
|
|
31
|
+
alignSelf: 'flex-start',
|
|
32
|
+
maxWidth: '100%',
|
|
33
|
+
padding: '3px 10px',
|
|
34
|
+
borderRadius: 999,
|
|
35
|
+
boxSizing: 'border-box',
|
|
36
|
+
fontSize: 12,
|
|
37
|
+
lineHeight: '18px',
|
|
38
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
39
|
+
background: 'color-mix(in srgb, var(--dsw-alias-label-primary) 5%, transparent)',
|
|
40
|
+
border: '1px solid color-mix(in srgb, var(--dsw-alias-label-primary) 14%, transparent)',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The leading dot — the only ornament, and it carries no meaning. */
|
|
44
|
+
const DOT: CSSProperties = {
|
|
45
|
+
width: 6,
|
|
46
|
+
height: 6,
|
|
47
|
+
borderRadius: '50%',
|
|
48
|
+
flex: '0 0 auto',
|
|
49
|
+
background: 'var(--dsw-alias-state-success, #34c759)',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TEXT: CSSProperties = {
|
|
53
|
+
// One line, ellipsised. The label list is host-bounded and already short.
|
|
54
|
+
overflow: 'hidden',
|
|
55
|
+
textOverflow: 'ellipsis',
|
|
56
|
+
whiteSpace: 'nowrap',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SelectionChipProps {
|
|
60
|
+
/** The latest snapshot, or `null` when the state is unknown. */
|
|
61
|
+
snapshot: SelectionSnapshot | null
|
|
62
|
+
/** Bound dictionary (the entry's locale seat). */
|
|
63
|
+
t: Translate
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Render the chip, or `null` when there is nothing grounded to show.
|
|
68
|
+
*
|
|
69
|
+
* Returning `null` (rather than an empty fragment) lets the dock skip the row
|
|
70
|
+
* entirely, so an absent selection costs no vertical space in the composer.
|
|
71
|
+
*/
|
|
72
|
+
export function SelectionChip({ snapshot, t }: SelectionChipProps): ReactElement | null {
|
|
73
|
+
const text = formatSelectionChip(snapshot, t)
|
|
74
|
+
|
|
75
|
+
if (text.length === 0) return null
|
|
76
|
+
|
|
77
|
+
const aria = selectionChipAriaLabel(snapshot, t)
|
|
78
|
+
|
|
79
|
+
return createElement(
|
|
80
|
+
'div',
|
|
81
|
+
{
|
|
82
|
+
style: ROOT,
|
|
83
|
+
// `data-*` markers so the entry can be identified in tests and by the
|
|
84
|
+
// host's own diagnostics without depending on the copy.
|
|
85
|
+
'data-dsh-plugin': 'huaqiu-eda-selection',
|
|
86
|
+
'data-dsh-part': 'selection-chip',
|
|
87
|
+
'data-editor': snapshot?.editor ?? 'UNSPECIFIED',
|
|
88
|
+
'data-revision': snapshot?.revision ?? '',
|
|
89
|
+
role: 'status',
|
|
90
|
+
'aria-label': aria.length > 0 ? aria : undefined,
|
|
91
|
+
},
|
|
92
|
+
createElement('span', { style: DOT, 'aria-hidden': true }),
|
|
93
|
+
createElement('span', { style: TEXT }, text),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure label formatting for the ambient `Current selection` chip.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately free of React and of the cordis context so the text the user
|
|
5
|
+
* reads is unit-testable on its own — the same call
|
|
6
|
+
* `@huaqiu/dsh-tool-symbol-footprint` makes for its sidebar copy.
|
|
7
|
+
*
|
|
8
|
+
* ── Grounding rule ──────────────────────────────────────────────────────────
|
|
9
|
+
* The chip may only state what the host actually reported:
|
|
10
|
+
*
|
|
11
|
+
* - the item COUNT (`item_count`) — never inferred from `labels.length`,
|
|
12
|
+
* because the host truncates the label list to a small bound;
|
|
13
|
+
* - the editor identity (`editor`), and only when it is one we model;
|
|
14
|
+
* - a BOUNDED label list, explicitly introduced as a partial view.
|
|
15
|
+
*
|
|
16
|
+
* Anything else — "the selected footprint", "net N$12", a guessed type — would
|
|
17
|
+
* be a claim the summary does not support, and the agent has the real entity
|
|
18
|
+
* ids one tool call away.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
import type { SelectionEditorName, SelectionSnapshot } from './hq-edge.js'
|
|
23
|
+
|
|
24
|
+
/** Translation function shape (the locale service's `bind` result). */
|
|
25
|
+
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
|
26
|
+
|
|
27
|
+
/** Fallback translator: the key itself, so a missing dictionary is never blank. */
|
|
28
|
+
export const identityTranslate: Translate = (key) => key
|
|
29
|
+
|
|
30
|
+
/** Editor display name for one editor identity. */
|
|
31
|
+
export function editorLabel(
|
|
32
|
+
editor: SelectionEditorName,
|
|
33
|
+
t: Translate = identityTranslate,
|
|
34
|
+
): string {
|
|
35
|
+
switch (editor) {
|
|
36
|
+
case 'PCB':
|
|
37
|
+
return t('editor.pcb')
|
|
38
|
+
case 'SCHEMATIC':
|
|
39
|
+
return t('editor.schematic')
|
|
40
|
+
default:
|
|
41
|
+
return t('editor.unspecified')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `"{count} items"` with the singular form honoured for one item.
|
|
47
|
+
*
|
|
48
|
+
* Both languages ship a dedicated singular key, so English reads "1 item"
|
|
49
|
+
* rather than "1 items".
|
|
50
|
+
*/
|
|
51
|
+
export function itemCountLabel(count: number, t: Translate = identityTranslate): string {
|
|
52
|
+
return count === 1 ? t('chip.item') : t('chip.items', { count })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How many labels the chip may append after the count.
|
|
57
|
+
*
|
|
58
|
+
* The host already bounds the list (8 entries × 64 chars), but the chip is one
|
|
59
|
+
* line of ambient UI, so it shows fewer than the host is willing to send.
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_CHIP_LABELS = 3
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The bounded, comma-joined label suffix, or `''` when there is nothing to show.
|
|
65
|
+
*
|
|
66
|
+
* The separator is a plain comma plus a space rather than a locale-specific
|
|
67
|
+
* list separator: the chip is a compact ambient indicator, and the host's
|
|
68
|
+
* labels are identifiers (R1, U3, …) that read the same in both languages.
|
|
69
|
+
*/
|
|
70
|
+
export function labelSuffix(labels: readonly string[]): string {
|
|
71
|
+
return labels.slice(0, MAX_CHIP_LABELS).join(', ')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The full chip text, e.g. `Current selection · 3 items (PCB): R1, R2, R3`.
|
|
76
|
+
*
|
|
77
|
+
* Returns `''` for anything that must NOT be rendered — an unknown state
|
|
78
|
+
* (`null`) or a known-empty selection. One gate for both, so the chip and the
|
|
79
|
+
* prompt section can never disagree about when the selection exists.
|
|
80
|
+
*/
|
|
81
|
+
export function formatSelectionChip(
|
|
82
|
+
snapshot: SelectionSnapshot | null,
|
|
83
|
+
t: Translate = identityTranslate,
|
|
84
|
+
): string {
|
|
85
|
+
if (snapshot === null || !snapshot.hasSelection || snapshot.itemCount <= 0) return ''
|
|
86
|
+
|
|
87
|
+
const parts: string[] = [t('chip.label'), t('chip.sep'), itemCountLabel(snapshot.itemCount, t)]
|
|
88
|
+
|
|
89
|
+
// The editor is parenthesised only when it is one we model; UNSPECIFIED
|
|
90
|
+
// means the host has not identified a focused editor, and "(editor)" would
|
|
91
|
+
// be noise.
|
|
92
|
+
if (snapshot.editor !== 'UNSPECIFIED') {
|
|
93
|
+
parts.push(`(${editorLabel(snapshot.editor, t)})`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const labels = labelSuffix(snapshot.labels)
|
|
97
|
+
|
|
98
|
+
// Appended WITHOUT a joining space: the separator lives inside the localized
|
|
99
|
+
// string (`': {labels}'`), and a space before a colon reads wrong in both
|
|
100
|
+
// languages.
|
|
101
|
+
return labels.length > 0 ? `${parts.join(' ')}${t('chip.labels', { labels })}` : parts.join(' ')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Screen-reader label for the chip. */
|
|
105
|
+
export function selectionChipAriaLabel(
|
|
106
|
+
snapshot: SelectionSnapshot | null,
|
|
107
|
+
t: Translate = identityTranslate,
|
|
108
|
+
): string {
|
|
109
|
+
if (snapshot === null || !snapshot.hasSelection || snapshot.itemCount <= 0) return ''
|
|
110
|
+
return t('chip.aria', { count: snapshot.itemCount })
|
|
111
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural view of the `hqEdge` host service's `selection` face.
|
|
3
|
+
*
|
|
4
|
+
* Declared structurally and NEVER imported from `@hqedge/dsh-edge-bridge`:
|
|
5
|
+
* that package is a DSH host plugin (its root export is a cordis entry), so
|
|
6
|
+
* importing it here would pull a plugin into a plugin. The contract is instead
|
|
7
|
+
* pinned by the edge-bridge's own unit tests, which assert the exact same
|
|
8
|
+
* field names and semantics this interface describes.
|
|
9
|
+
*
|
|
10
|
+
* Both halves of the bridge publish this shape, and both normalise it through
|
|
11
|
+
* the same `normalizeSelectionSnapshot` rules — so a browser-side chip and a
|
|
12
|
+
* node-side prompt provider reading the same snapshot always agree.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Editor identity, as an enum NAME (never the wire number). */
|
|
18
|
+
export type SelectionEditorName = 'UNSPECIFIED' | 'SCHEMATIC' | 'PCB'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One selection summary.
|
|
22
|
+
*
|
|
23
|
+
* `revision` is a STRING: protobuf uint64 crosses the HTTP boundary as bigint,
|
|
24
|
+
* and `JSON.stringify` throws on bigint, so hq-edge serialises it as a string.
|
|
25
|
+
*/
|
|
26
|
+
export interface SelectionSnapshot {
|
|
27
|
+
/** Whether the FOCUSED editor currently has a selection. */
|
|
28
|
+
hasSelection: boolean
|
|
29
|
+
/** Number of selected items. Zero when `hasSelection` is false. */
|
|
30
|
+
itemCount: number
|
|
31
|
+
/** Which editor the selection belongs to. */
|
|
32
|
+
editor: SelectionEditorName
|
|
33
|
+
/**
|
|
34
|
+
* Bounded, display-only labels (reference designators, symbol names, …).
|
|
35
|
+
*
|
|
36
|
+
* Host-truncated to a small fixed count and per-label length. Treat as a
|
|
37
|
+
* PARTIAL view: never infer `itemCount` from `labels.length`, and never
|
|
38
|
+
* claim these are all the selected items.
|
|
39
|
+
*/
|
|
40
|
+
labels: string[]
|
|
41
|
+
/** Monotonic per-host revision. Empty before the first published summary. */
|
|
42
|
+
revision: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The `selection` face of the `hqEdge` host service. */
|
|
46
|
+
export interface HqEdgeSelection {
|
|
47
|
+
/**
|
|
48
|
+
* The latest known selection summary, or `null` when UNKNOWN.
|
|
49
|
+
*
|
|
50
|
+
* `null` means "nothing has been published yet, or the sync failed" — which
|
|
51
|
+
* is deliberately NOT the same thing as a known-empty selection. A consumer
|
|
52
|
+
* must render nothing for either, but must never treat `null` as
|
|
53
|
+
* "definitely nothing selected".
|
|
54
|
+
*/
|
|
55
|
+
getCurrent(): SelectionSnapshot | null
|
|
56
|
+
/**
|
|
57
|
+
* Subscribe to selection changes. Fires immediately with the current state
|
|
58
|
+
* when one is already known.
|
|
59
|
+
* @returns a disposer removing exactly this listener.
|
|
60
|
+
*/
|
|
61
|
+
onChanged(listener: (selection: SelectionSnapshot | null) => void): () => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The slice of the `hqEdge` service this plugin consumes. */
|
|
65
|
+
export interface HqEdgeLike {
|
|
66
|
+
selection?: HqEdgeSelection
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Normalize one raw summary into {@link SelectionSnapshot}, or `null` when it
|
|
71
|
+
* carries no usable state.
|
|
72
|
+
*
|
|
73
|
+
* The bridge already normalizes, but this plugin can be pointed at a host
|
|
74
|
+
* service that predates the selection face (or at a hand-rolled test double),
|
|
75
|
+
* so the same defensive rules are applied again here:
|
|
76
|
+
*
|
|
77
|
+
* - null / undefined / non-object → `null` (unknown)
|
|
78
|
+
* - `hasSelection` not a boolean → `null` (cannot tell selected from not)
|
|
79
|
+
* - unknown editor name → `UNSPECIFIED`
|
|
80
|
+
* - non-string labels are dropped; the array is copied
|
|
81
|
+
*
|
|
82
|
+
* A well-formed `hasSelection: false` is KEPT: that is a known-empty state,
|
|
83
|
+
* and dropping it would erase the distinction the whole design rests on.
|
|
84
|
+
*
|
|
85
|
+
* @param raw - value read from `hqEdge.selection.getCurrent()`.
|
|
86
|
+
* @returns the normalized snapshot, or `null` for unknown/malformed.
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeSelectionSnapshot(raw: unknown): SelectionSnapshot | null {
|
|
89
|
+
if (raw === null || raw === undefined) return null
|
|
90
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
91
|
+
|
|
92
|
+
const record = raw as Record<string, unknown>
|
|
93
|
+
|
|
94
|
+
if (typeof record.hasSelection !== 'boolean') return null
|
|
95
|
+
|
|
96
|
+
const itemCount =
|
|
97
|
+
typeof record.itemCount === 'number' && Number.isFinite(record.itemCount) && record.itemCount > 0
|
|
98
|
+
? Math.floor(record.itemCount)
|
|
99
|
+
: 0
|
|
100
|
+
|
|
101
|
+
const editor = record.editor
|
|
102
|
+
const knownEditor: SelectionEditorName =
|
|
103
|
+
editor === 'SCHEMATIC' || editor === 'PCB' ? editor : 'UNSPECIFIED'
|
|
104
|
+
|
|
105
|
+
const labels = Array.isArray(record.labels)
|
|
106
|
+
? record.labels.filter((label): label is string => typeof label === 'string')
|
|
107
|
+
: []
|
|
108
|
+
|
|
109
|
+
const revision =
|
|
110
|
+
typeof record.revision === 'string'
|
|
111
|
+
? record.revision
|
|
112
|
+
: typeof record.revision === 'number' && Number.isFinite(record.revision)
|
|
113
|
+
? String(record.revision)
|
|
114
|
+
: ''
|
|
115
|
+
|
|
116
|
+
return { hasSelection: record.hasSelection, itemCount, editor: knownEditor, labels, revision }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether a snapshot is worth surfacing at all.
|
|
121
|
+
*
|
|
122
|
+
* Both the chip and the prompt section use this single gate, so the two can
|
|
123
|
+
* never disagree about when the selection "exists". Unknown (`null`) and
|
|
124
|
+
* known-empty both answer "no" — but for different reasons, and the chip keeps
|
|
125
|
+
* them apart internally so a later transition is not swallowed.
|
|
126
|
+
*/
|
|
127
|
+
export function hasVisibleSelection(snapshot: SelectionSnapshot | null): boolean {
|
|
128
|
+
return snapshot !== null && snapshot.hasSelection && snapshot.itemCount > 0
|
|
129
|
+
}
|