dsh-ptc-plus 0.3.2 → 0.4.2

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.
Files changed (206) hide show
  1. package/README.en.md +63 -80
  2. package/README.md +63 -80
  3. package/assets/ptc-plus-bindings-en.png +0 -0
  4. package/assets/ptc-plus-bindings-zh.png +0 -0
  5. package/assets/ptc-plus-repair-en.png +0 -0
  6. package/assets/ptc-plus-repair-zh.png +0 -0
  7. package/assets/ptc-plus-repl-workspace-en.png +0 -0
  8. package/assets/ptc-plus-repl-workspace-zh.png +0 -0
  9. package/assets/ptc-plus-settings-en.png +0 -0
  10. package/assets/ptc-plus-settings-zh.png +0 -0
  11. package/client.js +30827 -742
  12. package/compiler-amaro.cjs +5 -0
  13. package/compiler-amaro.cjs.map +6 -0
  14. package/compiler-asset-124f80c1bba92ff5fe79cfec6e8ad518f476de4a42514cae3919538887f510e8.txt +1 -0
  15. package/compiler-assets.json +3 -0
  16. package/compiler-core.cjs +556 -0
  17. package/compiler-core.cjs.map +6 -0
  18. package/compiler-platform.cjs +2 -0
  19. package/compiler-platform.cjs.map +6 -0
  20. package/compiler-typescript.cjs +388 -0
  21. package/compiler-typescript.cjs.map +6 -0
  22. package/docs/README.md +7 -0
  23. package/docs/adr/0004-declare-program-bindings-at-the-host.md +1 -1
  24. package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +5 -3
  25. package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +1 -1
  26. package/docs/adr/0009-bound-expensive-acceptance-default.md +2 -0
  27. package/docs/adr/0010-session-log-derived-recovery-tips.md +59 -5
  28. package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +1 -1
  29. package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +3 -1
  30. package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +3 -1
  31. package/docs/adr/0014-persist-cell-rewrite-policy.md +6 -2
  32. package/docs/adr/0015-preserve-source-positions-through-rewrites.md +1 -1
  33. package/docs/adr/0016-archive-resolved-review-findings.md +1 -1
  34. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +49 -0
  35. package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +3 -3
  36. package/docs/adr/0019-plugin-settings-and-kill-switch.md +5 -3
  37. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -2
  38. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +12 -0
  39. package/docs/adr/0023-global-user-bindings.md +104 -0
  40. package/docs/adr/0024-repl-console-observation.md +33 -0
  41. package/docs/adr/0025-use-versioned-logical-binding-identities.md +119 -0
  42. package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +21 -0
  43. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +37 -0
  44. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +103 -0
  45. package/docs/adr/0029-keep-plugin-unavailability-out-of-the-host-agent-lifecycle.md +27 -0
  46. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +27 -0
  47. package/docs/architecture.md +77 -21
  48. package/docs/binding-review-placement-design.md +200 -0
  49. package/docs/capability-projection.md +1 -1
  50. package/docs/client-ui.md +95 -19
  51. package/docs/durability-design.md +28 -10
  52. package/docs/evaluation.md +78 -6
  53. package/docs/installation.md +41 -6
  54. package/docs/publishing.md +5 -3
  55. package/docs/runtime-reference.md +76 -25
  56. package/docs/semantic-validation.md +29 -0
  57. package/docs/stateful-binding-updates-proposal.md +759 -0
  58. package/docs/user-bindings.en.md +66 -0
  59. package/docs/user-bindings.md +66 -0
  60. package/docs/value-wire.md +3 -3
  61. package/docs/verification-optimization-work.md +41 -0
  62. package/docs/verification.md +71 -0
  63. package/index.js +369 -92
  64. package/internal/ast-traversal.js +5 -2
  65. package/internal/async-local-storage-intrinsics.js +38 -0
  66. package/internal/binding-declaration-contract.js +8 -0
  67. package/internal/binding-descriptors.js +1 -0
  68. package/internal/binding-pattern-traversal.js +25 -0
  69. package/internal/binding-pattern.js +26 -29
  70. package/internal/binding-source-analysis.js +708 -0
  71. package/internal/binding-update-policy.js +72 -0
  72. package/internal/bounded-worker-round.js +56 -0
  73. package/internal/callable-definition-environment.js +42 -0
  74. package/internal/callable-source-catalog.js +129 -0
  75. package/internal/callable-source-encoding.js +47 -0
  76. package/internal/callable-source-facts.js +459 -0
  77. package/internal/callable-source-format.js +2 -0
  78. package/internal/callable-source-lowering.js +123 -0
  79. package/internal/cell-analysis-contract.js +25 -0
  80. package/internal/cell-analysis.js +253 -290
  81. package/internal/cell-completion.js +15 -0
  82. package/internal/cell-error.js +7 -0
  83. package/internal/cell-parser.js +66 -0
  84. package/internal/cell-rewriter.js +3 -677
  85. package/internal/commonjs-export-evidence.js +63 -0
  86. package/internal/commonjs-source-evidence.js +72 -0
  87. package/internal/compiler-amaro.cjs +2 -0
  88. package/internal/compiler-compression.js +12 -0
  89. package/internal/compiler-data.js +58 -0
  90. package/internal/compiler-descriptors.js +54 -0
  91. package/internal/compiler-dynamic-cache.js +48 -0
  92. package/internal/compiler-entry.js +35 -0
  93. package/internal/compiler-generated-names.js +17 -0
  94. package/internal/compiler-intrinsics.js +50 -0
  95. package/internal/compiler-module-links.js +37 -0
  96. package/internal/compiler-operations.js +61 -0
  97. package/internal/compiler-platform-factory.js +84 -0
  98. package/internal/compiler-platform.js +136 -0
  99. package/internal/compiler-record-roles.js +53 -0
  100. package/internal/compiler-region-output.js +259 -0
  101. package/internal/compiler-resource-lowering.js +103 -0
  102. package/internal/compiler-scope-facts.js +115 -0
  103. package/internal/compiler-service.js +109 -0
  104. package/internal/compiler-source-cache.js +32 -0
  105. package/internal/compiler-source-pieces.js +93 -0
  106. package/internal/compiler-source-regions.js +132 -0
  107. package/internal/compiler-storage-source.js +54 -0
  108. package/internal/compiler-text.js +15 -0
  109. package/internal/compiler-typescript.cjs +2 -0
  110. package/internal/config-spec.js +83 -27
  111. package/internal/cordis-tools-owner.js +130 -21
  112. package/internal/diagnostic.js +27 -1
  113. package/internal/direct-surface-owner.js +76 -23
  114. package/internal/dynamic-binding-evidence.js +16 -0
  115. package/internal/dynamic-environment-compiler.js +779 -0
  116. package/internal/dynamic-environment-integration.js +91 -0
  117. package/internal/dynamic-environment-runtime.js +383 -0
  118. package/internal/dynamic-native-calls.js +153 -0
  119. package/internal/dynamic-native-runtime.js +262 -0
  120. package/internal/dynamic-scope-analysis.js +90 -0
  121. package/internal/edit-transport-owner.js +27 -2
  122. package/internal/execution-seam-compat.js +193 -0
  123. package/internal/failure-reporting.js +136 -28
  124. package/internal/host-rpc.js +66 -0
  125. package/internal/isolated-worker.js +555 -0
  126. package/internal/kernel-child.js +139 -0
  127. package/internal/kernel-worker.js +1179 -233
  128. package/internal/language-semantics.js +14 -0
  129. package/internal/legacy-binding-catalog.js +48 -0
  130. package/internal/legacy-cell-analysis.js +600 -0
  131. package/internal/legacy-cell-parser.js +50 -0
  132. package/internal/legacy-cell-rewriter.js +631 -0
  133. package/internal/legacy-repl-convenience.js +291 -0
  134. package/internal/managed-module-operations.js +170 -0
  135. package/internal/module-compilation.js +322 -0
  136. package/internal/module-runtime-platform.js +19 -0
  137. package/internal/module-transform-contract.js +42 -0
  138. package/internal/native-await-declarations.js +68 -0
  139. package/internal/native-root-compilation.js +54 -0
  140. package/internal/native-root-dynamic.js +67 -0
  141. package/internal/record-utils.js +24 -15
  142. package/internal/recovery-tips.js +27 -15
  143. package/internal/rejected-cell-editor.js +13 -8
  144. package/internal/repl-convenience.js +2 -289
  145. package/internal/repl-memory-projection.js +81 -12
  146. package/internal/repl-observation-interest.js +70 -0
  147. package/internal/repl-rewrite-contract.js +6 -0
  148. package/internal/repl-scope-normalizer.js +2091 -0
  149. package/internal/repl-value-observer.js +117 -0
  150. package/internal/rpc-contract.js +52 -0
  151. package/internal/runtime-bridge-owner.js +172 -28
  152. package/internal/runtime-config.js +48 -52
  153. package/internal/runtime-contexts.js +82 -10
  154. package/internal/runtime-intrinsics.js +101 -0
  155. package/internal/runtime-messages.js +111 -0
  156. package/internal/session-cell-executor.js +402 -87
  157. package/internal/session-journal-recovery.js +684 -0
  158. package/internal/session-journal-schema.js +123 -0
  159. package/internal/session-journal.js +199 -654
  160. package/internal/session-log-view.js +42 -9
  161. package/internal/session-runtime.js +257 -95
  162. package/internal/session-state.js +247 -71
  163. package/internal/source-position-map.js +230 -71
  164. package/internal/stateful-module-compiler.js +154 -0
  165. package/internal/stateful-module-runtime.js +303 -0
  166. package/internal/stateful-root-compiler.js +600 -0
  167. package/internal/stateful-root-runtime.js +340 -0
  168. package/internal/typert-codec-compat.js +28 -0
  169. package/internal/typescript-transform.js +31 -0
  170. package/internal/user-binding-authoring.js +70 -0
  171. package/internal/user-binding-console-worker.js +244 -0
  172. package/internal/user-binding-console.js +148 -0
  173. package/internal/user-binding-draft-projection.js +335 -0
  174. package/internal/user-binding-model-context.js +31 -0
  175. package/internal/user-binding-runner.js +56 -0
  176. package/internal/user-bindings-owner.js +1065 -0
  177. package/internal/user-bindings-store.js +293 -0
  178. package/internal/user-bindings.js +361 -0
  179. package/internal/value-wire-schema.js +28 -0
  180. package/internal/value-wire.js +130 -91
  181. package/internal/worker-client.js +57 -67
  182. package/internal/worker-cwd-virtualization.js +80 -53
  183. package/internal/worker-environment.js +65 -0
  184. package/internal/worker-output-relay.js +29 -0
  185. package/internal/worker-repl-realm.js +134 -0
  186. package/internal/worker-shutdown.js +14 -0
  187. package/package.json +76 -7
  188. package/src/client-activity.js +95 -53
  189. package/src/client-authoring-view.js +580 -0
  190. package/src/client-binding-review.js +230 -0
  191. package/src/client-bindings-data.js +29 -0
  192. package/src/client-catalog.js +127 -0
  193. package/src/client-code-editor.js +84 -0
  194. package/src/client-console.js +112 -0
  195. package/src/client-copy.js +370 -0
  196. package/src/client-feature-gates.js +62 -0
  197. package/src/client-host-compat.js +150 -0
  198. package/src/client-indicator-view.js +267 -0
  199. package/src/client-repl-view.js +292 -0
  200. package/src/client-rpc.js +20 -0
  201. package/src/client-settings-view.js +221 -0
  202. package/src/client-styles.js +106 -0
  203. package/src/client-tool-view.js +108 -0
  204. package/src/client-transport.js +159 -0
  205. package/src/client-workbench.js +521 -0
  206. package/src/client.js +233 -615
@@ -0,0 +1,521 @@
1
+ import { blankBinding, editableBinding, bindingPayload } from './client-bindings-data.js'
2
+
3
+ /**
4
+ * User Bindings workbench. The controller owns every piece of edit state in one
5
+ * reducer: selection, the stored identity it was loaded from, the revision the
6
+ * next write is compared against, the editor document identity and the draft.
7
+ * Async responses carry the generation they started in, so a late response can
8
+ * never overwrite a newer selection, and the document identity only changes when
9
+ * the edited document itself changes - never because draft.id was retyped.
10
+ * Callers keep the controller in a stable owner so hiding the dialog does not
11
+ * discard an unsaved draft.
12
+ */
13
+ export function createUserBindingsWorkbench(React, deps) {
14
+ const { TypeScriptEditor, BindingConsole, IconButton, ActionButton, CodeBlock, Modal, icons, catalogOwner } = deps
15
+ const h = React.createElement
16
+
17
+ const initialState = Object.freeze({
18
+ documentId: 0,
19
+ consoleVersion: 0,
20
+ catalog: null,
21
+ catalogStatus: 'loading',
22
+ catalogError: null,
23
+ draft: null,
24
+ declaration: '',
25
+ original: null,
26
+ revision: null,
27
+ editing: false,
28
+ creating: false,
29
+ query: '',
30
+ importPath: '',
31
+ sourceOpen: false,
32
+ metadataOpen: false,
33
+ message: null,
34
+ busy: false,
35
+ })
36
+
37
+ // Selection keeps the document identity when the same stored entry stays in the
38
+ // editor; a replaced source releases the console environment instead. Loading a
39
+ // stored entry or clearing the editor always ends the new-draft mode, so a later
40
+ // reload or cancel of that unchanged entry is not mistaken for a different document.
41
+ const selectDocument = (state, loaded, resetConsole) => {
42
+ const entry = loaded?.entry ?? null
43
+ const sameDocument = entry !== null && state.draft !== null && !state.creating
44
+ && state.original?.id === entry.id
45
+ const replaced = resetConsole !== false && state.draft?.source !== entry?.source
46
+ return {
47
+ ...state,
48
+ documentId: sameDocument ? state.documentId : state.documentId + 1,
49
+ consoleVersion: replaced ? state.consoleVersion + 1 : state.consoleVersion,
50
+ draft: entry === null ? null : editableBinding(entry),
51
+ original: entry,
52
+ declaration: entry?.declaration ?? '',
53
+ revision: loaded?.revision ?? null,
54
+ editing: false,
55
+ creating: false,
56
+ }
57
+ }
58
+
59
+ // The reducer is a pure function of the actions it is handed; stale async
60
+ // responses are dropped before dispatch, never inside the reducer.
61
+ function reducer(state, action) {
62
+ switch (action.type) {
63
+ case 'begin':
64
+ return { ...state, busy: true, message: null }
65
+ case 'settle':
66
+ return { ...state, busy: false }
67
+ case 'failed':
68
+ return { ...state, message: action.message }
69
+ case 'catalog':
70
+ // A failed read reports its error but keeps the last good catalog, so the
71
+ // surface stays usable and the message area owns the failure.
72
+ return { ...state, catalog: action.snapshot.catalog ?? state.catalog,
73
+ catalogStatus: action.snapshot.status, catalogError: action.snapshot.error }
74
+ case 'patch':
75
+ return { ...state, ...action.patch }
76
+ case 'loaded':
77
+ return selectDocument(state, action.loaded, action.resetConsole)
78
+ case 'create':
79
+ return { ...state,
80
+ documentId: state.documentId + 1, consoleVersion: state.consoleVersion + 1,
81
+ draft: blankBinding(), original: null, revision: action.revision, editing: true, creating: true,
82
+ sourceOpen: true, metadataOpen: true, declaration: '', message: null, busy: false }
83
+ case 'edit':
84
+ return { ...state, draft: { ...state.draft, [action.key]: action.value }, editing: true,
85
+ message: null, declaration: action.key === 'modelContext' ? state.declaration : '' }
86
+ case 'validated':
87
+ return { ...state, draft: editableBinding(action.normalized),
88
+ declaration: action.normalized.declaration ?? '', message: { key: 'bindings.valid' } }
89
+ case 'saved':
90
+ return { ...state, catalog: action.next, catalogStatus: 'ready', catalogError: null,
91
+ documentId: state.draft?.id === action.normalized.id ? state.documentId : state.documentId + 1,
92
+ consoleVersion: state.original?.source !== action.normalized.source
93
+ ? state.consoleVersion + 1 : state.consoleVersion,
94
+ draft: editableBinding(action.normalized), declaration: action.normalized.declaration ?? '',
95
+ message: { key: 'bindings.saved' }, original: action.normalized,
96
+ revision: action.next.revision, editing: false, creating: false, sourceOpen: false }
97
+ case 'mutated': {
98
+ const next = { ...state, catalog: action.next, catalogStatus: 'ready', catalogError: null,
99
+ // A successful CAS on this baseline proves metadata-only changes preserve its source.
100
+ revision: state.revision === state.catalog?.revision ? action.next.revision : state.revision }
101
+ // The response catalog, not the entry the request started from, is the
102
+ // confirmed stored state; only that field changes, so unsaved edits stay.
103
+ const stored = action.entry === undefined ? undefined
104
+ : action.next.entries.find(entry => entry.id === action.entry.id)
105
+ if (stored !== undefined && state.draft?.id === stored.id) {
106
+ next.draft = { ...state.draft, enabled: stored.enabled }
107
+ next.original = state.original === null ? null : { ...state.original, enabled: stored.enabled }
108
+ }
109
+ if (action.removed === true && state.draft?.id === action.entry.id) {
110
+ next.draft = null
111
+ next.original = null
112
+ next.declaration = ''
113
+ next.revision = null
114
+ next.editing = false
115
+ // The removed entry was the edited document; no new draft survives it.
116
+ next.creating = false
117
+ next.documentId = state.documentId + 1
118
+ next.consoleVersion = state.consoleVersion + 1
119
+ }
120
+ if (action.imported === true) next.importPath = ''
121
+ next.message = action.removed === true ? { key: 'bindings.removed' }
122
+ : action.imported === true ? { key: 'bindings.imported' } : state.message
123
+ return next
124
+ }
125
+ case 'reloadResult': {
126
+ if (action.loaded !== null && action.loaded.revision !== action.next.revision) {
127
+ return { ...state, message: { key: 'bindings.reloadConflict' } }
128
+ }
129
+ const base = { ...state, catalog: action.next, catalogStatus: 'ready', catalogError: null }
130
+ if (state.editing) {
131
+ // Keep the unsaved draft; only its stored identity and baseline are refreshed.
132
+ // An entry that disappeared externally keeps its identity, so the next save
133
+ // is still an update of that entry and never an implicit creation.
134
+ return { ...base, original: action.loaded?.entry ?? state.original,
135
+ revision: action.loaded?.revision ?? action.next.revision,
136
+ message: { key: 'bindings.reloadedDraft' } }
137
+ }
138
+ return { ...selectDocument(base, action.loaded, true), message: { key: 'bindings.reloaded' } }
139
+ }
140
+ case 'reset':
141
+ return { ...initialState, documentId: state.documentId + 1,
142
+ consoleVersion: state.consoleVersion + 1 }
143
+ default:
144
+ return state
145
+ }
146
+ }
147
+
148
+ // `enabled` owns the management session: disabling it releases the draft and
149
+ // invalidates every in-flight response, so a closed or disabled surface is
150
+ // never completed by a late answer. `active` only suspends reads while a
151
+ // temporary Host takeover hides the surface.
152
+ function useWorkbenchController({ enabled, active = true, callUserBindings }) {
153
+ const [state, dispatch] = React.useReducer(reducer, initialState)
154
+ const generation = React.useRef(0)
155
+ // The generation that holds the single-flight lock, or null while free.
156
+ const running = React.useRef(null)
157
+ const autoLoaded = React.useRef(false)
158
+ const source = React.useMemo(() => catalogOwner.claim(), [catalogOwner])
159
+ const snapshot = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
160
+ React.useEffect(() => () => {
161
+ // A response arriving after this owner is gone must not dispatch.
162
+ generation.current += 1
163
+ source.release()
164
+ }, [source])
165
+ React.useEffect(() => {
166
+ dispatch({ type: 'catalog', snapshot })
167
+ // A read that fails on the shared source is this surface's failure too.
168
+ if (snapshot.status === 'error' && snapshot.error !== null) {
169
+ dispatch({ type: 'failed', message: { key: 'bindings.failed', params: { error: snapshot.error } } })
170
+ }
171
+ }, [snapshot])
172
+
173
+ // One operation at a time. Every response carries the generation it started
174
+ // in, so a late answer can never overwrite a newer selection or a new draft.
175
+ // A lock held by a released generation does not block the surface that
176
+ // replaced it; the released request still settles as a dropped answer.
177
+ const perform = React.useCallback(async operation => {
178
+ if (running.current === generation.current) return
179
+ const current = ++generation.current
180
+ running.current = current
181
+ const alive = () => current === generation.current
182
+ dispatch({ type: 'begin' })
183
+ try {
184
+ await operation(alive)
185
+ } catch (error) {
186
+ if (alive()) dispatch({ type: 'failed', message: {
187
+ key: 'bindings.failed',
188
+ params: { error: error instanceof Error ? error.message : String(error) },
189
+ } })
190
+ } finally {
191
+ if (running.current === current) running.current = null
192
+ if (alive()) dispatch({ type: 'settle' })
193
+ }
194
+ }, [])
195
+
196
+ const load = React.useCallback(id => perform(async alive => {
197
+ const loaded = await callUserBindings('load', { id })
198
+ if (!alive()) return
199
+ dispatch({ type: 'loaded', loaded, resetConsole: true })
200
+ }), [perform, callUserBindings])
201
+
202
+ React.useEffect(() => {
203
+ if (!enabled) {
204
+ autoLoaded.current = false
205
+ generation.current += 1
206
+ source.reset()
207
+ dispatch({ type: 'reset' })
208
+ return
209
+ }
210
+ if (!active) return
211
+ // Re-entering the surface re-reads the catalog, but keeps the draft it left behind.
212
+ autoLoaded.current = false
213
+ void source.read()
214
+ }, [enabled, active, source])
215
+
216
+ React.useEffect(() => {
217
+ if (!enabled || !active || autoLoaded.current) return
218
+ if (state.draft !== null || state.original !== null) {
219
+ autoLoaded.current = true
220
+ return
221
+ }
222
+ const entries = snapshot.status === 'ready' ? snapshot.catalog?.entries : undefined
223
+ if (entries === undefined || entries.length === 0) return
224
+ autoLoaded.current = true
225
+ void load(entries[0].id)
226
+ }, [enabled, active, snapshot, state.draft, state.original, load])
227
+
228
+ const edit = (key, value) => dispatch({ type: 'edit', key, value })
229
+ const patch = value => dispatch({ type: 'patch', patch: value })
230
+ // A new draft is saved against the catalog revision it was created from, so
231
+ // creation needs a confirmed one. The last good catalog stays usable while a
232
+ // refresh is in flight or fails, and an empty catalog is a valid baseline
233
+ // (revision 0); a first read that has not answered, or failed, is not. The
234
+ // button and the transition consume this one predicate.
235
+ const catalogRevision = state.catalog !== null && Number.isInteger(state.catalog.revision)
236
+ ? state.catalog.revision : null
237
+ const canCreate = catalogRevision !== null && !state.busy && !state.editing
238
+ const create = () => {
239
+ if (!canCreate) return
240
+ generation.current += 1
241
+ dispatch({ type: 'create', revision: catalogRevision })
242
+ }
243
+ const cancel = () => {
244
+ dispatch({ type: 'loaded', loaded: state.original === null ? null : { entry: state.original, revision: state.revision },
245
+ resetConsole: false })
246
+ patch({ sourceOpen: false, message: null })
247
+ }
248
+ const validate = () => perform(async alive => {
249
+ const normalized = await callUserBindings('validate', { entry: bindingPayload(state.draft) })
250
+ if (!alive()) return
251
+ dispatch({ type: 'validated', normalized })
252
+ })
253
+ const save = () => perform(async alive => {
254
+ const normalized = await callUserBindings('validate', { entry: bindingPayload(state.draft) })
255
+ if (!alive()) return
256
+ // Creating and editing are different host operations: a create may not
257
+ // silently take over an existing entry, and an edit may only rewrite the
258
+ // entry it was loaded from.
259
+ const next = await callUserBindings('save', {
260
+ intent: state.original === null ? 'create' : 'update',
261
+ originalId: state.original?.id ?? null,
262
+ entry: bindingPayload(editableBinding(normalized)),
263
+ expectedRevision: state.revision,
264
+ })
265
+ if (!alive()) return
266
+ source.accept(next)
267
+ dispatch({ type: 'saved', normalized, next })
268
+ })
269
+ const reload = () => perform(async alive => {
270
+ const next = await source.read({ reload: true })
271
+ if (next === undefined || !alive()) return
272
+ const selected = next.entries.find(entry => entry.id === state.original?.id)
273
+ const loaded = selected === undefined ? null : await callUserBindings('load', { id: selected.id })
274
+ if (!alive()) return
275
+ dispatch({ type: 'reloadResult', next, loaded })
276
+ })
277
+ const toggle = entry => perform(async alive => {
278
+ const next = await callUserBindings(entry.enabled ? 'disable' : 'enable', {
279
+ id: entry.id, expectedRevision: state.catalog.revision,
280
+ })
281
+ if (!alive()) return
282
+ source.accept(next)
283
+ dispatch({ type: 'mutated', next, entry })
284
+ })
285
+ const remove = entry => perform(async alive => {
286
+ const next = await callUserBindings('remove', {
287
+ id: entry.id, expectedRevision: state.catalog.revision,
288
+ })
289
+ if (!alive()) return
290
+ source.accept(next)
291
+ dispatch({ type: 'mutated', next, entry, removed: true })
292
+ })
293
+ const importSource = () => perform(async alive => {
294
+ const next = await callUserBindings('import', {
295
+ path: state.importPath, expectedRevision: state.catalog.revision,
296
+ })
297
+ if (!alive()) return
298
+ source.accept(next)
299
+ dispatch({ type: 'mutated', next, imported: true })
300
+ })
301
+
302
+ return { state, source, snapshot, callUserBindings, canCreate, edit, patch, create, cancel, load,
303
+ validate, save, reload, toggle, remove, importSource }
304
+ }
305
+
306
+ function UserBindingsWorkbench({ controller, enabled = true, t, heading = true, headingLabel }) {
307
+ const { state, canCreate } = controller
308
+ const { edit, patch, create, cancel, load, validate, save, reload, toggle, remove, importSource } = controller
309
+ if (!enabled) return null
310
+ const visibleEntries = state.catalog?.entries.filter(entry =>
311
+ entry.name.toLowerCase().includes(state.query.trim().toLowerCase())) ?? []
312
+ return h('section', { className: 'ptcPlusBindings', 'aria-label': t('bindings.title'), 'aria-busy': state.busy },
313
+ h('div', { className: 'ptcPlusBindingsHead' },
314
+ heading ? h('h3', { className: 'ptcPlusBindingsTitle' }, headingLabel ?? t('bindings.title')) : h('span'),
315
+ h('div', { className: 'ptcPlusBindingsActions' },
316
+ h(IconButton, {
317
+ icon: icons.refresh, label: t('bindings.reload'), disabled: state.busy,
318
+ onClick: reload,
319
+ }),
320
+ h(ActionButton, {
321
+ type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary', disabled: !canCreate,
322
+ onClick: create,
323
+ }, typeof icons.plus === 'function' ? h(icons.plus, { size: 16 }) : null, t('bindings.new')))),
324
+ state.catalog === null
325
+ ? h('p', {
326
+ // A failed read publishes its error on the shared source; without it
327
+ // the surface would keep announcing that it is still loading.
328
+ className: `ptcPlusMessage${state.message === null && state.catalogError === null ? '' : ' ptcPlusDanger'}`,
329
+ role: state.message === null && state.catalogError === null ? undefined : 'status',
330
+ }, state.message !== null ? t(state.message.key, state.message.params)
331
+ : state.catalogError !== null ? t('bindings.failed', { error: state.catalogError })
332
+ : t('bindings.loading'))
333
+ : h('div', { className: 'ptcPlusBindingsGrid' },
334
+ h('div', { className: 'ptcPlusBindingPane' },
335
+ h('label', { className: 'ptcPlusSearch' },
336
+ typeof icons.search === 'function' ? h(icons.search, { size: 16 }) : null,
337
+ h('input', { value: state.query, onChange: event => patch({ query: event.target.value }),
338
+ placeholder: t('bindings.search'), 'aria-label': t('bindings.search') })),
339
+ state.catalog.error === undefined
340
+ ? null
341
+ : h('p', { className: 'ptcPlusMessage ptcPlusDanger' }, state.catalog.error),
342
+ state.catalog.entries.length === 0
343
+ ? h('p', { className: 'ptcPlusMessage' }, t('bindings.empty'))
344
+ : h('ul', { className: 'ptcPlusBindingList' }, visibleEntries.map(entry => (
345
+ h('li', {
346
+ key: entry.id, className: 'ptcPlusBindingItem',
347
+ 'data-selected': state.draft?.id === entry.id ? true : undefined,
348
+ },
349
+ h('button', {
350
+ type: 'button', className: 'ptcPlusBindingSelect', disabled: state.busy || state.editing,
351
+ onClick: () => load(entry.id),
352
+ },
353
+ h('span', { className: 'ptcPlusBindingName', title: entry.name }, entry.name),
354
+ h('span', { className: 'ptcPlusBindingMeta', title: entry.symbols.join(', ') }, entry.scope)),
355
+ h('button', {
356
+ type: 'button', className: 'ptcPlusBindingSwitch', role: 'switch',
357
+ disabled: state.busy || state.editing, 'aria-checked': entry.enabled,
358
+ 'aria-label': `${entry.name}: ${t('bindings.enabled')}`,
359
+ title: t(entry.enabled ? 'bindings.disableAction' : 'bindings.enableAction'),
360
+ onClick: () => toggle(entry),
361
+ }, h('span', { 'aria-hidden': true })))
362
+ ))),
363
+ state.catalog.entries.length > 0 && visibleEntries.length === 0
364
+ ? h('p', { className: 'ptcPlusMessage' }, t('console.noMatches')) : null,
365
+ h('div', { className: 'ptcPlusBindingRun' },
366
+ h('input', {
367
+ className: 'ptcPlusInput', value: state.importPath, disabled: state.busy,
368
+ placeholder: t('bindings.importPath'), 'aria-label': t('bindings.importPath'),
369
+ onChange: event => patch({ importPath: event.target.value }),
370
+ }),
371
+ h(ActionButton, {
372
+ type: 'button', className: 'ptcPlusButton',
373
+ disabled: state.busy || state.editing || state.importPath.trim() === '', onClick: importSource,
374
+ }, t('bindings.import')))),
375
+ state.draft === null ? null : h('div', { className: 'ptcPlusBindingEditor' },
376
+ h('div', { className: 'ptcPlusEditorHead' },
377
+ h('strong', { className: 'ptcPlusEditorFile' }, state.draft.name || t('bindings.new')),
378
+ h('div', { className: 'ptcPlusBindingLifecycle' },
379
+ state.editing ? h(React.Fragment, null,
380
+ h(ActionButton, { disabled: state.busy, onClick: validate },
381
+ h(icons.check, { size: 14 }), t('bindings.validate')),
382
+ h(ActionButton, { 'data-kind': 'primary', disabled: state.busy, onClick: save }, t('bindings.save')),
383
+ h(ActionButton, { disabled: state.busy, onClick: cancel }, t('bindings.cancel'))) : null,
384
+ state.catalog.entries.some(entry => entry.id === state.draft.id)
385
+ ? h(IconButton, {
386
+ icon: icons.trash, label: t('bindings.remove'), 'data-kind': 'danger',
387
+ disabled: state.busy || state.editing, onClick: () => remove(state.draft),
388
+ })
389
+ : null)),
390
+ h('details', { className: 'ptcPlusBindingSection ptcPlusBindingSourcePreview', open: true },
391
+ h('summary', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.declaration')),
392
+ state.declaration === '' ? h('p', { className: 'ptcPlusMessage' }, t('bindings.unvalidated'))
393
+ : typeof CodeBlock === 'function'
394
+ ? h(CodeBlock, { code: state.declaration, lang: 'typescript', className: 'ptcPlusCodeBlock',
395
+ copyLabel: t('tool.copy'), copiedLabel: t('tool.copied') })
396
+ : h('pre', { className: 'ptcPlusDeclaration' }, state.declaration)),
397
+ h('section', { className: 'ptcPlusBindingSection ptcPlusModelPrompt' },
398
+ h('h4', { className: 'ptcPlusBindingSectionTitle' }, t('bindings.modelContext')),
399
+ h('div', { className: 'ptcPlusBindingFields' },
400
+ h('label', { className: 'ptcPlusBindingField ptcPlusPromptToggle', 'data-wide': true },
401
+ h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.includeDeclaration')),
402
+ h('input', { className: 'ptcPlusCheck', type: 'checkbox', checked: state.draft.modelContext.includeDeclaration, disabled: state.busy,
403
+ onChange: event => edit('modelContext', { ...state.draft.modelContext, includeDeclaration: event.target.checked }) })),
404
+ h('label', { className: 'ptcPlusBindingField', 'data-wide': true },
405
+ h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.instructions')),
406
+ h('textarea', { className: 'ptcPlusInput', rows: 3, maxLength: 4096,
407
+ value: state.draft.modelContext.instructions, disabled: state.busy,
408
+ onChange: event => edit('modelContext', { ...state.draft.modelContext, instructions: event.target.value }) })))),
409
+ h('div', { className: 'ptcPlusSourceSection' },
410
+ h('button', { type: 'button', className: 'ptcPlusSourceToggle',
411
+ 'aria-expanded': state.sourceOpen, onClick: () => patch({ sourceOpen: !state.sourceOpen }) },
412
+ h(icons.chevron, { size: 14, style: { transform: state.sourceOpen ? undefined : 'rotate(-90deg)' } }),
413
+ t('bindings.sourcePreview'),
414
+ h('span', { className: 'ptcPlusSourceFilename' }, state.draft.name ? `${state.draft.name}.ts` : '')),
415
+ h('div', { className: 'ptcPlusSourceActions' }, state.editing ? null
416
+ : h(ActionButton, { disabled: state.busy, onClick: () => patch({ editing: true, sourceOpen: true }) },
417
+ typeof icons.edit === 'function' ? h(icons.edit, { size: 14 }) : null, t('bindings.edit'))),
418
+ h('div', { className: 'ptcPlusSourceBody', hidden: !state.sourceOpen },
419
+ state.editing
420
+ ? h(TypeScriptEditor, { documentId: state.documentId, value: state.draft.source, disabled: state.busy,
421
+ label: t('bindings.source'), onChange: value => edit('source', value) })
422
+ : state.sourceOpen && typeof CodeBlock === 'function'
423
+ ? h(CodeBlock, { code: state.draft.source, lang: 'typescript', className: 'ptcPlusSourceCode',
424
+ copyLabel: t('tool.copy'), copiedLabel: t('tool.copied') })
425
+ : state.sourceOpen ? h('pre', { className: 'ptcPlusSourceCode' }, state.draft.source) : null)),
426
+ h('div', { className: 'ptcPlusWorkbenchFeedback', role: 'status' },
427
+ state.busy ? t('bindings.working')
428
+ : state.message === null ? '' : t(state.message.key, state.message.params)),
429
+ h(BindingConsole, { key: state.documentId, entryId: `workbench:${state.documentId}`,
430
+ source: state.draft.source, resetVersion: state.consoleVersion,
431
+ callUserBindings: controller.callUserBindings, t }),
432
+ h('details', { className: 'ptcPlusBindingSection ptcPlusEntrySettings', open: state.metadataOpen,
433
+ onToggle: event => patch({ metadataOpen: event.currentTarget.open }) },
434
+ h('summary', { className: 'ptcPlusBindingSectionTitle' }, t('bindings.entry')),
435
+ h('div', { className: 'ptcPlusBindingFields' },
436
+ h('label', { className: 'ptcPlusBindingField' },
437
+ h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.id')),
438
+ h('input', {
439
+ className: 'ptcPlusInput', value: state.draft.id,
440
+ // A stored entry is addressed by this id; only a new draft may choose it.
441
+ disabled: state.busy || !state.editing || state.original !== null,
442
+ onChange: event => edit('id', event.target.value),
443
+ })),
444
+ h('label', { className: 'ptcPlusBindingField' },
445
+ h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.name')),
446
+ h('input', {
447
+ className: 'ptcPlusInput', value: state.draft.name, disabled: state.busy || !state.editing,
448
+ onChange: event => edit('name', event.target.value),
449
+ })),
450
+ h('label', { className: 'ptcPlusBindingField' },
451
+ h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.scope')),
452
+ h('select', {
453
+ className: 'ptcPlusSelect', value: state.draft.scope, disabled: state.busy || !state.editing,
454
+ onChange: event => edit('scope', event.target.value),
455
+ }, h('option', { value: 'namespace' }, 'namespace'), h('option', { value: 'top-level' }, 'top-level'))),
456
+ h('label', { className: 'ptcPlusBindingField' },
457
+ h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.symbols')),
458
+ h('input', {
459
+ className: 'ptcPlusInput', value: state.draft.symbolsText, disabled: state.busy || !state.editing,
460
+ placeholder: t('bindings.symbolsPlaceholder'),
461
+ onChange: event => edit('symbolsText', event.target.value),
462
+ })),
463
+ h('label', { className: 'ptcPlusBindingField', 'data-wide': true },
464
+ h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.purpose')),
465
+ h('input', {
466
+ className: 'ptcPlusInput', value: state.draft.purpose, disabled: state.busy || !state.editing,
467
+ onChange: event => edit('purpose', event.target.value),
468
+ }))))),
469
+ state.message === null || state.draft !== null
470
+ ? null
471
+ : h('p', { className: 'ptcPlusMessage', role: 'status' }, t(state.message.key, state.message.params))))
472
+ }
473
+
474
+ function canReturnFocus(element) {
475
+ return element instanceof HTMLElement && element.isConnected
476
+ && element !== document.body && (element.tabIndex >= 0 || element.isContentEditable)
477
+ && !element.matches(':disabled, [aria-disabled="true"]')
478
+ && !element.closest('[hidden], [inert], [aria-hidden="true"]')
479
+ && element.getClientRects().length > 0 && getComputedStyle(element).visibility === 'visible'
480
+ }
481
+
482
+ function BindingsDialog({ controller, t, onClose, returnFocusRef, getReturnFocus }) {
483
+ const content = React.useRef(null)
484
+ const localReturnFocus = React.useRef(null)
485
+ const returnFocus = returnFocusRef ?? localReturnFocus
486
+ React.useEffect(() => {
487
+ const previous = document.activeElement
488
+ // A resumed mount may have no focused control; keep the last valid entry.
489
+ if (!content.current.contains(previous) && canReturnFocus(previous)) returnFocus.current = previous
490
+ content.current.querySelector('button')?.focus()
491
+ }, [returnFocus])
492
+ const close = () => {
493
+ const target = canReturnFocus(returnFocus.current) ? returnFocus.current : getReturnFocus?.()
494
+ returnFocus.current = null
495
+ onClose()
496
+ // Only explicit closure restores focus. Temporary hiding leaves Host focus alone.
497
+ if (canReturnFocus(target)) target.focus({ preventScroll: true })
498
+ }
499
+ const trapFocus = event => {
500
+ if (event.key !== 'Tab') return
501
+ const controls = [...content.current.querySelectorAll('button:not(:disabled),input:not(:disabled),textarea:not(:disabled),select:not(:disabled),[contenteditable=true],summary')]
502
+ .filter(element => element.getClientRects().length > 0)
503
+ const first = controls[0]
504
+ const last = controls.at(-1)
505
+ if ((event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) {
506
+ event.preventDefault()
507
+ ;(event.shiftKey ? last : first)?.focus()
508
+ }
509
+ }
510
+ return h(Modal, { open: true, onClose: close, title: t('bindings.title'), headless: true, className: 'ptcPlusBindingsModal' },
511
+ h('div', { className: 'ptcPlusBindingsDialog', ref: content, onKeyDown: trapFocus },
512
+ h('div', { className: 'ptcPlusBindingsDialogHead' },
513
+ h('h2', null, t('bindings.title')),
514
+ h('button', { type: 'button', className: 'ptcPlusDialogClose', onClick: close,
515
+ 'aria-label': t('bindings.close'), title: t('bindings.close') }, h(icons.close, { size: 16 }))),
516
+ h('div', { className: 'ptcPlusBindingsSurface' },
517
+ h(UserBindingsWorkbench, { controller, t, heading: false }))))
518
+ }
519
+
520
+ return { useWorkbenchController, UserBindingsWorkbench, BindingsDialog }
521
+ }