dsh-vscode-mode 0.17.0 → 0.18.0

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.
@@ -68,6 +68,7 @@ import { mdPreviewLoad, mdPreviewSave } from '../state/mdPreviewCache.js'
68
68
  import { onUiStateReady, reportUiStateEvent, uiStateSettled } from '../state/uiStatePersist.js'
69
69
  import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
70
70
  import { modelsForScope, rememberModel, retainModels } from '../state/modelCache.js'
71
+ import { clearModelWarmer, setModelWarmer } from '../state/modelWarmer.js'
71
72
  import { navFlashRangeOf } from '../navHighlight.js'
72
73
  import { chordOf, useKeybindingsVersion } from '../keybindings.js'
73
74
  import { getSidebarMinWidth } from '../sidebarMin.js'
@@ -260,6 +261,8 @@ export function EditorView(props) {
260
261
  const planActiveRef = React.useRef(null)
261
262
  planActiveRef.current = planActive
262
263
  const planHostRef = React.useRef(null)
264
+ // 进入只读计划预览前的磁盘选中页签,关闭该计划页签时恢复,避免跳到第一个页签。
265
+ const planReturnRef = React.useRef(null)
263
266
  React.useEffect(() => { setPlanTabs([]); setPlanActive(null) }, [sessionId, cwd])
264
267
  /** @private @author ddj 2026年10月08号 @param path Disk tab selection. Leave read-only plan mode before any disk navigation. */
265
268
  function setActive(path) {
@@ -271,6 +274,10 @@ export function EditorView(props) {
271
274
  const [layoutScope, setLayoutScope] = React.useState(null)
272
275
  const benchRef = React.useRef(workbench)
273
276
  benchRef.current = workbench
277
+ // 页签行归属的分组:焦点落在浮窗时行仍留在页面主组上(浮窗自带标题栏),与 renderGroup 同一判据。
278
+ // 行节点会随焦点在分组间迁移,绑定滚轮/滚入视野的 effect 必须跟着这个归属重跑。
279
+ const rowOnFloat = workbench.floats.some((item) => item.group === workbench.focused)
280
+ const rowOwner = rowOnFloat ? MAIN_GROUP : workbench.focused
274
281
  const [dragPreview, setDragPreview] = React.useState(null)
275
282
  const dragRef = React.useRef(null)
276
283
  const benchOps = React.useRef(null)
@@ -1853,11 +1860,11 @@ export function EditorView(props) {
1853
1860
  const right = left + el.offsetWidth
1854
1861
  if (left < host.scrollLeft) host.scrollLeft = left
1855
1862
  else if (right > host.scrollLeft + host.clientWidth) host.scrollLeft = right - host.clientWidth
1856
- }, [active, tabs.length])
1863
+ }, [active, planActive, planTabs.length, tabs.length, rowOwner])
1857
1864
 
1858
1865
  // 页签栏滚轮横向滚动(需求 3):页签栏溢出时,鼠标滚轮(含触控板纵向手势)横滚页签栏,
1859
1866
  // 不溢出时不接管(保持页面垂直滚动);Shift+滚轮交由浏览器原生横滚。详见 horizontalWheel.ts。
1860
- React.useEffect(() => attachHorizontalWheel(tabsHostRef.current), [])
1867
+ React.useEffect(() => attachHorizontalWheel(tabsHostRef.current), [rowOwner])
1861
1868
 
1862
1869
  // 鼠标侧键后退/前进:Logitech 官方默认「后退/前进」= XButton 鼠标事件(button 3/4),不产生键盘事件。
1863
1870
  // pointerdown 触发导航(preventDefault 后兼容 mousedown 可能不再触发,避免双触发);
@@ -2065,6 +2072,42 @@ export function EditorView(props) {
2065
2072
  return model
2066
2073
  }
2067
2074
 
2075
+ const warmRef = React.useRef(null)
2076
+ /**
2077
+ * 让目标文件的 Monaco model 存在(预热入口,供 Ctrl+hover 定义链接预览)。
2078
+ *
2079
+ * 为什么需要:Monaco 在渲染定义链接的源码预览时对目标 uri 调 `createModelReference`
2080
+ * 后只挂 then、没有 catch;本插件的定义目标是自造 `edrv://` uri,目标文件若从未打开过
2081
+ * 就没有已注册 model,会 reject("Model not found") 成为未捕获异常(见 state/modelWarmer.ts)。
2082
+ * 已缓存的路径直接返回成功:模型已在 Monaco 注册表里,无需再读盘。
2083
+ * 预热出的 model 走 modelsRef 常规 LRU(40 条、未附着超限被 dispose);正式打开该文件时
2084
+ * 由 loadContent 的 cached-model 分支 + checkCached 后台校验接管,不会显示陈旧内容。
2085
+ * @private
2086
+ * @author ddj 2026年10月20号
2087
+ * @param path 工作区相对路径
2088
+ * @returns 模型是否已存在/成功建出(失败一律 false,绝不抛)
2089
+ */
2090
+ warmRef.current = (path) => {
2091
+ if (!path) return Promise.resolve(false)
2092
+ if (modelsRef.current.get(path)) return Promise.resolve(true)
2093
+ // 图片/PDF 不建文本 model(各自走预览控件),预热对它们没有意义
2094
+ if (isImagePath(path) || isPdfPath(path)) return Promise.resolve(false)
2095
+ const sid = sessionId
2096
+ if (!sid) return Promise.resolve(false)
2097
+
2098
+ return rpc('edrv.read', { sessionId: sid, path }).then((res) => {
2099
+ if (!res?.ok || typeof res.content !== 'string') return false
2100
+ getModel(path, res.content)
2101
+ return true
2102
+ }).catch(() => false)
2103
+ }
2104
+ // 预热实现随挂载安装、卸载移除:编辑器未挂载时 warmModelFor 直接返回 false(退化为修复前行为)
2105
+ React.useEffect(() => {
2106
+ const warmImpl = (path) => warmRef.current?.(path) ?? Promise.resolve(false)
2107
+ setModelWarmer(warmImpl)
2108
+ return () => clearModelWarmer(warmImpl)
2109
+ }, [])
2110
+
2068
2111
  /**
2069
2112
  * 提交待执行的防抖保存(**真正执行保存**,不是取消)。
2070
2113
  * 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
@@ -3595,6 +3638,7 @@ export function EditorView(props) {
3595
3638
  if (!document) return
3596
3639
  flushSave()
3597
3640
  saveViewState(active)
3641
+ if (!planActiveRef.current) planReturnRef.current = active
3598
3642
  setDiskActive(null)
3599
3643
  setPlanTabs((prev) => [...prev.filter((item) => item.address !== document.address), document].slice(-32))
3600
3644
  setPlanActive(document.address)
@@ -3605,7 +3649,10 @@ export function EditorView(props) {
3605
3649
  function closePlan(address, event) {
3606
3650
  event?.stopPropagation()
3607
3651
  setPlanTabs((prev) => prev.filter((item) => item.address !== address))
3608
- if (planActiveRef.current === address) setActive(tabsRef.current[0]?.path ?? null)
3652
+ if (planActiveRef.current !== address) return
3653
+ const previous = planReturnRef.current
3654
+ planReturnRef.current = null
3655
+ setActive(tabsRef.current.some((tab) => tab.path === previous) ? previous : (tabsRef.current[0]?.path ?? null))
3609
3656
  }
3610
3657
  /** @private @author ddj 2026年10月08号 @param request Claimed disk or virtual open. Flushes saves without replacing dirty models. */
3611
3658
  function openReceiptFile(request) {
@@ -3916,11 +3963,17 @@ export function EditorView(props) {
3916
3963
  setWorkbench((current) => ({ ...current, tree: replace(current.tree) }))
3917
3964
  }
3918
3965
  /** @private @author ddj 2026年10月14号 @param id Leaf id. @returns Tabs and either focused full-feature editor or independent passive view. */
3919
- function renderGroup(id) {
3966
+ function renderGroup(id, floating) {
3920
3967
  const group = workbench.groups.find((item) => item.id === id)
3921
3968
  if (!group) return null
3922
3969
  const focused = id === workbench.focused
3923
- return React.createElement(EditorPane, { key: id, group, focused, dirty: dirtyMap, onSelect: selectGroup, onClose: closeTab, onMenu: paneMenu, onDrag: dragTab, consumeClick: consumeDrag },
3970
+ // 同一行:计划预览页签并入该组自己的页签行(extras),与磁盘页签共用一条滚动区,
3971
+ // 页面工具列固定在行尾(滚动区之外,横滚不带走)。焦点落在浮窗时,这一行仍留在页面分组上,
3972
+ // 避免整条工具栏跟着搬进浮窗;页签缓存 ref 只认页面上的这条行。
3973
+ const chrome = !floating && id === rowOwner
3974
+ // 计划预览占用的正文只会在焦点组里;承载页签行的组同时收起磁盘选中态,保证整屏只有计划页签一条下划线。
3975
+ const hideDiskActive = Boolean(planActive) && (chrome || focused)
3976
+ return React.createElement(EditorPane, { key: id, group, focused, dirty: dirtyMap, suppressActive: hideDiskActive, tabsRef: chrome ? tabsHostRef : undefined, extras: chrome ? planTabExtras : undefined, tools: chrome ? rowTools : undefined, onSelect: selectGroup, onClose: closeTab, onMenu: paneMenu, onDrag: dragTab, consumeClick: consumeDrag },
3924
3977
  focused ? React.createElement('div', { className: 'edrv-full-slot', ref: mountFull }) : React.createElement(React.Fragment, null,
3925
3978
  React.createElement(GroupPathbar, { path: group.active, onFocus: () => selectGroup(id) }),
3926
3979
  React.createElement(PassiveEditor, { key: String(scope) + ':' + group.active, path: group.active, groupId: id, mount: passiveMount })))
@@ -3932,15 +3985,18 @@ export function EditorView(props) {
3932
3985
  }
3933
3986
  // --endregion
3934
3987
 
3935
- const tabsEl = React.createElement('div', { className: 'edrv-tabs', ref: tabsHostRef, style: { flex: '1 1 auto', minWidth: 0 } },
3936
- planTabs.map((plan) => React.createElement('div', {
3937
- key: plan.address, className: 'edrv-tab' + (plan.address === planActive ? ' edrv-tab-active' : ''),
3988
+ // 计划预览页签不再是独立一行的工具条:它作为焦点分组页签行的尾部内容(EditorPane 的 extras)渲染,
3989
+ // 与磁盘页签同处一条滚动区,因此整块工作台只有一行页签。
3990
+ const planTabExtras = planTabs.length
3991
+ ? planTabs.map((plan) => React.createElement('div', {
3992
+ key: plan.address, className: 'edrv-tab edrv-tab-plan' + (plan.address === planActive ? ' edrv-tab-active' : ''),
3938
3993
  title: plan.title + '(计划预览 · 只读)', onClick: () => openPlanDoc(plan),
3939
- }, React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 } }, plan.title),
3940
- React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (event) => closePlan(plan.address, event) }, '×'))),
3941
- (openInput
3942
- ? React.createElement('input', { className: 'edrv-path-input', autoFocus: true, placeholder: '输入工作区相对/绝对路径,回车打开', value: pathDraft, onChange: (e) => setPathDraft(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') openPath(); if (e.key === 'Escape') setOpenInput(false) } })
3943
- : React.createElement('button', { className: 'edrv-tab-add', title: '打开文件(输入路径)', onClick: () => setOpenInput(true) }, '+')))
3994
+ }, React.createElement('span', { className: 'edrv-group-tab-name' }, plan.title),
3995
+ React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (event) => closePlan(plan.address, event) }, '×')))
3996
+ : null
3997
+ const openControl = openInput
3998
+ ? React.createElement('input', { className: 'edrv-path-input', autoFocus: true, placeholder: '输入工作区相对/绝对路径,回车打开', value: pathDraft, onChange: (e) => setPathDraft(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') openPath(); if (e.key === 'Escape') setOpenInput(false) } })
3999
+ : React.createElement('button', { className: 'edrv-tab-add', title: '打开文件(输入路径)', onClick: () => setOpenInput(true) }, '+')
3944
4000
 
3945
4001
  const activePlan = planTabs.find((plan) => plan.address === planActive)
3946
4002
  const pathBar = activePlan ? React.createElement('div', { className: 'edrv-pathbar', title: activePlan.address },
@@ -4022,8 +4078,9 @@ export function EditorView(props) {
4022
4078
  const navBackTarget = navRef.current.peekBack()
4023
4079
  const navForwardTarget = navRef.current.peekForward()
4024
4080
  const navNameOf = (entry) => (entry && entry.path ? String(entry.path).split(/[\\/]/).pop() : '')
4025
- const tabRow = React.createElement('div', { style: { display: 'flex', alignItems: 'center', background: 'var(--dsw-alias-bg-layer-1,transparent)', flexShrink: 0 } },
4026
- tabsEl,
4081
+ // 行尾固定工具列:位于焦点分组页签行的滚动区右侧,横滚页签时不跟着滚走。
4082
+ const rowTools = React.createElement('div', { className: 'edrv-tabs-tools' },
4083
+ openControl,
4027
4084
  (sum.totalFiles > 0
4028
4085
  ? React.createElement('button', { className: 'edrv-diffchip', title: '打开差异总览/归档', onClick: () => setLauncherOpen((o) => !o) }, '⚠ 差异 ' + sum.totalFiles + ' 文件')
4029
4086
  : null),
@@ -4441,7 +4498,8 @@ export function EditorView(props) {
4441
4498
  'data-edrv-diff-dock-shell': '1',
4442
4499
  }, React.createElement(DiffBox, { ...dockSnapshot, dock: true }))
4443
4500
  : null
4444
- // 主编辑列(侧边栏右侧):pathBar + tabRow + 编辑/差异区。
4501
+ // 编辑区容器:pathBar 和页签行现在都由焦点分组自己渲染,这里只放编辑正文与各种浮层。
4502
+
4445
4503
  const editorArea = React.createElement('div', { className: 'edrv-editor-area' },
4446
4504
  dapBar,
4447
4505
  addCfgBtn,
@@ -4466,11 +4524,11 @@ export function EditorView(props) {
4466
4524
  : null
4467
4525
  // 底部状态栏共用一整条:挂在 mainCol 层级(所有分组之下),不随焦点分组搬家(ddj 2026-10-16)
4468
4526
  const fullView = createPortal(React.createElement(React.Fragment, null, pathBar, sideHintEl, diskBanner(), editorArea), fullHost, 'edrv-full-editor')
4527
+ // 页签行已并入焦点分组自身(EditorPane 内一行),不再有独立的顶部工具条。
4469
4528
  const mainCol = React.createElement('div', { className: 'edrv-main-col edrv-workbench', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
4470
- React.createElement('div', { className: 'edrv-workbench-tools' }, tabRow),
4471
4529
  renderTree(workbench.tree), fullView,
4472
4530
  statusBar,
4473
- workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group))),
4531
+ workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group, true))),
4474
4532
  dragPreview ? createPortal(React.createElement(React.Fragment, null,
4475
4533
  dragPreview.rect ? React.createElement('div', { className: 'edrv-drag-overlay', style: dragPreview.rect }, React.createElement('span', { className: 'edrv-drop-label' }, dragPreview.label)) : null,
4476
4534
  React.createElement('div', { className: 'edrv-drag-ghost', style: { left: dragPreview.point.x + 12, top: dragPreview.point.y + 12 } }, baseNameOf(dragPreview.path) + ' · ' + dragPreview.label)), document.body) : null)
@@ -0,0 +1,75 @@
1
+ /** Official session-row slots; no DOM patching or session activation. @author ddj 2026年10月20号 */
2
+ import React, { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
3
+ import type { McpPhase } from '../../shared/mcp.js'
4
+ import { McpSessionState, type SessionMcpView } from '../mcpSessionState.js'
5
+
6
+ const labels: Record<McpPhase, string> = {
7
+ idle: '待使用,尚未启动', preparing: '读取项目配置', queued: '等待 MCP 启动名额', loading: '加载 MCP 插件模块',
8
+ starting: '等待 MCP 插件启动', stopping: '释放旧 MCP 实例', ready: '初始化完成,连接健康未验证',
9
+ error: '初始化异常', none: '无需加载项目 MCP',
10
+ }
11
+ const busy = new Set<McpPhase>(['preparing', 'queued', 'loading', 'starting', 'stopping'])
12
+ export interface McpRowProps { sessionId: string; store: McpSessionState }
13
+
14
+ /** @private @author ddj 2026年10月20号 @param props Root row identity and shared poller. @returns Stable per-session observation. */
15
+ function useMcpView({ sessionId, store }: McpRowProps): SessionMcpView {
16
+ const subscribe = useCallback((notify: () => void) => store.subscribe(sessionId, notify), [store, sessionId])
17
+ const snapshot = useCallback(() => store.get(sessionId), [store, sessionId])
18
+ return useSyncExternalStore(subscribe, snapshot, snapshot)
19
+ }
20
+
21
+ /** @public @author ddj 2026年10月20号 @param view Current observation. @returns Accessible row label; steady states deliberately do not occupy the leading slot. */
22
+ export function mcpRowLabel(view: SessionMcpView): string | undefined {
23
+ if (view.stale) return '项目 MCP 状态暂不可用(上次状态已过期)'
24
+ if (!view.status || (!busy.has(view.status.phase) && view.status.phase !== 'error')) return undefined
25
+ return '项目 MCP:' + labels[view.status.phase]
26
+ }
27
+
28
+ /** @public @author ddj 2026年10月20号 @param props Official row sessionId and poller. @returns 16px loading/error marker; official running/approval states retain precedence. */
29
+ export function McpSessionIcon(props: McpRowProps): React.ReactElement | null {
30
+ const view = useMcpView(props)
31
+ const label = mcpRowLabel(view)
32
+ if (!label) return null
33
+ const warning = view.stale || view.status?.phase === 'error'
34
+ return React.createElement('span', {
35
+ role: 'img', 'aria-label': label, title: label,
36
+ style: { display: 'inline-flex', width: 16, height: 16, alignItems: 'center', justifyContent: 'center',
37
+ color: warning ? 'var(--color-warning, #b7791f)' : 'var(--color-text-secondary, currentColor)', fontSize: 13 },
38
+ }, warning ? '⚠' : '◌')
39
+ }
40
+
41
+ /** @private @author ddj 2026年10月20号 @param start Stage start. @param end Stage finish or local clock. @returns Human-readable elapsed time, without claiming a timeout. */
42
+ function elapsed(start: number, end: number): string { return Math.max(0, (end - start) / 1000).toFixed(1) + ' 秒' }
43
+
44
+ /** @public @author ddj 2026年10月20号 @param props Official hover sessionId and poller. @returns Project-only diagnostic detail, never transport credentials. */
45
+ export function McpSessionDetail(props: McpRowProps): React.ReactElement {
46
+ const view = useMcpView(props)
47
+ const [now, setNow] = useState(Date.now)
48
+ const status = view.status
49
+ useEffect(() => {
50
+ if (!status || view.stale || !busy.has(status.phase)) return
51
+ const tick = setInterval(() => setNow(Date.now()), 1000)
52
+ return () => clearInterval(tick)
53
+ }, [status, view.stale])
54
+
55
+ const children: React.ReactNode[] = [React.createElement('strong', { key: 'title' }, '项目 MCP')]
56
+ if (view.stale) children.push(React.createElement('div', { key: 'stale', role: 'status' }, '状态暂不可用;以下如有记录,为上次观测结果'))
57
+ if (!status) children.push(React.createElement('div', { key: 'empty' }, view.stale ? '等待状态查询' : '当前无活跃 MCP 实例'))
58
+ else {
59
+ const end = status.finishedAt ?? now
60
+ const completed = status.servers.filter((server) => server.phase === 'ready').length
61
+ const slow = !view.stale && busy.has(status.phase) && now - status.phaseAt > 10000
62
+ children.push(React.createElement('div', { key: 'phase' }, labels[status.phase], slow ? '(耗时较长,不代表失败)' : ''))
63
+ children.push(React.createElement('div', { key: 'time' }, `${completed}/${status.servers.length} 已初始化 · 总耗时 ${elapsed(status.startedAt, end)} · 当前阶段 ${elapsed(status.phaseAt, end)}`))
64
+ if (status.configMs !== undefined) children.push(React.createElement('div', { key: 'config' }, `配置准备 ${elapsed(0, status.configMs)}`))
65
+ if (status.error) children.push(React.createElement('div', { key: 'error' }, status.error))
66
+ for (const server of status.servers) children.push(React.createElement('div', { key: 'server:' + server.serverName },
67
+ `${server.serverName}:${labels[server.phase]}${server.reused ? ' · 复用已有连接' : ''}${server.phase === 'idle' ? '' : ' · ' + elapsed(server.startedAt, server.finishedAt ?? now)}`,
68
+ server.queuedMs === undefined ? '' : ' · 排队 ' + elapsed(0, server.queuedMs),
69
+ server.connectMs === undefined ? '' : ' · 初始化 ' + elapsed(0, server.connectMs),
70
+ server.firstCallMs === undefined ? '' : ' · 首次工具执行 ' + elapsed(0, server.firstCallMs),
71
+ server.error ? ' · ' + server.error : ''))
72
+ }
73
+ children.push(React.createElement('small', { key: 'scope' }, '仅 VSCodeMode 管理的项目 MCP;不含全局或 preset MCP。'))
74
+ return React.createElement('section', { 'aria-label': '项目 MCP 状态', style: { display: 'grid', gap: 4, maxWidth: 320, overflowWrap: 'anywhere', fontSize: 12 } }, children)
75
+ }
@@ -10,6 +10,7 @@ import React from 'react'
10
10
  import { rpc } from '../rpc.js'
11
11
  import type { MpcProject, MpcServer } from '../../shared/mcp.js'
12
12
  import { configOf } from '../mcpForm.js'
13
+ import { parseMcpPolicy } from '../../shared/mcp.js'
13
14
  import { McpState } from '../mcpState.js'
14
15
  import { statusOf, statusTone } from '../../shared/mcpStatus.js'
15
16
  import '../styles/mcp.css'
@@ -25,10 +26,10 @@ import { PerfSettings } from './PerfSettings.js'
25
26
  import { IntegrationSettings } from './IntegrationSettings.js'
26
27
  import { AiSettings } from './AiSettings.js'
27
28
 
28
- const EMPTY = { serverName: '', transport: 'stdio', command: '', args: '', cwd: '', url: '', headers: '' }
29
+ const EMPTY = { serverName: '', transport: 'stdio', command: '', args: '', cwd: '', url: '', headers: '', startup: 'on-demand', sharing: 'agent', startupTimeoutMs: 60000 }
29
30
 
30
31
  /** @private @author ddj 2026年10月08号 @param props Server snapshot, page-wide busy state and actions. @returns Lifecycle-evidence card; enabled does not imply online. */
31
- function ServerCard({ server, busy, onRefresh, onToggle, onRemove }) {
32
+ function ServerCard({ server, busy, onRefresh, onToggle, onRemove, onConnect, onPolicy }) {
32
33
  return React.createElement('article', { className: 'vsm-mcp-card' },
33
34
  React.createElement('div', { className: 'vsm-mcp-card-head' },
34
35
  React.createElement('div', { className: 'vsm-mcp-title' }, React.createElement('span', { className: 'vsm-mcp-dot ' + statusTone(server) }), server.serverName),
@@ -39,12 +40,33 @@ function ServerCard({ server, busy, onRefresh, onToggle, onRemove }) {
39
40
  ),
40
41
  ),
41
42
  React.createElement('div', { className: 'vsm-mcp-meta' }, statusOf(server), ' · ', server.toolCount, ' 个工具 · ', server.transport,
42
- server.instanceCount !== undefined ? ' · ' + server.instanceCount + ' 个会话实例(各自连接)' : ''),
43
+ server.instanceCount !== undefined ? ' · ' + server.instanceCount + ' 个连接实例' : '',
44
+ server.sessionCount !== undefined ? ' · ' + server.sessionCount + ' 个会话使用' : ''),
45
+ server.policy && React.createElement(ProjectPolicy, { server, busy, onConnect, onPolicy }),
43
46
  server.error && React.createElement('div', { className: 'vsm-mcp-error' }, server.error),
44
47
  React.createElement('div', { className: 'vsm-mcp-tools' }, server.tools.map((tool) => React.createElement('span', { key: tool.name, className: 'vsm-mcp-chip', title: tool.description || tool.name }, tool.name))),
45
48
  )
46
49
  }
47
50
 
51
+ /** @private @author ddj 2026年10月20号 @param props Project policy and locked actions. @returns Explicit strategy controls and sharing risk notice. */
52
+ function ProjectPolicy({ server, busy, onConnect, onPolicy }) {
53
+ /** @author ddj 2026年10月20号 @param event Strategy selection. Saves only an explicit user change. */
54
+ const startup = (event) => onPolicy?.(server, { ...server.policy, startup: event.target.value })
55
+ /** @author ddj 2026年10月20号 @param event Sharing selection. Requires acknowledgement before state sharing. */
56
+ const sharing = (event) => {
57
+ const mode = event.target.value
58
+ if (mode === 'workspace' && !window.confirm('同一工作区的会话将共享此 MCP 的连接和内部状态;工具串行不等于业务状态隔离。确认服务适合共享?')) return
59
+ onPolicy?.(server, { ...server.policy, sharing: mode })
60
+ }
61
+ return React.createElement('div', { className: 'vsm-mcp-meta' },
62
+ React.createElement('label', null, '启动 ', React.createElement('select', { disabled: !!busy, value: server.policy.startup, onChange: startup },
63
+ React.createElement('option', { value: 'on-demand' }, '按需启动'), React.createElement('option', { value: 'background' }, '后台预热'))),
64
+ React.createElement('label', null, ' 连接 ', React.createElement('select', { disabled: !!busy, value: server.policy.sharing, onChange: sharing },
65
+ React.createElement('option', { value: 'agent' }, '逐会话隔离'), React.createElement('option', { value: 'workspace' }, '工作区共享(共享状态)'))),
66
+ React.createElement('button', { disabled: !!busy || !server.enabled, onClick: onConnect }, '连接当前活跃会话'),
67
+ React.createElement('small', null, '默认按需启动,不阻塞新对话;首次使用仍需等待。共享仅显式启用。'))
68
+ }
69
+
48
70
  /** 按项目标题或路径过滤,大小写不敏感。 */
49
71
  export function filterProjects(projects, query) {
50
72
  const needle = String(query ?? '').trim().toLowerCase()
@@ -94,7 +116,7 @@ function ProjectPicker({ projects, value, onChange }) {
94
116
  }
95
117
 
96
118
  /** @private @author ddj 2026年10月08号 @param props Project view, shared busy lock and actions. @returns Project cards with actions disabled for any active mutation. */
97
- function ProjectGroup({ project, busy, onAdd, onRefresh, onToggle, onRemove }) {
119
+ function ProjectGroup({ project, busy, onAdd, onRefresh, onToggle, onRemove, onConnect, onPolicy }) {
98
120
  const head = React.createElement('div', { className: 'vsm-project-head' },
99
121
  React.createElement('div', { className: 'vsm-project-title' }, React.createElement('span', { className: 'vsm-project-icon' }, '▣'), React.createElement('div', null, React.createElement('span', { className: 'vsm-project-name' }, project.title), React.createElement('span', { className: 'vsm-project-path' }, project.workspacePath))),
100
122
  React.createElement('button', { className: 'vsm-primary vsm-small', disabled: !!busy, onClick: () => onAdd(project) }, '+ 添加 MCP'),
@@ -103,7 +125,7 @@ function ProjectGroup({ project, busy, onAdd, onRefresh, onToggle, onRemove }) {
103
125
  if (project.missingDir) body = React.createElement('div', { className: 'vsm-project-empty' }, '项目目录已不存在,无法管理 MCP')
104
126
  else if (project.fileError) body = React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, project.fileError)
105
127
  else if (!project.servers.length) body = React.createElement('div', { className: 'vsm-project-empty' }, '此项目未配置 MCP')
106
- else body = project.servers.map((server) => React.createElement(ServerCard, { key: server.serverName, server, busy, onRefresh: (id) => onRefresh(project, server, id), onToggle: () => onToggle(project, server), onRemove: (id) => onRemove(project, server, id) }))
128
+ else body = project.servers.map((server) => React.createElement(ServerCard, { key: server.serverName, server, busy, onConnect: () => onConnect(project, server), onPolicy: (server, policy) => onPolicy(project, server, policy), onRefresh: (id) => onRefresh(project, server, id), onToggle: () => onToggle(project, server), onRemove: (id) => onRemove(project, server, id) }))
107
129
  return React.createElement('section', { className: 'vsm-project' }, head, body)
108
130
  }
109
131
 
@@ -513,7 +535,7 @@ function McpManagePanel(props) {
513
535
  const form = showForm
514
536
  ? React.createElement(McpForm, { title: '添加全局 MCP', draft, busy, edit, save: () => saveGlobal(closeGlobalForm), close: closeGlobalForm })
515
537
  : projectForm
516
- ? React.createElement(McpForm, { title: '添加项目 MCP · ' + projectForm.title, draft, busy, edit, save: () => saveProject(projectForm.workspacePath, closeProjectForm), close: closeProjectForm })
538
+ ? React.createElement(McpForm, { project: true, title: '添加项目 MCP · ' + projectForm.title, draft, busy, edit, save: () => saveProject(projectForm.workspacePath, closeProjectForm), close: closeProjectForm })
517
539
  : null
518
540
  return React.createElement(React.Fragment, null,
519
541
  React.createElement('div', { className: 'vsm-mcp-subhead' },
@@ -538,7 +560,7 @@ function manageBody(props, tab, path, select, add) {
538
560
  const project = projects.find((item) => item.workspacePath === path)
539
561
  return React.createElement(React.Fragment, null,
540
562
  React.createElement(ProjectPicker, { projects, value: path, onChange: select }),
541
- project ? React.createElement(ProjectGroup, { project, busy, onAdd: add, onRefresh: refreshProject, onToggle: toggleProject, onRemove: removeProject }) : React.createElement('div', { className: 'vsm-mcp-empty' }, '请选择项目'),
563
+ project ? React.createElement(ProjectGroup, { project, busy, onAdd: add, onRefresh: refreshProject, onToggle: toggleProject, onRemove: removeProject, onConnect: props.connectProject, onPolicy: props.policyProject }) : React.createElement('div', { className: 'vsm-mcp-empty' }, '请选择项目'),
542
564
  )
543
565
  }
544
566
 
@@ -593,7 +615,7 @@ function projectAction(state, path, name, method, extra = {}) {
593
615
  }
594
616
 
595
617
  /** @private @author ddj 2026年10月08号 @param ref Mounted controller reference. @returns Global/project card actions sharing a synchronous lock. */
596
- function cardActions(ref) {
618
+ function cardActions(ref, getSession) {
597
619
  /** @author ddj 2026年10月08号 @param id Global identity. @returns Locked refresh. */
598
620
  const refreshGlobal = (id) => globalAction(ref.current, id, 'mcp.refresh', { id })
599
621
  /** @author ddj 2026年10月08号 @param server Current configuration. @returns Locked enable change. */
@@ -606,7 +628,15 @@ function cardActions(ref) {
606
628
  const toggleProject = (project, server) => projectAction(ref.current, project.workspacePath, server.serverName, 'mcp.projectToggle', { enabled: !server.enabled })
607
629
  /** @author ddj 2026年10月08号 @param project Project. @param server Configuration. @returns Confirmed locked deletion. */
608
630
  const removeProject = (project, server) => !ref.current?.view.busy && window.confirm('确认删除此项目的 MCP「' + server.serverName + '」?') && projectAction(ref.current, project.workspacePath, server.serverName, 'mcp.projectRemove')
609
- return { refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject }
631
+ /** @author ddj 2026年10月20号 @param project Owner workspace. @param server Enabled service. @returns Explicit connection for an already-active current session. */
632
+ const connectProject = (project, server) => {
633
+ const sessionId = getSession?.()
634
+ if (!sessionId) return ref.current?.mutate('connect', async () => ({ ok: false, error: '请先打开此项目的活跃会话' }), () => undefined)
635
+ return projectAction(ref.current, project.workspacePath, server.serverName, 'mcp.projectConnect', { sessionId })
636
+ }
637
+ /** @author ddj 2026年10月20号 @param project Owner. @param server Masked editable config. @param policy Explicit user strategy. @returns Serialized strategy persistence. */
638
+ const policyProject = (project, server, policy) => projectAction(ref.current, project.workspacePath, server.serverName, 'mcp.projectSave', { config: server.config, policy })
639
+ return { refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject, connectProject, policyProject }
610
640
  }
611
641
 
612
642
  /** @private @author ddj 2026年10月08号 @param state Controller. @param draft Editable fields. @param setDraft Draft setter. @param path Optional project target. @param close Success-only close callback. @returns Locked save, validation included. */
@@ -614,7 +644,7 @@ function saveDraft(state, draft, setDraft, path, close) {
614
644
  /** @author ddj 2026年10月08号 @returns Validated save result. @throws Malformed arguments/headers. */
615
645
  const request = () => {
616
646
  const config = configOf(draft)
617
- return path === null ? rpc('mcp.save', { config }) : rpc('mcp.projectSave', { workspacePath: path, serverName: config.serverName, config })
647
+ return path === null ? rpc('mcp.save', { config }) : rpc('mcp.projectSave', { workspacePath: path, serverName: config.serverName, config, policy: parseMcpPolicy({ startup: draft.startup, sharing: draft.sharing, startupTimeoutMs: Number(draft.startupTimeoutMs) }) })
618
648
  }
619
649
  /** @author ddj 2026年10月08号 @param result Save result. @description Update current view and only then reset/close the form. */
620
650
  const commit = (result) => {
@@ -649,7 +679,7 @@ export function McpSettings(props) {
649
679
  return cleanup
650
680
  }
651
681
  React.useEffect(selectTab, [tab])
652
- const { refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject } = cardActions(ref)
682
+ const { refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject, connectProject, policyProject } = cardActions(ref, props?.getMcpSession)
653
683
  /** @author ddj 2026年10月08号 @param close Success-only close callback. @returns Global save completion. */
654
684
  const saveGlobal = (close) => saveDraft(ref.current, draft, setDraft, null, close)
655
685
  /** @author ddj 2026年10月08号 @param path Target project. @param close Success-only close callback. @returns Project save completion. */
@@ -662,7 +692,7 @@ export function McpSettings(props) {
662
692
  if (loading) body = React.createElement('div', { className: 'vsm-mcp-empty' }, '正在读取 MCP 服务…')
663
693
  else if (tab === 'general') body = React.createElement(GeneralSettings, { registry: openerRegistry, getShortcuts })
664
694
  else if (tab === 'mcp') body = React.createElement(McpManagePanel, { servers, projects, busy, draft, edit, resetDraft,
665
- saveGlobal, saveProject,
695
+ saveGlobal, saveProject, connectProject, policyProject,
666
696
  refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject })
667
697
  else if (tab === 'compat') body = React.createElement(CompatSection, { getSummary: compatSummary })
668
698
  else if (tab === 'lsp') body = React.createElement(LspSettings, null)
@@ -677,7 +707,7 @@ export function McpSettings(props) {
677
707
  }
678
708
 
679
709
  /** @private @author ddj 2026年10月08号 @param props Draft, shared busy lock, edit/save/close callbacks. @returns Lossless argument/header form; failed save stays open. */
680
- function McpForm({ title, draft, busy, edit, save, close }) {
710
+ function McpForm({ title, draft, busy, edit, save, close, project }) {
681
711
  /** @author ddj 2026年10月08号 @param key Draft field. @returns Short input change callback. */
682
712
  const change = (key) => (event) => edit(key, event.target.value)
683
713
  /** @author ddj 2026年10月08号 @param key Draft field. @param placeholder Input hint. @returns Disabled-while-busy input. */
@@ -697,6 +727,10 @@ function McpForm({ title, draft, busy, edit, save, close }) {
697
727
  React.createElement('label', null, '名称', input('serverName', '例如 codegraph')),
698
728
  React.createElement('label', null, '传输方式', React.createElement('select', { disabled: !!busy, value: draft.transport, onChange: change('transport') }, React.createElement('option', { value: 'stdio' }, 'stdio'), React.createElement('option', { value: 'streamable-http' }, 'streamable-http'))),
699
729
  fields,
730
+ project && React.createElement('label', null, '启动策略', React.createElement('select', { disabled: !!busy, value: draft.startup, onChange: change('startup') }, React.createElement('option', { value: 'on-demand' }, '按需启动(默认)'), React.createElement('option', { value: 'background' }, '后台预热'))),
731
+ project && React.createElement('label', null, '连接模式', React.createElement('select', { disabled: !!busy, value: draft.sharing, onChange: change('sharing') }, React.createElement('option', { value: 'agent' }, '逐会话隔离(默认)'), React.createElement('option', { value: 'workspace' }, '工作区共享(共享服务内部状态)'))),
732
+ project && React.createElement('label', null, '初始化预算(毫秒,1000–300000)', input('startupTimeoutMs', '60000')),
733
+ project && React.createElement('small', null, '新对话不等待连接。共享必须确认服务适合共享状态;不为 CodeGraph 自动启用。'),
700
734
  React.createElement('div', { className: 'vsm-mcp-dialog-actions' }, React.createElement('button', { disabled: !!busy, onClick: close }, '取消'), React.createElement('button', { className: 'vsm-primary', disabled: !!busy, onClick: save }, '保存配置')),
701
735
  ))
702
736
  }
package/src/mcp.ts CHANGED
@@ -218,6 +218,7 @@ export async function saveMcp(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
218
218
 
219
219
  /** @private @author ddj 2026年10月08号 @param ctx Host services. @param config Configuration. @returns Saved status; validation failures do not write. */
220
220
  async function saveGlobal(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
221
+ if (config && 'vscodeMode' in config) throw new Error('vscodeMode 启动和共享策略仅适用于项目 MCP')
221
222
  validateConfig(config)
222
223
  await checkMcpName(ctx, config.serverName)
223
224
  const loader = ctx.get('loader')
@@ -0,0 +1,163 @@
1
+ /** Private official-MCP registrations, never a global provider. @author ddj 2026年10月20号 */
2
+ import { loadHostModule } from './hostImport.js'
3
+ import type { Ctx } from './store.js'
4
+ import type { McpPool, PoolEntry } from './mcpPool.js'
5
+
6
+ export interface BridgeScope {
7
+ ctx: any
8
+ dispose(): Promise<void>
9
+ }
10
+ export interface McpSection {
11
+ name: string
12
+ order: number
13
+ interpolate?: boolean
14
+ text: string | ((context?: any) => string)
15
+ }
16
+ export interface McpProvider {
17
+ request(request: any, execution: any): Promise<any>
18
+ }
19
+ export type ScopeMaker = (ctx: any, key: object) => BridgeScope
20
+
21
+ /** Per-agent public surface; original definitions still run inside the normal registry pipeline. */
22
+ export class McpBinding {
23
+ private readonly removals: Array<() => void> = []
24
+ private unsubscribe?: () => void
25
+ private closed = false
26
+
27
+ /** @public @author ddj 2026年10月20号 @param agent Public scope. @param pool Connection owner. @param entry Exact generation. @param valid Authorization recheck. */
28
+ constructor(private readonly agent: any, private readonly pool: McpPool, private readonly entry: PoolEntry, private readonly valid: () => boolean, private readonly called?: (elapsed: number) => void) {}
29
+
30
+ /** @private @author ddj 2026年10月20号 @param execution Registry caller. @throws Stale generation or foreign caller. */
31
+ private check(execution?: any): void {
32
+ if (this.closed || !this.valid() || !this.entry.live || (execution && execution.agent !== this.agent)) throw new Error('项目 MCP 作用域或配置已失效')
33
+ }
34
+
35
+ /** @public @author ddj 2026年10月20号 Reconciles exact definitions after official tool-list updates. */
36
+ sync(): void {
37
+ this.check()
38
+ const bridge = this.entry.bridge
39
+ if (!bridge) return
40
+ // Resources and instructions can change independently of tool names; replace the whole small scoped surface.
41
+ this.clear()
42
+ for (const [name, definition] of bridge.tools) {
43
+ /** @author ddj 2026年10月20号 @param args Registry-validated arguments. @param execution Unmodified caller context. @returns Official canonical output. */
44
+ const execute = async (args: unknown, execution: any): Promise<unknown> => {
45
+ this.check(execution)
46
+ return this.pool.run(this.entry, async () => {
47
+ this.check(execution)
48
+ if (bridge.tools.get(name) !== definition) throw new Error('MCP 工具定义已更新,请重新发现')
49
+ const startedAt = Date.now()
50
+ const result = await definition.execute(args, execution)
51
+ this.called?.(Date.now() - startedAt)
52
+ return result
53
+ }, execution.signal)
54
+ }
55
+ this.removals.push(this.agent.ctx.get('tools').register({ ...definition, execute }))
56
+ }
57
+ // Resource ownership stays with the runtime's single lazy provider, avoiding duplicate namespace registration.
58
+ for (const section of bridge.sections.values()) {
59
+ /** @author ddj 2026年10月20号 @param context Actual prompt assembly. @returns Only valid-generation instructions. */
60
+ const text = (context: any): string => {
61
+ if (!this.valid() || this.closed) return ''
62
+ if (typeof section.text === 'function') return section.text(context)
63
+ return section.text
64
+ }
65
+ this.removals.push(this.agent.ctx.get('systemPrompt').section({ ...section, text }))
66
+ }
67
+ /** @author ddj 2026年10月20号 Reconciles dynamic official registrations only for a live subscriber. */
68
+ const update = (): void => { if (!this.closed && this.valid() && this.entry.live) this.sync() }
69
+ if (!this.unsubscribe) this.unsubscribe = bridge.subscribe(update)
70
+ }
71
+
72
+ /** @private @author ddj 2026年10月20号 Removes only this binding's exact scoped registrations. */
73
+ private clear(): void {
74
+ for (const remove of this.removals.splice(0).reverse()) remove()
75
+ }
76
+
77
+ /** @public @author ddj 2026年10月20号 Revokes proxies synchronously before releasing a lease. */
78
+ dispose(): void {
79
+ this.closed = true
80
+ this.unsubscribe?.()
81
+ this.unsubscribe = undefined
82
+ this.clear()
83
+ }
84
+ }
85
+
86
+ /** A connection's private surface. Public consumers register scoped proxies, not this scope. */
87
+ export class McpBridge {
88
+ readonly tools = new Map<string, any>()
89
+ readonly resources = new Map<string, McpProvider>()
90
+ readonly sections = new Map<string, McpSection>()
91
+ private readonly listeners = new Set<() => void>()
92
+ private closed = false
93
+ private closing?: Promise<void>
94
+
95
+ /** @private @author ddj 2026年10月20号 @param scope Private lifetime and registration context. */
96
+ private constructor(readonly scope: BridgeScope) {}
97
+
98
+ /** @public @author ddj 2026年10月20号 @param ctx Host owner. @param make Optional real scope factory for installed-host tests. @returns Isolated registration adapter. */
99
+ static async create(ctx: Ctx, make?: ScopeMaker): Promise<McpBridge> {
100
+ const factory = make ?? (await loadHostModule('@deepseek-ai/dsh-scope') as { createScope: ScopeMaker }).createScope
101
+ const scope = factory(ctx, {})
102
+ // Providers must be installed after the backing scope's initial activation transaction.
103
+ await scope.ctx.fiber?.await?.()
104
+ const bridge = new McpBridge(scope)
105
+ bridge.install()
106
+ return bridge
107
+ }
108
+
109
+ /** @private @author ddj 2026年10月20号 Installs only private services; no project registration reaches the root. */
110
+ private install(): void {
111
+ const privateCtx = this.scope.ctx.isolate('tools').isolate('mcpResources').isolate('systemPrompt')
112
+ privateCtx.provide('tools', { register: (definition: any) => this.register(this.tools, definition.name, definition) })
113
+ privateCtx.provide('mcpResources', { register: (name: string, provider: McpProvider) => this.register(this.resources, name, provider) })
114
+ privateCtx.provide('systemPrompt', {
115
+ getSectionOrder: () => 3100,
116
+ section: (section: McpSection) => this.register(this.sections, section.name, section),
117
+ })
118
+ this.scope.ctx = privateCtx
119
+ }
120
+
121
+ /** @private @author ddj 2026年10月20号 @param registry Private generation map. @param name Stable registration name. @param entry Exact value. @returns Exact-entry removal, safe after replacement. */
122
+ private register<T>(registry: Map<string, T>, name: string, entry: T): () => void {
123
+ if (this.closed) throw new Error('MCP bridge is disposed')
124
+ if (registry.has(name)) throw new Error('Duplicate private MCP registration: ' + name)
125
+ registry.set(name, entry)
126
+ this.changed()
127
+
128
+ return () => {
129
+ if (registry.get(name) !== entry) return
130
+ registry.delete(name)
131
+ this.changed()
132
+ }
133
+ }
134
+
135
+ /** @public @author ddj 2026年10月20号 @param listener Surface change subscriber. @returns Subscription disposer. */
136
+ subscribe(listener: () => void): () => void {
137
+ this.listeners.add(listener)
138
+ return () => { this.listeners.delete(listener) }
139
+ }
140
+
141
+ /** @private @author ddj 2026年10月20号 Notifies consumers; observer errors cannot corrupt the official sync transaction. */
142
+ private changed(): void {
143
+ for (const listener of this.listeners) {
144
+ try { listener() } catch { /* Runtime owns diagnostics for its subscriber. */ }
145
+ }
146
+ }
147
+
148
+ /** @public @author ddj 2026年10月20号 @returns Quiescent private-scope teardown. */
149
+ dispose(): Promise<void> {
150
+ this.closed = true
151
+ return this.closing ??= this.closeScope()
152
+ }
153
+
154
+ /** @private @author ddj 2026年10月20号 @returns Exact scope closure before forgetting registrations. */
155
+ private async closeScope(): Promise<void> {
156
+ await this.scope.dispose()
157
+ this.tools.clear()
158
+ this.resources.clear()
159
+ this.sections.clear()
160
+ this.changed()
161
+ this.listeners.clear()
162
+ }
163
+ }