dsh-code-ui 1.4.1 → 1.5.1

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/README.md CHANGED
@@ -36,9 +36,9 @@
36
36
  - 📋 复制
37
37
  - 📥 粘贴到备注
38
38
  - 📝 添加备注
39
- - 🌐 翻译(创建翻译引用,交给 AI 翻译)
39
+ - 🌐 翻译(🌐 chip 直插输入框,交给 AI 翻译)
40
40
  - 🔗 引用给 AI
41
- - **引用 chip**:引用后输入框上方显示 `文件名(起行-止行)` + ✕,删除引用
41
+ - **行内引用 chip**:引用/翻译后 chip 直接嵌入输入框句中(`1:文件名:起行-止行`,翻译带 🌐 前缀),点击跳转编辑器;`@` 菜单可把引用插入句中任意位置;发送时 chip 原位展开为引用行,随消息一次性消耗(chip 清空、【引用/备注】留档可查);允许重复引用(chip 可见、可删)
42
42
  - **内嵌 AI 输入框**(悬浮布局):直接输入、`Ctrl+Enter` 发送;发送时不粘贴引用正文,只附一行引用指针,内容由 AI 调用 `get_referenced_snippets` 工具读取
43
43
  - **主题四态**:跟随 DSH(默认)/ 跟随系统 / 浅色 / 深色,插件卡片切换
44
44
  - **以此文件夹为根**:文件浏览器里可选任意目录作为工作区根
@@ -76,7 +76,7 @@ npm install dsh-code-ui
76
76
  1. 点顶部悬浮模式条的 `code UI` 打开编辑器(默认切换按钮;也可在插件卡片换成会话头 `⌨` 按钮),默认布局为右侧停靠
77
77
  2. 在文件树/浏览器里打开文件
78
78
  3. 选中代码右键 → 引用 / 翻译 / 备注 / 复制
79
- 4. 悬浮布局底部输入框直接发消息(`Ctrl+Enter` 发送);停靠布局用 DSH 原生输入条,引用只以标签显示,正文由 AI 工具读取
79
+ 4. 悬浮布局底部输入框直接发消息(`Ctrl+Enter` 发送);停靠布局用 DSH 原生输入条,引用以行内 chip 嵌在输入框句中,正文由 AI 工具读取
80
80
  5. 也可对 AI 说「读取我引用的代码」,AI 会调用 `get_referenced_snippets` 工具
81
81
 
82
82
  ## 目录结构
@@ -114,6 +114,7 @@ dsh-code-ui/
114
114
 
115
115
  | 时间 | 版本 | 修复 / 新增 |
116
116
  | --- | --- | --- |
117
+ | 2026-09-11 | v1.5.0 | 里程碑:**引用体系全面升级——行内引用 chip**。引用/翻译后标签不再挂在输入框上方,而是以 chip 直接嵌入输入框句中(`1:文件名:起行-止行`,翻译带 🌐 前缀),点击跳转编辑器、退格即删;`@` 菜单可把引用插入句中任意位置;发送时 chip 原位展开为 `- 代码引用 / - 翻译引用 文件名:行号(路径)` 引用行,正文由 AI 经 `get_referenced_snippets` 读取。实现:注册宿主 InputTrigger 触发源(candidates/onPick/codec)+ ReferenceChipNode 装饰器直插(shell.insertReference,实时取 `conversation.input.shell(sid).snapshot` 的 draft/draftRev);chip ref 自包含 JSON(抗刷新/重启)。配套:**引用一次性消耗**——发送即标记已使用、chip 清空、【引用/备注】留档可查;**允许重复引用**(chip 可见可删,取消 host 去重);**引用编号全链路贯通**——标签/消息指针/引用卡/AI 工具输出同号,对 AI 说「引用N」即指对应条目;【引用/备注】条目右键菜单(复制 / 翻译 / 引用给AI);消息区引用卡 + chip 文件图标/点击跳转;提示条 ✕ 手动关闭兜底;移除「引用消耗模式」配置项(固定一次性)与输入条上方 tag 条(被行内 chip 取代)。工程:发送补丁改挂 shell.submit 必经点(原生输入条回车/按钮走 keyboard.submit,原 inputActions 补丁只覆盖插件输入卡);纯 chip 携带的发送同样消耗 |
117
118
  | 2026-09-09 | v1.4.1 | 修复:编辑器**每敲一个字符文本抖动一下**。根因:hljs 就绪后,每次击键高亮层经历「tokenizer 顶上 → 60ms 防抖后换 hljs」**两次全量 innerHTML 重写**,浏览器重写 innerHTML 时 `scrollTop`/`scrollLeft` 被重置为 0——凡滚动过(垂直或水平)的文件,每次击键可见文本都会位移。修复:≤32KB 的文件改为**同步走 hljs**(单帧渲染直接出最终结果,无交换帧、工作量减半),>32KB 保留 60ms 防抖路径;并在每次渲染提交后从 textarea 回同步高亮层与行号滚动位(兜底所有 innerHTML 重写场景)。新增:**编辑器状态按会话记忆**——切换对话时快照当前会话的标签页/激活项并恢复目标会话的(此前 `applyRoot` 换根即清空标签与缓冲,切会话打开的文件全消失);滚动位按文件路径记忆、标签恢复挂载即自动回滚;焦点不恢复,由用户自己点击决定;缓冲/脏标记以绝对路径为键全局保留,切走再切回未保存内容与 ● 状态原样还在,原「有脏文件暂不跟随根」守卫随之退役,根始终跟随新会话;**文件树展开状态同样按会话记忆**——目录展开与已加载子目录随会话快照/恢复(`applyRoot` 仅在 nodes 里没有该根节点时才重建树,同根换会话不再折叠),侧栏不再切一次对话就全部收起 |
118
119
  | 2026-09-08 | v1.4.0 | 里程碑:**语法高亮升级 highlight.js 11**(193 种语言,替换自研 tokenizer)。宿主 `webServer` exact 路由下发 `lib/vendor/` 全量包与主题 CSS,客户端首次高亮时懒加载、不拖累 SPA 启动;Electron(file://)/断网/路由缺失自动回退原 tokenizer 保底可用。插件卡片新增「语法配色」:自动(默认,随编辑器明暗 GitHub ↔ GitHub Dark)+ 10 款固定主题(GitHub / Dracula / Monokai / Nord / VS…),仅染 token 色、编辑器底色仍归「编辑器主题」管理,切换即时生效并持久化。性能:重高亮 ~60ms 防抖 + >512KB 大文件自动降级轻量高亮。`tools/vendor-hljs.mjs` 重建管线入库(支持 `--themes-only`、fetch 超时重试、Dracula 取官方源)。兼容性:适配 dsh 0.1.2+ 新版 settings 服务——不再 import 已删除的 `installSettingsSection`/`settingsNamespace` 具名导出(具名导出缺失会让宿主启动即退出),照 dshmarket 内联 `register`/`watch` 接线(服务 API 新旧一致),旧宿主 0.1.0-rc.7+ 同样可跑。交互优化:文件树选中态改为实心选中色 + 选中文字反白 + 左缘 2px 白色强调条——底色跟随宿主 `--dsw-alias-button-info-fill` 令牌(与 dshUI「对话/轨迹」页签同源,回退 #4176e6),宿主换肤自动同步;原层级灰在浅色主题下与侧栏底色仅差 5/255,选中几乎隐形。文件树根目录**按会话隔离**:宿主以 sessionId→root 映射记忆各会话的手动根(「以此文件夹为根」只影响当前会话,不再全局污染),切换会话即解析新会话的 cwd 或其记忆根——修复「新会话打开仍是旧会话地址」(原手动根为宿主级全局变量,且客户端跟随标志不持久化导致刷新后跟随失效且无提示)。未发消息的新会话也能解析根目录:会话创建时所选工作区路径已写入不可变 `SessionHeader.cwd`,经 `sessions` 服务可查(原仅查 `agents`,agent 要到首条消息后才存在);重启后重开旧对话(agent 尚未恢复)时经 `sessionController.inspect` 读持久化 header 兜底。空新对话(blank 会话)根因:宿主对 blank 会话隐藏会话头 chrome(hideChrome),插件 InputBridge 所在 slot 不渲染、捕获不到新会话 id——新增常驻 SessionWatch(root 作用域 `shell.overlay` + 标准 props `useSessions`)监听当前选中会话,blank 会话(宿主侧 agent 在 session/create 时即建、cwd=所选工作区)也能即时换根。标签页**右键菜单**:关闭 / 关闭其他 / 关闭右侧标签页 / 关闭已保存 / 关闭全部 + 复制路径(无对象项自动置灰);关闭波及未保存文件时弹确认框(保存并关闭 / 不保存 / 取消,多文件列清单、>5 折叠计数,保存失败者保留打开)——原标签 ✕ 按钮静默丢弃未保存改动,一并接入同一确认流程 |
119
120
  | 2026-09-02 | v1.3.2 | 调整:**切换按钮默认改为悬浮模式条**(原会话头 ⌨ 按钮,已保存选择的用户不受影响);**文件树默认根跟随当前会话的工作区目录**——打开编辑器即定位到当前对话的项目根,会话切换自动换根(有未保存文件时暂不跟随并提示,保存后点 ⟳ 即切换);手动「以此文件夹为根」后不再自动跟随 |
package/lib/client.js CHANGED
@@ -153,18 +153,23 @@ window.__ModuleLoader__.load({
153
153
  .cdex-ref-head{display:flex;align-items:center;gap:6px;margin-bottom:6px;font-size:12px;}
154
154
  .cdex-ref-path{flex:1;color:var(--dsw-alias-label-secondary,#9d9d9d);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
155
155
  .cdex-ref-code{background:var(--dsw-alias-bg-base,#1e1e1e);border-radius:4px;padding:6px;font-family:Consolas,monospace;font-size:12px;white-space:pre-wrap;word-break:break-all;color:var(--dsw-alias-label-primary,#d4d4d4);max-height:140px;overflow:auto;}
156
+ /* 已使用(随消息消耗)的引用记录:整体弱化 + 「已使用」徽章 */
157
+ .cdex-ref-used{opacity:.62;}
158
+ .cdex-ref-usedbadge{flex:none;font-size:10px;line-height:16px;padding:0 6px;border-radius:8px;background:var(--dsw-alias-bg-layer-3,#3a3a41);color:var(--dsw-alias-label-secondary,#9d9d9d);}
156
159
  .cdex-section{color:var(--dsw-alias-label-secondary,#8a8a8a);font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:12px 0 6px;}
157
160
  .cdex-hint{color:var(--dsw-alias-label-secondary,#8a8a8a);font-size:12px;margin-top:12px;line-height:1.6;}
158
161
  .cdex-toast{position:fixed;z-index:10003;top:36px;left:50%;transform:translateX(-50%);background:var(--cdex-accent-bg,#0f1115);color:var(--cdex-accent-fg,#ffffff);padding:9px 16px;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.4);max-width:80vw;cursor:grab;user-select:none;touch-action:none;}
159
162
  .cdex-toast.placed{transform:none;cursor:grabbing;}
160
163
  .cdex-toast-grip{margin-right:8px;opacity:.55;font-size:12px;letter-spacing:-1px;}
164
+ .cdex-toast-x{flex:none;border:none;background:transparent;color:inherit;cursor:pointer;font-size:12px;line-height:1;padding:2px 0 2px 10px;margin-left:10px;opacity:.7;}
165
+ .cdex-toast-x:hover{opacity:1;}
161
166
  .cdex-resize{position:absolute;right:0;bottom:0;width:18px;height:18px;cursor:nwse-resize;z-index:10;}
162
167
  .cdex-resize::after{content:'';position:absolute;right:4px;bottom:4px;width:8px;height:8px;border-right:2px solid var(--dsw-alias-label-secondary,#7f849c);border-bottom:2px solid var(--dsw-alias-label-secondary,#7f849c);}
163
168
  .cdex-aiinput-wrap{position:relative;padding:10px 14px 16px;background:transparent;border-top:1px solid var(--dsw-alias-border-l1,#3e3e42);}
164
169
  .cdex-aiinput-card{background:var(--dsw-alias-bg-overlay,#252526);border:1px solid var(--dsw-alias-border-l1,#3e3e42);border-radius:14px;box-shadow:0 2px 12px rgba(0,0,0,.18);overflow:hidden;}
165
170
  .cdex-aiinput-tags{display:flex;flex-wrap:wrap;gap:6px;padding:10px 12px 0;}
166
- .cdex-aiinput-tag{display:inline-flex;align-items:center;gap:6px;background:var(--cdex-accent-bg,#0f1115);color:var(--cdex-accent-fg,#ffffff);border-radius:12px;padding:3px 10px 3px 12px;font-size:12px;}
167
- .cdex-aiinput-tag-label{white-space:nowrap;}
171
+ .cdex-aiinput-tag{display:inline-flex;align-items:center;gap:6px;background:var(--cdex-accent-bg,#0f1115);color:var(--cdex-accent-fg,#ffffff);border-radius:12px;padding:3px 10px 3px 12px;font-size:12px;max-width:260px;min-width:0;}
172
+ .cdex-aiinput-tag-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;}
168
173
  .cdex-aiinput-tag-tr{background:#b45309;color:#fff;}
169
174
  .cdex-aiinput-tag-x{border:none;background:transparent;color:var(--cdex-accent-fg,#ffffff);cursor:pointer;font-size:13px;padding:0;line-height:1;opacity:.85;}
170
175
  .cdex-aiinput-tag-x:hover{opacity:1;}
@@ -240,6 +245,26 @@ window.__ModuleLoader__.load({
240
245
  .cdex-workbench,.cdex-modebar{--cdex-accent-bg:var(--dsw-alias-brand-primary,#0f1115);--cdex-accent-fg:var(--dsw-alias-bg-base,#ffffff);}
241
246
  .cdex-tokens-light{--cdex-tk-kw:#af00db;--cdex-tk-str:#a31515;--cdex-tk-com:#008000;--cdex-tk-num:#098658;--cdex-tk-fn:#795e26;--cdex-tk-punc:#555555;}
242
247
  .cdex-tokens-dark{--cdex-tk-kw:#c586c0;--cdex-tk-str:#ce9178;--cdex-tk-com:#6a9955;--cdex-tk-num:#b5cea8;--cdex-tk-fn:#dcdcaa;--cdex-tk-punc:#808080;}
248
+ /* —— 引用 chip 升级:文件图标 + hover 全路径 + 点击跳转编辑器 —— */
249
+ .cdex-aiinput-tag{cursor:pointer;transition:filter .12s ease;}
250
+ .cdex-aiinput-tag:hover{filter:brightness(1.18);}
251
+ .cdex-tag-ic{display:inline-flex;align-items:center;flex:0 0 auto;}
252
+ .cdex-tag-ic svg{display:block;}
253
+ /* —— 引用 / 备注视图行可点击跳转 —— */
254
+ .cdex-ref-link{cursor:pointer;}
255
+ .cdex-ref-link:hover{color:var(--dsw-alias-label-primary,#d4d4d4);}
256
+ .cdex-ref-link .cdex-tag-ic{margin-right:2px;vertical-align:-1px;}
257
+ /* —— 消息区引用卡(Trae 式:左竖线 + 图标 + 文件名行号行,点击跳回编辑器) —— */
258
+ .cdex-refcard{margin-top:8px;border-left:2px solid var(--dsw-alias-button-info-fill,#4176e6);background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.08));border-radius:0 8px 8px 0;padding:6px 10px;display:flex;flex-direction:column;gap:2px;max-width:100%;box-sizing:border-box;}
259
+ .cdex-refcard-head{font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a8a);margin-bottom:2px;}
260
+ .cdex-refcard-row{display:flex;align-items:center;gap:6px;font-size:12px;line-height:20px;cursor:pointer;border-radius:4px;padding:0 4px;min-width:0;}
261
+ .cdex-refcard-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14));}
262
+ .cdex-refcard-ic{display:inline-flex;align-items:center;flex:0 0 auto;}
263
+ .cdex-refcard-ic svg{display:block;}
264
+ .cdex-refcard-num{flex:none;min-width:16px;height:16px;border-radius:8px;background:var(--dsw-alias-bg-layer-3,#3a3a3f);color:var(--dsw-alias-label-secondary,#a8a8ad);font-size:10px;line-height:16px;text-align:center;padding:0 4px;}
265
+ .cdex-refcard-name{color:var(--dsw-alias-label-primary,#d4d4d4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
266
+ .cdex-refcard-lines{color:var(--dsw-alias-button-info-fill,#4176e6);font-family:Consolas,'Cascadia Code',Menlo,monospace;font-size:11.5px;flex:0 0 auto;}
267
+ .cdex-refcard-kind{flex:0 0 auto;margin-left:auto;font-size:10px;line-height:16px;padding:0 6px;border-radius:8px;background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.18));color:var(--dsw-alias-label-secondary,#9d9d9d);}
243
268
  `; document.head.appendChild(tag); return () => tag.remove(); })
244
269
 
245
270
  /* ============ §3 状态层 ============ */
@@ -297,20 +322,33 @@ window.__ModuleLoader__.load({
297
322
  return store
298
323
  }
299
324
 
325
+ let toastSeq = 0
300
326
  function showToast(msg) {
301
- store.toast = { msg }
327
+ store.toast = { msg, seq: ++toastSeq }
302
328
  store.toastKeep = false
303
329
  emit()
304
330
  scheduleToastHide(2800)
305
331
  }
306
- // 提示自动消失;正在被拖拽(toastKeep)时顺延,松手后短暂展示再消失
332
+ // 提示自动消失;正在被拖拽(toastKeep)时顺延,松手后短暂展示再消失。
333
+ // seq 世代戳:手动 ✕ 关闭后残留的定时器不会误关下一条新提示。
334
+ // timer 是宿主服务(ctx.get('timer')),可能缺失——缺失时回退 setTimeout,
335
+ // 否则 scheduleToastHide 静默不挂定时器,提示条永远不关(历史上「有时不自动关闭」的根因)。
307
336
  function scheduleToastHide(ms) {
308
- if (timer && typeof timer.timeout === 'function') {
309
- timer.timeout(() => {
310
- if (store.toastKeep) { scheduleToastHide(600); return }
311
- store.toast = null; emit()
312
- }, ms)
337
+ const seq = store.toast ? store.toast.seq : 0
338
+ const fire = () => {
339
+ if (!store.toast || store.toast.seq !== seq) return // 已被手动关闭或已换新提示
340
+ if (store.toastKeep) { scheduleToastHide(600); return }
341
+ store.toast = null
342
+ emit()
313
343
  }
344
+ if (timer && typeof timer.timeout === 'function') timer.timeout(fire, ms)
345
+ else if (typeof setTimeout === 'function') setTimeout(fire, ms)
346
+ }
347
+ // 手动关闭(✕ 兜底):连带清 toastKeep,避免卡在拖拽态让后续提示无限顺延
348
+ function closeToastNow() {
349
+ store.toast = null
350
+ store.toastKeep = false
351
+ emit()
314
352
  }
315
353
  // 提示条拖拽:按住移动自定义位置(默认顶部居中,拖过之后记住位置,防止挡内容)
316
354
  function onToastPointerDown(e) {
@@ -347,6 +385,10 @@ window.__ModuleLoader__.load({
347
385
  return h('div', { className: 'cdex-toast' + (st.toastPos ? ' placed' : ''), style: style, title: '按住可拖动位置(不再挡内容)', onPointerDown: onToastPointerDown },
348
386
  h('span', { className: 'cdex-toast-grip' }, '⠿'),
349
387
  st.toast.msg,
388
+ // 手动关闭兜底:自动消失偶发失效时用户可一键关掉;✕ 上阻断 pointerdown,不触发拖拽
389
+ h('button', { className: 'cdex-toast-x', title: '关闭提示',
390
+ onPointerDown: (e) => { e.stopPropagation() },
391
+ onClick: (e) => { e.stopPropagation(); closeToastNow() } }, '✕'),
350
392
  )
351
393
  }
352
394
 
@@ -407,13 +449,36 @@ window.__ModuleLoader__.load({
407
449
  return parts.length ? parts[parts.length - 1] : String(p || '')
408
450
  }
409
451
  function tagLabel(r) {
452
+ // 短小原则:只显示 文件名:起-止 行号,让用户知道引了什么即可;完整路径放 hover title
410
453
  const name = baseName(r.path)
411
- if (r.lineStart && r.lineEnd) {
412
- return name + '(' + r.lineStart + '-' + r.lineEnd + ')'
413
- }
414
- if (!r.lineStart && r.kind === 'cite') return name + '(整文件)'
454
+ if (r.lineStart && r.lineEnd) return name + ':' + r.lineStart + '-' + r.lineEnd
415
455
  return name
416
456
  }
457
+ // —— 文件类型图标:按扩展名给色,chip / 引用视图 / 消息引用卡共用 ——
458
+ const FILE_ICON_COLOR = {
459
+ ts: '#4a9eff', tsx: '#4a9eff', js: '#e2c08d', jsx: '#e2c08d', mjs: '#e2c08d', cjs: '#e2c08d', json: '#d7ba7d',
460
+ md: '#9da5b4', mdx: '#9da5b4', txt: '#9da5b4',
461
+ css: '#b180d7', scss: '#b180d7', less: '#b180d7',
462
+ html: '#e37933', htm: '#e37933', xml: '#e37933', vue: '#41b883', svelte: '#e37933',
463
+ py: '#4ec9b0', rs: '#d19a66', go: '#4ec9b0', java: '#c78d5a', kt: '#c78d5a',
464
+ c: '#8a919f', h: '#8a919f', cpp: '#8a919f', cc: '#8a919f', hpp: '#8a919f', cs: '#4a9eff',
465
+ sh: '#89d185', ps1: '#4a9eff', bat: '#89d185', cmd: '#89d185',
466
+ yml: '#c7a252', yaml: '#c7a252', toml: '#c7a252', ini: '#c7a252', conf: '#c7a252', env: '#c7a252',
467
+ png: '#dd6eb4', jpg: '#dd6eb4', jpeg: '#dd6eb4', gif: '#dd6eb4', webp: '#dd6eb4', svg: '#dd6eb4', ico: '#dd6eb4',
468
+ zip: '#c7a252', gz: '#c7a252', tgz: '#c7a252', lock: '#8a919f',
469
+ }
470
+ function fileIconSvg(path) {
471
+ const name = baseName(path)
472
+ const dot = name.lastIndexOf('.')
473
+ const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
474
+ const color = FILE_ICON_COLOR[ext] || '#8a919f'
475
+ return '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">'
476
+ + '<path d="M4 1.5h5L13 5.5v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1z" stroke="' + color + '" stroke-width="1.2" stroke-linejoin="round"/>'
477
+ + '<path d="M9 1.5V5.5H13" stroke="' + color + '" stroke-width="1.2" stroke-linejoin="round"/></svg>'
478
+ }
479
+ const GLOBE_SVG = '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">'
480
+ + '<circle cx="8" cy="8" r="6.2" stroke="#7cc4ff" stroke-width="1.2"/>'
481
+ + '<path d="M1.8 8h12.4M8 1.8c2.4 1.9 2.4 10.5 0 12.4M8 1.8c-2.4 1.9-2.4 10.5 0 12.4" stroke="#7cc4ff" stroke-width="1.1"/></svg>'
417
482
  function parentPath(p) {
418
483
  const s = String(p).replace(/[\\/]+$/, '')
419
484
  const idx = Math.max(s.lastIndexOf('\\'), s.lastIndexOf('/'))
@@ -828,7 +893,12 @@ window.__ModuleLoader__.load({
828
893
  if (!store.rootPath || (sid && sid !== store.rootSessionId)) {
829
894
  const r = await callHost('get-root', sid ? { sessionId: sid } : {})
830
895
  const root = (r && r.root) || null
831
- if (!root) { store.error = '未找到工作区根目录'; emit(); return }
896
+ if (!root) {
897
+ // 透出真实失败原因(transport failure / RPC 协议错误),不再只给笼统红字:
898
+ // 通道整体 405(请求落 SPA 静态兜底)这类传输层问题一眼可辨,避免误判成根目录解析问题
899
+ store.error = (r && r.error) ? ('获取根目录失败:' + r.error) : '未找到工作区根目录'
900
+ emit(); return
901
+ }
832
902
  await applyRoot(root)
833
903
  store.rootSessionId = sid
834
904
  } else {
@@ -878,6 +948,50 @@ window.__ModuleLoader__.load({
878
948
  }
879
949
  loadNotes(path)
880
950
  }
951
+ // —— 引用跳转:chip / 引用视图 / 消息引用卡共用。打开文件并选中行区间(选中即高亮),滚动到可视区 ——
952
+ function editorLineOffset(content, line) {
953
+ // line 为 1-based 行号;返回该行首字符在 buffer 中的偏移
954
+ const lines = String(content).split('\n')
955
+ let off = 0
956
+ for (let i = 0; i < line - 1 && i < lines.length; i++) off += lines[i].length + 1
957
+ return off
958
+ }
959
+ function waitForEditorBuffer(path, timeoutMs) {
960
+ // openFile 异步读盘后 React 才提交 textarea 重挂载;rAF 轮询直到编辑区内容与缓冲一致
961
+ const deadline = Date.now() + (timeoutMs || 2000)
962
+ return new Promise((resolve) => {
963
+ const tick = () => {
964
+ const ta = editorTaEl
965
+ const buf = store.buffers[path]
966
+ if (ta && buf != null && ta.value === buf) { resolve(true); return }
967
+ if (Date.now() > deadline) { resolve(false); return }
968
+ if (typeof window !== 'undefined' && window.requestAnimationFrame) window.requestAnimationFrame(tick)
969
+ else setTimeout(tick, 50)
970
+ }
971
+ tick()
972
+ })
973
+ }
974
+ async function jumpToRef(r) {
975
+ if (!r || !r.path) return
976
+ await openFile(r.path)
977
+ const content = store.buffers[r.path]
978
+ if (!r.lineStart || typeof content !== 'string') return
979
+ const ok = await waitForEditorBuffer(r.path)
980
+ const ta = editorTaEl
981
+ if (!ok || !ta) return
982
+ try {
983
+ const lines = content.split('\n')
984
+ const start = editorLineOffset(content, r.lineStart)
985
+ const endLine = Math.max(r.lineStart, r.lineEnd || r.lineStart)
986
+ let end = start
987
+ for (let i = r.lineStart - 1; i < Math.min(endLine, lines.length); i++) end += lines[i].length + 1
988
+ ta.focus({ preventScroll: true })
989
+ ta.setSelectionRange(Math.min(start, ta.value.length), Math.min(end, ta.value.length))
990
+ // 20px 行高与 .cdex-textarea/.cdex-code-ta 一致;目标行滚到视口上 1/3 处
991
+ ta.scrollTop = Math.max(0, (r.lineStart - 1) * 20 - ta.clientHeight / 3)
992
+ ta.dispatchEvent(new Event('scroll')) // 复用 onScroll:同步高亮层/行号槽并记忆滚动位
993
+ } catch (e) {}
994
+ }
881
995
  function closeTab(path) {
882
996
  const i = store.tabs.findIndex((t) => t.path === path)
883
997
  if (i < 0) return
@@ -1016,14 +1130,29 @@ window.__ModuleLoader__.load({
1016
1130
  emit()
1017
1131
  }
1018
1132
  async function doCite(sel) {
1019
- const ref = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'cite' })
1020
- if (ref && ref.reference) { store.references.push(ref.reference); emit() }
1021
- showToast('已引用:' + baseName(sel.path))
1133
+ const res = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'cite' })
1134
+ if (res && res.reference) {
1135
+ // 允许重复引用:chip 在输入框句中可视化,重复用户看得见、可删,host 端不再去重
1136
+ if (!store.references.some((x) => x.id === res.reference.id)) store.references.push(res.reference)
1137
+ emit()
1138
+ // 路线A:优先把 chip 直接插进输入框末尾;失败(服务未就绪/草稿已变更等)仅提示
1139
+ let chipped = false
1140
+ try { chipped = insertRefChipDirect(res.reference) } catch (e) {}
1141
+ showToast(chipped ? '已插入输入框:' + chipLabelFor(res.reference) : '已引用:' + baseName(sel.path))
1142
+ }
1022
1143
  }
1023
1144
  async function doTranslate(sel) {
1024
- const ref = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'translate' })
1025
- if (ref && ref.reference) { store.references.push(ref.reference); emit() }
1026
- showToast('已创建翻译引用(输入框上方已加 🌐 标签)。对 AI 说「翻译我引用的内容」即可。')
1145
+ const res = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'translate' })
1146
+ if (res && res.reference) {
1147
+ if (!store.references.some((x) => x.id === res.reference.id)) store.references.push(res.reference)
1148
+ emit()
1149
+ // 与引用同款:优先把 🌐 chip 直接插进输入框末尾;失败退回提示(可在【引用/备注】查看)
1150
+ let chipped = false
1151
+ try { chipped = insertRefChipDirect(res.reference) } catch (e) {}
1152
+ showToast(chipped
1153
+ ? '已插入输入框:' + chipLabelFor(res.reference)
1154
+ : '已创建翻译引用(可在【引用/备注】查看)。对 AI 说「翻译我引用的内容」即可。')
1155
+ }
1027
1156
  }
1028
1157
  function openNoteEditor(sel) {
1029
1158
  store.noteEditor = { path: sel.path, lineStart: sel.lineStart, lineEnd: sel.lineEnd, note: '' }
@@ -1049,11 +1178,14 @@ window.__ModuleLoader__.load({
1049
1178
  n.children = entries.map((e) => { store.nodes[e.path] = e; return e.path })
1050
1179
  emit()
1051
1180
  }
1052
- // 整文件引用:只给 AI 绝对路径,AI 用自己的读取工具打开
1181
+ // 整文件引用:只给 AI 绝对路径,AI 用自己的读取工具打开(允许重复引用,不去重)
1053
1182
  async function citeWholeFile(path) {
1054
- const ref = await callHost('add-reference', { path: path, language: langOf(path), lineStart: null, lineEnd: null, text: '', kind: 'cite' })
1055
- if (ref && ref.reference) { store.references.push(ref.reference); emit() }
1056
- showToast('已引用整个文件:' + baseName(path) + '(AI 将自行读取)')
1183
+ const res = await callHost('add-reference', { path: path, language: langOf(path), lineStart: null, lineEnd: null, text: '', kind: 'cite' })
1184
+ if (res && res.reference) {
1185
+ if (!store.references.some((x) => x.id === res.reference.id)) store.references.push(res.reference)
1186
+ emit()
1187
+ showToast('已引用整个文件:' + baseName(path) + '(AI 将自行读取)')
1188
+ }
1057
1189
  }
1058
1190
  // 重命名后同步标签页/缓冲/激活路径/watcher
1059
1191
  function renameTabSync(fromPath, toPath) {
@@ -1158,33 +1290,153 @@ window.__ModuleLoader__.load({
1158
1290
  emit()
1159
1291
  }
1160
1292
  // —— 引用自动附带(AI 上下文) ——
1293
+ // —— 引用一次性消耗:引用只能随一条消息使用,发送后标记 used(chip 即刻清空);
1294
+ // 已使用记录保留在【引用/备注】供用户查找(需要重复引用就重新选中代码再引用,不做重复引用按钮)。
1295
+ // 标记 used 而非删除:AI 在消息发出后才调 get_referenced_snippets,删掉就读不到了。
1296
+ function pendingRefs() {
1297
+ return store.references.filter((r) => !r.used)
1298
+ }
1299
+ // 编号唯一出口:待用引用按创建序 1..N(删除后重排,发送后清零)。
1300
+ // tag 显示 / 消息指针 / 引用卡 全部用它,保证用户说的「引用N」三处同号。
1301
+ function numberedPendingRefs() {
1302
+ return pendingRefs().map((r, i) => ({ r: r, n: i + 1 }))
1303
+ }
1304
+ function consumePendingRefs() {
1305
+ const pend = pendingRefs()
1306
+ if (pend.length === 0) return
1307
+ const ids = pend.map((r) => r.id)
1308
+ for (let i = 0; i < pend.length; i++) {
1309
+ // 冻结发送时刻的编号到记录上:AI 经 get_referenced_snippets 读到的编号 = 用户在 tag 上看到的编号
1310
+ pend[i].used = true
1311
+ pend[i].usedAt = new Date().toISOString()
1312
+ pend[i].seq = i + 1
1313
+ }
1314
+ emit()
1315
+ callHost('use-references', { ids }).then(() => {})
1316
+ }
1161
1317
  function buildSendPayload(draft) {
1162
- // 引用文本不再粘贴进消息(明面上不展示):引用统一存 host 端,AI 通过 get_referenced_snippets
1163
- // 工具读取;消息里只附一行「标签级」指针,让 AI 知道有引用可读。
1164
- // 整文件引用(无行号):直接给出绝对路径,AI 用自己的读取工具打开。
1318
+ // 引用文本不粘贴进消息(明面上不展示代码正文):引用统一存 host 端,AI 通过 get_referenced_snippets
1319
+ // 工具读取;消息里附「结构化指针」——人可读、AI 可读,消息区引用卡也按它解析还原(单一事实源)。
1320
+ // 只取未使用引用(used 的已随旧消息消耗,不再重复附带)。整文件引用:给出绝对路径。
1165
1321
  const text = draft || ''
1166
1322
  if (text.indexOf('[code UI] 已标记') >= 0) return text // 幂等:已带指针不重复附加
1167
- const citeRefs = store.references.filter((r) => r.kind === 'cite')
1168
- const trRefs = store.references.filter((r) => r.kind === 'translate')
1169
- const codeRefs = citeRefs.filter((r) => r.lineStart)
1170
- const fileRefs = citeRefs.filter((r) => !r.lineStart)
1323
+ // 编号 = 待用引用创建序(1 起),与输入框 tag、AI 工具输出三处同号:用户说「引用N」AI 即知所指
1324
+ // 已被行内 chip 携带的引用不再自动附带(发送时由 codec.serialize 在 chip 位置展开)。
1325
+ // chip 集合以草稿 occurrences 实时为准(用户删 chip 后自动回到自动附带),chipRefs 为兜底
1326
+ const chipIds = store.draftChipIds || store.chipRefs
1327
+ const pend = numberedPendingRefs().filter((x) => !chipIds || !chipIds.has(x.r.id))
1328
+ const codeRefs = pend.filter((x) => x.r.kind === 'cite' && x.r.lineStart)
1329
+ const fileRefs = pend.filter((x) => x.r.kind === 'cite' && !x.r.lineStart)
1330
+ const trRefs = pend.filter((x) => x.r.kind === 'translate')
1331
+ const items = []
1332
+ for (const x of codeRefs) items.push('- ' + x.n + ': 代码引用 ' + baseName(x.r.path) + ':' + x.r.lineStart + '-' + x.r.lineEnd + '(' + x.r.path + ')')
1333
+ for (const x of fileRefs) items.push('- ' + x.n + ': 文件引用 ' + baseName(x.r.path) + '(整文件,绝对路径:' + x.r.path + ')')
1334
+ for (const x of trRefs) items.push(x.r.lineStart
1335
+ ? '- ' + x.n + ': 翻译引用 ' + baseName(x.r.path) + ':' + x.r.lineStart + '-' + x.r.lineEnd + '(' + x.r.path + ')'
1336
+ : '- ' + x.n + ': 翻译引用 ' + baseName(x.r.path) + '(' + x.r.path + ')')
1171
1337
  const parts = []
1172
1338
  if (codeRefs.length > 0) parts.push(codeRefs.length + ' 段代码引用')
1173
1339
  if (fileRefs.length > 0) parts.push(fileRefs.length + ' 个文件引用')
1174
1340
  if (trRefs.length > 0) parts.push(trRefs.length + ' 条翻译请求')
1175
1341
  if (parts.length === 0) return text
1176
- let ptr = '[code UI] 已标记 ' + parts.join('、') + '。'
1177
- if (fileRefs.length > 0) {
1178
- ptr += '\n文件引用(整个文件,请用你的文件读取工具打开以下绝对路径):\n'
1179
- + fileRefs.map((r) => '- ' + r.path).join('\n')
1180
- }
1181
- if (codeRefs.length > 0 || trRefs.length > 0) {
1182
- ptr += '\n(代码/翻译引用内容未粘贴在消息里,请调用 get_referenced_snippets 工具读取)。'
1183
- }
1342
+ let ptr = '[code UI] 已标记 ' + parts.join('、') + '(代码正文不进消息,请调用 get_referenced_snippets 工具读取;条目编号与用户输入框上的引用 tag 序号一致,用户说「引用N」即指该条):'
1343
+ if (items.length > 0) ptr += '\n' + items.join('\n')
1184
1344
  const sep = text.trim() ? '\n\n' : ''
1185
1345
  return text + sep + ptr
1186
1346
  }
1187
1347
 
1348
+ /* —— 消息区引用卡:解析消息里的结构化指针,在用户气泡内追加 Trae 式引用行 —— */
1349
+ function parseRefsFromMessage(text) {
1350
+ const out = []
1351
+ if (!text || text.indexOf('[code UI] 已标记') < 0) return out
1352
+ const reCode = /^- (?:(\d+): )?代码引用 (.+):(\d+)-(\d+)((.+))$/gm
1353
+ const reTr = /^- (?:(\d+): )?翻译引用 (.+):(\d+)-(\d+)((.+))$/gm
1354
+ const reTrFile = /^- (?:(\d+): )?翻译引用 ([^:\n]+)((.+))$/gm // 名字段禁冒号:避免吞掉上面带行号条目
1355
+ const reFile = /^- (?:(\d+): )?文件引用 ([^:\n]+)(整文件,绝对路径:(.+))$/gm
1356
+ let m
1357
+ while ((m = reCode.exec(text)) !== null) out.push({ num: m[1] ? +m[1] : null, name: m[2], lineStart: +m[3], lineEnd: +m[4], path: m[5], kind: 'cite' })
1358
+ while ((m = reTr.exec(text)) !== null) out.push({ num: m[1] ? +m[1] : null, name: m[2], lineStart: +m[3], lineEnd: +m[4], path: m[5], kind: 'translate' })
1359
+ while ((m = reFile.exec(text)) !== null) out.push({ num: m[1] ? +m[1] : null, name: m[2], path: m[3], kind: 'cite' })
1360
+ while ((m = reTrFile.exec(text)) !== null) out.push({ num: m[1] ? +m[1] : null, name: m[2], path: m[3], kind: 'translate' })
1361
+ return out
1362
+ }
1363
+ function buildRefCardEl(refs) {
1364
+ if (!refs || refs.length === 0) return null
1365
+ const card = document.createElement('div')
1366
+ card.className = 'cdex-refcard'
1367
+ const head = document.createElement('div')
1368
+ head.className = 'cdex-refcard-head'
1369
+ head.textContent = '引用上下文 ' + refs.length + ' 项 · 点击行跳转编辑器'
1370
+ card.appendChild(head)
1371
+ for (const r of refs) {
1372
+ const row = document.createElement('div')
1373
+ row.className = 'cdex-refcard-row'
1374
+ row.title = r.path + (r.lineStart ? ' : 第 ' + r.lineStart + '-' + r.lineEnd + ' 行' : '')
1375
+ const ic = document.createElement('span')
1376
+ ic.className = 'cdex-refcard-ic'
1377
+ ic.innerHTML = r.kind === 'translate' ? GLOBE_SVG : fileIconSvg(r.path)
1378
+ row.appendChild(ic)
1379
+ if (r.num) {
1380
+ const nb = document.createElement('span')
1381
+ nb.className = 'cdex-refcard-num'
1382
+ nb.textContent = String(r.num)
1383
+ row.appendChild(nb)
1384
+ }
1385
+ const nm = document.createElement('span')
1386
+ nm.className = 'cdex-refcard-name'
1387
+ nm.textContent = r.name || baseName(r.path)
1388
+ row.appendChild(nm)
1389
+ if (r.lineStart) {
1390
+ const ln = document.createElement('span')
1391
+ ln.className = 'cdex-refcard-lines'
1392
+ ln.textContent = ':' + r.lineStart + '-' + r.lineEnd
1393
+ row.appendChild(ln)
1394
+ }
1395
+ const kind = document.createElement('span')
1396
+ kind.className = 'cdex-refcard-kind'
1397
+ kind.textContent = r.kind === 'translate' ? '翻译' : (r.lineStart ? '代码' : '整文件')
1398
+ row.appendChild(kind)
1399
+ row.addEventListener('click', () => { jumpToRef({ path: r.path, lineStart: r.lineStart, lineEnd: r.lineEnd }) })
1400
+ card.appendChild(row)
1401
+ }
1402
+ return card
1403
+ }
1404
+ // 用户气泡结构(与 dsh 宿主对话区一致):row[data-time-hover-root] > stack > bubble;
1405
+ // 只做「追加」不做搬移/改写——不碰 React 管理的节点,宿主重渲染丢卡后由观察器补挂。
1406
+ function scanRefCards(root) {
1407
+ const rows = root.querySelectorAll('[data-time-hover-root]:not([data-turn-tail])')
1408
+ for (const row of rows) {
1409
+ const text = row.textContent
1410
+ if (!text || text.indexOf('[code UI] 已标记') < 0) continue
1411
+ const stack = row.firstElementChild
1412
+ const bubble = stack ? stack.lastElementChild : null
1413
+ const target = (bubble instanceof HTMLElement) ? bubble : row
1414
+ if (target.querySelector(':scope > .cdex-refcard')) continue
1415
+ const refs = parseRefsFromMessage(text)
1416
+ if (refs.length === 0) continue
1417
+ const card = buildRefCardEl(refs)
1418
+ if (card) target.appendChild(card)
1419
+ }
1420
+ }
1421
+ function observeRefCards() {
1422
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined' || !document.body) return () => {}
1423
+ let scheduled = false
1424
+ const scan = () => { scheduled = false; try { scanRefCards(document) } catch (e) {} }
1425
+ const schedule = () => {
1426
+ if (scheduled) return
1427
+ scheduled = true
1428
+ if (typeof window !== 'undefined' && window.requestAnimationFrame) window.requestAnimationFrame(scan)
1429
+ else setTimeout(scan, 60)
1430
+ }
1431
+ const mo = new MutationObserver(schedule)
1432
+ mo.observe(document.body, { childList: true, subtree: true })
1433
+ schedule()
1434
+ return () => {
1435
+ try { mo.disconnect() } catch (e) {}
1436
+ try { document.querySelectorAll('.cdex-refcard').forEach((el) => el.remove()) } catch (e) {}
1437
+ }
1438
+ }
1439
+
1188
1440
  /* ============ §6 Pointer 处理层(窗口拖拽 / 缩放 / 模式条移动) ============ */
1189
1441
  function onBarPointerDown(e) {
1190
1442
  if (e.target && typeof e.target.closest === 'function' && e.target.closest('button')) return
@@ -1254,6 +1506,40 @@ window.__ModuleLoader__.load({
1254
1506
 
1255
1507
  /* ============ §7 组件层(V.* 命名空间) ============ */
1256
1508
  let selfSending = false // AiInput 副本自己发送时置位,让挂钩的 submit 不重复附加指针
1509
+ // shell.submit 补丁:宿主输入条的回车/发送按钮走 keyboard.submit = shell.submit(SessionInputShell),
1510
+ // 不经过 inputActions.submit(那条只有发送按钮内部转发和插件输入卡用)——补丁必须挂在 shell.submit
1511
+ // 这个必经点,否则原生输入条发送时引用既不附带指针也不消耗。
1512
+ const patchedShells = new WeakSet()
1513
+ function ensureShellSubmitPatch() {
1514
+ try {
1515
+ const sid = store.inputSessionId
1516
+ if (!sid) return
1517
+ const conversation = ctx.get('conversation')
1518
+ const hub = conversation && conversation.input
1519
+ if (!hub || typeof hub.shell !== 'function') return
1520
+ const shell = hub.shell(sid)
1521
+ if (!shell || typeof shell.submit !== 'function' || typeof shell.setDraft !== 'function') return
1522
+ if (patchedShells.has(shell)) return
1523
+ patchedShells.add(shell)
1524
+ const origSubmit = shell.submit
1525
+ shell.submit = function () {
1526
+ let hadRefs = false
1527
+ try {
1528
+ const hasRefs = pendingRefs().length > 0
1529
+ hadRefs = hasRefs
1530
+ const draft = store.inputDraft || ''
1531
+ if (hasRefs && !selfSending && String(draft).indexOf('[code UI]') < 0) {
1532
+ shell.setDraft(buildSendPayload(draft))
1533
+ }
1534
+ } catch (e) {}
1535
+ const result = origSubmit.apply(shell, arguments)
1536
+ try { if (hadRefs && !selfSending) consumePendingRefs() } catch (e) {}
1537
+ return result
1538
+ }
1539
+ } catch (e) {}
1540
+ }
1541
+ // (历史:曾在 conversation.input.dock 插槽挂"输入条上方引用 tag 条"(P1-15),
1542
+ // 行内 chip(路线A)落地后冗余,已移除——引用的直接可视化由输入框内 chip 承担。)
1257
1543
  function InputBridge(props) {
1258
1544
  const useInput = props.useInput
1259
1545
  const inputState = typeof useInput === 'function' ? useInput((s) => s) : undefined
@@ -1263,9 +1549,21 @@ window.__ModuleLoader__.load({
1263
1549
  if (inputState) {
1264
1550
  store.inputDraft = inputState.draft
1265
1551
  store.inputPhase = inputState.phase
1552
+ // 路线A直接插 chip 所需:compose() 快照的 draftRev(插入 CAS)+ 草稿中 code-ui chip 携带的引用 id
1553
+ if (typeof inputState.draftRev === 'number') store.inputDraftRev = inputState.draftRev
1554
+ try {
1555
+ const ids = new Set()
1556
+ const occs = Array.isArray(inputState.occurrences) ? inputState.occurrences : []
1557
+ for (const o of occs) {
1558
+ if (!o || o.source !== 'code-ui') continue
1559
+ const cr = parseChipRef(o.ref)
1560
+ if (cr && cr.id) ids.add(cr.id)
1561
+ }
1562
+ store.draftChipIds = ids
1563
+ } catch (e) {}
1266
1564
  }
1267
- // 捕获当前会话 id:编辑器状态按会话记忆(标签快照/恢复 + 根解析),见 handleSessionSwitch
1268
- if (props.sessionId) handleSessionSwitch(props.sessionId)
1565
+ if (props.sessionId) { handleSessionSwitch(props.sessionId); store.inputSessionId = props.sessionId }
1566
+ ensureShellSubmitPatch() // 每次渲染尝试挂 shell.submit 补丁(WeakSet 幂等,会话切换后补新 shell)
1269
1567
  emit()
1270
1568
  })
1271
1569
  // 挂钩 DSH 原生输入条的发送:存在引用时把指针附进草稿再发(整文件=绝对路径)。
@@ -1276,14 +1574,21 @@ window.__ModuleLoader__.load({
1276
1574
  const orig = inputActions.submit
1277
1575
  inputActions.__cdexPatched = true
1278
1576
  inputActions.submit = function () {
1577
+ let attached = false
1578
+ let hadRefs = false
1279
1579
  try {
1280
- const hasRefs = store.references.length > 0
1580
+ const hasRefs = pendingRefs().length > 0
1581
+ hadRefs = hasRefs
1281
1582
  const draft = store.inputDraft || ''
1282
1583
  if (hasRefs && !selfSending && draft.indexOf('[code UI]') < 0) {
1283
1584
  inputActions.setDraft(buildSendPayload(draft))
1585
+ attached = true
1284
1586
  }
1285
1587
  } catch (e) {}
1286
- return orig.apply(inputActions, arguments)
1588
+ const result = orig.apply(inputActions, arguments)
1589
+ // 引用随本条消息消耗:chip 展开(attached=false)与自动附带(attached=true)两条路径都要消耗
1590
+ try { if (hadRefs && !selfSending) consumePendingRefs() } catch (e) {}
1591
+ return result
1287
1592
  }
1288
1593
  return () => {
1289
1594
  try { inputActions.submit = orig; delete inputActions.__cdexPatched } catch (e) {}
@@ -1308,8 +1613,9 @@ window.__ModuleLoader__.load({
1308
1613
  const st = useStore()
1309
1614
  let taEl = null
1310
1615
  const disabled = !st.inputActions
1311
- // 引用与翻译请求都只显示「标签」:内容不进输入框/消息正文,AI 经 get_referenced_snippets 工具读取
1312
- const refs = st.references
1616
+ // 引用与翻译请求都只显示「标签」:内容不进输入框/消息正文,AI 经 get_referenced_snippets 工具读取。
1617
+ // 只显示未使用引用:used 的已随旧消息消耗(chip 清空),记录在【引用/备注】的已使用区。
1618
+ const refs = st.references.filter((r) => !r.used)
1313
1619
  const citeCount = refs.filter((r) => r.kind === 'cite').length
1314
1620
  const trCount = refs.length - citeCount
1315
1621
  const submit = () => {
@@ -1326,15 +1632,20 @@ window.__ModuleLoader__.load({
1326
1632
  st.inputActions.setDraft(payload)
1327
1633
  st.inputActions.submit()
1328
1634
  } finally { selfSending = false }
1635
+ // 引用随本条消息消耗(chip 清空;记录留在【引用/备注】已使用区)
1636
+ consumePendingRefs()
1329
1637
  taEl.value = ''
1330
1638
  }
1331
1639
  return h('div', { className: 'cdex-aiinput-wrap' },
1332
1640
  h('div', { className: 'cdex-aiinput-card' },
1333
1641
  disabled ? h('div', { className: 'cdex-aiinput-warn' }, '⚠ 未打开会话:请先在主界面新建或选择一个会话,之后即可在此发送(引用标签会保留)') : null,
1334
1642
  refs.length > 0 ? h('div', { className: 'cdex-aiinput-tags' },
1335
- refs.map((r) => h('span', { className: 'cdex-aiinput-tag' + (r.kind === 'translate' ? ' cdex-aiinput-tag-tr' : ''), key: r.id },
1336
- h('span', { className: 'cdex-aiinput-tag-label' }, (r.kind === 'translate' ? '🌐 翻译 ' : '🔗 ') + tagLabel(r)),
1337
- h('button', { className: 'cdex-aiinput-tag-x', title: r.kind === 'translate' ? '删除翻译引用' : '删除引用', onClick: () => delReference(r.id) }, '✕'),
1643
+ refs.map((r, i) => h('span', { className: 'cdex-aiinput-tag' + (r.kind === 'translate' ? ' cdex-aiinput-tag-tr' : ''), key: r.id,
1644
+ title: '#' + (i + 1) + ' ' + r.path + (r.lineStart ? ' : 第 ' + r.lineStart + '-' + r.lineEnd + ' 行' : '') + '(点击跳转编辑器,✕ 删除;对 AI 说「引用' + (i + 1) + '」即指此条)',
1645
+ onClick: () => jumpToRef(r) },
1646
+ h('span', { className: 'cdex-tag-ic', dangerouslySetInnerHTML: { __html: r.kind === 'translate' ? GLOBE_SVG : fileIconSvg(r.path) } }),
1647
+ h('span', { className: 'cdex-aiinput-tag-label' }, (r.kind === 'translate' ? '🌐' : '') + (i + 1) + ':' + tagLabel(r)),
1648
+ h('button', { className: 'cdex-aiinput-tag-x', title: r.kind === 'translate' ? '删除翻译引用' : '删除引用', onClick: (e) => { e.stopPropagation(); delReference(r.id) } }, '✕'),
1338
1649
  )),
1339
1650
  ) : null,
1340
1651
  h('div', { className: 'cdex-aiinput' },
@@ -1478,18 +1789,61 @@ window.__ModuleLoader__.load({
1478
1789
  )
1479
1790
  }
1480
1791
  function RefItem(r) {
1481
- return h('div', { className: 'cdex-ref', key: r.id },
1792
+ return h('div', { className: 'cdex-ref' + (r.used ? ' cdex-ref-used' : ''), key: r.id,
1793
+ title: '右键:复制 / 翻译 / 引用(给AI)', onContextMenu: (e) => onRefContext(e, r) },
1482
1794
  h('div', { className: 'cdex-ref-head' },
1483
- h('span', { className: 'cdex-ref-path' }, r.path, r.lineStart ? (' :' + r.lineStart + '-' + r.lineEnd) : (r.kind === 'cite' ? ' (整个文件)' : '')),
1484
- h('button', { className: 'cdex-btn-ghost', onClick: () => delReference(r.id) }, '✕'),
1795
+ h('span', { className: 'cdex-ref-path cdex-ref-link',
1796
+ title: r.path + (r.lineStart ? ' : 第 ' + r.lineStart + '-' + r.lineEnd + ' 行' : '') + '(点击跳转编辑器)',
1797
+ onClick: () => jumpToRef(r) },
1798
+ h('span', { className: 'cdex-tag-ic', dangerouslySetInnerHTML: { __html: r.kind === 'translate' ? GLOBE_SVG : fileIconSvg(r.path) } }),
1799
+ r.path, r.lineStart ? (' :' + r.lineStart + '-' + r.lineEnd) : (r.kind === 'cite' ? ' (整个文件)' : ''),
1800
+ ),
1801
+ r.used ? h('span', { className: 'cdex-ref-usedbadge', title: r.usedAt ? ('随消息消耗于 ' + r.usedAt) : '已随消息消耗' }, '已使用') : null,
1802
+ h('button', { className: 'cdex-btn-ghost', title: r.used ? '删除已使用记录' : '删除引用', onClick: () => delReference(r.id) }, '✕'),
1485
1803
  ),
1486
1804
  h('div', { className: 'cdex-ref-code' }, r.text ? r.text.slice(0, 500) : '(AI 将按绝对路径自行读取整个文件)'),
1487
1805
  )
1488
1806
  }
1807
+ /* —— 【引用/备注】条目右键菜单:复制 / 翻译 / 引用(给AI)——
1808
+ 引用记录自带 path/行区间/正文,直接复用;备注只存批注不存代码,
1809
+ 翻译/引用前先经 read-file 从磁盘取回对应行内容。 */
1810
+ function refMenuItems(e, items) {
1811
+ e.preventDefault()
1812
+ store.ctxMenu = { x: e.clientX, y: e.clientY, items }
1813
+ emit()
1814
+ }
1815
+ async function noteToSel(n) {
1816
+ if (!n.lineStart) return { path: n.path, language: langOf(n.path), lineStart: null, lineEnd: null, text: '' } // 整文件备注:按整文件引用处理
1817
+ const res = await callHost('read-file', { path: n.path })
1818
+ if (!res || res.error) { showToast('读取原文件失败:' + ((res && res.error) || '未知错误')); return null }
1819
+ const lines = String(res.content || '').split('\n')
1820
+ const s = Math.max(1, n.lineStart)
1821
+ const t = Math.min(lines.length, Math.max(s, n.lineEnd || s))
1822
+ return { path: n.path, language: langOf(n.path), lineStart: s, lineEnd: t, text: lines.slice(s - 1, t).join('\n') }
1823
+ }
1824
+ function onRefContext(e, r) {
1825
+ const isWhole = !r.lineStart
1826
+ const sel = { path: r.path, language: r.language || langOf(r.path), lineStart: r.lineStart, lineEnd: r.lineEnd, text: r.text || '' }
1827
+ refMenuItems(e, [
1828
+ { icon: '📋', label: isWhole ? '复制路径' : '复制', act: () => { copyText(isWhole ? r.path : sel.text); showToast(isWhole ? '路径已复制' : '代码已复制') } },
1829
+ { icon: '🌐', label: '翻译', act: () => { doTranslate(sel) } },
1830
+ { icon: '🔗', label: '引用(给AI)', act: () => { doCite(sel) } },
1831
+ ])
1832
+ }
1833
+ function onNoteContext(e, n) {
1834
+ refMenuItems(e, [
1835
+ { icon: '📋', label: '复制', act: () => { copyText(n.note || ''); showToast('备注已复制') } },
1836
+ { icon: '🌐', label: '翻译', act: () => { noteToSel(n).then((sel) => { if (sel) doTranslate(sel) }) } },
1837
+ { icon: '🔗', label: '引用(给AI)', act: () => { noteToSel(n).then((sel) => { if (sel) doCite(sel) }) } },
1838
+ ])
1839
+ }
1489
1840
  function ReferencesView() {
1490
1841
  const st = useStore()
1491
- const citeList = st.references.filter((r) => r.kind === 'cite')
1492
- const trList = st.references.filter((r) => r.kind === 'translate')
1842
+ // 待用(chip 里挂着、随下一条消息发送)与已使用(已随消息消耗的历史记录)分区展示
1843
+ const pending = st.references.filter((r) => !r.used)
1844
+ const usedList = st.references.filter((r) => r.used)
1845
+ const citeList = pending.filter((r) => r.kind === 'cite')
1846
+ const trList = pending.filter((r) => r.kind === 'translate')
1493
1847
  return h('div', { className: 'cdex-sidebar', style: { width: st.sidebarW } },
1494
1848
  h('div', { className: 'cdex-sb-resize', title: '拖拽调整宽度(拖到最窄松手即收起)', onPointerDown: onSbResizeDown }),
1495
1849
  h('div', { className: 'cdex-sidebar-head' },
@@ -1501,15 +1855,20 @@ window.__ModuleLoader__.load({
1501
1855
  citeList.length === 0 ? h('div', { className: 'cdex-empty' }, '选中代码后右键「引用」。') : citeList.map((r) => RefItem(r)),
1502
1856
  h('div', { className: 'cdex-section' }, '🌐 翻译请求 (' + trList.length + ')'),
1503
1857
  trList.length === 0 ? h('div', { className: 'cdex-empty' }, '选中代码后右键「翻译」。') : trList.map((r) => RefItem(r)),
1858
+ h('div', { className: 'cdex-section' }, '🕘 已使用 (' + usedList.length + ')'),
1859
+ usedList.length === 0
1860
+ ? h('div', { className: 'cdex-empty' }, '尚无已消耗的引用。引用随消息发送即标记已使用,记录保留在此供查找。')
1861
+ : usedList.map((r) => RefItem(r)),
1504
1862
  h('div', { className: 'cdex-section' }, '📝 备注 (' + st.activeNotes.length + ')'),
1505
- st.activeNotes.length === 0 ? h('div', { className: 'cdex-empty' }, '当前文件暂无备注。') : st.activeNotes.map((n) => h('div', { className: 'cdex-ref', key: n.id },
1863
+ st.activeNotes.length === 0 ? h('div', { className: 'cdex-empty' }, '当前文件暂无备注。') : st.activeNotes.map((n) => h('div', { className: 'cdex-ref', key: n.id,
1864
+ title: '右键:复制 / 翻译 / 引用(给AI)', onContextMenu: (e) => onNoteContext(e, n) },
1506
1865
  h('div', { className: 'cdex-ref-head' },
1507
1866
  h('span', { className: 'cdex-ref-path' }, n.path, n.lineStart ? (' :' + n.lineStart + '-' + n.lineEnd) : ''),
1508
1867
  h('button', { className: 'cdex-btn-ghost', onClick: () => delNote(n.id) }, '✕'),
1509
1868
  ),
1510
1869
  h('div', { className: 'cdex-ref-code' }, n.note),
1511
1870
  )),
1512
- h('div', { className: 'cdex-hint' }, '在输入框对 AI 说「读取/翻译我引用的代码」,AI 会调用 get_referenced_snippets 工具获取内容。'),
1871
+ h('div', { className: 'cdex-hint' }, '引用一次性使用:随消息发送即消耗(chip 清空,AI 仍可读取本次内容)。条目右键可快速复制 / 翻译 / 引用(给AI)。在输入框对 AI 说「读取/翻译我引用的代码」,AI 会调用 get_referenced_snippets 工具获取内容。'),
1513
1872
  ),
1514
1873
  )
1515
1874
  }
@@ -1953,6 +2312,68 @@ window.__ModuleLoader__.load({
1953
2312
  let unmountEditorFns = null
1954
2313
  let layoutMode = 'dock' // dock(右侧停靠,默认) / float(悬浮窗口):编辑器以哪种布局呈现
1955
2314
  let toggleStyle = 'bar' // bar(悬浮模式条,默认) / header(会话头 ⌨ 按钮):用哪种按钮切换界面
2315
+ // chip 显示标签:编号 + 短标签;翻译类加 🌐 前缀与代码引用区分
2316
+ function chipLabelFor(r) {
2317
+ const hit = numberedPendingRefs().find((x) => x.r.id === r.id)
2318
+ const num = hit ? hit.n : '?'
2319
+ return (r.kind === 'translate' ? '🌐' : '') + num + ':' + tagLabel(r)
2320
+ }
2321
+ // —— 直接插入行内 chip(不经 @ 菜单)——
2322
+ // 数据全部取实时值:conversation.input (= inputHub).shell(sid).snapshot 的 draft/draftRev
2323
+ // (compose 快照,宿主每次编辑同步发布),不再依赖本插件组件的渲染同步时机。
2324
+ // 位置 = detect 文本末尾:draft 是 clipboard 投影,chip 在 detect 里占 1 个占位符,
2325
+ // 需按 occurrences 把 clipboard 长度换算回 detect 长度(每颗 chip 减 clipboardText长-1)。
2326
+ // 与官方 toggleCommandMenu 的合成 span 同构(span.draftRev CAS)。
2327
+ // 返回 true = chip 已插入输入框末尾;false = 条件不满足(调用方退回 tag 条展示)。
2328
+ function insertRefChipDirect(r) {
2329
+ try {
2330
+ const conversation = ctx.get('conversation')
2331
+ const inputHub = conversation && conversation.input
2332
+ if (!inputHub || typeof inputHub.shell !== 'function') return false
2333
+ const sid = store.inputSessionId
2334
+ if (!sid) return false
2335
+ const shell = inputHub.shell(sid)
2336
+ const snap = shell && shell.snapshot
2337
+ if (!snap || typeof snap.draftRev !== 'number' || typeof snap.draft !== 'string') return false
2338
+ if (snap.phase && snap.phase !== 'plain' && snap.phase !== 'claimed') return false
2339
+ const inputTriggers = store.inputTriggers
2340
+ if (!inputTriggers || !inputTriggers.live || !inputTriggers.live.controllers) return false
2341
+ const controller = inputTriggers.live.controllers.get(sid)
2342
+ if (!controller || typeof controller.execute !== 'function') return false
2343
+ let detectLen = snap.draft.length
2344
+ const occs = Array.isArray(snap.occurrences) ? snap.occurrences : []
2345
+ for (const o of occs) {
2346
+ if (o && typeof o.length === 'number') detectLen -= Math.max(0, o.length - 1)
2347
+ }
2348
+ if (detectLen < 0) detectLen = 0
2349
+ const label = chipLabelFor(r)
2350
+ const outcome = { insert: { source: 'code-ui', ref: JSON.stringify({ id: r.id, path: r.path, lineStart: r.lineStart, lineEnd: r.lineEnd, kind: r.kind }), label: label, appearance: 'file', clipboardText: label } }
2351
+ return controller.execute(outcome, { start: detectLen, end: detectLen, draftRev: snap.draftRev }) === true
2352
+ } catch (e) { return false }
2353
+ }
2354
+ // chip ref(JSON 串)→ 引用数据;解析失败返回 null
2355
+ function parseChipRef(refJson) {
2356
+ try {
2357
+ const r = JSON.parse(refJson)
2358
+ if (!r || !r.path) return null
2359
+ return r
2360
+ } catch (e) { return null }
2361
+ }
2362
+ function chipShortLabel(r) {
2363
+ const name = baseName(r.path)
2364
+ return r.lineStart && r.lineEnd ? name + ':' + r.lineStart + '-' + r.lineEnd : name
2365
+ }
2366
+ // chip → 模型侧文本:与消息指针条目同格式(AI 侧读取口径一致)
2367
+ function formatRefModelLine(r) {
2368
+ if (r.kind === 'translate') {
2369
+ return r.lineStart
2370
+ ? '- 翻译引用 ' + baseName(r.path) + ':' + r.lineStart + '-' + r.lineEnd + '(' + r.path + ')'
2371
+ : '- 翻译引用 ' + baseName(r.path) + '(' + r.path + ')'
2372
+ }
2373
+ return r.lineStart
2374
+ ? '- 代码引用 ' + baseName(r.path) + ':' + r.lineStart + '-' + r.lineEnd + '(' + r.path + ')'
2375
+ : '- 文件引用 ' + baseName(r.path) + '(整文件,绝对路径:' + r.path + ')'
2376
+ }
1956
2377
  function mountEditor() {
1957
2378
  if (unmountEditorFns) return
1958
2379
  const disposers = []
@@ -1966,6 +2387,65 @@ window.__ModuleLoader__.load({
1966
2387
  { name: 'shell.overlay', id: 'cdex-session-watch', order: 4, label: '' },
1967
2388
  (props) => h(SessionWatch, props),
1968
2389
  )))
2390
+ // 消息区引用卡:观察宿主对话区,把带 [code UI] 指针的用户消息补挂引用卡(卸载时连卡移除)
2391
+ disposers.push(observeRefCards())
2392
+ // —— 路线A原型:@ 菜单行内引用 chip(宿主 ReferenceChipNode 装饰器)——
2393
+ // 注册官方触发源:输入框打 @ 弹菜单出现「code UI 引用」分组(与 @file 同菜单),
2394
+ // 选中即在光标处插入行内 chip;发送时宿主调 codec.serialize 把每颗 chip 在原位
2395
+ // 展开成模型文本(这里 = 与指针条目同格式的引用行)。chip 携带的引用不走自动
2396
+ // 附带(防重复),消耗仍随消息(consumePendingRefs 标记 used,记录保留)。
2397
+ // 已知原型局限:chip 在草稿里被删掉后 chipRefs 未同步,该引用本次不自动附带。
2398
+ // inputTriggers 服务由 dsh-client-ui-input-trigger 在 client 启动期注册,插件
2399
+ // client 可能先于它启动——ctx.get 会拿到 undefined 静默跳过,改用 inject 等待。
2400
+ if (!store.chipRefs) store.chipRefs = new Set()
2401
+ ctx.inject(['inputTriggers'], (scope) => {
2402
+ try {
2403
+ const inputTriggers = scope.inputTriggers
2404
+ if (!inputTriggers || typeof inputTriggers.registerSource !== 'function') return
2405
+ store.inputTriggers = inputTriggers
2406
+ disposers.push(ctx.effect(() => inputTriggers.registerSource({
2407
+ trigger: '@',
2408
+ name: 'code-ui',
2409
+ showGroupTitle: true,
2410
+ order: 100,
2411
+ candidates: (session, req) => {
2412
+ const pend = numberedPendingRefs()
2413
+ if (pend.length === 0) return Promise.resolve([])
2414
+ const query = String((req && req.query) || '').toLowerCase()
2415
+ return Promise.resolve(pend
2416
+ .filter((x) => !query || tagLabel(x.r).toLowerCase().indexOf(query) >= 0 || baseName(x.r.path).toLowerCase().indexOf(query) >= 0)
2417
+ .map((x) => ({
2418
+ name: (x.r.kind === 'translate' ? '🌐' : '') + x.n + ':' + tagLabel(x.r),
2419
+ description: x.r.path,
2420
+ icon: 'file',
2421
+ section: 'code UI 引用',
2422
+ value: x.r.id,
2423
+ })))
2424
+ },
2425
+ onPick: (arg) => {
2426
+ const r = store.references.find((x) => x.id === arg.candidate.value)
2427
+ if (!r) return undefined
2428
+ store.chipRefs.add(r.id)
2429
+ emit()
2430
+ // ref 字段封入完整引用数据(JSON):chip 自包含,serialize 不依赖 client store——
2431
+ // 硬刷新/重启后草稿恢复的 chip 仍能正确展开(对齐 @file 的 ref=mention 自包含设计)
2432
+ const label = chipLabelFor(r)
2433
+ return { insert: { source: 'code-ui', ref: JSON.stringify({ id: r.id, path: r.path, lineStart: r.lineStart, lineEnd: r.lineEnd, kind: r.kind }), label: label, appearance: 'file', clipboardText: label } }
2434
+ },
2435
+ codec: {
2436
+ clipboardText: (refJson) => {
2437
+ const r = parseChipRef(refJson)
2438
+ return r ? chipShortLabel(r) : String(refJson)
2439
+ },
2440
+ serialize: async (refJson) => {
2441
+ const r = parseChipRef(refJson)
2442
+ if (!r) return '(code UI 引用数据无效: ' + refJson + ')'
2443
+ return formatRefModelLine(r)
2444
+ },
2445
+ },
2446
+ }), 'code-ui: @ 引用源原型'))
2447
+ } catch (e) {}
2448
+ })
1969
2449
  // 切换按钮:按用户选择注册(与布局模式解耦,任意组合)
1970
2450
  if (toggleStyle === 'bar') {
1971
2451
  // 悬浮模式条:可拖动横条,两键切换 code UI / dsh UI
package/lib/index.js CHANGED
@@ -664,15 +664,22 @@ function rpcHandler(ctx) {
664
664
  }
665
665
 
666
666
  if (endpoint === "add-reference") {
667
+ const path = args.path || "";
668
+ const kind = args.kind || "cite";
669
+ const lineStart = args.lineStart || null;
670
+ const lineEnd = args.lineEnd || null;
671
+ // 允许重复引用:chip 在输入框句中可视化,重复了用户看得见、可 ✕ 删除,无需 host 去重拦截。
672
+ // (原逻辑曾对同 path+行区间+kind 的未使用引用去重,行内 chip 落地后按用户要求取消)
667
673
  const id = "r" + (idSeq++);
668
674
  const ref = {
669
675
  id,
670
- path: args.path || "",
676
+ path,
671
677
  language: args.language || "",
672
- lineStart: args.lineStart || null,
673
- lineEnd: args.lineEnd || null,
678
+ lineStart,
679
+ lineEnd,
674
680
  text: args.text || "",
675
- kind: args.kind || "cite",
681
+ kind,
682
+ used: false, // 一次性消耗标记:随消息发送后由 use-references 置 true(chip 清空,记录保留)
676
683
  createdAt: now(),
677
684
  };
678
685
  references.push(ref);
@@ -683,6 +690,17 @@ function rpcHandler(ctx) {
683
690
  return ok({ references });
684
691
  }
685
692
 
693
+ if (endpoint === "use-references") {
694
+ // 引用一次性消耗:把本条消息附带的引用标记为 used(不删除——AI 在消息发出后才经
695
+ // get_referenced_snippets 读取,删除就读不到了;used 记录保留供用户在【引用/备注】查找)。
696
+ const ids = Array.isArray(args.ids) ? args.ids : [];
697
+ let n = 0;
698
+ for (const r of references) {
699
+ if (!r.used && ids.indexOf(r.id) >= 0) { r.used = true; r.usedAt = now(); n++; }
700
+ }
701
+ return ok({ ok: true, used: n });
702
+ }
703
+
686
704
  if (endpoint === "delete-reference") {
687
705
  const i = references.findIndex((r) => r.id === args.id);
688
706
  if (i >= 0) references.splice(i, 1);
@@ -911,7 +929,8 @@ function registerSnippetsTool(tools) {
911
929
  const whole = !r.lineStart;
912
930
  const label = r.kind === "translate" ? "翻译请求" : whole ? "文件引用" : "代码引用";
913
931
  const loc = (r.path || "") + (whole ? "(整个文件)" : ":" + r.lineStart + "-" + r.lineEnd);
914
- const lang = r.language ? " [" + r.language + "]" : "";
932
+ const num = r.seq ? ",用户 tag 编号 " + r.seq : "";
933
+ const lang = (r.language ? " [" + r.language + "]" : "") + (r.used ? "(已使用" + (r.usedAt ? " " + r.usedAt : "") + num + ")" : "");
915
934
  const body = whole
916
935
  ? "整个文件的引用:请用你的文件读取工具打开上述绝对路径自行读取。"
917
936
  : (r.text || "");
@@ -933,6 +952,69 @@ function registerSnippetsTool(tools) {
933
952
  export const name = "dsh-code-ui";
934
953
  export const inject = ["tools"];
935
954
 
955
+ // —— 兼容兜底:宿主 connection.rpc.handle 不可用时的自挂通道路由 ——
956
+ // 背景(2026-09-11 405 事故):宿主 connection 的 rpc-host.ts register() 直接访问 owner.webServer,
957
+ // 而 owner 是 connection 插件自己的 ctx(inject 只有 credentials),Cordis guard 抛
958
+ // "cannot get property webServer without inject" → 插件通道路由永不注册 → 客户端全部 405
959
+ //(POST 落 webServer 的 SPA 静态兜底),文件树/更新器等 RPC 功能全挂。
960
+ // 宿主修复前的版本上,插件侧唯一可行的适配就是自己挂同路径前缀路由:
961
+ // ① 信任/认证门复用宿主公开 API connection.requestRejection(HostConnectionHandle 文档明示
962
+ // 用于「Apply Connection's Host/Origin checks and browser authentication to another Web route」),
963
+ // 因此 Host/Origin 围栏与浏览器会话 cookie 校验和宿主通道完全同策略,不降低安全性;
964
+ // ② 信封协议(client-request / server-response + rpcId 关联)按 Connection 契约复刻。
965
+ // 宿主修复上线后此兜底不再触发(仅当 handle() 抛错时才启用),可随最低支持版本提升而删除。
966
+ function selfHostedChannelRoute(connection, channel, handler) {
967
+ const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
968
+ return async (req, res) => {
969
+ try {
970
+ const rejection = connection.requestRejection(req);
971
+ if (rejection !== undefined) {
972
+ res.writeHead(rejection);
973
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
974
+ return;
975
+ }
976
+ const url = new URL(req.url || "/", "http://dsh.invalid");
977
+ const endpoint = url.pathname.startsWith(channel + "/") ? url.pathname.slice(channel.length + 1) : null;
978
+ const segments = endpoint === null ? [] : endpoint.split("/");
979
+ if (req.method !== "POST" || !endpoint
980
+ || segments.some((s) => !s || s === "." || s === ".." || !ENDPOINT_SEGMENT.test(s))) {
981
+ res.writeHead(404);
982
+ res.end("not found");
983
+ return;
984
+ }
985
+ if (String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase() !== "application/json") {
986
+ res.writeHead(415);
987
+ res.end("content type must be application/json");
988
+ return;
989
+ }
990
+ const chunks = [];
991
+ for await (const chunk of req) chunks.push(chunk);
992
+ let message;
993
+ try { message = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
994
+ catch (_) { res.writeHead(400); res.end("body is not JSON"); return; }
995
+ const rpcId = message && typeof message.rpcId === "string" ? message.rpcId : "invalid-request";
996
+ const send = (result) => {
997
+ res.writeHead(200, { "content-type": "application/json" });
998
+ res.end(JSON.stringify({ type: "server-response", rpcId, result }));
999
+ };
1000
+ if (!message || message.type !== "client-request" || typeof message.method !== "string") {
1001
+ send({ ok: false, error: { code: "gateway/bad-request", message: "invalid client-request message", details: {} } });
1002
+ return;
1003
+ }
1004
+ if (message.method !== endpoint) {
1005
+ send({ ok: false, error: { code: "gateway/bad-request", message: "method does not match endpoint", details: {} } });
1006
+ return;
1007
+ }
1008
+ let result;
1009
+ try { result = await handler(endpoint, message.payload, undefined); }
1010
+ catch (err) { res.writeHead(500); res.end("handler failure: " + String(err)); return; }
1011
+ send(result);
1012
+ } catch (err) {
1013
+ try { res.writeHead(500); res.end("channel failure: " + String(err)); } catch (_) {}
1014
+ }
1015
+ };
1016
+ }
1017
+
936
1018
  export function apply(ctx) {
937
1019
  // 注册 settings 命名空间(插件配置页按命名空间分发卡片;内部自带 ctx.inject(['settings']),
938
1020
  // 无 settings 服务的宿主上静默不运行,不影响其它功能)
@@ -947,12 +1029,59 @@ export function apply(ctx) {
947
1029
  registerSnippetsTool(c.tools);
948
1030
  });
949
1031
 
950
- // 等待 Connection 服务就绪后注册 RPC 通道
1032
+ // 等待 Connection 服务就绪后注册 RPC 通道。
1033
+ // 注:2026-09-11 的 405 事故根因在宿主侧——connection 的 rpc-host.ts register() 曾直接访问
1034
+ // owner.webServer,而 owner 是 connection 插件自己的 ctx(inject 列表只有 credentials),
1035
+ // Cordis guard 抛 "cannot get property webServer without inject" → 前缀路由永不注册 →
1036
+ // 所有插件 RPC 405(POST 落到 SPA 静态兜底;dsh-context 等其它插件同样中招)。
1037
+ // 宿主已修:改经 connection 自己的 webServer 作用域挂载(与 /api 路由同路),
1038
+ // 因此插件侧无需注入 webServer(实测调用方声明也救不了)。
1039
+ // 防御性注册:不同 dsh-client-connection 版本的 handle() 对第三参(options)宽容度不同
1040
+ //(0.1.5-rc.2 为 (channel, handler) 两参、多余参数被忽略;其它版本可能校验/依赖 options)。
1041
+ // 带参注册抛错时回退两参注册;仍失败则向宿主控制台大声报错——通道注册失败时客户端
1042
+ // 所有 RPC 都会 405(POST 落到 SPA 静态兜底),不留日志极难定位。
951
1043
  ctx.inject(["connection"], (c) => {
952
1044
  const connection = c.connection;
953
1045
  if (connection === undefined) return;
954
- const dispose = connection.rpc.handle(CHANNEL, rpcHandler(ctx), { authority: "loopback" });
955
- c.effect(() => dispose, "dsh-code-ui: rpc channel");
1046
+ const handler = rpcHandler(ctx);
1047
+ let dispose;
1048
+ let hostChannel = false;
1049
+ try {
1050
+ dispose = connection.rpc.handle(CHANNEL, handler, { authority: "loopback" });
1051
+ hostChannel = true;
1052
+ } catch (err) {
1053
+ console.error("[dsh-code-ui] 带 options 注册 RPC 通道失败,回退无 options 注册:" + ((err && err.message) || err));
1054
+ try {
1055
+ dispose = connection.rpc.handle(CHANNEL, handler);
1056
+ hostChannel = true;
1057
+ } catch (err2) {
1058
+ console.error("[dsh-code-ui] 宿主 RPC 通道注册失败(将启用插件自挂路由兜底):" + ((err2 && err2.message) || err2));
1059
+ }
1060
+ }
1061
+ if (hostChannel) {
1062
+ console.log("[dsh-code-ui] RPC 通道已注册(宿主 connection 通道):" + CHANNEL);
1063
+ c.effect(() => dispose, "dsh-code-ui: rpc channel");
1064
+ } else if (typeof connection.requestRejection !== "function") {
1065
+ // 安全护栏:拿不到宿主的信任/认证门就绝不自挂路由——否则等于开一条绕过
1066
+ // Host/Origin 围栏与浏览器会话校验的裸路由。
1067
+ console.error("[dsh-code-ui] 宿主通道不可用且无 requestRejection,已放弃自挂路由兜底;请更新 DSH 宿主以修复通道注册 bug");
1068
+ } else {
1069
+ // 兜底:自挂同路径前缀路由(与 vendor 静态路由同款机制),复用 requestRejection 认证门。
1070
+ // 仅在宿主通道不可用时启用,宿主修复后不会走到这里。
1071
+ c.inject(["webServer"], (w) => {
1072
+ try {
1073
+ const disposeSelf = w.webServer.register({
1074
+ kind: "prefix",
1075
+ path: CHANNEL,
1076
+ handler: selfHostedChannelRoute(connection, CHANNEL, handler),
1077
+ });
1078
+ c.effect(() => disposeSelf, "dsh-code-ui: self-hosted rpc channel");
1079
+ console.log("[dsh-code-ui] 已启用插件自挂路由兜底:" + CHANNEL);
1080
+ } catch (err3) {
1081
+ console.error("[dsh-code-ui] 兜底通道路由注册失败:" + ((err3 && err3.message) || err3));
1082
+ }
1083
+ });
1084
+ }
956
1085
  c.effect(() => () => unwatchAllFiles(), "dsh-code-ui: file watchers");
957
1086
  });
958
1087
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-code-ui",
3
- "version": "1.4.1",
4
- "description": "Cursor 风格的 AI 代码编辑器工作台插件(DSH):文件树、多标签页编辑、语法高亮、引用/翻译给 AI、右侧停靠/悬浮窗口双布局",
3
+ "version": "1.5.1",
4
+ "description": "Cursor 风格的 AI 代码编辑器工作台插件(DSH):文件树、多标签页编辑、语法高亮、引用/翻译行内标签给 AI、右侧停靠/悬浮窗口双布局",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "repository": {