dsh-vscode-mode 0.9.0 → 0.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -285,6 +285,17 @@ function mintRpcId(): string {
285
285
  }
286
286
  }
287
287
 
288
+ /**
289
+ * wire 槽实参组装(gateway `invoke` 铁证:`request.args[parameter.wire]` 具名槽,
290
+ * `session/prompt` 的槽名 = "request",值 = RpcRequest `{rpcId, payload}`)。
291
+ * @author ddj 2026年09月24号
292
+ * @param payload 业务载荷
293
+ * @returns 具名槽实参 `{ args: { request: RpcRequest } }`
294
+ */
295
+ function slotArg(payload: object): { args: { request: { rpcId: string; payload: object } } } {
296
+ return { args: { request: { rpcId: mintRpcId(), payload } } }
297
+ }
298
+
288
299
  /**
289
300
  * RPC 返回解析(照官方 dsh-client-runtime 调用形态:`const { result } = await api.sessions.create(...)`)。
290
301
  * 官方 api.sessions 面返回**信封** `{ rpcId, result: RpcResult }`——须解 .result;
@@ -335,6 +346,7 @@ async function apiSessionCreate(cwd: string | undefined): Promise<string> {
335
346
  */
336
347
  export async function apiSessionPrompt(sessionId: string, text: string): Promise<boolean> {
337
348
  try {
349
+ const payload = { sessionId, mode: 'queue', content: [{ type: 'text', text }] }
338
350
  const res = await fetch(SESSION_PROMPT_PATH, {
339
351
  method: 'POST',
340
352
  headers: { 'content-type': 'application/json' },
@@ -342,7 +354,7 @@ export async function apiSessionPrompt(sessionId: string, text: string): Promise
342
354
  type: 'client-request',
343
355
  rpcId: mintRpcId(),
344
356
  method: 'session/prompt',
345
- payload: { sessionId, mode: 'queue', content: [{ type: 'text', text }] },
357
+ payload: slotArg(payload),
346
358
  }),
347
359
  })
348
360
  const json = await res.json().catch(() => null) as { result?: { ok?: boolean } } | null
@@ -362,25 +374,10 @@ export async function apiSessionPrompt(sessionId: string, text: string): Promise
362
374
  * @returns 新会话结果(含 id)
363
375
  */
364
376
  export async function newDraftSession(ctx: CtxLike, cwd: string | undefined): Promise<DraftSessionResult> {
365
- // 主路径:wire 直调
366
- try {
367
- const id = await apiSessionCreate(cwd)
368
- if (id) return { ok: true, id }
369
- } catch { /* wire 不可用:回落 service 面 */ }
377
+ // 主路径:client store manager 面(官方 connectWorkspace 同链路)——create 自带 store upsert,
378
+ // 「on resolution the child is in the list store and open() can target it」;
379
+ // wire 直调虽能建会话但 store 无感,故只作最后兜底。
370
380
  try {
371
- // 回落 1:remote.session service 面(官方返回信封 {rpcId, result},见 rpcOk;实参双形态兼容)
372
- const remote = ctx.get('remote.session') as {
373
- create?: (req: unknown) => Promise<unknown>
374
- } | undefined
375
- if (typeof remote?.create === 'function') {
376
- const payload = cwd ? { cwd } : {}
377
- for (const arg of [payload, { rpcId: mintRpcId(), payload }]) {
378
- const r = rpcOk(await remote.create(arg))
379
- const id = r.value as { sessionId?: unknown } | undefined
380
- if (r.ok && typeof id?.sessionId === 'string') return { ok: true, id: id.sessionId }
381
- }
382
- }
383
- // 回落 2:旧版 client store 形态(id / {id} / {sessionId} / {session:{id}} 四解)
384
381
  const sessions = ctx.get('sessions') as { create?: (opts?: { cwd?: string }) => unknown } | undefined
385
382
  if (typeof sessions?.create === 'function') {
386
383
  const created = await sessions.create(cwd ? { cwd } : undefined)
@@ -392,6 +389,25 @@ export async function newDraftSession(ctx: CtxLike, cwd: string | undefined): Pr
392
389
  : ''
393
390
  if (id) return { ok: true, id }
394
391
  }
392
+ } catch { /* 回落下一通道 */ }
393
+ try {
394
+ // 回落 1:api.sessions service 面(返回信封 {rpcId, result},见 rpcOk;多形态容错)
395
+ const remote = ctx.get('remote.session') as { create?: (req: unknown) => Promise<unknown> } | undefined
396
+ if (typeof remote?.create === 'function') {
397
+ const payload = cwd ? { cwd } : {}
398
+ for (const arg of [slotArg(payload), { request: { rpcId: mintRpcId(), payload } }, { rpcId: mintRpcId(), payload }, payload]) {
399
+ try {
400
+ const r = rpcOk(await remote.create(arg))
401
+ const id = r.value as { sessionId?: unknown } | undefined
402
+ if (r.ok && typeof id?.sessionId === 'string') return { ok: true, id: id.sessionId }
403
+ } catch { /* 单形态失败试下一形态 */ }
404
+ }
405
+ }
406
+ } catch { /* 回落 wire */ }
407
+ // 回落 2:wire 直调(创建成功但 store 无感,open 切换不保证;任务发送仍可用)
408
+ try {
409
+ const id = await apiSessionCreate(cwd)
410
+ if (id) return { ok: true, id }
395
411
  } catch { /* 全部失败:调用方降级 */ }
396
412
  return { ok: false }
397
413
  }
@@ -559,11 +575,21 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
559
575
  const startDraftSession: AddToConversation['startDraftSession'] = async (cwd) => {
560
576
  const result = await newDraftSession(ctx, cwd)
561
577
  if (result.ok && result.id) {
562
- // 尽力切换到新会话(缺失/异常不影响结果——仅视图不切换)
563
578
  try {
564
- const sessions = ctx.get('sessions') as { open?: (id: string) => void } | undefined
565
- sessions?.open?.(result.id)
566
- } catch { /* 切换失败仅视图停留 */ }
579
+ // 主动铸造 scope(scope 对新会话惰性铸造,inputFor/composer 面依赖它)
580
+ const sessions = ctx.get('sessions') as {
581
+ materializeScope?: (id: string) => unknown
582
+ manager?: { select?: (id: string) => void; rename?: (id: string, title: string) => void }
583
+ open?: (id: string) => void
584
+ } | undefined
585
+ sessions?.materializeScope?.(result.id)
586
+ // 命名醒目标题(manager.rename 在面实证):会话列表一眼可见、点击即入
587
+ const title = 'AI 深度分析 · ' + (String(cwd ?? '').split(/[\\/]/).pop() || 'SVN 变更')
588
+ void sessions?.manager?.rename?.(result.id, title)
589
+ // 切换尝试(open/select 未对插件暴露时静默跳过:rename 已保证可见性)
590
+ void sessions?.manager?.select?.(result.id)
591
+ void sessions?.open?.(result.id)
592
+ } catch { /* 面能力缺失不致命:任务发送不受影响 */ }
567
593
  }
568
594
  return result
569
595
  }
@@ -578,18 +604,38 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
578
604
  */
579
605
  const sendTask: AddToConversation['sendTask'] = async (sessionId, text) => {
580
606
  if (!sessionId) return false
607
+ // 主路径:**官方 composer 发送链路**(InputLike setDraft + submit —— UI「发送」的内部路径)。
608
+ // inputFor 依赖 sessions.scope(id):新会话铸造有时延,重试 3 次后再降级。
609
+ const composerOnce = async (): Promise<boolean> => {
610
+ const input = inputFor(ctx, sessionId) as { setDraft?: (t: string) => void; submit?: (mode?: unknown) => void } | undefined
611
+ if (input && typeof input.setDraft === 'function' && typeof input.submit === 'function') {
612
+ input.setDraft(text)
613
+ input.submit()
614
+ return true
615
+ }
616
+ return false
617
+ }
581
618
  try {
582
- if (await apiSessionPrompt(sessionId, text)) return true
583
- } catch { /* 回落 service 面 */ }
619
+ for (let attempt = 3; attempt > 0; attempt--) {
620
+ if (await composerOnce()) return true
621
+ if (attempt > 1) await new Promise((resolve) => setTimeout(resolve, 500))
622
+ }
623
+ } catch { /* 降级下一通道 */ }
624
+ // 兜底 1:remote.session service 面(多形态容错)
584
625
  try {
585
626
  const remote = ctx.get('remote.session') as { prompt?: (req: unknown) => Promise<unknown> } | undefined
586
627
  if (typeof remote?.prompt === 'function') {
587
- // 官方 api.sessions.prompt(request) 返回信封(rpcOk 解 .result);实参双形态兼容
588
628
  const payload = { sessionId, mode: 'queue', content: [{ type: 'text', text }] }
589
- for (const arg of [payload, { rpcId: mintRpcId(), payload }]) {
590
- if (rpcOk(await remote.prompt(arg)).ok) return true
629
+ for (const arg of [slotArg(payload), { request: { rpcId: mintRpcId(), payload } }, { rpcId: mintRpcId(), payload }, payload]) {
630
+ try {
631
+ if (rpcOk(await remote.prompt(arg)).ok) return true
632
+ } catch { /* 单形态失败试下一形态 */ }
591
633
  }
592
634
  }
635
+ } catch { /* 降级下一通道 */ }
636
+ // 兜底 2:HTTP wire
637
+ try {
638
+ return await apiSessionPrompt(sessionId, text)
593
639
  } catch { /* 全失败 */ }
594
640
  return false
595
641
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { dapStore } from './store.js'
9
9
  import { pathOfModel } from '../monaco/lsp/lspClient.js'
10
+ import { LUA_RESERVED_WORD_SET } from '../luaReservedWords.js'
10
11
  import { BLOCK_ATTR, EQ_ATTR, EXPR_ATTR, HEAD_ATTR, TYPE_ATTR, VAL_ATTR, createTreeState, escapeHtml, initialHtml } from './hoverTree.js'
11
12
 
12
13
  const GLOBAL_KEY = '__edrvDapHoverProvider__'
@@ -35,12 +36,6 @@ function treeKeyOf(target: { expression: string; lineNumber: number; startColumn
35
36
  return target.expression + '@' + path + ':' + target.lineNumber + ':' + target.startColumn + '-' + target.endColumn
36
37
  }
37
38
 
38
- /** Lua 保留字集合(保留字不是可求值表达式,避免 adapter 返回 syntax err 噪声)。 */
39
- const LUA_KEYWORDS = new Set([
40
- 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function', 'goto',
41
- 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then', 'true', 'until', 'while',
42
- ])
43
-
44
39
  /** 可求值路径表达式:标识符链,成员用 . 或 : 连接(注释里的中文文本等一律不匹配)。 */
45
40
  const EVAL_EXPR = /^[A-Za-z_][A-Za-z0-9_]*(?:[.:][A-Za-z_][A-Za-z0-9_]*)*$/
46
41
 
@@ -51,7 +46,7 @@ const EVAL_EXPR = /^[A-Za-z_][A-Za-z0-9_]*(?:[.:][A-Za-z_][A-Za-z0-9_]*)*$/
51
46
  * @returns 是否可求值
52
47
  */
53
48
  function isEvalExpr(text: string): boolean {
54
- return EVAL_EXPR.test(text) && !LUA_KEYWORDS.has(text)
49
+ return EVAL_EXPR.test(text) && !LUA_RESERVED_WORD_SET.has(text)
55
50
  }
56
51
 
57
52
  /**
@@ -27,6 +27,8 @@ import { createFileOpenerRegistry, scanSidebar, officialSidebarOpener, shouldCla
27
27
  import type { FileOpenerRegistry } from './fileOpeners.js'
28
28
  import { installOpenPathRouter, vscodeOpener, autoValue } from './openPathRouter.js'
29
29
  import { patchRemoteOpen, probeRemoteOpen } from './remoteOpenRouter.js'
30
+ import { patchSettingsDoc, probeSettingsDoc } from './settingsDocRouter.js'
31
+ import { openEditorView } from './events.js'
30
32
  import { setupExtOpen } from './externalOpen.js'
31
33
  import { SettingsContext } from './settingsContext.js'
32
34
  import { SIDEBAR_PLUGIN, registerSlotSafely, settingsBridge } from './compat.js'
@@ -104,6 +106,29 @@ function setupCommands(ctx: any): void {
104
106
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
107
  export function apply(ctx: any): void {
106
108
  const schedule = (fn: () => void, ms: number) => ctx.timeout(fn, ms)
109
+ ctx.effect(() => {
110
+ let disposed = false
111
+ let attempts = 0
112
+ let restore: (() => void) | null = null
113
+ const install = (): void => {
114
+ if (disposed || restore || attempts >= 15) return
115
+ attempts += 1
116
+ const service = probeSettingsDoc(ctx)
117
+ if (service) {
118
+ restore = patchSettingsDoc(service, async () => {
119
+ const result = await rpc('vscode.settingsDocumentPath', {})
120
+ return result.ok ? { ok: true, path: result.path } : { ok: false, error: result.error }
121
+ }, openEditorView, (message) => log.warn(message))
122
+ if (restore) return
123
+ }
124
+ if (attempts < 15) schedule(install, 2000)
125
+ }
126
+ install()
127
+ return () => {
128
+ disposed = true
129
+ restore?.()
130
+ }
131
+ }, 'vscode-mode: settings document editor route')
107
132
 
108
133
  // 指令系统(命令栏 + 指令注册表)先装配:命令栏在被任何 React slot 渲染前即可唤起
109
134
  setupCommands(ctx)
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Lua 5.3 保留字,供编辑器补全与调试悬停共同使用。
3
+ * 作者 ddj 2026年09月24号
4
+ */
5
+ export const LUA_RESERVED_WORDS = [
6
+ 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function', 'goto',
7
+ 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then', 'true', 'until', 'while',
8
+ ] as const
9
+
10
+ export const LUA_RESERVED_WORD_SET: ReadonlySet<string> = new Set(LUA_RESERVED_WORDS)
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 为 Lua 的 LSP 补全补充服务器缺失的保留字与注解基础类型候选。
3
+ * 作者 ddj 2026年09月24号
4
+ */
5
+ import { LUA_RESERVED_WORDS } from '../../luaReservedWords.js'
6
+
7
+ const LUA_BASE_TYPES = [
8
+ 'any', 'boolean', 'integer', 'nil', 'number', 'string', 'table', 'thread', 'userdata',
9
+ ] as const
10
+ const TYPE_START = /^\s*---@(?:(?:type|return)\s+|param\s+\S+\s+|field\s+(?:(?:public|private|protected|package)\s+)?\S+\s+)(.*)$/
11
+
12
+ interface KeywordRange {
13
+ startLineNumber: number
14
+ endLineNumber: number
15
+ startColumn: number
16
+ endColumn: number
17
+ }
18
+
19
+ interface KeywordItem {
20
+ label: string
21
+ kind: number
22
+ insertText: string
23
+ range: KeywordRange
24
+ detail: string
25
+ }
26
+
27
+ /**
28
+ * 构造轻量本地候选,保留服务器同名项的优先权交给 mergeKeywords。
29
+ * @author ddj 2026年09月24号
30
+ * @param words 候选文本
31
+ * @param kind Monaco 候选类型
32
+ * @param range 当前单词范围
33
+ * @param detail 候选说明
34
+ * @returns 可直接交给 Monaco 的候选
35
+ */
36
+ function keywordItems(words: readonly string[], kind: number, range: KeywordRange, detail: string): KeywordItem[] {
37
+ return words.map((label) => ({ label, kind, insertText: label, range, detail }))
38
+ }
39
+
40
+ /**
41
+ * 判断光标是否在 EmmyLua 注解的类型槽;普通注释和描述文字均不匹配。
42
+ * @author ddj 2026年09月24号
43
+ * @param model 当前文档
44
+ * @param position 光标所在行
45
+ * @param range 当前词范围
46
+ * @returns 是否应提示基础类型
47
+ */
48
+ export function isLuaTypeSlot(
49
+ model: { getLineContent(lineNumber: number): string },
50
+ position: { lineNumber: number },
51
+ range: Pick<KeywordRange, 'startColumn'>,
52
+ ): boolean {
53
+ const beforeWord = model.getLineContent(position.lineNumber).slice(0, range.startColumn - 1)
54
+ const match = TYPE_START.exec(beforeWord)
55
+ if (!match) return false
56
+ const context = match[1] ?? ''
57
+ const typePrefix = context.trimEnd()
58
+ if (/[.:]$/.test(typePrefix)) return false
59
+ return !(/\s$/.test(context) && typePrefix && !/[|&,<]$/.test(typePrefix))
60
+ }
61
+
62
+ /**
63
+ * 在普通代码位置生成关键字;成员名和类型注解位置只交给对应候选。
64
+ * @author ddj 2026年09月24号
65
+ * @param monaco Monaco 语言枚举
66
+ * @param model 当前文档
67
+ * @param position 光标所在行
68
+ * @param range 当前单词的替换范围
69
+ * @returns 当前上下文可用的 Lua 关键字候选
70
+ */
71
+ export function luaKeywordsAt(
72
+ monaco: { languages: { CompletionItemKind: { Keyword: number } } },
73
+ model: { getLineContent(lineNumber: number): string },
74
+ position: { lineNumber: number },
75
+ range: KeywordRange,
76
+ ): KeywordItem[] {
77
+ const line = model.getLineContent(position.lineNumber)
78
+ const beforeWord = line.charAt(range.startColumn - 2)
79
+ if (beforeWord === '.' || beforeWord === ':' || /^\s*---@/.test(line)) return []
80
+ return keywordItems(LUA_RESERVED_WORDS, monaco.languages.CompletionItemKind.Keyword, range, 'Lua 关键字')
81
+ }
82
+
83
+ /**
84
+ * 仅在注解类型槽补最基本类型,项目类型和服务器自带类型仍由 LSP 提供。
85
+ * @author ddj 2026年09月24号
86
+ * @param monaco Monaco 语言枚举
87
+ * @param model 当前文档
88
+ * @param position 光标所在行
89
+ * @param range 当前单词的替换范围
90
+ * @returns 类型候选;非注解返回空数组
91
+ */
92
+ export function luaTypesAt(
93
+ monaco: { languages: { CompletionItemKind: { Keyword: number } } },
94
+ model: { getLineContent(lineNumber: number): string },
95
+ position: { lineNumber: number },
96
+ range: KeywordRange,
97
+ ): KeywordItem[] {
98
+ if (!isLuaTypeSlot(model, position, range)) return []
99
+ return keywordItems(LUA_BASE_TYPES, monaco.languages.CompletionItemKind.Keyword, range, 'Lua 基础类型')
100
+ }
101
+
102
+ /**
103
+ * 合并 LSP 与本地关键字,服务器同名项优先并保持原顺序。
104
+ * @author ddj 2026年09月24号
105
+ * @param server 服务器候选
106
+ * @param keywords 本地关键字候选
107
+ * @returns 去重后的补全列表
108
+ */
109
+ export function mergeKeywords<T extends { label: string }, U extends { label: string }>(
110
+ server: T[],
111
+ keywords: U[],
112
+ ): Array<T | U> {
113
+ if (keywords.length === 0) return server
114
+ const labels = new Set(server.map((item) => item.label))
115
+ const merged: Array<T | U> = [...server]
116
+ for (const keyword of keywords) {
117
+ if (labels.has(keyword.label)) continue
118
+ merged.push(keyword)
119
+ }
120
+ return merged
121
+ }
@@ -5,8 +5,9 @@
5
5
  * 并提供 definition/references/documentSymbol/hover/status 的 RPC 薄封装。
6
6
  * 作者 ddj 2026-08-27
7
7
  */
8
- import { rpc } from '../../rpc.js'
8
+ import { rpc, dbg } from '../../rpc.js'
9
9
  import { monoToLsp } from '../../../shared/lsp.js'
10
+ import { classifyMemberSet } from './memberScope.js'
10
11
 
11
12
  /** edrv:// 模型 URI → 工作区相对/绝对路径(与 host 侧 path 对齐)。 */
12
13
  export function pathOfModel(model) {
@@ -23,9 +24,11 @@ export function pathOfModel(model) {
23
24
  }
24
25
 
25
26
  const SYNC_DEBOUNCE_MS = 250
27
+ const MAX_DOC_VERSION = 2147483647
26
28
 
27
29
  let sessionId
28
30
  const syncTimers = new Map()
31
+ const docVersions = new Map()
29
32
  let statusCache = { servers: [], at: 0 }
30
33
  let progressListeners = new Set()
31
34
  let ready = false
@@ -47,6 +50,9 @@ function reportStatus(text) {
47
50
  */
48
51
  export function setLspSession(id) {
49
52
  if (sessionId === id) return
53
+ for (const timer of syncTimers.values()) clearTimeout(timer)
54
+ syncTimers.clear()
55
+ docVersions.clear()
50
56
  sessionId = id
51
57
  void refreshStatus()
52
58
  }
@@ -87,6 +93,18 @@ function publishProgress() {
87
93
  }
88
94
  }
89
95
 
96
+ /**
97
+ * LSP 版本须可被 Roslyn 的 Int32 反序列化;时间戳会直接使其退出。
98
+ * @author ddj 2026年09月24号
99
+ * @param path 文档路径
100
+ * @returns 当前文档下一个协议版本
101
+ */
102
+ function nextDocVer(path) {
103
+ const version = Math.min(MAX_DOC_VERSION, (docVersions.get(path) ?? 0) + 1)
104
+ docVersions.set(path, version)
105
+ return version
106
+ }
107
+
90
108
  /**
91
109
  * 立即同步某文档到 host(首次打开/查询前保证服务器已认识该文档)。
92
110
  * sessionId 缺失时仍发起请求:host 对唯一活跃会话有兜底解析,弃发会白白丢失文档归属。
@@ -99,13 +117,13 @@ export async function syncDoc(path, text, immediate = false) {
99
117
  if (!path || typeof text !== 'string') return
100
118
  if (immediate) {
101
119
  clearPending(path)
102
- await rpc('edrv.lsp.sync', { sessionId, path, text, version: Date.now() }).catch(() => {})
120
+ await rpc('edrv.lsp.sync', { sessionId, path, text, version: nextDocVer(path) }).catch(() => {})
103
121
  return
104
122
  }
105
123
  clearPending(path)
106
124
  syncTimers.set(path, setTimeout(() => {
107
125
  syncTimers.delete(path)
108
- rpc('edrv.lsp.sync', { sessionId, path, text, version: Date.now() }).catch(() => {})
126
+ rpc('edrv.lsp.sync', { sessionId, path, text, version: nextDocVer(path) }).catch(() => {})
109
127
  }, SYNC_DEBOUNCE_MS))
110
128
  }
111
129
 
@@ -117,6 +135,7 @@ function clearPending(path) {
117
135
  /** 文档关闭:停止去抖并通知 host。 */
118
136
  export function closeDoc(path) {
119
137
  clearPending(path)
138
+ docVersions.delete(path)
120
139
  void rpc('edrv.lsp.close', { sessionId, path }).catch(() => {})
121
140
  }
122
141
 
@@ -171,22 +190,65 @@ export async function fetchHover(path, text, position) {
171
190
  return res.hover || null
172
191
  }
173
192
 
193
+ /**
194
+ * 成员触发字符:这类位置的补全最易被服务器的旧解析树错答(外层作用域 / 全局集)。
195
+ * @author ddj 2026年09月24号
196
+ */
197
+ const MEMBER_TRIGGERS = new Set(['.', ':'])
198
+
199
+ /**
200
+ * 记录成员补全诊断(仅日志,不改结果)。
201
+ *
202
+ * 现场:`ECommon.` 这类成员位置偶尔返回 `this` 的外层作用域成员或全局集
203
+ * (含 `this`/`...` 与大量 `CS_*`),根因指向服务器对刚输入文本的解析新鲜度,
204
+ * 且非确定性。此处只把每次成员补全的形态打标,供真实使用中取证后再定修复。
205
+ * @author ddj 2026年09月24号
206
+ * @param path 工作区相对路径
207
+ * @param position Monaco 位置(1-based)
208
+ * @param context LSP CompletionContext(取触发字符)
209
+ * @param prefix 当前输入前缀
210
+ * @param list 服务器归一化后的补全结果
211
+ */
212
+ function logMemberDiag(path, position, context, prefix, list) {
213
+ const ch = context?.triggerCharacter
214
+ if (!MEMBER_TRIGGERS.has(ch)) return
215
+ const info = classifyMemberSet(list.items)
216
+ dbg(sessionId, '成员补全诊断 ch=' + ch
217
+ + ' path=' + path
218
+ + ' pos=' + position.lineNumber + ':' + position.column
219
+ + ' prefix=' + JSON.stringify(prefix ?? '')
220
+ + ' 条数=' + info.total
221
+ + ' CS_*=' + info.csCount
222
+ + ' 分类=' + info.kind
223
+ + ' 首' + info.head.length + '=' + info.head.join(','))
224
+ }
225
+
174
226
  /**
175
227
  * 查询补全列表(列表阶段不带 documentation,由 resolve 补全)。
176
- * @author ddj 2026年09月22号
228
+ *
229
+ * `prefix` 必须随请求上报:host 在 500 条上限处做前缀感知截断,不带前缀时
230
+ * 只能按服务器原始顺序切,项目全局(ECommon 等)会被上万个 CS_* 桩挤出上限。
231
+ * @author ddj 2026年09月22号 / 2026年09月24号
177
232
  * @param path 工作区相对路径
178
233
  * @param text 文档全文(未保存内容必须进服务器,否则按旧文本解析)
179
234
  * @param position Monaco 位置(1-based)
180
235
  * @param context LSP CompletionContext(触发类型/触发字符)
236
+ * @param prefix 当前输入前缀(Monaco getWordUntilPosition 的词;可缺省)
181
237
  * @returns { items, incomplete };失败或空时 null
182
238
  */
183
- export async function fetchCompletions(path, text, position, context) {
239
+ export async function fetchCompletions(path, text, position, context, prefix) {
184
240
  await syncDoc(path, text, true)
185
241
  const args = { path, position: monoToLsp(position.lineNumber, position.column) }
186
242
  if (context) args.context = context
243
+ if (prefix) args.prefix = prefix
187
244
  const res = await queryLsp('查询补全', 'edrv.lsp.completion', args)
188
245
  if (!res || !res.completions) return null
189
- return res.completions
246
+ const list = res.completions
247
+ if (list.truncated === true) {
248
+ dbg(sessionId, '补全结果被截断(候选超上限):' + path + ' prefix=' + String(prefix ?? ''))
249
+ }
250
+ logMemberDiag(path, position, context, prefix, list)
251
+ return list
190
252
  }
191
253
 
192
254
  /**
@@ -0,0 +1,90 @@
1
+ /**
2
+ * dsh-vscode-mode client — 成员补全结果集的形态分类(纯函数,node 可测)。
3
+ *
4
+ * 背景:`ECommon.` 这类成员位置偶尔被错答为**外层作用域集**(`this` 的成员)
5
+ * 或**全局集**(含 `this`/`...` 与大量 `CS_*` xLua 绑定桩),而正确答案应是
6
+ * `ECommon` 自身的字段。根因指向服务器对「刚输入文本」的解析新鲜度,且非确定性。
7
+ * 本模块只做「形态打标」供诊断日志使用,**不改变任何补全结果**。
8
+ *
9
+ * 作者 ddj 2026-09-24
10
+ */
11
+ import type { LspCompletionItem } from '../../../shared/lsp.js'
12
+
13
+ /** 结果集形态:empty 空;globals 全局集;outerScope 外层作用域集;member 成员集;other 其他。 */
14
+ export type CompletionSetKind = 'empty' | 'globals' | 'outerScope' | 'member' | 'other'
15
+
16
+ /** 分类结果:形态 + 计数 + 前几项(供日志与人工判读)。 */
17
+ export interface CompletionSetInfo {
18
+ /** 形态标签 */
19
+ kind: CompletionSetKind
20
+ /** 条目总数 */
21
+ total: number
22
+ /** `CS_*` 绑定桩条数 */
23
+ csCount: number
24
+ /** 绑定桩占比(0–1;空集为 0) */
25
+ csRatio: number
26
+ /** 前若干条 label(便于人工判读,不参与判定) */
27
+ head: string[]
28
+ }
29
+
30
+ /**
31
+ * 外层作用域(`this` 成员)特征名。
32
+ *
33
+ * ⚠️ 这是**捕获派生的启发式标记**,不是通用的作用域判定:名单来自 2026-09-24
34
+ * 在 `RechargeHeartView.lua` 真实键盘复现到的 87 条错答样本(`this` 成员列表)。
35
+ * 用途仅是把「疑似外层作用域」的观测标出来供取证;未命中足够标记时归入 `member`,
36
+ * 因此**不会**把正常的成员集误报成外层集。
37
+ */
38
+ const INSTANCE_MARKERS = new Set([
39
+ 'addframe', 'addtimer', 'clearalltimers', 'buyheart', 'canvas', 'canvasgroup',
40
+ ])
41
+
42
+ /**
43
+ * 取条目匹配文本(Monaco 口径:filterText 优先,回落 label)。
44
+ *
45
+ * 与 host 侧 `lsp/completionCap.ts` 的同名私有函数刻意各留一份:两者分属
46
+ * host / client 两个打包平面,不为 3 行逻辑建立跨平面依赖。
47
+ * @author ddj 2026年09月24号
48
+ * @param item 补全项
49
+ * @returns 匹配文本;无可用文本时为空串
50
+ */
51
+ function textOfItem(item: LspCompletionItem): string {
52
+ const raw = typeof item?.filterText === 'string' && item.filterText ? item.filterText : item?.label
53
+ return typeof raw === 'string' ? raw : ''
54
+ }
55
+
56
+ /**
57
+ * 依据条目形态判定结果集属于哪一类。
58
+ * @author ddj 2026年09月24号
59
+ * @param lower 全部条目文本(已小写)
60
+ * @param total 条目总数
61
+ * @param csCount `CS_*` 条数
62
+ * @param csRatio `CS_*` 占比
63
+ * @returns 形态标签
64
+ */
65
+ function kindOf(lower: string[], total: number, csCount: number, csRatio: number): CompletionSetKind {
66
+ if (total === 0) return 'empty'
67
+ if (lower.includes('this') || lower.includes('...') || csRatio > 0.5) return 'globals'
68
+ if (csCount === 0 && lower.filter((t) => INSTANCE_MARKERS.has(t)).length >= 2) return 'outerScope'
69
+ if (csCount === 0) return 'member'
70
+ return 'other'
71
+ }
72
+
73
+ /**
74
+ * 分类成员补全结果集(纯函数,无副作用)。
75
+ * @author ddj 2026年09月24号
76
+ * @param items 服务器归一化后的补全条目(非数组按空处理)
77
+ * @param headSize 日志携带的前缀条数(缺省 3)
78
+ * @returns 形态、计数与前几项
79
+ */
80
+ export function classifyMemberSet(items: LspCompletionItem[], headSize: number = 3): CompletionSetInfo {
81
+ const list = Array.isArray(items) ? items : []
82
+ // 判定用 filterText 优先(与过滤器同口径);日志展示用 label(人工判读友好)。
83
+ const texts = list.map(textOfItem)
84
+ const lower = texts.map((t) => t.toLowerCase())
85
+ const total = list.length
86
+ const csCount = texts.filter((t) => t.startsWith('CS_')).length
87
+ const csRatio = total ? csCount / total : 0
88
+ const head = list.slice(0, Math.max(0, headSize)).map((item) => String(item?.label ?? ''))
89
+ return { kind: kindOf(lower, total, csCount, csRatio), total, csCount, csRatio, head }
90
+ }
@@ -18,6 +18,7 @@ import {
18
18
  LSP_SEMANTIC_TOKEN_MODIFIERS, LSP_SEMANTIC_TOKEN_TYPES,
19
19
  LSP_COMPLETION_KIND_NAMES, LSP_INSERT_TEXT_FORMAT_SNIPPET,
20
20
  } from '../../../shared/lsp.js'
21
+ import { luaKeywordsAt, luaTypesAt, mergeKeywords } from './keywords.js'
21
22
 
22
23
  const LSP_LANGS = ['lua', 'csharp']
23
24
 
@@ -352,18 +353,27 @@ function completionsFor(monaco, lang) {
352
353
  provideCompletionItems: async (model, position, context, token) => {
353
354
  const path = pathOfModel(model)
354
355
  if (!path) return { suggestions: [] }
355
- const list = await fetchCompletions(path, model.getValue(), position, toLspContext(context))
356
- if (!list || !list.items.length || token?.isCancellationRequested) return { suggestions: [] }
357
356
  const word = model.getWordUntilPosition(position)
357
+ // 前缀必须上报:host 在条目上限处按前缀分桶截断,否则项目全局
358
+ // (ECommon 等)会被 xLua 绑定桩(上万个 CS_*)挤出上限且无法恢复。
359
+ const list = await fetchCompletions(path, model.getValue(), position, toLspContext(context), word.word)
360
+ if (token?.isCancellationRequested) return { suggestions: [] }
361
+ if (lang !== 'lua' && (!list || !list.items.length)) return { suggestions: [] }
358
362
  const fallback = {
359
363
  startLineNumber: position.lineNumber,
360
364
  endLineNumber: position.lineNumber,
361
365
  startColumn: word.startColumn,
362
366
  endColumn: word.endColumn,
363
367
  }
368
+ const serverItems = (list?.items ?? []).map((item) => toMonoSuggestion(monaco, item, fallback, path))
364
369
  return {
365
- suggestions: list.items.map((item) => toMonoSuggestion(monaco, item, fallback, path)),
366
- incomplete: list.incomplete === true,
370
+ suggestions: lang === 'lua'
371
+ ? mergeKeywords(serverItems, [
372
+ ...luaKeywordsAt(monaco, model, position, fallback),
373
+ ...luaTypesAt(monaco, model, position, fallback),
374
+ ])
375
+ : serverItems,
376
+ incomplete: list?.incomplete === true,
367
377
  }
368
378
  },
369
379
  resolveCompletionItem: async (item, token) => {