dsh-ptc-plus 0.5.0 → 0.5.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 (74) hide show
  1. package/README.en.md +15 -3
  2. package/README.md +15 -3
  3. package/client.cjs +1983 -234
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +2 -2
  8. package/docs/adr/0023-global-user-bindings.md +1 -1
  9. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  10. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  11. package/docs/adr/0038-freeze-session-capability-plans.md +1 -1
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +35 -0
  13. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  14. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  15. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  16. package/docs/adr/README.md +2 -0
  17. package/docs/architecture.md +2 -2
  18. package/docs/capability-planning-components.md +2 -2
  19. package/docs/client-ui.md +13 -10
  20. package/docs/client-visual-contract.md +1 -1
  21. package/docs/durability-design.md +7 -1
  22. package/docs/installation.md +5 -3
  23. package/docs/publishing.md +2 -2
  24. package/docs/releases/v0.5.2.md +79 -0
  25. package/docs/runtime-reference.md +42 -3
  26. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  27. package/docs/verification.md +6 -2
  28. package/index.js +63 -19
  29. package/internal/bounded-literal.js +85 -0
  30. package/internal/capability-command-receipt.js +3 -1
  31. package/internal/capability-environment.js +171 -13
  32. package/internal/capability-plan.js +11 -8
  33. package/internal/capability-statistics.js +6 -2
  34. package/internal/cell-error.js +13 -0
  35. package/internal/config-spec.js +122 -2
  36. package/internal/direct-surface-owner.js +14 -10
  37. package/internal/edit-transport-owner.js +96 -17
  38. package/internal/execution-provider-owner.js +25 -0
  39. package/internal/execution-seam-compat.js +18 -0
  40. package/internal/isolated-worker.js +134 -17
  41. package/internal/kernel-worker.js +167 -3
  42. package/internal/legacy-cell-analysis.js +14 -2
  43. package/internal/legacy-repl-convenience.js +16 -3
  44. package/internal/official-repl-priority.js +51 -0
  45. package/internal/projection-retention.js +238 -0
  46. package/internal/prompt-text.js +858 -0
  47. package/internal/recovery-tips.js +50 -11
  48. package/internal/rejected-cell-editor.js +3 -2
  49. package/internal/runtime-bridge-owner.js +11 -8
  50. package/internal/runtime-config.js +2 -0
  51. package/internal/runtime-contexts.js +14 -5
  52. package/internal/runtime-messages.js +43 -15
  53. package/internal/sdk-projection.js +2 -2
  54. package/internal/session-cell-executor.js +132 -11
  55. package/internal/session-journal-recovery.js +1009 -99
  56. package/internal/session-journal-schema.js +7 -5
  57. package/internal/session-journal.js +177 -14
  58. package/internal/session-log-view.js +111 -24
  59. package/internal/session-runtime.js +674 -33
  60. package/internal/session-state.js +27 -0
  61. package/internal/stateful-root-compiler.js +11 -0
  62. package/internal/stateful-root-runtime.js +17 -0
  63. package/internal/user-binding-console.js +7 -0
  64. package/internal/user-bindings-owner.js +8 -27
  65. package/internal/user-bindings.js +2 -2
  66. package/internal/validated-parse-repair.js +6 -1
  67. package/internal/worker-client.js +27 -4
  68. package/package.json +5 -2
  69. package/src/client-activity.js +27 -6
  70. package/src/client-capabilities-view.js +774 -125
  71. package/src/client-copy.js +231 -18
  72. package/src/client-repl-view.js +6 -1
  73. package/src/client-styles.js +68 -2
  74. package/src/client-tool-view.js +9 -2
@@ -4,6 +4,10 @@ import { CAPABILITY_CONFIGURATION_KEYS, capabilityProfileConfiguration, validate
4
4
  import { readCapabilityCommandReceipt } from '../internal/capability-command-receipt.js'
5
5
  import { createFallbackModal } from './client-primitives.js'
6
6
  import { CAPABILITY_SORT_COLUMNS, capabilitySortDirection, cycleCapabilitySort, sortCapabilityEntries } from './client-capability-sort.js'
7
+ import {
8
+ PROMPT_ASSETS, PROMPT_TEXT_MAX_SLOT_BYTES, PROMPT_TEXT_SCHEMA,
9
+ accountPromptText, parsePromptTextDocument, resolvePromptText,
10
+ } from '../internal/prompt-text.js'
7
11
 
8
12
  export function capabilityCommandText(t, text) {
9
13
  if (text?.key) return t(text.key, text.params)
@@ -11,6 +15,20 @@ export function capabilityCommandText(t, text) {
11
15
  return receipt ? t(`capabilities.receipt.${receipt.state}`, { session: receipt.sessionId, error: receipt.error ?? '' }) : text
12
16
  }
13
17
 
18
+ // Host-owned context the model sees in PTC mode. The plugin never receives the host's
19
+ // own instruction text and cannot edit these bytes; it can only name the section it
20
+ // appends into or replaces. Listing them keeps the pane answering "what does the model
21
+ // actually see" instead of silently showing plugin-owned anchors only.
22
+ const HOST_CONTEXT_SECTIONS = Object.freeze([
23
+ Object.freeze({ id: 'host:system-prompt', presence: 'capabilities.prompt.hostPresenceEvery' }),
24
+ Object.freeze({ id: 'host:tools:sdk', presence: 'capabilities.prompt.hostPresenceCapabilities' }),
25
+ ])
26
+ // The prompt table is one column model shared by the colgroup, the header and every
27
+ // data row. Slot chips do not get a column: they render in their own full-width
28
+ // sub-row, because a narrow chip column stacked a five-slot anchor into a tall stack.
29
+ const PROMPT_TABLE_COLUMN_KEYS = Object.freeze(['channel', 'anchor', 'presence', 'bytes', 'reason', 'kind'])
30
+ const PROMPT_TABLE_COLUMNS = PROMPT_TABLE_COLUMN_KEYS.length
31
+
14
32
  function capabilityChoiceLabel(t, value) {
15
33
  return t(value === 'inherit' ? 'capabilities.quick.noOverride'
16
34
  : ['enabled', 'pending', 'unknown'].includes(value) ? `capabilities.quick.${value}`
@@ -30,13 +48,66 @@ function validCatalogRule(rule, temporary = false) {
30
48
  ? ['enabled', 'closed'].includes(choice) : CAPABILITY_DISCLOSURES.includes(choice))))
31
49
  }
32
50
 
51
+ /** A Chat snapshot without a node store cannot order a receipt against the conversation. */
52
+ const noSnapshot = () => undefined
53
+
54
+ /**
55
+ * The first human message is the conversation's opening position. DSH may reuse
56
+ * a blank Session, so capability commands recorded before that message are
57
+ * history from an earlier attempt, not part of this conversation; they must not
58
+ * appear above the user's first sentence.
59
+ */
60
+ export function firstHumanMessageSeq(nodes) {
61
+ if (nodes === null || nodes === undefined || typeof nodes.values !== 'function') return null
62
+ let first = null
63
+ for (const node of nodes.values()) {
64
+ if (node?.kind !== 'user' || node.data?.source?.kind !== 'user') continue
65
+ const seq = node.anchorSeq ?? node.data?.seq
66
+ if (!Number.isSafeInteger(seq)) continue
67
+ if (first === null || seq < first) first = seq
68
+ }
69
+ return first
70
+ }
71
+
72
+ /**
73
+ * A capability receipt belongs to chat only after the conversation has opened.
74
+ * A blank Session has nothing to announce, and a receipt recorded before the
75
+ * first human message describes a previous attempt's publication. A truncated
76
+ * Chat window cannot prove where the conversation opened, so its earliest
77
+ * loaded message must not hide a receipt the session may still own.
78
+ */
79
+ export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true } = {}) {
80
+ if (blank === true) return false
81
+ if (windowComplete !== true) return true
82
+ return !(Number.isSafeInteger(node?.seq) && Number.isSafeInteger(firstUserSeq) && node.seq < firstUserSeq)
83
+ }
84
+
33
85
  export function createCapabilityCommandCard(React) {
34
86
  const h = React.createElement
35
- return function CapabilityCommandCard({ node, t }) {
87
+ return function CapabilityCommandCard({ node, t, useChat, useSession }) {
36
88
  const [showSource, setShowSource] = React.useState(false)
89
+ // Both hooks are always called so the seat keeps one hook order whether or
90
+ // not the surrounding Chat or Session supplies its store.
91
+ const readSession = typeof useSession === 'function' ? useSession : noSnapshot
92
+ const blank = readSession(snapshot => snapshot?.blank === true) === true
93
+ // `hasMore` marks a window that does not reach the session start; only a
94
+ // complete window may claim its earliest human message is the first one.
95
+ const windowComplete = readSession(snapshot => snapshot?.hasMore !== true)
96
+ const readChat = typeof useChat === 'function' ? useChat : noSnapshot
97
+ const firstUserSeq = readChat(snapshot => firstHumanMessageSeq(snapshot?.nodes))
98
+ // A capability notice is the outcome of a `/capabilities` command the user
99
+ // submitted. What a new session publishes is a model-visible runtime context,
100
+ // not chat, so a node without that submitted input is not a user command and
101
+ // renders no notice: a fresh session has no change to announce.
102
+ if (typeof node.args !== 'string') return null
103
+ if (!capabilityReceiptVisible(node, { blank, firstUserSeq, windowComplete })) return null
37
104
  const command = `/${node.name ?? 'capabilities'}${node.args ? ` ${String(node.args).trim()}` : ''}`
38
105
  const text = node.outcome?.text
39
106
  const receipt = readCapabilityCommandReceipt(text)
107
+ // Pending is the plan's publication state, not an outcome: it becomes true
108
+ // only at the next accepted model step, so a durable notice would state a
109
+ // future that a historical row can no longer verify.
110
+ if (receipt?.state === 'pending') return null
40
111
  const legacyRefresh = text === 'Session capability environment refreshed; the model-visible notification is recorded.'
41
112
  if (receipt || String(node.args ?? '').trim() === 'refresh' || legacyRefresh) {
42
113
  const outcome = receipt ? t(`capabilities.card.${receipt.state}`, { error: receipt.error ?? '' })
@@ -128,12 +199,13 @@ export function capabilityPlanReason(t, entry) {
128
199
  export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal = createFallbackModal(React) }) {
129
200
  const h = React.createElement
130
201
  const field = key => CONFIG_FIELDS.find(item => item.key === key)
131
- const responsibilityGroups = [
132
- { tab: 'manual', keys: ['rules'] },
133
- { tab: 'budgets', keys: ['description', 'recovery', 'preview'] },
134
- { tab: 'statistics', keys: ['statistics'] },
135
- ]
136
- const responsibilities = responsibilityGroups.flatMap(group => group.keys)
202
+ // Each group owns one settings tab; the prompt text switch lives in the same
203
+ // capabilities section, so 新会话设置 must list it or a saved choice would be
204
+ // invisible there.
205
+ // 新会话设置 is one row of equally sized choices. Groups only existed to give each
206
+ // tab a caption; that produced a second row as soon as 提示文本 was added, so the
207
+ // choices stay flat and the navigation target is decided by `onNavigate` instead.
208
+ const responsibilities = ['rules', 'description', 'recovery', 'preview', 'statistics', 'promptText']
137
209
  const choicesFor = (configuration, plan) => {
138
210
  const historical = plan && ['ptc-capability-plan/v1', 'ptc-capability-plan/v3'].includes(plan.schema)
139
211
  && configuration && configuration.capabilityPolicy === undefined
@@ -143,10 +215,13 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
143
215
  enabled: configuration?.capabilityManagementEnabled ?? plan?.enabled,
144
216
  statistics,
145
217
  recoveryEnabled: configuration?.capabilityRecoveryEnabled ?? (historical ? plan.enabled : undefined),
146
- previewEnabled: configuration?.modelPreviewEnabled ?? (historical ? plan.enabled : undefined) }
218
+ previewEnabled: configuration?.modelPreviewEnabled ?? (historical ? plan.enabled : undefined),
219
+ // The switch is new: a stored configuration without it means "on", the
220
+ // documented default, while an unreadable configuration stays unknown.
221
+ promptTextEnabled: configuration === undefined ? undefined : configuration.capabilityPromptTextEnabled !== false }
147
222
  }
148
223
  const flagsFor = settings => ({ rules: settings.enabled, description: settings.enabled, statistics: settings.statistics?.enabled,
149
- recovery: settings.recoveryEnabled, preview: settings.previewEnabled })
224
+ recovery: settings.recoveryEnabled, preview: settings.previewEnabled, promptText: settings.promptTextEnabled })
150
225
  const choiceState = (retained, applied, key) => typeof retained[key] !== 'boolean' ? 'unknown'
151
226
  : retained[key] ? typeof applied[key] !== 'boolean' ? 'unknown' : applied[key] === false ? 'paused' : 'selected'
152
227
  : key === 'rules' || key === 'description' ? 'host' : 'unselected'
@@ -176,17 +251,16 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
176
251
  return h('section', { className: 'ptcPlusCombination', 'aria-label': t('capabilities.combination.saved') },
177
252
  h('div', { className: 'ptcPlusTaskHead' }, h('h4', null, t('capabilities.combination.saved')),
178
253
  h('span', { className: 'ptcPlusCapabilitySaveState', role: 'status' }, feedback)),
179
- h('div', { className: 'ptcPlusCombinationChoices' }, ...responsibilityGroups.map(group =>
180
- h('div', { key: group.tab, className: 'ptcPlusCombinationGroup', role: 'group',
181
- 'aria-label': t(`capabilities.tab.${group.tab}`) },
182
- h('strong', { className: 'ptcPlusCombinationGroupTitle' }, t(`capabilities.tab.${group.tab}`)),
183
- h('div', { className: 'ptcPlusCombinationGroupChoices' }, ...group.keys.map(key => {
184
- const state = choiceState(retained, applied, key)
185
- return h('button', { key, type: 'button', className: 'ptcPlusCombinationChoice', 'data-state': state,
186
- 'aria-controls': key === 'rules' || key === 'statistics' ? `${id}-pane-${group.tab}` : `${id}-context-pane-${key}`,
187
- onClick: () => onNavigate(key) }, h('span', null, t(`capabilities.combination.${key}`)),
188
- h('small', null, t(`capabilities.combination.state.${state}`)))
189
- }))))),
254
+ h('div', { className: 'ptcPlusCombinationChoices' }, ...responsibilities.map(key => {
255
+ const state = choiceState(retained, applied, key)
256
+ return h('button', { key, type: 'button', className: 'ptcPlusCombinationChoice', 'data-state': state,
257
+ 'data-choice': key,
258
+ 'aria-controls': key === 'promptText' ? `${id}-pane-prompt`
259
+ : key === 'rules' || key === 'statistics' ? `${id}-pane-${key === 'rules' ? 'manual' : 'statistics'}`
260
+ : `${id}-context-pane-${key}`,
261
+ onClick: () => onNavigate(key) }, h('span', null, t(`capabilities.combination.${key}`)),
262
+ h('small', null, t(`capabilities.combination.state.${state}`)))
263
+ })),
190
264
  h('p', { className: 'ptcPlusDetail', role: 'status' }, effect),
191
265
  h('details', { className: 'ptcPlusCapabilitySessionTools' }, h('summary', { tabIndex: 0 }, t('capabilities.existingSessions')),
192
266
  renderRefresh?.(),
@@ -286,7 +360,6 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
286
360
  'aria-label': t('capabilities.batch.cancel'), onClick: close }, '×')),
287
361
  h('div', { className: 'ptcPlusCapabilityBatchBody' },
288
362
  h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.targets', { count: operation.targets.length })),
289
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.scope', { scope: operation.scope })),
290
363
  operation.excluded ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.excluded', { count: operation.excluded })) : null,
291
364
  operation.targets.length ? h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
292
365
  ...operation.targets.map(target => h('li', { key: target.id }, h('span', null, target.name), h('code', null, target.id))))
@@ -298,7 +371,8 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
298
371
  options: ['keep', 'automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
299
372
  .map(choice => ({ value: choice, label: t(choice === 'keep' ? 'capabilities.batch.keep'
300
373
  : choice === 'automatic' ? 'capabilities.policy.inheritRule'
301
- : choice === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${choice}`) })) })))),
374
+ : choice === 'enabled' ? 'capabilities.action.enable'
375
+ : choice === 'closed' ? 'capabilities.action.close' : `capabilities.${choice}`) })) })))),
302
376
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.effect')),
303
377
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sourceClosure')),
304
378
  missing ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.changedTargets', { count: missing })) : null,
@@ -321,6 +395,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
321
395
  const [availability, setAvailability] = React.useState('all')
322
396
  const [sort, setSort] = React.useState(null)
323
397
  const [selected, setSelected] = React.useState(null)
398
+ const [picked, setPicked] = React.useState([])
324
399
  const [filtersOpen, setFiltersOpen] = React.useState(false)
325
400
  const [batchOperation, setBatchOperation] = React.useState(null)
326
401
  const [inheritedRulesOpen, setInheritedRulesOpen] = React.useState(false)
@@ -379,6 +454,15 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
379
454
  : column === 'source' ? displayedSource(item)
380
455
  : column === 'availability' ? t(`capabilities.rowState.${state(item)}`)
381
456
  : t(`capabilities.${item.resolvedRule.disclosure}`))
457
+ const pickedIds = picked.filter(id => entries.some(item => item.id === id))
458
+ const pickedEntries = pickedIds.map(id => entries.find(item => item.id === id)).filter(Boolean)
459
+ const pickedTargets = pickedEntries.filter(item => item.availability === 'available')
460
+ const allVisiblePicked = visible.length > 0 && visible.every(item => pickedIds.includes(item.id))
461
+ const someVisiblePicked = visible.some(item => pickedIds.includes(item.id))
462
+ const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
463
+ const toggleAllVisible = () => setPicked(current => allVisiblePicked
464
+ ? current.filter(id => !visible.some(item => item.id === id))
465
+ : [...new Set([...current, ...visible.map(item => item.id)])])
382
466
  const entry = visible.find(item => item.id === selected)
383
467
  const plan = sessionId ? plans?.find(item => item.sessionId === sessionId) : undefined
384
468
  const effective = plan?.effective.entries.find(item => item.id === entry?.id)
@@ -430,11 +514,15 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
430
514
  h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
431
515
  onClick: () => setFiltersOpen(true) }, t('capabilities.filterAction')),
432
516
  h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
433
- disabled: disabled('capabilityPolicy'), onClick: () => setBatchOperation({
434
- targets: visible.filter(item => item.availability === 'available').map(item => ({ id: item.id, name: item.name })),
435
- excluded: visible.filter(item => item.availability !== 'available').length,
436
- scope: [...filterLabels, ...(query.trim() ? [t('capabilities.batch.search', { query: query.trim() })] : [])].join(' · ') || t('capabilities.batch.all'),
437
- }) }, t('capabilities.batchSettings')),
517
+ 'aria-label': t('capabilities.batchSettings'),
518
+ disabled: disabled('capabilityPolicy') || pickedTargets.length === 0,
519
+ onClick: () => setBatchOperation({
520
+ targets: pickedTargets.map(item => ({ id: item.id, name: item.name })),
521
+ excluded: pickedIds.length - pickedTargets.length,
522
+ }) }, t('capabilities.batchSettings'),
523
+ pickedIds.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
524
+ title: t('capabilities.selectionCount', { count: pickedIds.length }) },
525
+ String(pickedIds.length)) : null),
438
526
  h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
439
527
  onClick: () => setInheritedRulesOpen(true) }, t('capabilities.inheritedRules'))),
440
528
  filtersOpen ? h(CapabilityDialog, { title: t('capabilities.filterAction'), onClose: () => setFiltersOpen(false), t,
@@ -468,7 +556,12 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
468
556
  h('div', { className: 'ptcPlusCapabilityTable', role: 'table', 'aria-label': t('capabilities.management') },
469
557
  h('div', { className: 'ptcPlusCapabilityColumns', role: 'row' },
470
558
  ...CAPABILITY_SORT_COLUMNS.map(column => h('span', { key: column.key, role: 'columnheader',
471
- 'aria-sort': capabilitySortDirection(sort, column.key) },
559
+ 'aria-sort': capabilitySortDirection(sort, column.key),
560
+ ...(column.key === 'name' ? { 'aria-label': t('capabilities.member'), className: 'ptcPlusCapabilityPickHead' } : {}) },
561
+ ...(column.key === 'name' ? [h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: allVisiblePicked,
562
+ ref: node => { if (node) node.indeterminate = someVisiblePicked && !allVisiblePicked },
563
+ disabled: visible.length === 0, 'aria-label': t('capabilities.selectAllVisible'),
564
+ onChange: toggleAllVisible })] : []),
472
565
  h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
473
566
  onClick: () => setSort(current => cycleCapabilitySort(current, column.key)) },
474
567
  t(`capabilities.${column.label}`), h('span', { 'aria-hidden': true },
@@ -476,7 +569,12 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
476
569
  h('div', { className: 'ptcPlusCapabilityList', role: 'rowgroup' }, ...visible.map(item => h('div', { key: item.id, className: 'ptcPlusCapabilityItem', role: 'row',
477
570
  'data-selected': entry?.id === item.id, 'data-availability': item.availability,
478
571
  onClick: () => setSelected(item.id) },
479
- h('div', { className: 'ptcPlusCapabilityIdentity', role: 'cell' }, h('span', { className: 'ptcPlusCapabilityName' }, item.name), h('code', null, item.id)),
572
+ h('div', { className: 'ptcPlusCapabilityIdentity', role: 'cell' },
573
+ h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: pickedIds.includes(item.id),
574
+ 'aria-label': t('capabilities.selectForBatch', { name: item.name ?? item.id }),
575
+ onClick: event => event.stopPropagation(), onChange: () => togglePick(item.id) }),
576
+ h('div', { className: 'ptcPlusCapabilityIdentityText' },
577
+ h('span', { className: 'ptcPlusCapabilityName' }, item.name), h('code', null, item.id))),
480
578
  h('span', { className: 'ptcPlusCapabilityBadge', role: 'cell', 'data-kind': type(item) }, t(`capabilities.type.${type(item)}`)),
481
579
  h('span', { className: 'ptcPlusCapabilitySource', role: 'cell' }, displayedSource(item)),
482
580
  // The cell stays a plain cell so its empty area selects the row; only the switch and
@@ -865,17 +963,375 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
865
963
  event.currentTarget.parentElement.querySelectorAll('[role=tab]')[next].focus({ preventScroll: true })
866
964
  }
867
965
 
966
+ /**
967
+ * Per-asset editor for the fixed prompt text the plugin writes.
968
+ *
969
+ * One row per registered asset: the built-in sentence stays visible as the
970
+ * placeholder, so a user compares against it instead of a blank box. The
971
+ * document is written through the normal settings path, which validates it;
972
+ * a document the panel cannot read is reported here instead of blanking the
973
+ * whole workbench.
974
+ */
975
+ // Registry codes are protocol identifiers. The pane shows a localized label and
976
+ // keeps the raw code in the element title, so a Chinese UI never reads like a
977
+ // dump of `fact-derived` / `protocol-exact-text` constants.
978
+ const PROMPT_REASON_KEYS = Object.freeze({
979
+ 'fact-derived': 'factDerived',
980
+ 'aggregate-frozen': 'aggregateFrozen',
981
+ 'protocol-exact-text': 'protocolExactText',
982
+ 'protocol-structural': 'protocolStructural',
983
+ 'shadows-host': 'shadowsHost',
984
+ 'other-owner': 'otherOwner',
985
+ })
986
+
987
+ function promptReasonLabel(t, reason) {
988
+ const key = PROMPT_REASON_KEYS[reason]
989
+ return key === undefined ? reason : t(`capabilities.prompt.reason.${key}`)
990
+ }
991
+
992
+ function promptKindLabel(t, kind) {
993
+ return kind === undefined ? '' : t(`capabilities.prompt.kind.${kind}`)
994
+ }
995
+
996
+ function PromptTextSettings({ value, disabled, onChange, t, id }) {
997
+ let document
998
+ let readError
999
+ try {
1000
+ document = parsePromptTextDocument(value.promptText)
1001
+ } catch (error) {
1002
+ readError = error.message
1003
+ document = { schema: PROMPT_TEXT_SCHEMA, overrides: {} }
1004
+ }
1005
+ // The workbench receives `disabled` as a per-field predicate, not a boolean.
1006
+ // The feature's own pause switch is separate from the settings lock: off means
1007
+ // every channel falls back to the built-in text while the saved document stays.
1008
+ const paused = value.capabilityPromptTextEnabled === false
1009
+ const blocked = paused || disabled === true
1010
+ || (typeof disabled === 'function' && disabled('promptText') === true)
1011
+ const resolved = resolvePromptText(value.promptText)
1012
+ const account = accountPromptText(resolved)
1013
+ // The pane stages edits in a draft and writes the document once, so the fields
1014
+ // and the preview always describe the same pending bytes. 保存 writes the draft;
1015
+ // 取消 drops it; a saved document still differs from the built-ins, so reopening
1016
+ // the pane shows that difference without a draft.
1017
+ const [draft, setDraft] = React.useState(null)
1018
+ const [selectedAnchorId, setSelectedAnchorId] = React.useState(null)
1019
+ const anchorPanelRef = React.useRef(null)
1020
+ const [openSlots, setOpenSlots] = React.useState({})
1021
+ const overrides = draft ?? document.overrides
1022
+ const byteCount = text => new TextEncoder().encode(text).length
1023
+ const group = number => String(Math.round(number)).replace(/\B(?=(\d{3})+(?!\d))/gu, ' ')
1024
+ const bytePair = bytes => t('capabilities.prompt.bytesPair', {
1025
+ bytes: group(bytes), kb: (bytes / 1024).toFixed(1),
1026
+ })
1027
+ const write = next => onChange(CONFIG_FIELDS.find(field => field.key === 'promptText'),
1028
+ JSON.stringify({ schema: PROMPT_TEXT_SCHEMA, overrides: next }))
1029
+ const update = (slotId, next) => {
1030
+ const base = { ...overrides }
1031
+ if (next === undefined) delete base[slotId]
1032
+ else base[slotId] = next
1033
+ setDraft(base)
1034
+ }
1035
+ const save = () => {
1036
+ if (draft === null) return
1037
+ write(draft)
1038
+ setDraft(null)
1039
+ }
1040
+ const cancel = () => setDraft(null)
1041
+ const pending = draft !== null
1042
+ const unmeasuredSlots = account.counts.unmeasuredSlots
1043
+ const dynamicSlots = account.counts.dynamicSlots
1044
+ const unwiredAnchors = account.anchors.filter(anchor => anchor.wired !== true)
1045
+ const measuredBytes = account.owners.A + account.owners.B + account.owners.C
1046
+ const ownerOf = anchor => (anchor.owner === 'mixed' ? 'mixed' : anchor.owner)
1047
+ const ownerAnchors = key => account.anchors.filter(anchor => ownerOf(anchor) === key).length
1048
+ // Icons are monochrome inline SVG: the mockup's cards carry a glyph, and an
1049
+ // icon font or an emoji would bring its own colour into the settings surface.
1050
+ const icon = path => h('svg', {
1051
+ viewBox: '0 0 20 20', width: 16, height: 16, fill: 'none', stroke: 'currentColor',
1052
+ 'stroke-width': 1.6, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
1053
+ }, ...path.map((d, index) => h('path', { key: index, d })))
1054
+ const card = (key, labelKey, glyph) => {
1055
+ const mixed = key === 'mixed'
1056
+ const bytes = account.owners[key] ?? 0
1057
+ const value_ = mixed
1058
+ ? t('capabilities.prompt.cardMixed', { count: ownerAnchors('mixed') })
1059
+ : bytes > 0
1060
+ ? `${bytePair(bytes)} · ${t('capabilities.prompt.cardMetaAnchors', { count: ownerAnchors(key) })}`
1061
+ : t('capabilities.prompt.cardMetaAnchors', { count: ownerAnchors(key) })
1062
+ return h('div', { key, className: 'ptcPlusPromptCard', 'data-owner': key },
1063
+ h('span', { className: 'ptcPlusPromptCardIcon' }, glyph),
1064
+ h('span', { className: 'ptcPlusPromptCardBody' },
1065
+ h('span', { className: 'ptcPlusPromptCardLabel' }, t(labelKey)),
1066
+ mixed
1067
+ ? h('span', { className: 'ptcPlusPromptCardMeta' }, value_)
1068
+ : h('span', { className: 'ptcPlusPromptCardValue', 'data-owner-bytes': String(account.owners[key] ?? 0) }, value_)))
1069
+ }
1070
+ const cards = h('div', { className: 'ptcPlusPromptCards' },
1071
+ card('A', 'capabilities.prompt.ownerEditable', icon(['M4 16h3l9-9-3-3-9 9v3Z', 'M12.5 4.5 15.5 7.5'])),
1072
+ card('B', 'capabilities.prompt.ownerReadOnly', icon(['M5.5 9V6.5a4.5 4.5 0 0 1 9 0V9', 'M4.5 9h11v7h-11Z'])),
1073
+ card('C', 'capabilities.prompt.ownerOther', icon(['M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z', 'M4 17c0-3 2.7-5 6-5s6 2 6 5'])),
1074
+ card('mixed', 'capabilities.prompt.ownerMixed', icon(['M4 6h12', 'M4 10h12', 'M4 14h12', 'M13 4v4', 'M7 8v4'])))
1075
+ const strip = h('p', { className: 'ptcPlusPromptStrip', 'data-prompt-strip': String(unwiredAnchors.length) },
1076
+ t('capabilities.prompt.strip', {
1077
+ anchors: unwiredAnchors.length, bytes: group(measuredBytes),
1078
+ kb: (measuredBytes / 1024).toFixed(1), total: account.counts.total,
1079
+ }))
1080
+ const reasonsOf = anchor => {
1081
+ const reasons = [...new Set(anchor.slots.map(slot => slot.reason).filter(Boolean))]
1082
+ return reasons.length === 0 ? '—' : reasons.map(reason => promptReasonLabel(t, reason)).join(' + ')
1083
+ }
1084
+ const kindsOf = anchor => [...new Set(anchor.slots.map(slot => slot.kind))]
1085
+ .map(kind => promptKindLabel(t, kind)).join(' + ')
1086
+ const splitOf = anchor => {
1087
+ const measured = anchor.slots.filter(slot => slot.measured === true && slot.bytes > 0)
1088
+ return measured.length > 1
1089
+ ? t('capabilities.prompt.anchorBytesSplit', {
1090
+ parts: measured.map(slot => `${slot.slotId.split('.').pop()} ${slot.bytes}`).join(' / '),
1091
+ })
1092
+ : null
1093
+ }
1094
+ const select = anchorId => setSelectedAnchorId(anchorId)
1095
+ // The expanded panel sits below the table, so on a short viewport selecting a
1096
+ // lower row would otherwise leave the editor it opened off-screen.
1097
+ React.useEffect(() => {
1098
+ if (selectedAnchorId === null) return
1099
+ const node = anchorPanelRef.current
1100
+ if (node === null || typeof node.scrollIntoView !== 'function') return
1101
+ node.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
1102
+ }, [selectedAnchorId])
1103
+ const rowKey = (event, anchorId) => {
1104
+ if (event.key !== 'Enter' && event.key !== ' ') return
1105
+ event.preventDefault()
1106
+ select(anchorId)
1107
+ }
1108
+ const anchorRow = (anchor, index) => {
1109
+ const split = splitOf(anchor)
1110
+ return h('tr', {
1111
+ key: anchor.anchorId,
1112
+ className: 'ptcPlusPromptRow',
1113
+ 'data-anchor-id': anchor.anchorId,
1114
+ 'data-wired': anchor.wired === true ? 'true' : 'false',
1115
+ // A plain <table> row cannot carry aria-selected (that needs role="grid"),
1116
+ // so the expanded row is announced with aria-current instead.
1117
+ 'data-selected': selectedAnchorId === anchor.anchorId ? 'true' : 'false',
1118
+ 'aria-current': selectedAnchorId === anchor.anchorId ? 'true' : undefined,
1119
+ tabIndex: 0,
1120
+ onClick: () => select(anchor.anchorId),
1121
+ onKeyDown: event => rowKey(event, anchor.anchorId),
1122
+ },
1123
+ h('td', { className: 'ptcPlusPromptIndex' }, String(index)),
1124
+ h('td', null, h('span', { className: 'ptcPlusPromptChannel', 'data-channel': anchor.channel }, anchor.channel)),
1125
+ h('td', null, h('code', { className: 'ptcPlusPromptAnchorId', title: anchor.anchorId }, anchor.anchorId)),
1126
+ h('td', null, h('span', { className: 'ptcPlusPromptPresence', title: t(anchor.presence) }, t(anchor.presence))),
1127
+ h('td', { className: 'ptcPlusPromptBytesCell' },
1128
+ anchor.wired !== true
1129
+ ? h('span', { 'data-anchor-bytes': 'unwired' }, t('capabilities.prompt.unwiredRow', { slots: anchor.unmeasuredSlots }))
1130
+ : h('span', { 'data-anchor-bytes': String(anchor.bytes) },
1131
+ paused ? t('capabilities.prompt.pausedBytes', { bytes: anchor.bytes }) : t('capabilities.prompt.cardBytes', { bytes: anchor.bytes }),
1132
+ split === null ? null : h('small', { className: 'ptcPlusPromptSplit', 'data-anchor-split': 'true' }, split))),
1133
+ h('td', { className: 'ptcPlusPromptReasonCell' }, reasonsOf(anchor)),
1134
+ h('td', { className: 'ptcPlusPromptKindCell' }, kindsOf(anchor),
1135
+ anchor.wired !== true ? h('span', { className: 'ptcPlusPromptTag', 'data-prompt-unwired-tag': 'true' },
1136
+ t('capabilities.prompt.unwiredTag')) : null))
1137
+ }
1138
+ const hostRow = (section, index) => h('tr', {
1139
+ key: section.id, className: 'ptcPlusPromptRow ptcPlusPromptHostRow', 'data-host-row': section.id,
1140
+ },
1141
+ h('td', { className: 'ptcPlusPromptIndex' }, String(index)),
1142
+ h('td', null, h('span', { className: 'ptcPlusPromptChannel' }, 'prompt')),
1143
+ h('td', null, h('code', { className: 'ptcPlusPromptAnchorId', title: section.id }, section.id)),
1144
+ h('td', null, h('span', { className: 'ptcPlusPromptPresence' }, t(section.presence))),
1145
+ h('td', { className: 'ptcPlusPromptBytesCell' }, t('capabilities.prompt.hostUnavailable')),
1146
+ h('td', { className: 'ptcPlusPromptReasonCell' }, '—'),
1147
+ h('td', { className: 'ptcPlusPromptKindCell' }, t('capabilities.prompt.ownerHost')))
1148
+ const table = h('table', { className: 'ptcPlusPromptTable' },
1149
+ h('colgroup', null,
1150
+ h('col', { className: 'ptcPlusPromptColIndex' }),
1151
+ h('col', { className: 'ptcPlusPromptColChannel' }),
1152
+ h('col', { className: 'ptcPlusPromptColAnchor' }),
1153
+ h('col', { className: 'ptcPlusPromptColPresence' }),
1154
+ h('col', { className: 'ptcPlusPromptColBytes' }),
1155
+ h('col', { className: 'ptcPlusPromptColReason' }),
1156
+ h('col', { className: 'ptcPlusPromptColKind' })),
1157
+ h('thead', null, h('tr', null,
1158
+ h('th', { scope: 'col', className: 'ptcPlusPromptIndex' }, ''),
1159
+ h('th', { scope: 'col' }, t('capabilities.prompt.columnChannel')),
1160
+ h('th', { scope: 'col' }, t('capabilities.prompt.columnAnchor')),
1161
+ h('th', { scope: 'col' }, t('capabilities.prompt.columnPresence')),
1162
+ h('th', { scope: 'col' }, t('capabilities.prompt.columnBytesSplit')),
1163
+ h('th', { scope: 'col' }, t('capabilities.prompt.columnReason')),
1164
+ h('th', { scope: 'col' }, t('capabilities.prompt.columnKind')))),
1165
+ h('tbody', null,
1166
+ ...account.anchors.map((anchor, index) => anchorRow(anchor, index + 1)),
1167
+ ...HOST_CONTEXT_SECTIONS.map((section, index) => hostRow(section, account.anchors.length + index + 1))))
1168
+ const chip = (anchor, slot) => {
1169
+ const templateBytes = slot.measured === true && slot.kind === 'projected'
1170
+ const aggregateSet = slot.kind === 'aggregate'
1171
+ const shared = {
1172
+ className: 'ptcPlusPromptChip',
1173
+ 'data-slot-id': slot.slotId,
1174
+ 'data-slot-kind': slot.kind,
1175
+ ...(templateBytes ? { 'data-prompt-template-bytes': 'true' } : {}),
1176
+ ...(aggregateSet ? { 'data-prompt-aggregate-set': 'true' } : {}),
1177
+ title: templateBytes
1178
+ ? `${slot.slotId} · ${t('capabilities.prompt.templateBytes')}`
1179
+ : aggregateSet ? `${slot.slotId} · ${t('capabilities.prompt.aggregateFrozen')}` : slot.slotId,
1180
+ }
1181
+ if (slot.owner !== 'A') {
1182
+ return h('span', { key: slot.slotId, ...shared, 'data-readonly': 'true' },
1183
+ h('span', { className: 'ptcPlusPromptChipLabel' }, slot.slotId),
1184
+ h('span', { className: 'ptcPlusPromptChipNote' },
1185
+ t('capabilities.prompt.readonlyReason', { reason: promptReasonLabel(t, slot.reason), kind: promptKindLabel(t, slot.kind) })))
1186
+ }
1187
+ const selected = (openSlots[anchor.anchorId] ?? editableSlots(anchor)[0]?.slotId) === slot.slotId
1188
+ return h('button', {
1189
+ key: slot.slotId,
1190
+ ...shared,
1191
+ type: 'button',
1192
+ disabled: blocked,
1193
+ 'aria-pressed': selected,
1194
+ onClick: () => setOpenSlots(previous => ({ ...previous, [anchor.anchorId]: slot.slotId })),
1195
+ }, h('span', { className: 'ptcPlusPromptChipLabel' }, slot.slotId))
1196
+ }
1197
+ // One editor per anchor: the chips choose which editable slot is open, so the
1198
+ // run_code anchor keeps its three descriptions behind one field.
1199
+ const editableSlots = anchor => anchor.slots.filter(slot => slot.owner === 'A'
1200
+ && PROMPT_ASSETS.find(candidate => candidate.id === slot.slotId)?.defaultText !== null)
1201
+ const editorFor = anchor => {
1202
+ const slots = editableSlots(anchor)
1203
+ if (anchor.wired !== true || slots.length === 0) return null
1204
+ const selectedId = openSlots[anchor.anchorId] ?? slots[0].slotId
1205
+ const slot = slots.find(candidate => candidate.slotId === selectedId) ?? slots[0]
1206
+ const asset = PROMPT_ASSETS.find(candidate => candidate.id === slot.slotId)
1207
+ const override = Object.hasOwn(overrides, slot.slotId) ? overrides[slot.slotId] : undefined
1208
+ const current = override ?? asset.defaultText
1209
+ // The built-in sentence is the field's value, not a placeholder: the user must be
1210
+ // able to tweak the shipped wording without retyping it.
1211
+ return h('div', { key: slot.slotId, className: 'ptcPlusPromptEditor', 'data-editor-slot': slot.slotId },
1212
+ h('textarea', {
1213
+ id: `${id}-prompt-${slot.slotId}`, rows: 3, disabled: blocked, spellCheck: false,
1214
+ className: 'ptcPlusPromptTextarea', 'aria-label': slot.slotId, value: current,
1215
+ onChange: event => update(slot.slotId, event.target.value === '' ? undefined : event.target.value),
1216
+ }),
1217
+ h('div', { className: 'ptcPlusPromptEditorFoot' },
1218
+ h('span', { className: 'ptcPlusPromptHint' },
1219
+ t('capabilities.prompt.bytesOfLimit', { used: byteCount(current), max: PROMPT_TEXT_MAX_SLOT_BYTES })),
1220
+ h('span', { className: 'ptcPlusPromptEditorActions' },
1221
+ asset.omittable ? h('button', {
1222
+ type: 'button', className: 'ptcPlusButton', disabled: blocked,
1223
+ onClick: () => update(slot.slotId, ''),
1224
+ }, t('capabilities.prompt.omit')) : null,
1225
+ h('button', {
1226
+ type: 'button', className: 'ptcPlusButton', disabled: blocked || override === undefined,
1227
+ onClick: () => update(slot.slotId, undefined),
1228
+ }, t('capabilities.prompt.reset')))))
1229
+ }
1230
+ const defaultAnchor = account.anchors.find(anchor => anchor.wired === true && editableSlots(anchor).length > 0)
1231
+ const selectedAnchor = account.anchors.find(anchor => anchor.anchorId === selectedAnchorId) ?? defaultAnchor
1232
+ const selectedIndex = selectedAnchor === undefined ? 0 : account.anchors.indexOf(selectedAnchor) + 1
1233
+ const anchorPanel = h('section', { className: 'ptcPlusPromptPanel', 'data-prompt-panel': 'anchor', ref: anchorPanelRef },
1234
+ h('h5', { className: 'ptcPlusPromptPanelTitle' },
1235
+ t('capabilities.prompt.rowExpand', { index: selectedIndex })),
1236
+ selectedAnchor === undefined
1237
+ ? h('p', { className: 'ptcPlusPromptHint' }, t('capabilities.prompt.rowExpandHint'))
1238
+ : h('div', { className: 'ptcPlusPromptPanelBody' },
1239
+ h('div', { className: 'ptcPlusPromptChips', 'data-anchor-chips': selectedAnchor.anchorId },
1240
+ ...selectedAnchor.slots.map(slot => chip(selectedAnchor, slot))),
1241
+ selectedAnchor.wired !== true
1242
+ ? h('p', { className: 'ptcPlusPromptHint' }, t('capabilities.prompt.unwiredRow', { slots: selectedAnchor.unmeasuredSlots }))
1243
+ : editorFor(selectedAnchor)))
1244
+ // Saving shows what will change. The preview is derived from the same draft the
1245
+ // editors write, so it cannot disagree with them; a saved document that already
1246
+ // differs from the built-ins shows here too, because saving would rewrite it.
1247
+ const changes = account.anchors.flatMap(anchor => anchor.slots
1248
+ .map(slot => ({ slot, builtin: PROMPT_ASSETS.find(asset => asset.id === slot.slotId)?.defaultText }))
1249
+ .filter(entry => typeof entry.builtin === 'string'
1250
+ && Object.hasOwn(overrides, entry.slot.slotId)
1251
+ && overrides[entry.slot.slotId] !== entry.builtin)
1252
+ .map(entry => {
1253
+ const effective = overrides[entry.slot.slotId]
1254
+ const effect = anchor.channel === 'plan' ? t('capabilities.prompt.effectPlan')
1255
+ : anchor.channel === 'install' ? t('capabilities.prompt.effectInstall')
1256
+ : anchor.channel === 'prompt' ? t('capabilities.prompt.effectPrompt')
1257
+ : t('capabilities.prompt.effectTrigger')
1258
+ return {
1259
+ anchor, slot: entry.slot, effect,
1260
+ before: byteCount(entry.builtin), after: byteCount(effective), delta: byteCount(effective) - byteCount(entry.builtin),
1261
+ }
1262
+ }))
1263
+ const restoreSlot = slotId => update(slotId, undefined)
1264
+ const diffPanel = h('section', {
1265
+ className: 'ptcPlusPromptPanel',
1266
+ ...(changes.length === 0 ? {} : { 'data-prompt-diff': 'true' }),
1267
+ 'data-prompt-diff-panel': 'true',
1268
+ },
1269
+ h('h5', { className: 'ptcPlusPromptPanelTitle' }, t('capabilities.prompt.diffTitle')),
1270
+ changes.length === 0
1271
+ ? h('p', { className: 'ptcPlusPromptHint' }, t('capabilities.prompt.diffEmpty'))
1272
+ : h('ul', { className: 'ptcPlusPromptDiffList' },
1273
+ ...changes.map((entry, index) => h('li', {
1274
+ key: entry.slot.slotId, className: 'ptcPlusPromptDiffRow',
1275
+ 'data-diff-anchor': entry.anchor.anchorId, 'data-diff-slot': entry.slot.slotId,
1276
+ },
1277
+ h('span', { className: 'ptcPlusPromptDot', 'data-tone': String(index % 3) }),
1278
+ h('span', { className: 'ptcPlusPromptDiffBody' },
1279
+ h('span', { className: 'ptcPlusPromptDiffHead' },
1280
+ h('code', { className: 'ptcPlusPromptDiffSlot' }, entry.slot.slotId),
1281
+ h('span', { className: 'ptcPlusPromptHint' }, entry.effect)),
1282
+ h('span', { className: 'ptcPlusPromptDiffDelta' },
1283
+ t('capabilities.prompt.diffBytes', {
1284
+ before: group(entry.before), after: group(entry.after),
1285
+ delta: entry.delta > 0 ? `+${group(entry.delta)}` : group(entry.delta),
1286
+ }),
1287
+ h('button', {
1288
+ type: 'button', className: 'ptcPlusButton', disabled: blocked,
1289
+ onClick: () => restoreSlot(entry.slot.slotId),
1290
+ }, t('capabilities.prompt.reset'))))))),
1291
+ h('div', { className: 'ptcPlusPromptActions' },
1292
+ h('button', {
1293
+ type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary', disabled: blocked || !pending,
1294
+ onClick: save,
1295
+ }, t('capabilities.prompt.save')),
1296
+ h('button', { type: 'button', className: 'ptcPlusButton', disabled: blocked || !pending, onClick: cancel },
1297
+ t('capabilities.prompt.cancel'))))
1298
+ return h('section', {
1299
+ className: 'ptcPlusPromptText', 'data-prompt-paused': paused ? 'true' : 'false',
1300
+ },
1301
+ h('header', { className: 'ptcPlusPromptHeader' },
1302
+ h('span', { className: 'ptcPlusPromptHeaderIcon' }, icon(['M10 2.5 12 8l5.5 2-5.5 2-2 5.5-2-5.5L2.5 10 8 8Z'])),
1303
+ h('span', { className: 'ptcPlusPromptHeaderBody' },
1304
+ h('h4', { className: 'ptcPlusPromptTitle' }, t('capabilities.prompt.title')),
1305
+ h('p', { className: 'ptcPlusPromptSubtitle' }, t('capabilities.prompt.subtitle')))),
1306
+ paused
1307
+ ? h('p', { className: 'ptcPlusPromptPaused', role: 'status', 'data-prompt-paused-notice': 'true' },
1308
+ t('capabilities.prompt.paused'))
1309
+ : null,
1310
+ readError === undefined ? null : h('p', { className: 'ptcPlusPromptHint' }, t('capabilities.prompt.unreadable', { error: readError })),
1311
+ ...resolved.reported.map((entry, index) => h('p', { key: `report-${index}`, className: 'ptcPlusPromptHint' },
1312
+ t('capabilities.prompt.reported', {
1313
+ asset: entry.slotId ?? entry.anchorId ?? 'document', reason: entry.reason,
1314
+ }))),
1315
+ cards,
1316
+ strip,
1317
+ h('p', { className: 'ptcPlusPromptHint', 'data-prompt-slot-counts': 'true' },
1318
+ t('capabilities.prompt.slotCounts', { bytes: group(measuredBytes), unmeasured: unmeasuredSlots, dynamic: dynamicSlots })),
1319
+ table,
1320
+ h('div', { className: 'ptcPlusPromptPanels' }, anchorPanel, diffPanel),
1321
+ h('p', { className: 'ptcPlusPromptIntro' }, t('capabilities.prompt.intro')))
1322
+ }
1323
+
868
1324
  function CapabilityWorkbench({ value, configurationReady, catalog, catalogError, plans, plansError, plansPending, plansStale, disabled, onChange, onReload, t, renderField, renderPlans, renderRefresh, id, selectedSessionId, onSessionSelect,
869
1325
  allocation, allocationError, allocationPending, onApplyConfiguration, configurationWritable, saving, saveStatus }) {
870
1326
  const [tab, setTab] = React.useState('manual')
871
1327
  const [contextSection, setContextSection] = React.useState('description')
872
- const tabs = ['manual', 'budgets', 'statistics', 'plans']
1328
+ const tabs = ['manual', 'budgets', 'statistics', 'prompt', 'plans']
873
1329
  const feedback = saving ? t('state.syncing') : saveStatus && saveStatus.key !== 'status.applied' ? t(saveStatus.key, saveStatus.params)
874
1330
  : t(configurationWritable ? 'capabilities.savedForNew' : 'footer.readOnly')
875
1331
  return h('div', { className: 'ptcPlusCapabilityWorkbench' },
876
1332
  h(CombinationSummary, { value, configurationReady, plans, plansError, plansPending, plansStale, selectedSessionId,
877
1333
  feedback, onReload, renderRefresh, renderPlans, t, id, onNavigate: key => {
878
- setTab(key === 'rules' ? 'manual' : key === 'statistics' ? 'statistics' : 'budgets')
1334
+ setTab(key === 'rules' ? 'manual' : key === 'statistics' ? 'statistics' : key === 'promptText' ? 'prompt' : 'budgets')
879
1335
  if (['description', 'recovery', 'preview'].includes(key)) setContextSection(key)
880
1336
  } }),
881
1337
  h('div', { className: 'ptcPlusCapabilityTabs', role: 'tablist', 'aria-label': t('capabilities.settingsTabs') },
@@ -885,7 +1341,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
885
1341
  }, t(`capabilities.tab.${key}`)))),
886
1342
  ...tabs.map(key => h('div', { key, className: 'ptcPlusCapabilityPane', role: 'tabpanel', id: `${id}-pane-${key}`,
887
1343
  'aria-labelledby': `${id}-tab-${key}`, hidden: tab !== key },
888
- h('div', { className: 'ptcPlusTaskHead' }, h('h4', null, t(`capabilities.title.${key}`)),
1344
+ key === 'prompt' ? null : h('div', { className: 'ptcPlusTaskHead' }, h('h4', null, t(`capabilities.title.${key}`)),
889
1345
  key === 'manual' ? h('label', { className: 'ptcPlusCapabilityManagement' }, h('span', null, t('capabilities.management')),
890
1346
  h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', checked: value.capabilityManagementEnabled === true,
891
1347
  disabled: disabled('capabilityManagementEnabled'), 'aria-label': t('capabilities.management'),
@@ -902,7 +1358,10 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
902
1358
  h(AllocationEvidence, { allocation, allocationError, allocationPending, selectedSessionId, statistics: true, onReload, t }))
903
1359
  : key === 'budgets' ? h(ContextSettings, { value, configurationReady, disabled, onChange, t, renderField, allocation, allocationError, allocationPending,
904
1360
  selectedSessionId, onReload, renderRefresh, id, section: contextSection, setSection: setContextSection })
905
- : h(ConfigurationProfiles, { value, plans, disabled, writable: configurationWritable, onChange, onApplyConfiguration, t }))))
1361
+ // Rendered only while its tab is active: eight idle textareas in a hidden
1362
+ // pane would change every generic textbox query in the settings card.
1363
+ : key === 'prompt' ? (tab === 'prompt' ? h(PromptTextSettings, { value, disabled, onChange, t, id }) : null)
1364
+ : h(ConfigurationProfiles, { value, plans, disabled, writable: configurationWritable, onChange, onApplyConfiguration, t }))))
906
1365
  }
907
1366
 
908
1367
  return { CapabilityWorkbench }
@@ -973,9 +1432,13 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
973
1432
  const available = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
974
1433
  const [catalog, setCatalog] = React.useState([])
975
1434
  const [management, setManagement] = React.useState('unknown')
1435
+ const [savedSettings, setSavedSettings] = React.useState(null)
976
1436
  const [loading, setLoading] = React.useState(false)
977
1437
  const [error, setError] = React.useState(null)
978
1438
  const [drafts, setDrafts] = React.useState(emptyDrafts)
1439
+ const [picked, setPicked] = React.useState([])
1440
+ const [batchOpen, setBatchOpen] = React.useState(false)
1441
+ const [batchChoices, setBatchChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
979
1442
  const [selectedId, setSelectedId] = React.useState(null)
980
1443
  const [pane, setPane] = React.useState('list')
981
1444
  const [query, setQuery] = React.useState('')
@@ -1009,7 +1472,10 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1009
1472
  applying.current = false
1010
1473
  setCatalog([])
1011
1474
  setManagement('unknown')
1475
+ setSavedSettings(null)
1012
1476
  setDrafts(emptyDrafts())
1477
+ setPicked([])
1478
+ setBatchOpen(false)
1013
1479
  setSelectedId(null)
1014
1480
  setPane('list')
1015
1481
  setFocusDimension(null)
@@ -1026,8 +1492,11 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1026
1492
  throw new Error('Invalid capability catalog')
1027
1493
  }
1028
1494
  setCatalog(payload.entries)
1495
+ setPicked(current => current.filter(id => payload.entries.some(entry => entry.id === id)))
1029
1496
  setManagement(['managed', 'paused', 'off', 'unknown'].includes(payload.management)
1030
1497
  ? payload.management : 'unknown')
1498
+ setSavedSettings(payload.savedSettings !== null && typeof payload.savedSettings === 'object'
1499
+ && !Array.isArray(payload.savedSettings) ? payload.savedSettings : null)
1031
1500
  setError(null)
1032
1501
  return payload.entries
1033
1502
  } catch (failure) {
@@ -1038,24 +1507,24 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1038
1507
  }, [sessionId])
1039
1508
  React.useEffect(() => {
1040
1509
  if (!open || !enabled || !available) {
1041
- request.current?.abort(); request.current = null; setLoading(false); return
1510
+ if (!applying.current) { request.current?.abort(); request.current = null }
1511
+ setLoading(false)
1512
+ return
1042
1513
  }
1514
+ // An in-flight apply owns the shared catalog request and reloads it on completion.
1515
+ if (applying.current) return
1043
1516
  const controller = new AbortController()
1044
1517
  request.current = controller
1045
1518
  void load(controller.signal)
1046
1519
  return () => { controller.abort(); if (request.current === controller) request.current = null }
1047
1520
  }, [open, enabled, available, load])
1048
- React.useEffect(() => {
1049
- if (!enabled || !available) {
1050
- request.current?.abort()
1051
- request.current = null
1052
- applying.current = false
1053
- setPending(false)
1054
- }
1055
- }, [enabled, available])
1056
1521
  const choices = drafts
1057
1522
  const choiceFor = entry => choices[entry.id] ?? entry.temporary ?? 'inherit'
1058
- const writesBlocked = management === 'paused' || management === 'off'
1523
+ // Session writes also require the saved configuration to keep capability
1524
+ // management and context on; a frozen managed session cannot be edited
1525
+ // while the current settings have them off.
1526
+ const savedBlocks = savedSettings !== null && (savedSettings.enabled === false || savedSettings.contextEnabled === false)
1527
+ const writesBlocked = management === 'paused' || management === 'off' || savedBlocks
1059
1528
  const sourceKey = entry => entry.source?.kind === 'mcp' ? 'mcp:' + (entry.source.server ?? '')
1060
1529
  : entry.source?.kind === 'skill' ? 'skill:' + (entry.source.bucket ?? '')
1061
1530
  : entry.source?.kind ?? 'unknown'
@@ -1109,26 +1578,51 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1109
1578
  ?? ['tools', 'skills', 'mcp', 'mcp_tools'].map(group => matches.find(entry => category(entry) === group)).find(Boolean)
1110
1579
  const staged = Object.entries(choices).flatMap(([id, choice]) => typeof choice === 'object'
1111
1580
  ? Object.entries(choice).map(([dimension, selection]) => [id, selection, dimension]) : [[id, choice]])
1112
- const chooseDimension = (id, dimension, choice) => {
1113
- setDrafts(current => {
1114
- const patch = typeof current[id] === 'object' ? { ...current[id] } : {}
1115
- const original = catalog.find(entry => entry.id === id)?.temporaryRule?.[dimension] ?? 'inherit'
1116
- if (choice === original && choice === 'inherit') delete patch[dimension]
1581
+ const pickedRows = picked.map(id => catalog.find(entry => entry.id === id)).filter(Boolean)
1582
+ const allMatchesPicked = matches.length > 0 && matches.every(entry => picked.includes(entry.id))
1583
+ const someMatchesPicked = matches.some(entry => picked.includes(entry.id))
1584
+ const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
1585
+ const toggleAllMatches = () => setPicked(current => allMatchesPicked
1586
+ ? current.filter(id => !matches.some(entry => entry.id === id))
1587
+ : [...new Set([...current, ...matches.map(entry => entry.id)])])
1588
+ // One staging rule for single and batch edits: an item keeps one temporary
1589
+ // patch, "inherit" removes the dimension and reselecting the stored choice
1590
+ // never dirties the draft.
1591
+ const stageDimension = (current, entry, dimension, choice) => {
1592
+ const next = { ...current }
1593
+ const patch = typeof next[entry.id] === 'object' && next[entry.id] !== null ? { ...next[entry.id] } : {}
1594
+ const original = storedDimension(entry, dimension) ?? 'inherit'
1595
+ const selectedChoice = patch[dimension] ?? original
1596
+ if (choice !== selectedChoice) {
1597
+ if (choice === original) delete patch[dimension]
1117
1598
  else patch[dimension] = choice
1118
- const next = { ...current }; if (Object.keys(patch).length) next[id] = patch; else delete next[id]; return next
1119
- })
1599
+ }
1600
+ if (Object.keys(patch).length) next[entry.id] = patch; else delete next[entry.id]
1601
+ return next
1602
+ }
1603
+ const chooseDimension = (id, dimension, choice) => {
1604
+ const entry = catalog.find(item => item.id === id)
1605
+ if (entry) setDrafts(current => stageDimension(current, entry, dimension, choice))
1120
1606
  }
1121
1607
  const choose = (id, next) => {
1122
1608
  const entry = catalog.find(item => item.id === id)
1123
1609
  if (!entry) return
1124
1610
  const original = entry.temporary ?? 'inherit'
1611
+ if (next === (choices[id] ?? original)) return
1125
1612
  setDrafts(current => {
1126
1613
  const nextChoices = { ...current }
1127
- if (next === original && next === 'inherit') delete nextChoices[id]
1614
+ if (next === original) delete nextChoices[id]
1128
1615
  else nextChoices[id] = next
1129
1616
  return nextChoices
1130
1617
  })
1131
1618
  }
1619
+ const stageBatch = (ids, selections) => setDrafts(current => ids.reduce((draft, id) => {
1620
+ const entry = catalog.find(item => item.id === id)
1621
+ if (!entry) return draft
1622
+ return ['availability', 'disclosure'].reduce((next, dimension) =>
1623
+ selections[dimension] === undefined || selections[dimension] === 'keep'
1624
+ ? next : stageDimension(next, entry, dimension, selections[dimension]), draft)
1625
+ }, current))
1132
1626
  const hasInheritanceReset = staged.some(([, selection]) => selection === 'inherit')
1133
1627
  const apply = async targetScope => {
1134
1628
  if (applying.current || !enabled || !available || writesBlocked || staged.length === 0) return
@@ -1140,58 +1634,71 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1140
1634
  const controller = new AbortController()
1141
1635
  request.current?.abort()
1142
1636
  request.current = controller
1143
- let completed = 0
1144
- let pendingCount = 0
1145
- let failure = null
1146
- let savedOnly = null
1147
- for (const [id, selection, dimension] of staged) {
1148
- if (controller.signal.aborted) break
1149
- try {
1150
- const result = await commands.run(sessionId, `set ${targetScope} ${encodeURIComponent(id)} ${dimension ? dimension + ' ' : ''}${selection}`, controller.signal)
1151
- const receipt = readCapabilityCommandReceipt(result.text)
1152
- if (!receipt || receipt.sessionId !== sessionId || !['recorded', 'pending'].includes(receipt.state)) {
1153
- throw new Error(receipt ? capabilityCommandText(t, result.text) : t('capabilities.quick.unknownReceipt'))
1637
+ // One owner releases the shared request slot and the pending state on every exit path, so a
1638
+ // closed, superseded or failed apply cannot strand the workbench in "applying".
1639
+ const finish = () => {
1640
+ if (request.current === controller) request.current = null
1641
+ applying.current = false
1642
+ if (mounted.current) setPending(false)
1643
+ }
1644
+ try {
1645
+ let completed = 0
1646
+ let pendingCount = 0
1647
+ let failure = null
1648
+ let savedOnly = null
1649
+ for (const [id, selection, dimension] of staged) {
1650
+ if (controller.signal.aborted) break
1651
+ try {
1652
+ const result = await commands.run(sessionId, `set ${targetScope} ${encodeURIComponent(id)} ${dimension ? dimension + ' ' : ''}${selection}`, controller.signal)
1653
+ const receipt = readCapabilityCommandReceipt(result.text)
1654
+ if (!receipt || receipt.sessionId !== sessionId || !['recorded', 'pending'].includes(receipt.state)) {
1655
+ throw new Error(receipt ? capabilityCommandText(t, result.text) : t('capabilities.quick.unknownReceipt'))
1656
+ }
1657
+ if (receipt?.state === 'pending') pendingCount += 1
1658
+ completed += 1
1659
+ } catch (cause) {
1660
+ failure = String(cause?.message ?? cause)
1661
+ savedOnly = readCapabilityCommandReceipt(failure)?.state === 'saved-only'
1662
+ ? readCapabilityCommandReceipt(failure) : null
1663
+ break
1154
1664
  }
1155
- if (receipt?.state === 'pending') pendingCount += 1
1156
- completed += 1
1157
- } catch (cause) {
1158
- failure = String(cause?.message ?? cause)
1159
- savedOnly = readCapabilityCommandReceipt(failure)?.state === 'saved-only'
1160
- ? readCapabilityCommandReceipt(failure) : null
1161
- break
1162
1665
  }
1666
+ if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1667
+ const refreshed = await load(controller.signal)
1668
+ if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1669
+ const confirmed = refreshed ? staged.slice(0, completed).filter(([id, selection, dimension]) => {
1670
+ const entry = refreshed.find(item => item.id === id)
1671
+ if (dimension) return entry && (targetScope === 'session'
1672
+ ? (entry.temporaryRule?.[dimension] ?? 'inherit') === selection
1673
+ : (entry.savedRule?.[dimension] ?? 'automatic') === selection && entry.temporaryRule?.[dimension] === undefined)
1674
+ return entry && (targetScope === 'session' ? entry.temporary === selection
1675
+ : entry.saved === selection && entry.temporary === 'inherit')
1676
+ }) : []
1677
+ setDrafts(current => {
1678
+ const nextChoices = { ...current }
1679
+ for (const [id, selection, dimension] of confirmed) {
1680
+ if (savedOnly && id === staged[completed]?.[0] && dimension === staged[completed]?.[2]) continue
1681
+ if (dimension) {
1682
+ if (nextChoices[id]?.[dimension] !== submitted[id]?.[dimension]) continue
1683
+ const patch = { ...nextChoices[id] }; delete patch[dimension]
1684
+ if (Object.keys(patch).length) nextChoices[id] = patch; else delete nextChoices[id]
1685
+ } else if (nextChoices[id] === submitted[id]) delete nextChoices[id]
1686
+ }
1687
+ return nextChoices
1688
+ })
1689
+ if (savedOnly) setStatus({ key: 'capabilities.quick.savedOnly', params: { count: completed, error: savedOnly.error ?? '' } })
1690
+ else if (failure || !refreshed || confirmed.length < completed) setStatus({ key: 'capabilities.quick.partial',
1691
+ params: { count: confirmed.length, error: failure ?? t('capabilities.quick.confirmationUnavailable') } })
1692
+ else setStatus({ key: pendingCount ? 'capabilities.quick.pendingApplied' : 'capabilities.quick.applied', params: { count: completed } })
1693
+ } finally {
1694
+ finish()
1163
1695
  }
1164
- if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1165
- const refreshed = await load(controller.signal)
1166
- if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1167
- const confirmed = refreshed ? staged.slice(0, completed).filter(([id, selection, dimension]) => {
1168
- const entry = refreshed.find(item => item.id === id)
1169
- if (dimension) return entry && (targetScope === 'session'
1170
- ? (entry.temporaryRule?.[dimension] ?? 'inherit') === selection
1171
- : (entry.savedRule?.[dimension] ?? 'automatic') === selection && entry.temporaryRule?.[dimension] === undefined)
1172
- return entry && (targetScope === 'session' ? entry.temporary === selection
1173
- : entry.saved === selection && entry.temporary === 'inherit')
1174
- }) : []
1175
- setDrafts(current => {
1176
- const nextChoices = { ...current }
1177
- for (const [id, selection, dimension] of confirmed) {
1178
- if (savedOnly && id === staged[completed]?.[0] && dimension === staged[completed]?.[2]) continue
1179
- if (dimension) {
1180
- if (nextChoices[id]?.[dimension] !== submitted[id]?.[dimension]) continue
1181
- const patch = { ...nextChoices[id] }; delete patch[dimension]
1182
- if (Object.keys(patch).length) nextChoices[id] = patch; else delete nextChoices[id]
1183
- } else if (nextChoices[id] === submitted[id]) delete nextChoices[id]
1184
- }
1185
- return nextChoices
1186
- })
1187
- if (savedOnly) setStatus({ key: 'capabilities.quick.savedOnly', params: { count: completed, error: savedOnly.error ?? '' } })
1188
- else if (failure || !refreshed || confirmed.length < completed) setStatus({ key: 'capabilities.quick.partial',
1189
- params: { count: confirmed.length, error: failure ?? t('capabilities.quick.confirmationUnavailable') } })
1190
- else setStatus({ key: pendingCount ? 'capabilities.quick.pendingApplied' : 'capabilities.quick.applied', params: { count: completed } })
1191
- if (request.current === controller) { request.current = null; applying.current = false; setPending(false) }
1192
1696
  }
1193
1697
  const discard = () => { setDrafts(emptyDrafts()); setStatus(null) }
1194
1698
  const close = () => {
1699
+ // A confirmed apply keeps its own lifecycle: refusing the close keeps an accidental
1700
+ // Escape or backdrop click from aborting host commands the user already submitted.
1701
+ if (applying.current) return
1195
1702
  request.current?.abort()
1196
1703
  request.current = null
1197
1704
  applying.current = false
@@ -1203,35 +1710,124 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1203
1710
  const filter = (label, value, onChange, options, disabled = false) => h('div', { className: 'ptcPlusCapabilityFilter' },
1204
1711
  h('span', null, label), h(Select, { label, value, onChange, disabled,
1205
1712
  options: options.map(([key, text]) => ({ value: key, label: text })) }))
1713
+ const ruleLabel = (rule, empty) => capabilityRuleLabel(t, rule, empty)
1714
+ const statusLabel = value => value && typeof value === 'object' ? ruleLabel(value) : capabilityChoiceLabel(t, value)
1715
+ // The stored temporary value for one dimension, including the legacy scalar
1716
+ // form where a single selection covered both dimensions.
1717
+ const storedDimension = (entry, dimension) => {
1718
+ const rule = entry.temporaryRule !== null && typeof entry.temporaryRule === 'object' && !Array.isArray(entry.temporaryRule)
1719
+ ? entry.temporaryRule : undefined
1720
+ const override = rule?.[dimension]
1721
+ if (override !== undefined) return override
1722
+ if (entry.temporary === 'automatic') return 'automatic'
1723
+ if (typeof entry.temporary === 'string' && !['inherit', 'automatic', 'unknown', 'pending'].includes(entry.temporary)) {
1724
+ return dimension === 'availability' ? (entry.temporary === 'closed' ? 'closed' : 'enabled')
1725
+ : (CAPABILITY_DISCLOSURES.includes(entry.temporary) ? entry.temporary : undefined)
1726
+ }
1727
+ return undefined
1728
+ }
1729
+ // The value a dimension falls back to when this session keeps no temporary
1730
+ // change; the frozen plan already resolved the saved layers.
1731
+ const frozenDimension = (entry, dimension) => {
1732
+ const source = entry.sourceDimensions?.[dimension]
1733
+ if (typeof source === 'string') return source
1734
+ const effective = entry.effectiveDimensions?.[dimension]
1735
+ if (typeof effective === 'string' && storedDimension(entry, dimension) === undefined) return effective
1736
+ if (dimension === 'availability') return entry.effective === 'closed' ? 'closed'
1737
+ : ['full', 'on-demand', 'host', 'enabled'].includes(entry.effective) ? 'enabled' : undefined
1738
+ return ['full', 'on-demand', 'host'].includes(entry.effective) ? entry.effective : undefined
1739
+ }
1740
+ const stateText = value => value === undefined ? t('capabilities.quick.unknown') : statusLabel(value)
1741
+ const dimensionCurrent = (entry, dimension) => choices[entry.id]?.[dimension] ?? storedDimension(entry, dimension) ?? 'inherit'
1742
+ const dimensionImpact = (entry, dimension) => {
1743
+ const draft = choices[entry.id]?.[dimension]
1744
+ const choice = draft ?? storedDimension(entry, dimension) ?? 'inherit'
1745
+ const stagedChange = draft !== undefined
1746
+ if (choice === 'inherit') return { state: frozenDimension(entry, dimension), origin: 'capabilities.quick.originPlan', staged: stagedChange }
1747
+ if (choice === 'automatic') return { upper: true, staged: stagedChange }
1748
+ return { state: choice, origin: 'capabilities.quick.originSession', staged: stagedChange }
1749
+ }
1750
+ const impactLine = impact => impact.upper
1751
+ ? t(impact.staged ? 'capabilities.quick.impactUpperPending' : 'capabilities.quick.impactUpper')
1752
+ : t(impact.staged ? 'capabilities.quick.impactPending' : 'capabilities.quick.impact',
1753
+ { state: stateText(impact.state), source: t(impact.origin) })
1754
+ const dimensionOptions = (entry, dimension) => {
1755
+ const restore = stateText(frozenDimension(entry, dimension))
1756
+ const inherit = storedDimension(entry, dimension) === undefined
1757
+ ? t('capabilities.quick.noChange', { state: restore })
1758
+ : t('capabilities.quick.undoTemp', { state: restore })
1759
+ // "Let the level above decide" only differs from "no change" while the
1760
+ // session's frozen source still carries an explicit rule for the dimension.
1761
+ const sourceDimension = entry.sourceRule?.[dimension]
1762
+ const distinctRule = sourceDimension !== undefined && sourceDimension !== 'automatic'
1763
+ return ['inherit', ...(distinctRule || dimensionCurrent(entry, dimension) === 'automatic' ? ['automatic'] : []),
1764
+ ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
1765
+ .map(choice => ({ value: choice, label: choice === 'inherit' ? inherit
1766
+ : choice === 'automatic' ? t('capabilities.quick.upperRule')
1767
+ : t(choice === 'enabled' ? 'capabilities.action.enable' : choice === 'closed' ? 'capabilities.action.close' : `capabilities.${choice}`) }))
1768
+ }
1769
+ const legacyFrozenState = entry => {
1770
+ const stored = typeof entry.temporary === 'string' && !['inherit', 'automatic', 'unknown', 'pending'].includes(entry.temporary)
1771
+ if (stored) return ['full', 'on-demand', 'closed'].includes(entry.sessionSourceSelection) ? entry.sessionSourceSelection : undefined
1772
+ return ['full', 'on-demand', 'closed', 'host'].includes(entry.effective) ? entry.effective : undefined
1773
+ }
1774
+ const choiceCurrent = entry => choices[entry.id] ?? entry.temporary ?? 'inherit'
1775
+ const choiceImpact = entry => {
1776
+ const draft = choices[entry.id]
1777
+ const choice = draft ?? entry.temporary ?? 'inherit'
1778
+ const stagedChange = draft !== undefined
1779
+ if (choice === 'inherit') return { state: legacyFrozenState(entry), origin: 'capabilities.quick.originPlan', staged: stagedChange }
1780
+ if (choice === 'automatic') return { upper: true, staged: stagedChange }
1781
+ return { state: choice, origin: 'capabilities.quick.originSession', staged: stagedChange }
1782
+ }
1206
1783
  const choiceOptions = selected ? [
1207
1784
  'inherit', 'automatic',
1208
1785
  ...(selected.category === 'server' ? [] : ['full', 'on-demand']), 'closed',
1209
- ].map(choice => ({ value: choice, label: t(choice === 'inherit' ? 'capabilities.quick.inherit' : `capabilities.${choice}`) })) : []
1210
- const ruleLabel = (rule, empty) => capabilityRuleLabel(t, rule, empty)
1786
+ ].map(choice => ({ value: choice, label: choice === 'inherit'
1787
+ ? t(selected.temporary !== undefined && selected.temporary !== 'inherit' ? 'capabilities.quick.undoChange' : 'capabilities.quick.keepUnchanged')
1788
+ : choice === 'automatic' ? t('capabilities.quick.upperRule')
1789
+ : t(`capabilities.${choice}`) })) : []
1211
1790
  const dimensionFacts = dimensions => ['availability', 'disclosure'].map(dimension =>
1212
1791
  `${t(`capabilities.policy.${dimension}`)}: ${statusLabel(dimensions[dimension])} (${dimensions[`${dimension}Source`] ?? t('capabilities.quick.unknown')})`).join(' · ')
1213
- const statusLabel = value => value && typeof value === 'object' ? ruleLabel(value) : capabilityChoiceLabel(t, value)
1792
+ // A dimension whose effective value comes from the session-only temporary
1793
+ // choice is marked beside the frozen inheritance it replaces.
1794
+ const temporaryDimension = (entry, dimension) => {
1795
+ const rule = entry.temporaryRule !== null && typeof entry.temporaryRule === 'object' && !Array.isArray(entry.temporaryRule)
1796
+ ? entry.temporaryRule : undefined
1797
+ const override = rule?.[dimension]
1798
+ if (override !== undefined && override !== 'automatic') return override
1799
+ if (typeof entry.temporary === 'string' && !['inherit', 'automatic'].includes(entry.temporary)) {
1800
+ return dimension === 'availability' ? (entry.temporary === 'closed' ? 'closed' : 'enabled') : entry.temporary
1801
+ }
1802
+ return undefined
1803
+ }
1804
+ const dimensionCell = (entry, dimension, value) => temporaryDimension(entry, dimension) === value
1805
+ ? t('capabilities.quick.temporaryState', { state: statusLabel(value) }) : statusLabel(value)
1214
1806
  const openEditor = (entry, dimension = 'availability') => { setSelectedId(entry.id); setPane('detail'); setFocusDimension(dimension) }
1215
1807
  const renderRow = entry => h('div', { key: entry.id, role: 'row',
1216
1808
  className: 'ptcPlusSessionCapabilityRow ptcPlusCapabilityItem', 'data-capability-id': entry.id, 'data-selected': selected?.id === entry.id,
1217
1809
  onClick: () => openEditor(entry) },
1218
1810
  h('div', { role: 'cell', className: 'ptcPlusCapabilityIdentity' },
1219
- h('button', { type: 'button', className: 'ptcPlusSessionCapabilityName ptcPlusCapabilityName',
1220
- 'aria-current': selected?.id === entry.id ? 'true' : undefined,
1221
- onClick: event => { event.stopPropagation(); openEditor(entry) } }, entry.name ?? entry.id),
1222
- entry.name && entry.name !== entry.id ? h('code', null, entry.id) : null),
1811
+ h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: picked.includes(entry.id), disabled: writesBlocked,
1812
+ 'aria-label': t('capabilities.selectForBatch', { name: entry.name ?? entry.id }),
1813
+ onClick: event => event.stopPropagation(), onChange: () => togglePick(entry.id) }),
1814
+ h('div', { className: 'ptcPlusCapabilityIdentityText' },
1815
+ h('button', { type: 'button', className: 'ptcPlusSessionCapabilityName ptcPlusCapabilityName',
1816
+ 'aria-current': selected?.id === entry.id ? 'true' : undefined,
1817
+ onClick: event => { event.stopPropagation(); openEditor(entry) } }, entry.name ?? entry.id),
1818
+ entry.name && entry.name !== entry.id ? h('code', null, entry.id) : null)),
1223
1819
  h('span', { role: 'cell', className: 'ptcPlusCapabilityBadge', 'data-kind': type(entry) }, t(`capabilities.type.${type(entry)}`)),
1224
1820
  h('span', { role: 'cell', className: 'ptcPlusCapabilitySource' }, displayedSource(entry)),
1225
1821
  h('div', { role: 'cell', className: 'ptcPlusCapabilityAvailability', 'data-state': effectiveAvailability(entry) },
1226
1822
  h('button', { type: 'button', className: 'ptcPlusCapabilityContext',
1227
1823
  'aria-label': t('capabilities.policy.availabilityFor', { name: entry.id }),
1228
1824
  title: t('capabilities.quick.effective'),
1229
- onClick: event => { event.stopPropagation(); openEditor(entry, 'availability') } }, statusLabel(effectiveAvailability(entry)))),
1825
+ onClick: event => { event.stopPropagation(); openEditor(entry, 'availability') } }, dimensionCell(entry, 'availability', effectiveAvailability(entry)))),
1230
1826
  h('div', { role: 'cell', className: 'ptcPlusCapabilityContextCell' },
1231
1827
  h('button', { type: 'button', className: 'ptcPlusCapabilityContext',
1232
1828
  'aria-label': t('capabilities.editContextFor', { name: entry.id }),
1233
1829
  title: t('capabilities.quick.effective'),
1234
- onClick: event => { event.stopPropagation(); openEditor(entry, 'disclosure') } }, statusLabel(effectiveContext(entry)))),
1830
+ onClick: event => { event.stopPropagation(); openEditor(entry, 'disclosure') } }, dimensionCell(entry, 'disclosure', effectiveContext(entry)))),
1235
1831
  h('span', { role: 'cell', className: 'ptcPlusSessionCapabilityPending', 'data-changed': Object.hasOwn(choices, entry.id),
1236
1832
  'data-label': t('capabilities.quick.changed') },
1237
1833
  Object.hasOwn(choices, entry.id) ? statusLabel(choices[entry.id]) : '—'))
@@ -1240,7 +1836,7 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1240
1836
  h('div', { className: 'ptcPlusSessionCapabilityHead' },
1241
1837
  h('div', null, h('h2', null, t('capabilities.quick.modalTitle')),
1242
1838
  h('p', null, t('capabilities.quick.sessionIdentity', { session: sessionId }))),
1243
- h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('settings.close'), onClick: close }, '×')),
1839
+ h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('settings.close'), disabled: pending, onClick: close }, '×')),
1244
1840
  h('div', { className: 'ptcPlusSessionCapabilityToolbar' },
1245
1841
  h('label', { className: 'ptcPlusSearch' },
1246
1842
  h('svg', { viewBox: '0 0 24 24', width: 16, height: 16, 'aria-hidden': true },
@@ -1256,13 +1852,27 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1256
1852
  filter(t('capabilities.quick.state'), stateFilter, setStateFilter,
1257
1853
  [['all', t('capabilities.quick.allStates')], ['changed', t('capabilities.quick.changed')],
1258
1854
  ...['full', 'on-demand', 'closed', 'pending'].map(key => [key, statusLabel(key)])])),
1259
- writesBlocked ? h('p', { className: 'ptcPlusCapabilityNotice', role: 'status' },
1260
- t(management === 'paused' ? 'capabilities.quick.sessionPaused' : 'capabilities.quick.sessionManagementOff')) : null,
1855
+ // A blocked workbench states why writes are off and how to restore them; the banner styling
1856
+ // keeps that condition from reading as an incidental list message.
1857
+ writesBlocked ? h('p', { className: 'ptcPlusCapabilityNotice ptcPlusCapabilityBlocked', role: 'status' },
1858
+ h('svg', { className: 'ptcPlusCapabilityBlockedIcon', viewBox: '0 0 16 16', width: 14, height: 14,
1859
+ 'aria-hidden': true, fill: 'none' },
1860
+ h('path', { d: 'M8 2.3 14.7 13.5H1.3L8 2.3Z', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round' }),
1861
+ h('path', { d: 'M8 6.4v3M8 11.2v.7', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' })),
1862
+ t(management === 'paused' ? 'capabilities.quick.sessionPaused'
1863
+ : management === 'off' ? 'capabilities.quick.sessionManagementOff'
1864
+ : savedSettings?.contextEnabled === false ? 'capabilities.quick.savedPaused'
1865
+ : 'capabilities.quick.savedManagementOff')) : null,
1261
1866
  h('div', { className: 'ptcPlusSessionCapabilityWorkspace', 'data-pane': pane },
1262
1867
  h('div', { className: 'ptcPlusSessionCapabilityList', role: 'table', 'aria-label': t('capabilities.quick.members'), ref: list },
1263
1868
  h('div', { className: 'ptcPlusSessionCapabilityColumns ptcPlusCapabilityColumns', role: 'row' },
1264
1869
  ...CAPABILITY_SORT_COLUMNS.map(column => h('span', { key: column.key, role: 'columnheader',
1265
- 'aria-sort': capabilitySortDirection(sort, column.key) },
1870
+ 'aria-sort': capabilitySortDirection(sort, column.key),
1871
+ ...(column.key === 'name' ? { 'aria-label': t('capabilities.member'), className: 'ptcPlusCapabilityPickHead' } : {}) },
1872
+ ...(column.key === 'name' ? [h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: allMatchesPicked,
1873
+ ref: node => { if (node) node.indeterminate = someMatchesPicked && !allMatchesPicked },
1874
+ disabled: writesBlocked || matches.length === 0, 'aria-label': t('capabilities.selectAllVisible'),
1875
+ onChange: toggleAllMatches })] : []),
1266
1876
  h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
1267
1877
  onClick: () => setSort(current => cycleCapabilitySort(current, column.key)) },
1268
1878
  t(`capabilities.${column.label}`), h('span', { 'aria-hidden': true },
@@ -1308,34 +1918,73 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1308
1918
  h('ul', null, ...catalog.filter(entry => entry.id !== selected.id
1309
1919
  && entry.source?.kind === 'mcp' && entry.source.server === selected.source?.server)
1310
1920
  .map(entry => h('li', { key: entry.id }, entry.name ?? entry.id)))) : null,
1311
- selected.savedRule ? h(React.Fragment, null, ...['availability', 'disclosure'].map(dimension =>
1312
- h('div', { className: 'ptcPlusCapabilityFilter', key: dimension, 'data-capability-dimension': dimension },
1921
+ selected.savedRule ? h(React.Fragment, null, ...['availability', 'disclosure'].map(dimension => h(React.Fragment, { key: dimension },
1922
+ h('div', { className: 'ptcPlusCapabilityFilter', 'data-capability-dimension': dimension },
1313
1923
  h('span', null, t(`capabilities.policy.${dimension}`)),
1314
1924
  h(Select, { label: t(`capabilities.policy.${dimension}`), disabled: !enabled || !available || pending || writesBlocked,
1315
- value: choices[selected.id]?.[dimension] ?? selected.temporaryRule?.[dimension] ?? 'inherit',
1925
+ value: dimensionCurrent(selected, dimension),
1316
1926
  onChange: next => chooseDimension(selected.id, dimension, next),
1317
- options: ['inherit', 'automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
1318
- .map(choice => ({ value: choice, label: t(choice === 'inherit' ? 'capabilities.quick.inherit'
1319
- : choice === 'automatic' ? 'capabilities.policy.inheritRule' : choice === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${choice}`) })) }))))
1320
- : h('div', { className: 'ptcPlusCapabilityFilter' },
1321
- h('span', null, t('capabilities.quick.choice')),
1322
- h(Select, { label: t('capabilities.quick.choice'), value: choiceFor(selected),
1323
- disabled: !enabled || !available || pending || writesBlocked, options: choiceOptions,
1324
- onChange: next => choose(selected.id, next) })),
1325
- choices[selected.id] === 'inherit' ? h('p', { className: 'ptcPlusDetail' },
1927
+ options: dimensionOptions(selected, dimension) })),
1928
+ h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': dimension },
1929
+ impactLine(dimensionImpact(selected, dimension))))))
1930
+ : h(React.Fragment, null,
1931
+ h('div', { className: 'ptcPlusCapabilityFilter' },
1932
+ h('span', null, t('capabilities.quick.choice')),
1933
+ h(Select, { label: t('capabilities.quick.choice'), value: choiceCurrent(selected),
1934
+ disabled: !enabled || !available || pending || writesBlocked, options: choiceOptions,
1935
+ onChange: next => choose(selected.id, next) })),
1936
+ h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': 'selection' },
1937
+ impactLine(choiceImpact(selected)))),
1938
+ (typeof choices[selected.id] === 'string' ? choices[selected.id] === 'inherit'
1939
+ : Object.values(choices[selected.id] ?? {}).includes('inherit')) ? h('p', { className: 'ptcPlusDetail' },
1326
1940
  t('capabilities.quick.inheritSessionOnly')) : null) : h('p', null, t('capabilities.noMatches')))),
1941
+ batchOpen ? h(Modal, { open: true, onClose: () => setBatchOpen(false), title: t('capabilities.batchSettings'),
1942
+ closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
1943
+ h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
1944
+ h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('capabilities.batch.cancel'),
1945
+ onClick: () => setBatchOpen(false) }, '\u00d7')),
1946
+ h('div', { className: 'ptcPlusCapabilityBatchBody' },
1947
+ h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.selected', { count: pickedRows.length })),
1948
+ h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
1949
+ ...pickedRows.map(entry => h('li', { key: entry.id }, h('span', null, entry.name ?? entry.id), h('code', null, entry.id)))),
1950
+ h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
1951
+ h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
1952
+ h(Select, { value: batchChoices[dimension], label: t(`capabilities.batch.${dimension}`),
1953
+ onChange: choice => setBatchChoices(current => ({ ...current, [dimension]: choice })),
1954
+ options: ['keep', 'inherit', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
1955
+ .map(choice => ({ value: choice, label: choice === 'keep' ? t('capabilities.batch.keep')
1956
+ : choice === 'inherit' ? t('capabilities.batch.sessionInherit')
1957
+ : choice === 'enabled' ? t('capabilities.action.enable')
1958
+ : choice === 'closed' ? t('capabilities.action.close') : t(`capabilities.${choice}`) })) })))),
1959
+ h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sessionStage'))),
1960
+ h('div', { className: 'ptcPlusCapabilityBatchFooter' },
1961
+ h('button', { type: 'button', className: 'ptcPlusButton', onClick: () => setBatchOpen(false) }, t('capabilities.batch.cancel')),
1962
+ h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
1963
+ disabled: pickedRows.length === 0 || !['availability', 'disclosure'].some(dimension => batchChoices[dimension] !== 'keep'),
1964
+ onClick: () => { stageBatch(pickedRows.map(entry => entry.id), batchChoices); setBatchOpen(false) } },
1965
+ t('capabilities.batch.stage', { count: pickedRows.length })))) : null,
1327
1966
  h('div', { className: 'ptcPlusSessionCapabilityFooter' },
1328
1967
  h('span', { role: 'status' }, pending ? t('capabilities.quick.applying') : status ? t(status.key, status.params)
1329
1968
  : t('capabilities.quick.changes', { count: staged.length })),
1330
1969
  h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending || staged.length === 0, onClick: discard },
1331
1970
  t('capabilities.quick.discard')),
1332
- h('button', { type: 'button', className: 'ptcPlusButton', title: t('capabilities.quick.sessionHint'),
1971
+ // Session-scoped temporary application stays the footer's primary action; saving global
1972
+ // defaults is an explicit secondary choice, so no default-looking control changes global state.
1973
+ h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary', title: t('capabilities.quick.sessionHint'),
1333
1974
  disabled: pending || !enabled || !available || writesBlocked || staged.length === 0, onClick: () => void apply('session') },
1334
1975
  t('capabilities.quick.applySession')),
1335
- h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
1976
+ h('button', { type: 'button', className: 'ptcPlusButton',
1336
1977
  title: t(hasInheritanceReset ? 'capabilities.quick.inheritSessionOnly' : 'capabilities.quick.globalHint'),
1337
1978
  disabled: pending || !enabled || !available || writesBlocked || staged.length === 0 || hasInheritanceReset,
1338
1979
  onClick: () => void apply('global') },
1339
- t('capabilities.quick.applyGlobal'))))
1980
+ t('capabilities.quick.applyGlobal')),
1981
+ h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
1982
+ 'aria-label': t('capabilities.batchSettings'),
1983
+ disabled: pending || writesBlocked || picked.length === 0,
1984
+ onClick: () => { setBatchChoices({ availability: 'keep', disclosure: 'keep' }); setBatchOpen(true) } },
1985
+ t('capabilities.batchSettings'),
1986
+ picked.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
1987
+ title: t('capabilities.selectionCount', { count: picked.length }) },
1988
+ String(picked.length)) : null)))
1340
1989
  }
1341
1990
  }