dsh-code-ui 1.4.0 → 1.5.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/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 状态层 ============ */
@@ -281,6 +306,7 @@ window.__ModuleLoader__.load({
281
306
  toastPos: null,
282
307
  sessionId: null, // 当前会话 id(InputBridge 从 slot kit 捕获;blank 会话头隐藏时由 SessionWatch 兜底):文件树根按会话解析/记忆
283
308
  rootSessionId: null, // 当前根归属哪个会话(落后于 sessionId 时触发重新解析;手动根由宿主按会话记忆)
309
+ sessionEditor: {}, // 会话 id -> { tabs, activePath, nodes, rootPath }:切换会话时快照/恢复编辑器标签与文件树展开状态(滚动位按文件路径记忆于 scrollPos)
284
310
  scrollPos: {}, // 文件路径 -> {top,left}:每个标签记住自己的滚动位置(切换后恢复)
285
311
  error: null,
286
312
  }
@@ -296,20 +322,33 @@ window.__ModuleLoader__.load({
296
322
  return store
297
323
  }
298
324
 
325
+ let toastSeq = 0
299
326
  function showToast(msg) {
300
- store.toast = { msg }
327
+ store.toast = { msg, seq: ++toastSeq }
301
328
  store.toastKeep = false
302
329
  emit()
303
330
  scheduleToastHide(2800)
304
331
  }
305
- // 提示自动消失;正在被拖拽(toastKeep)时顺延,松手后短暂展示再消失
332
+ // 提示自动消失;正在被拖拽(toastKeep)时顺延,松手后短暂展示再消失。
333
+ // seq 世代戳:手动 ✕ 关闭后残留的定时器不会误关下一条新提示。
334
+ // timer 是宿主服务(ctx.get('timer')),可能缺失——缺失时回退 setTimeout,
335
+ // 否则 scheduleToastHide 静默不挂定时器,提示条永远不关(历史上「有时不自动关闭」的根因)。
306
336
  function scheduleToastHide(ms) {
307
- if (timer && typeof timer.timeout === 'function') {
308
- timer.timeout(() => {
309
- if (store.toastKeep) { scheduleToastHide(600); return }
310
- store.toast = null; emit()
311
- }, 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()
312
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()
313
352
  }
314
353
  // 提示条拖拽:按住移动自定义位置(默认顶部居中,拖过之后记住位置,防止挡内容)
315
354
  function onToastPointerDown(e) {
@@ -346,6 +385,10 @@ window.__ModuleLoader__.load({
346
385
  return h('div', { className: 'cdex-toast' + (st.toastPos ? ' placed' : ''), style: style, title: '按住可拖动位置(不再挡内容)', onPointerDown: onToastPointerDown },
347
386
  h('span', { className: 'cdex-toast-grip' }, '⠿'),
348
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() } }, '✕'),
349
392
  )
350
393
  }
351
394
 
@@ -406,13 +449,36 @@ window.__ModuleLoader__.load({
406
449
  return parts.length ? parts[parts.length - 1] : String(p || '')
407
450
  }
408
451
  function tagLabel(r) {
452
+ // 短小原则:只显示 文件名:起-止 行号,让用户知道引了什么即可;完整路径放 hover title
409
453
  const name = baseName(r.path)
410
- if (r.lineStart && r.lineEnd) {
411
- return name + '(' + r.lineStart + '-' + r.lineEnd + ')'
412
- }
413
- if (!r.lineStart && r.kind === 'cite') return name + '(整文件)'
454
+ if (r.lineStart && r.lineEnd) return name + ':' + r.lineStart + '-' + r.lineEnd
414
455
  return name
415
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>'
416
482
  function parentPath(p) {
417
483
  const s = String(p).replace(/[\\/]+$/, '')
418
484
  const idx = Math.max(s.lastIndexOf('\\'), s.lastIndexOf('/'))
@@ -620,6 +686,7 @@ window.__ModuleLoader__.load({
620
686
  ]
621
687
  const HLJS_VENDOR_BASE = '/dsh-code-ui/vendor/'
622
688
  const HLJS_BIG_CHARS = 512 * 1024 // 超大文件不做 hljs(防卡顿),继续用轻量 tokenizer
689
+ const HLJS_SYNC_MAX = 32 * 1024 // 该阈值内同步走 hljs:单帧渲染,杜绝 tokenizer↔hljs 交换帧(交换帧会重写 innerHTML 丢滚动位 → 每敲一个字符文本抖动)
623
690
  // langOf 的输出大多是 hljs 语言 id/别名;少数需归一,其余交给 hljs.getLanguage 解析,失败回退 plaintext
624
691
  const HLJS_LANG_FIX = { text: 'plaintext', jsx: 'javascript', tsx: 'typescript', htm: 'xml' }
625
692
  function hljsLangOf(lang) {
@@ -714,8 +781,24 @@ window.__ModuleLoader__.load({
714
781
  if (hljsState !== 'ready' || content.length > HLJS_BIG_CHARS) return highlight(content, lang)
715
782
  const cached = hljsCache.get(p)
716
783
  if (cached && cached.content === content) return cached.html
784
+ // 小文件同步走 hljs:一次渲染直接出最终结果。原「tokenizer 顶上 + 60ms 后换 hljs」
785
+ // 的交换帧会让高亮层每击键重写两次 innerHTML(浏览器重写时 scrollTop/scrollLeft 被
786
+ // 重置为 0),表现为滚动过的文件每敲一个字符文本抖动一下。
787
+ if (content.length <= HLJS_SYNC_MAX) {
788
+ let html
789
+ try {
790
+ html = window.hljs.highlight(content, { language: hljsLangOf(lang) || 'plaintext', ignoreIllegals: true }).value
791
+ } catch (e) {
792
+ html = highlight(content, lang) // 单文件异常(不应发生):退回 tokenizer 输出
793
+ }
794
+ // 与 tokenizer 输出保持行数一致:末尾换行补齐(textarea 会渲染最后的空行)
795
+ if (content.endsWith('\n') && !html.endsWith('\n')) html += '\n'
796
+ if (hljsCache.size > 24) { const first = hljsCache.keys().next().value; if (first !== undefined) hljsCache.delete(first) }
797
+ hljsCache.set(p, { content, html })
798
+ return html
799
+ }
717
800
  scheduleHljs(p, content, lang)
718
- return highlight(content, lang) // 间歇帧:tokenizer 顶上,保证字符即时可见有色
801
+ return highlight(content, lang) // 大文件:tokenizer 顶上(同步、即时),60ms 防抖后换 hljs
719
802
  }
720
803
 
721
804
  /* ============ §4 RPC 操作层(文件树 / 标签页 / 引用备注) ============ */
@@ -755,22 +838,51 @@ window.__ModuleLoader__.load({
755
838
  await applyRoot((res && res.root) ? res.root : path)
756
839
  showToast('工作区根目录:' + store.rootPath)
757
840
  }
758
- // 换根并整体重置文件树/标签页/缓冲(自动跟随与手动设置共用)
841
+ // 换根:标签页/缓冲/脏标记按会话记忆(handleSessionSwitch 快照恢复,均以绝对路径为键、
842
+ // 跨根安全)。树状态(目录展开/已加载子目录)同样按会话恢复——nodes 里已有该根节点时
843
+ // 跳过重置(同根换会话、或恢复了目标会话的树快照),只在新根没有树状态时重建。
844
+ // 注意不能用 toggleExpand 展开:它是切换语义,会把已展开的根折叠回去。
759
845
  async function applyRoot(newRoot) {
760
846
  store.rootPath = newRoot
761
- store.nodes = {}
762
- store.tabs = []
763
- store.activePath = null
764
- store.buffers = {}
765
- store.savedContent = {}
766
- store.dirty = {}
847
+ if (!store.nodes[newRoot]) {
848
+ store.nodes = {}
849
+ store.browsePath = null
850
+ store.browseEntries = []
851
+ store.nodes[newRoot] = { name: baseName(newRoot), path: newRoot, isDir: true, children: [], expanded: true, loaded: false }
852
+ store.filePickerOpen = false
853
+ emit()
854
+ await toggleExpand(newRoot)
855
+ return
856
+ }
857
+ emit()
858
+ }
859
+ // —— 会话切换:编辑器状态按会话记忆 ——
860
+ // 切换时快照当前会话的标签页/激活项/文件树(展开+已加载子目录)/根路径,恢复目标会话的;
861
+ // 滚动位按文件路径记忆于 store.scrollPos(标签恢复挂载时自动回滚),焦点不恢复、由用户
862
+ // 自己点击决定。缓冲/脏标记以绝对路径为键全局保留:切走再切回,未保存内容与 ● 状态
863
+ // 原样还在,因此不再需要「有脏文件暂不跟随」守卫——根始终跟随新会话。
864
+ function handleSessionSwitch(nextSid) {
865
+ if (!nextSid || nextSid === store.sessionId) return
866
+ const prevSid = store.sessionId
867
+ const keyOf = (s) => s || '__no-session__'
868
+ if (prevSid && store.rootPath) {
869
+ store.sessionEditor[keyOf(prevSid)] = {
870
+ tabs: store.tabs,
871
+ activePath: store.activePath,
872
+ nodes: store.nodes,
873
+ rootPath: store.rootPath,
874
+ }
875
+ }
876
+ const saved = store.sessionEditor[keyOf(nextSid)]
877
+ store.tabs = saved ? saved.tabs : []
878
+ store.activePath = saved ? saved.activePath : null
879
+ store.nodes = saved && saved.nodes ? saved.nodes : {}
880
+ store.rootPath = saved && saved.rootPath ? saved.rootPath : null
767
881
  store.activeNotes = []
768
- store.browsePath = null
769
- store.browseEntries = []
770
- store.nodes[newRoot] = { name: baseName(newRoot), path: newRoot, isDir: true, children: [], expanded: true, loaded: false }
771
- store.filePickerOpen = false
882
+ store.sessionId = nextSid
772
883
  emit()
773
- await toggleExpand(newRoot)
884
+ if (store.activePath) loadNotes(store.activePath)
885
+ refresh()
774
886
  }
775
887
  // —— 文件树 ——
776
888
  async function refresh() {
@@ -831,6 +943,50 @@ window.__ModuleLoader__.load({
831
943
  }
832
944
  loadNotes(path)
833
945
  }
946
+ // —— 引用跳转:chip / 引用视图 / 消息引用卡共用。打开文件并选中行区间(选中即高亮),滚动到可视区 ——
947
+ function editorLineOffset(content, line) {
948
+ // line 为 1-based 行号;返回该行首字符在 buffer 中的偏移
949
+ const lines = String(content).split('\n')
950
+ let off = 0
951
+ for (let i = 0; i < line - 1 && i < lines.length; i++) off += lines[i].length + 1
952
+ return off
953
+ }
954
+ function waitForEditorBuffer(path, timeoutMs) {
955
+ // openFile 异步读盘后 React 才提交 textarea 重挂载;rAF 轮询直到编辑区内容与缓冲一致
956
+ const deadline = Date.now() + (timeoutMs || 2000)
957
+ return new Promise((resolve) => {
958
+ const tick = () => {
959
+ const ta = editorTaEl
960
+ const buf = store.buffers[path]
961
+ if (ta && buf != null && ta.value === buf) { resolve(true); return }
962
+ if (Date.now() > deadline) { resolve(false); return }
963
+ if (typeof window !== 'undefined' && window.requestAnimationFrame) window.requestAnimationFrame(tick)
964
+ else setTimeout(tick, 50)
965
+ }
966
+ tick()
967
+ })
968
+ }
969
+ async function jumpToRef(r) {
970
+ if (!r || !r.path) return
971
+ await openFile(r.path)
972
+ const content = store.buffers[r.path]
973
+ if (!r.lineStart || typeof content !== 'string') return
974
+ const ok = await waitForEditorBuffer(r.path)
975
+ const ta = editorTaEl
976
+ if (!ok || !ta) return
977
+ try {
978
+ const lines = content.split('\n')
979
+ const start = editorLineOffset(content, r.lineStart)
980
+ const endLine = Math.max(r.lineStart, r.lineEnd || r.lineStart)
981
+ let end = start
982
+ for (let i = r.lineStart - 1; i < Math.min(endLine, lines.length); i++) end += lines[i].length + 1
983
+ ta.focus({ preventScroll: true })
984
+ ta.setSelectionRange(Math.min(start, ta.value.length), Math.min(end, ta.value.length))
985
+ // 20px 行高与 .cdex-textarea/.cdex-code-ta 一致;目标行滚到视口上 1/3 处
986
+ ta.scrollTop = Math.max(0, (r.lineStart - 1) * 20 - ta.clientHeight / 3)
987
+ ta.dispatchEvent(new Event('scroll')) // 复用 onScroll:同步高亮层/行号槽并记忆滚动位
988
+ } catch (e) {}
989
+ }
834
990
  function closeTab(path) {
835
991
  const i = store.tabs.findIndex((t) => t.path === path)
836
992
  if (i < 0) return
@@ -969,14 +1125,29 @@ window.__ModuleLoader__.load({
969
1125
  emit()
970
1126
  }
971
1127
  async function doCite(sel) {
972
- const ref = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'cite' })
973
- if (ref && ref.reference) { store.references.push(ref.reference); emit() }
974
- showToast('已引用:' + baseName(sel.path))
1128
+ const res = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'cite' })
1129
+ if (res && res.reference) {
1130
+ // 允许重复引用:chip 在输入框句中可视化,重复用户看得见、可删,host 端不再去重
1131
+ if (!store.references.some((x) => x.id === res.reference.id)) store.references.push(res.reference)
1132
+ emit()
1133
+ // 路线A:优先把 chip 直接插进输入框末尾;失败(服务未就绪/草稿已变更等)仅提示
1134
+ let chipped = false
1135
+ try { chipped = insertRefChipDirect(res.reference) } catch (e) {}
1136
+ showToast(chipped ? '已插入输入框:' + chipLabelFor(res.reference) : '已引用:' + baseName(sel.path))
1137
+ }
975
1138
  }
976
1139
  async function doTranslate(sel) {
977
- const ref = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'translate' })
978
- if (ref && ref.reference) { store.references.push(ref.reference); emit() }
979
- showToast('已创建翻译引用(输入框上方已加 🌐 标签)。对 AI 说「翻译我引用的内容」即可。')
1140
+ const res = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'translate' })
1141
+ if (res && res.reference) {
1142
+ if (!store.references.some((x) => x.id === res.reference.id)) store.references.push(res.reference)
1143
+ emit()
1144
+ // 与引用同款:优先把 🌐 chip 直接插进输入框末尾;失败退回提示(可在【引用/备注】查看)
1145
+ let chipped = false
1146
+ try { chipped = insertRefChipDirect(res.reference) } catch (e) {}
1147
+ showToast(chipped
1148
+ ? '已插入输入框:' + chipLabelFor(res.reference)
1149
+ : '已创建翻译引用(可在【引用/备注】查看)。对 AI 说「翻译我引用的内容」即可。')
1150
+ }
980
1151
  }
981
1152
  function openNoteEditor(sel) {
982
1153
  store.noteEditor = { path: sel.path, lineStart: sel.lineStart, lineEnd: sel.lineEnd, note: '' }
@@ -1002,11 +1173,14 @@ window.__ModuleLoader__.load({
1002
1173
  n.children = entries.map((e) => { store.nodes[e.path] = e; return e.path })
1003
1174
  emit()
1004
1175
  }
1005
- // 整文件引用:只给 AI 绝对路径,AI 用自己的读取工具打开
1176
+ // 整文件引用:只给 AI 绝对路径,AI 用自己的读取工具打开(允许重复引用,不去重)
1006
1177
  async function citeWholeFile(path) {
1007
- const ref = await callHost('add-reference', { path: path, language: langOf(path), lineStart: null, lineEnd: null, text: '', kind: 'cite' })
1008
- if (ref && ref.reference) { store.references.push(ref.reference); emit() }
1009
- showToast('已引用整个文件:' + baseName(path) + '(AI 将自行读取)')
1178
+ const res = await callHost('add-reference', { path: path, language: langOf(path), lineStart: null, lineEnd: null, text: '', kind: 'cite' })
1179
+ if (res && res.reference) {
1180
+ if (!store.references.some((x) => x.id === res.reference.id)) store.references.push(res.reference)
1181
+ emit()
1182
+ showToast('已引用整个文件:' + baseName(path) + '(AI 将自行读取)')
1183
+ }
1010
1184
  }
1011
1185
  // 重命名后同步标签页/缓冲/激活路径/watcher
1012
1186
  function renameTabSync(fromPath, toPath) {
@@ -1111,33 +1285,153 @@ window.__ModuleLoader__.load({
1111
1285
  emit()
1112
1286
  }
1113
1287
  // —— 引用自动附带(AI 上下文) ——
1288
+ // —— 引用一次性消耗:引用只能随一条消息使用,发送后标记 used(chip 即刻清空);
1289
+ // 已使用记录保留在【引用/备注】供用户查找(需要重复引用就重新选中代码再引用,不做重复引用按钮)。
1290
+ // 标记 used 而非删除:AI 在消息发出后才调 get_referenced_snippets,删掉就读不到了。
1291
+ function pendingRefs() {
1292
+ return store.references.filter((r) => !r.used)
1293
+ }
1294
+ // 编号唯一出口:待用引用按创建序 1..N(删除后重排,发送后清零)。
1295
+ // tag 显示 / 消息指针 / 引用卡 全部用它,保证用户说的「引用N」三处同号。
1296
+ function numberedPendingRefs() {
1297
+ return pendingRefs().map((r, i) => ({ r: r, n: i + 1 }))
1298
+ }
1299
+ function consumePendingRefs() {
1300
+ const pend = pendingRefs()
1301
+ if (pend.length === 0) return
1302
+ const ids = pend.map((r) => r.id)
1303
+ for (let i = 0; i < pend.length; i++) {
1304
+ // 冻结发送时刻的编号到记录上:AI 经 get_referenced_snippets 读到的编号 = 用户在 tag 上看到的编号
1305
+ pend[i].used = true
1306
+ pend[i].usedAt = new Date().toISOString()
1307
+ pend[i].seq = i + 1
1308
+ }
1309
+ emit()
1310
+ callHost('use-references', { ids }).then(() => {})
1311
+ }
1114
1312
  function buildSendPayload(draft) {
1115
- // 引用文本不再粘贴进消息(明面上不展示):引用统一存 host 端,AI 通过 get_referenced_snippets
1116
- // 工具读取;消息里只附一行「标签级」指针,让 AI 知道有引用可读。
1117
- // 整文件引用(无行号):直接给出绝对路径,AI 用自己的读取工具打开。
1313
+ // 引用文本不粘贴进消息(明面上不展示代码正文):引用统一存 host 端,AI 通过 get_referenced_snippets
1314
+ // 工具读取;消息里附「结构化指针」——人可读、AI 可读,消息区引用卡也按它解析还原(单一事实源)。
1315
+ // 只取未使用引用(used 的已随旧消息消耗,不再重复附带)。整文件引用:给出绝对路径。
1118
1316
  const text = draft || ''
1119
1317
  if (text.indexOf('[code UI] 已标记') >= 0) return text // 幂等:已带指针不重复附加
1120
- const citeRefs = store.references.filter((r) => r.kind === 'cite')
1121
- const trRefs = store.references.filter((r) => r.kind === 'translate')
1122
- const codeRefs = citeRefs.filter((r) => r.lineStart)
1123
- const fileRefs = citeRefs.filter((r) => !r.lineStart)
1318
+ // 编号 = 待用引用创建序(1 起),与输入框 tag、AI 工具输出三处同号:用户说「引用N」AI 即知所指
1319
+ // 已被行内 chip 携带的引用不再自动附带(发送时由 codec.serialize 在 chip 位置展开)。
1320
+ // chip 集合以草稿 occurrences 实时为准(用户删 chip 后自动回到自动附带),chipRefs 为兜底
1321
+ const chipIds = store.draftChipIds || store.chipRefs
1322
+ const pend = numberedPendingRefs().filter((x) => !chipIds || !chipIds.has(x.r.id))
1323
+ const codeRefs = pend.filter((x) => x.r.kind === 'cite' && x.r.lineStart)
1324
+ const fileRefs = pend.filter((x) => x.r.kind === 'cite' && !x.r.lineStart)
1325
+ const trRefs = pend.filter((x) => x.r.kind === 'translate')
1326
+ const items = []
1327
+ for (const x of codeRefs) items.push('- ' + x.n + ': 代码引用 ' + baseName(x.r.path) + ':' + x.r.lineStart + '-' + x.r.lineEnd + '(' + x.r.path + ')')
1328
+ for (const x of fileRefs) items.push('- ' + x.n + ': 文件引用 ' + baseName(x.r.path) + '(整文件,绝对路径:' + x.r.path + ')')
1329
+ for (const x of trRefs) items.push(x.r.lineStart
1330
+ ? '- ' + x.n + ': 翻译引用 ' + baseName(x.r.path) + ':' + x.r.lineStart + '-' + x.r.lineEnd + '(' + x.r.path + ')'
1331
+ : '- ' + x.n + ': 翻译引用 ' + baseName(x.r.path) + '(' + x.r.path + ')')
1124
1332
  const parts = []
1125
1333
  if (codeRefs.length > 0) parts.push(codeRefs.length + ' 段代码引用')
1126
1334
  if (fileRefs.length > 0) parts.push(fileRefs.length + ' 个文件引用')
1127
1335
  if (trRefs.length > 0) parts.push(trRefs.length + ' 条翻译请求')
1128
1336
  if (parts.length === 0) return text
1129
- let ptr = '[code UI] 已标记 ' + parts.join('、') + '。'
1130
- if (fileRefs.length > 0) {
1131
- ptr += '\n文件引用(整个文件,请用你的文件读取工具打开以下绝对路径):\n'
1132
- + fileRefs.map((r) => '- ' + r.path).join('\n')
1133
- }
1134
- if (codeRefs.length > 0 || trRefs.length > 0) {
1135
- ptr += '\n(代码/翻译引用内容未粘贴在消息里,请调用 get_referenced_snippets 工具读取)。'
1136
- }
1337
+ let ptr = '[code UI] 已标记 ' + parts.join('、') + '(代码正文不进消息,请调用 get_referenced_snippets 工具读取;条目编号与用户输入框上的引用 tag 序号一致,用户说「引用N」即指该条):'
1338
+ if (items.length > 0) ptr += '\n' + items.join('\n')
1137
1339
  const sep = text.trim() ? '\n\n' : ''
1138
1340
  return text + sep + ptr
1139
1341
  }
1140
1342
 
1343
+ /* —— 消息区引用卡:解析消息里的结构化指针,在用户气泡内追加 Trae 式引用行 —— */
1344
+ function parseRefsFromMessage(text) {
1345
+ const out = []
1346
+ if (!text || text.indexOf('[code UI] 已标记') < 0) return out
1347
+ const reCode = /^- (?:(\d+): )?代码引用 (.+):(\d+)-(\d+)((.+))$/gm
1348
+ const reTr = /^- (?:(\d+): )?翻译引用 (.+):(\d+)-(\d+)((.+))$/gm
1349
+ const reTrFile = /^- (?:(\d+): )?翻译引用 ([^:\n]+)((.+))$/gm // 名字段禁冒号:避免吞掉上面带行号条目
1350
+ const reFile = /^- (?:(\d+): )?文件引用 ([^:\n]+)(整文件,绝对路径:(.+))$/gm
1351
+ let m
1352
+ 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' })
1353
+ 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' })
1354
+ while ((m = reFile.exec(text)) !== null) out.push({ num: m[1] ? +m[1] : null, name: m[2], path: m[3], kind: 'cite' })
1355
+ while ((m = reTrFile.exec(text)) !== null) out.push({ num: m[1] ? +m[1] : null, name: m[2], path: m[3], kind: 'translate' })
1356
+ return out
1357
+ }
1358
+ function buildRefCardEl(refs) {
1359
+ if (!refs || refs.length === 0) return null
1360
+ const card = document.createElement('div')
1361
+ card.className = 'cdex-refcard'
1362
+ const head = document.createElement('div')
1363
+ head.className = 'cdex-refcard-head'
1364
+ head.textContent = '引用上下文 ' + refs.length + ' 项 · 点击行跳转编辑器'
1365
+ card.appendChild(head)
1366
+ for (const r of refs) {
1367
+ const row = document.createElement('div')
1368
+ row.className = 'cdex-refcard-row'
1369
+ row.title = r.path + (r.lineStart ? ' : 第 ' + r.lineStart + '-' + r.lineEnd + ' 行' : '')
1370
+ const ic = document.createElement('span')
1371
+ ic.className = 'cdex-refcard-ic'
1372
+ ic.innerHTML = r.kind === 'translate' ? GLOBE_SVG : fileIconSvg(r.path)
1373
+ row.appendChild(ic)
1374
+ if (r.num) {
1375
+ const nb = document.createElement('span')
1376
+ nb.className = 'cdex-refcard-num'
1377
+ nb.textContent = String(r.num)
1378
+ row.appendChild(nb)
1379
+ }
1380
+ const nm = document.createElement('span')
1381
+ nm.className = 'cdex-refcard-name'
1382
+ nm.textContent = r.name || baseName(r.path)
1383
+ row.appendChild(nm)
1384
+ if (r.lineStart) {
1385
+ const ln = document.createElement('span')
1386
+ ln.className = 'cdex-refcard-lines'
1387
+ ln.textContent = ':' + r.lineStart + '-' + r.lineEnd
1388
+ row.appendChild(ln)
1389
+ }
1390
+ const kind = document.createElement('span')
1391
+ kind.className = 'cdex-refcard-kind'
1392
+ kind.textContent = r.kind === 'translate' ? '翻译' : (r.lineStart ? '代码' : '整文件')
1393
+ row.appendChild(kind)
1394
+ row.addEventListener('click', () => { jumpToRef({ path: r.path, lineStart: r.lineStart, lineEnd: r.lineEnd }) })
1395
+ card.appendChild(row)
1396
+ }
1397
+ return card
1398
+ }
1399
+ // 用户气泡结构(与 dsh 宿主对话区一致):row[data-time-hover-root] > stack > bubble;
1400
+ // 只做「追加」不做搬移/改写——不碰 React 管理的节点,宿主重渲染丢卡后由观察器补挂。
1401
+ function scanRefCards(root) {
1402
+ const rows = root.querySelectorAll('[data-time-hover-root]:not([data-turn-tail])')
1403
+ for (const row of rows) {
1404
+ const text = row.textContent
1405
+ if (!text || text.indexOf('[code UI] 已标记') < 0) continue
1406
+ const stack = row.firstElementChild
1407
+ const bubble = stack ? stack.lastElementChild : null
1408
+ const target = (bubble instanceof HTMLElement) ? bubble : row
1409
+ if (target.querySelector(':scope > .cdex-refcard')) continue
1410
+ const refs = parseRefsFromMessage(text)
1411
+ if (refs.length === 0) continue
1412
+ const card = buildRefCardEl(refs)
1413
+ if (card) target.appendChild(card)
1414
+ }
1415
+ }
1416
+ function observeRefCards() {
1417
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined' || !document.body) return () => {}
1418
+ let scheduled = false
1419
+ const scan = () => { scheduled = false; try { scanRefCards(document) } catch (e) {} }
1420
+ const schedule = () => {
1421
+ if (scheduled) return
1422
+ scheduled = true
1423
+ if (typeof window !== 'undefined' && window.requestAnimationFrame) window.requestAnimationFrame(scan)
1424
+ else setTimeout(scan, 60)
1425
+ }
1426
+ const mo = new MutationObserver(schedule)
1427
+ mo.observe(document.body, { childList: true, subtree: true })
1428
+ schedule()
1429
+ return () => {
1430
+ try { mo.disconnect() } catch (e) {}
1431
+ try { document.querySelectorAll('.cdex-refcard').forEach((el) => el.remove()) } catch (e) {}
1432
+ }
1433
+ }
1434
+
1141
1435
  /* ============ §6 Pointer 处理层(窗口拖拽 / 缩放 / 模式条移动) ============ */
1142
1436
  function onBarPointerDown(e) {
1143
1437
  if (e.target && typeof e.target.closest === 'function' && e.target.closest('button')) return
@@ -1207,6 +1501,40 @@ window.__ModuleLoader__.load({
1207
1501
 
1208
1502
  /* ============ §7 组件层(V.* 命名空间) ============ */
1209
1503
  let selfSending = false // AiInput 副本自己发送时置位,让挂钩的 submit 不重复附加指针
1504
+ // shell.submit 补丁:宿主输入条的回车/发送按钮走 keyboard.submit = shell.submit(SessionInputShell),
1505
+ // 不经过 inputActions.submit(那条只有发送按钮内部转发和插件输入卡用)——补丁必须挂在 shell.submit
1506
+ // 这个必经点,否则原生输入条发送时引用既不附带指针也不消耗。
1507
+ const patchedShells = new WeakSet()
1508
+ function ensureShellSubmitPatch() {
1509
+ try {
1510
+ const sid = store.inputSessionId
1511
+ if (!sid) return
1512
+ const conversation = ctx.get('conversation')
1513
+ const hub = conversation && conversation.input
1514
+ if (!hub || typeof hub.shell !== 'function') return
1515
+ const shell = hub.shell(sid)
1516
+ if (!shell || typeof shell.submit !== 'function' || typeof shell.setDraft !== 'function') return
1517
+ if (patchedShells.has(shell)) return
1518
+ patchedShells.add(shell)
1519
+ const origSubmit = shell.submit
1520
+ shell.submit = function () {
1521
+ let hadRefs = false
1522
+ try {
1523
+ const hasRefs = pendingRefs().length > 0
1524
+ hadRefs = hasRefs
1525
+ const draft = store.inputDraft || ''
1526
+ if (hasRefs && !selfSending && String(draft).indexOf('[code UI]') < 0) {
1527
+ shell.setDraft(buildSendPayload(draft))
1528
+ }
1529
+ } catch (e) {}
1530
+ const result = origSubmit.apply(shell, arguments)
1531
+ try { if (hadRefs && !selfSending) consumePendingRefs() } catch (e) {}
1532
+ return result
1533
+ }
1534
+ } catch (e) {}
1535
+ }
1536
+ // (历史:曾在 conversation.input.dock 插槽挂"输入条上方引用 tag 条"(P1-15),
1537
+ // 行内 chip(路线A)落地后冗余,已移除——引用的直接可视化由输入框内 chip 承担。)
1210
1538
  function InputBridge(props) {
1211
1539
  const useInput = props.useInput
1212
1540
  const inputState = typeof useInput === 'function' ? useInput((s) => s) : undefined
@@ -1216,15 +1544,21 @@ window.__ModuleLoader__.load({
1216
1544
  if (inputState) {
1217
1545
  store.inputDraft = inputState.draft
1218
1546
  store.inputPhase = inputState.phase
1547
+ // 路线A直接插 chip 所需:compose() 快照的 draftRev(插入 CAS)+ 草稿中 code-ui chip 携带的引用 id
1548
+ if (typeof inputState.draftRev === 'number') store.inputDraftRev = inputState.draftRev
1549
+ try {
1550
+ const ids = new Set()
1551
+ const occs = Array.isArray(inputState.occurrences) ? inputState.occurrences : []
1552
+ for (const o of occs) {
1553
+ if (!o || o.source !== 'code-ui') continue
1554
+ const cr = parseChipRef(o.ref)
1555
+ if (cr && cr.id) ids.add(cr.id)
1556
+ }
1557
+ store.draftChipIds = ids
1558
+ } catch (e) {}
1219
1559
  }
1220
- // 捕获当前会话 id:文件树根按会话隔离——切换即解析新会话的根
1221
- //(自动=该会话 cwd;手动=宿主 sessionRoots 里该会话记忆的根)
1222
- if (props.sessionId && props.sessionId !== store.sessionId) {
1223
- store.sessionId = props.sessionId
1224
- const hasDirty = Object.keys(store.dirty || {}).some((k) => store.dirty[k])
1225
- if (hasDirty) showToast('会话已切换:有未保存文件,工作区暂不跟随(保存后点 ⟳ 刷新即可)')
1226
- else refresh()
1227
- }
1560
+ if (props.sessionId) { handleSessionSwitch(props.sessionId); store.inputSessionId = props.sessionId }
1561
+ ensureShellSubmitPatch() // 每次渲染尝试挂 shell.submit 补丁(WeakSet 幂等,会话切换后补新 shell)
1228
1562
  emit()
1229
1563
  })
1230
1564
  // 挂钩 DSH 原生输入条的发送:存在引用时把指针附进草稿再发(整文件=绝对路径)。
@@ -1235,14 +1569,21 @@ window.__ModuleLoader__.load({
1235
1569
  const orig = inputActions.submit
1236
1570
  inputActions.__cdexPatched = true
1237
1571
  inputActions.submit = function () {
1572
+ let attached = false
1573
+ let hadRefs = false
1238
1574
  try {
1239
- const hasRefs = store.references.length > 0
1575
+ const hasRefs = pendingRefs().length > 0
1576
+ hadRefs = hasRefs
1240
1577
  const draft = store.inputDraft || ''
1241
1578
  if (hasRefs && !selfSending && draft.indexOf('[code UI]') < 0) {
1242
1579
  inputActions.setDraft(buildSendPayload(draft))
1580
+ attached = true
1243
1581
  }
1244
1582
  } catch (e) {}
1245
- return orig.apply(inputActions, arguments)
1583
+ const result = orig.apply(inputActions, arguments)
1584
+ // 引用随本条消息消耗:chip 展开(attached=false)与自动附带(attached=true)两条路径都要消耗
1585
+ try { if (hadRefs && !selfSending) consumePendingRefs() } catch (e) {}
1586
+ return result
1246
1587
  }
1247
1588
  return () => {
1248
1589
  try { inputActions.submit = orig; delete inputActions.__cdexPatched } catch (e) {}
@@ -1259,11 +1600,7 @@ window.__ModuleLoader__.load({
1259
1600
  const useSessions = props.useSessions
1260
1601
  const sid = typeof useSessions === 'function' ? useSessions((s) => s.current) : undefined
1261
1602
  React.useEffect(() => {
1262
- if (!sid || sid === store.sessionId) return
1263
- store.sessionId = sid
1264
- const hasDirty = Object.keys(store.dirty || {}).some((k) => store.dirty[k])
1265
- if (hasDirty) showToast('会话已切换:有未保存文件,工作区暂不跟随(保存后点 ⟳ 刷新即可)')
1266
- else refresh()
1603
+ if (sid) handleSessionSwitch(sid)
1267
1604
  }, [sid])
1268
1605
  return null
1269
1606
  }
@@ -1271,8 +1608,9 @@ window.__ModuleLoader__.load({
1271
1608
  const st = useStore()
1272
1609
  let taEl = null
1273
1610
  const disabled = !st.inputActions
1274
- // 引用与翻译请求都只显示「标签」:内容不进输入框/消息正文,AI 经 get_referenced_snippets 工具读取
1275
- const refs = st.references
1611
+ // 引用与翻译请求都只显示「标签」:内容不进输入框/消息正文,AI 经 get_referenced_snippets 工具读取。
1612
+ // 只显示未使用引用:used 的已随旧消息消耗(chip 清空),记录在【引用/备注】的已使用区。
1613
+ const refs = st.references.filter((r) => !r.used)
1276
1614
  const citeCount = refs.filter((r) => r.kind === 'cite').length
1277
1615
  const trCount = refs.length - citeCount
1278
1616
  const submit = () => {
@@ -1289,15 +1627,20 @@ window.__ModuleLoader__.load({
1289
1627
  st.inputActions.setDraft(payload)
1290
1628
  st.inputActions.submit()
1291
1629
  } finally { selfSending = false }
1630
+ // 引用随本条消息消耗(chip 清空;记录留在【引用/备注】已使用区)
1631
+ consumePendingRefs()
1292
1632
  taEl.value = ''
1293
1633
  }
1294
1634
  return h('div', { className: 'cdex-aiinput-wrap' },
1295
1635
  h('div', { className: 'cdex-aiinput-card' },
1296
1636
  disabled ? h('div', { className: 'cdex-aiinput-warn' }, '⚠ 未打开会话:请先在主界面新建或选择一个会话,之后即可在此发送(引用标签会保留)') : null,
1297
1637
  refs.length > 0 ? h('div', { className: 'cdex-aiinput-tags' },
1298
- refs.map((r) => h('span', { className: 'cdex-aiinput-tag' + (r.kind === 'translate' ? ' cdex-aiinput-tag-tr' : ''), key: r.id },
1299
- h('span', { className: 'cdex-aiinput-tag-label' }, (r.kind === 'translate' ? '🌐 翻译 ' : '🔗 ') + tagLabel(r)),
1300
- h('button', { className: 'cdex-aiinput-tag-x', title: r.kind === 'translate' ? '删除翻译引用' : '删除引用', onClick: () => delReference(r.id) }, '✕'),
1638
+ refs.map((r, i) => h('span', { className: 'cdex-aiinput-tag' + (r.kind === 'translate' ? ' cdex-aiinput-tag-tr' : ''), key: r.id,
1639
+ title: '#' + (i + 1) + ' ' + r.path + (r.lineStart ? ' : 第 ' + r.lineStart + '-' + r.lineEnd + ' 行' : '') + '(点击跳转编辑器,✕ 删除;对 AI 说「引用' + (i + 1) + '」即指此条)',
1640
+ onClick: () => jumpToRef(r) },
1641
+ h('span', { className: 'cdex-tag-ic', dangerouslySetInnerHTML: { __html: r.kind === 'translate' ? GLOBE_SVG : fileIconSvg(r.path) } }),
1642
+ h('span', { className: 'cdex-aiinput-tag-label' }, (r.kind === 'translate' ? '🌐' : '') + (i + 1) + ':' + tagLabel(r)),
1643
+ h('button', { className: 'cdex-aiinput-tag-x', title: r.kind === 'translate' ? '删除翻译引用' : '删除引用', onClick: (e) => { e.stopPropagation(); delReference(r.id) } }, '✕'),
1301
1644
  )),
1302
1645
  ) : null,
1303
1646
  h('div', { className: 'cdex-aiinput' },
@@ -1441,18 +1784,61 @@ window.__ModuleLoader__.load({
1441
1784
  )
1442
1785
  }
1443
1786
  function RefItem(r) {
1444
- return h('div', { className: 'cdex-ref', key: r.id },
1787
+ return h('div', { className: 'cdex-ref' + (r.used ? ' cdex-ref-used' : ''), key: r.id,
1788
+ title: '右键:复制 / 翻译 / 引用(给AI)', onContextMenu: (e) => onRefContext(e, r) },
1445
1789
  h('div', { className: 'cdex-ref-head' },
1446
- h('span', { className: 'cdex-ref-path' }, r.path, r.lineStart ? (' :' + r.lineStart + '-' + r.lineEnd) : (r.kind === 'cite' ? ' (整个文件)' : '')),
1447
- h('button', { className: 'cdex-btn-ghost', onClick: () => delReference(r.id) }, '✕'),
1790
+ h('span', { className: 'cdex-ref-path cdex-ref-link',
1791
+ title: r.path + (r.lineStart ? ' : 第 ' + r.lineStart + '-' + r.lineEnd + ' 行' : '') + '(点击跳转编辑器)',
1792
+ onClick: () => jumpToRef(r) },
1793
+ h('span', { className: 'cdex-tag-ic', dangerouslySetInnerHTML: { __html: r.kind === 'translate' ? GLOBE_SVG : fileIconSvg(r.path) } }),
1794
+ r.path, r.lineStart ? (' :' + r.lineStart + '-' + r.lineEnd) : (r.kind === 'cite' ? ' (整个文件)' : ''),
1795
+ ),
1796
+ r.used ? h('span', { className: 'cdex-ref-usedbadge', title: r.usedAt ? ('随消息消耗于 ' + r.usedAt) : '已随消息消耗' }, '已使用') : null,
1797
+ h('button', { className: 'cdex-btn-ghost', title: r.used ? '删除已使用记录' : '删除引用', onClick: () => delReference(r.id) }, '✕'),
1448
1798
  ),
1449
1799
  h('div', { className: 'cdex-ref-code' }, r.text ? r.text.slice(0, 500) : '(AI 将按绝对路径自行读取整个文件)'),
1450
1800
  )
1451
1801
  }
1802
+ /* —— 【引用/备注】条目右键菜单:复制 / 翻译 / 引用(给AI)——
1803
+ 引用记录自带 path/行区间/正文,直接复用;备注只存批注不存代码,
1804
+ 翻译/引用前先经 read-file 从磁盘取回对应行内容。 */
1805
+ function refMenuItems(e, items) {
1806
+ e.preventDefault()
1807
+ store.ctxMenu = { x: e.clientX, y: e.clientY, items }
1808
+ emit()
1809
+ }
1810
+ async function noteToSel(n) {
1811
+ if (!n.lineStart) return { path: n.path, language: langOf(n.path), lineStart: null, lineEnd: null, text: '' } // 整文件备注:按整文件引用处理
1812
+ const res = await callHost('read-file', { path: n.path })
1813
+ if (!res || res.error) { showToast('读取原文件失败:' + ((res && res.error) || '未知错误')); return null }
1814
+ const lines = String(res.content || '').split('\n')
1815
+ const s = Math.max(1, n.lineStart)
1816
+ const t = Math.min(lines.length, Math.max(s, n.lineEnd || s))
1817
+ return { path: n.path, language: langOf(n.path), lineStart: s, lineEnd: t, text: lines.slice(s - 1, t).join('\n') }
1818
+ }
1819
+ function onRefContext(e, r) {
1820
+ const isWhole = !r.lineStart
1821
+ const sel = { path: r.path, language: r.language || langOf(r.path), lineStart: r.lineStart, lineEnd: r.lineEnd, text: r.text || '' }
1822
+ refMenuItems(e, [
1823
+ { icon: '📋', label: isWhole ? '复制路径' : '复制', act: () => { copyText(isWhole ? r.path : sel.text); showToast(isWhole ? '路径已复制' : '代码已复制') } },
1824
+ { icon: '🌐', label: '翻译', act: () => { doTranslate(sel) } },
1825
+ { icon: '🔗', label: '引用(给AI)', act: () => { doCite(sel) } },
1826
+ ])
1827
+ }
1828
+ function onNoteContext(e, n) {
1829
+ refMenuItems(e, [
1830
+ { icon: '📋', label: '复制', act: () => { copyText(n.note || ''); showToast('备注已复制') } },
1831
+ { icon: '🌐', label: '翻译', act: () => { noteToSel(n).then((sel) => { if (sel) doTranslate(sel) }) } },
1832
+ { icon: '🔗', label: '引用(给AI)', act: () => { noteToSel(n).then((sel) => { if (sel) doCite(sel) }) } },
1833
+ ])
1834
+ }
1452
1835
  function ReferencesView() {
1453
1836
  const st = useStore()
1454
- const citeList = st.references.filter((r) => r.kind === 'cite')
1455
- const trList = st.references.filter((r) => r.kind === 'translate')
1837
+ // 待用(chip 里挂着、随下一条消息发送)与已使用(已随消息消耗的历史记录)分区展示
1838
+ const pending = st.references.filter((r) => !r.used)
1839
+ const usedList = st.references.filter((r) => r.used)
1840
+ const citeList = pending.filter((r) => r.kind === 'cite')
1841
+ const trList = pending.filter((r) => r.kind === 'translate')
1456
1842
  return h('div', { className: 'cdex-sidebar', style: { width: st.sidebarW } },
1457
1843
  h('div', { className: 'cdex-sb-resize', title: '拖拽调整宽度(拖到最窄松手即收起)', onPointerDown: onSbResizeDown }),
1458
1844
  h('div', { className: 'cdex-sidebar-head' },
@@ -1464,15 +1850,20 @@ window.__ModuleLoader__.load({
1464
1850
  citeList.length === 0 ? h('div', { className: 'cdex-empty' }, '选中代码后右键「引用」。') : citeList.map((r) => RefItem(r)),
1465
1851
  h('div', { className: 'cdex-section' }, '🌐 翻译请求 (' + trList.length + ')'),
1466
1852
  trList.length === 0 ? h('div', { className: 'cdex-empty' }, '选中代码后右键「翻译」。') : trList.map((r) => RefItem(r)),
1853
+ h('div', { className: 'cdex-section' }, '🕘 已使用 (' + usedList.length + ')'),
1854
+ usedList.length === 0
1855
+ ? h('div', { className: 'cdex-empty' }, '尚无已消耗的引用。引用随消息发送即标记已使用,记录保留在此供查找。')
1856
+ : usedList.map((r) => RefItem(r)),
1467
1857
  h('div', { className: 'cdex-section' }, '📝 备注 (' + st.activeNotes.length + ')'),
1468
- st.activeNotes.length === 0 ? h('div', { className: 'cdex-empty' }, '当前文件暂无备注。') : st.activeNotes.map((n) => h('div', { className: 'cdex-ref', key: n.id },
1858
+ st.activeNotes.length === 0 ? h('div', { className: 'cdex-empty' }, '当前文件暂无备注。') : st.activeNotes.map((n) => h('div', { className: 'cdex-ref', key: n.id,
1859
+ title: '右键:复制 / 翻译 / 引用(给AI)', onContextMenu: (e) => onNoteContext(e, n) },
1469
1860
  h('div', { className: 'cdex-ref-head' },
1470
1861
  h('span', { className: 'cdex-ref-path' }, n.path, n.lineStart ? (' :' + n.lineStart + '-' + n.lineEnd) : ''),
1471
1862
  h('button', { className: 'cdex-btn-ghost', onClick: () => delNote(n.id) }, '✕'),
1472
1863
  ),
1473
1864
  h('div', { className: 'cdex-ref-code' }, n.note),
1474
1865
  )),
1475
- h('div', { className: 'cdex-hint' }, '在输入框对 AI 说「读取/翻译我引用的代码」,AI 会调用 get_referenced_snippets 工具获取内容。'),
1866
+ h('div', { className: 'cdex-hint' }, '引用一次性使用:随消息发送即消耗(chip 清空,AI 仍可读取本次内容)。条目右键可快速复制 / 翻译 / 引用(给AI)。在输入框对 AI 说「读取/翻译我引用的代码」,AI 会调用 get_referenced_snippets 工具获取内容。'),
1476
1867
  ),
1477
1868
  )
1478
1869
  }
@@ -1496,6 +1887,19 @@ window.__ModuleLoader__.load({
1496
1887
  }
1497
1888
  function Editor() {
1498
1889
  const st = useStore()
1890
+ // 高亮层 innerHTML 被替换时浏览器会把 scrollTop/scrollLeft 重置为 0;每次渲染提交后
1891
+ // 从 textarea 回同步(也覆盖大文件 60ms 防抖换帧路径),杜绝输入时文本抖动。
1892
+ // 必须放在 early return 之前(Hooks 顺序约束)。
1893
+ React.useEffect(() => {
1894
+ const ta = editorTaEl
1895
+ if (!ta) return
1896
+ const layer = ta.parentNode
1897
+ if (!layer) return
1898
+ const preEl = layer.querySelector('.cdex-code-pre')
1899
+ if (preEl) { preEl.scrollTop = ta.scrollTop; preEl.scrollLeft = ta.scrollLeft }
1900
+ const gutEl = layer.parentNode ? layer.parentNode.querySelector('.cdex-gutter') : null
1901
+ if (gutEl) gutEl.scrollTop = ta.scrollTop
1902
+ })
1499
1903
  if (!st.activePath) {
1500
1904
  // 空态时显式展示当前工作区根,避免「以此文件夹为根」后误认为未切换
1501
1905
  return h('div', { className: 'cdex-editor' },
@@ -1903,6 +2307,68 @@ window.__ModuleLoader__.load({
1903
2307
  let unmountEditorFns = null
1904
2308
  let layoutMode = 'dock' // dock(右侧停靠,默认) / float(悬浮窗口):编辑器以哪种布局呈现
1905
2309
  let toggleStyle = 'bar' // bar(悬浮模式条,默认) / header(会话头 ⌨ 按钮):用哪种按钮切换界面
2310
+ // chip 显示标签:编号 + 短标签;翻译类加 🌐 前缀与代码引用区分
2311
+ function chipLabelFor(r) {
2312
+ const hit = numberedPendingRefs().find((x) => x.r.id === r.id)
2313
+ const num = hit ? hit.n : '?'
2314
+ return (r.kind === 'translate' ? '🌐' : '') + num + ':' + tagLabel(r)
2315
+ }
2316
+ // —— 直接插入行内 chip(不经 @ 菜单)——
2317
+ // 数据全部取实时值:conversation.input (= inputHub).shell(sid).snapshot 的 draft/draftRev
2318
+ // (compose 快照,宿主每次编辑同步发布),不再依赖本插件组件的渲染同步时机。
2319
+ // 位置 = detect 文本末尾:draft 是 clipboard 投影,chip 在 detect 里占 1 个占位符,
2320
+ // 需按 occurrences 把 clipboard 长度换算回 detect 长度(每颗 chip 减 clipboardText长-1)。
2321
+ // 与官方 toggleCommandMenu 的合成 span 同构(span.draftRev CAS)。
2322
+ // 返回 true = chip 已插入输入框末尾;false = 条件不满足(调用方退回 tag 条展示)。
2323
+ function insertRefChipDirect(r) {
2324
+ try {
2325
+ const conversation = ctx.get('conversation')
2326
+ const inputHub = conversation && conversation.input
2327
+ if (!inputHub || typeof inputHub.shell !== 'function') return false
2328
+ const sid = store.inputSessionId
2329
+ if (!sid) return false
2330
+ const shell = inputHub.shell(sid)
2331
+ const snap = shell && shell.snapshot
2332
+ if (!snap || typeof snap.draftRev !== 'number' || typeof snap.draft !== 'string') return false
2333
+ if (snap.phase && snap.phase !== 'plain' && snap.phase !== 'claimed') return false
2334
+ const inputTriggers = store.inputTriggers
2335
+ if (!inputTriggers || !inputTriggers.live || !inputTriggers.live.controllers) return false
2336
+ const controller = inputTriggers.live.controllers.get(sid)
2337
+ if (!controller || typeof controller.execute !== 'function') return false
2338
+ let detectLen = snap.draft.length
2339
+ const occs = Array.isArray(snap.occurrences) ? snap.occurrences : []
2340
+ for (const o of occs) {
2341
+ if (o && typeof o.length === 'number') detectLen -= Math.max(0, o.length - 1)
2342
+ }
2343
+ if (detectLen < 0) detectLen = 0
2344
+ const label = chipLabelFor(r)
2345
+ 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 } }
2346
+ return controller.execute(outcome, { start: detectLen, end: detectLen, draftRev: snap.draftRev }) === true
2347
+ } catch (e) { return false }
2348
+ }
2349
+ // chip ref(JSON 串)→ 引用数据;解析失败返回 null
2350
+ function parseChipRef(refJson) {
2351
+ try {
2352
+ const r = JSON.parse(refJson)
2353
+ if (!r || !r.path) return null
2354
+ return r
2355
+ } catch (e) { return null }
2356
+ }
2357
+ function chipShortLabel(r) {
2358
+ const name = baseName(r.path)
2359
+ return r.lineStart && r.lineEnd ? name + ':' + r.lineStart + '-' + r.lineEnd : name
2360
+ }
2361
+ // chip → 模型侧文本:与消息指针条目同格式(AI 侧读取口径一致)
2362
+ function formatRefModelLine(r) {
2363
+ if (r.kind === 'translate') {
2364
+ return r.lineStart
2365
+ ? '- 翻译引用 ' + baseName(r.path) + ':' + r.lineStart + '-' + r.lineEnd + '(' + r.path + ')'
2366
+ : '- 翻译引用 ' + baseName(r.path) + '(' + r.path + ')'
2367
+ }
2368
+ return r.lineStart
2369
+ ? '- 代码引用 ' + baseName(r.path) + ':' + r.lineStart + '-' + r.lineEnd + '(' + r.path + ')'
2370
+ : '- 文件引用 ' + baseName(r.path) + '(整文件,绝对路径:' + r.path + ')'
2371
+ }
1906
2372
  function mountEditor() {
1907
2373
  if (unmountEditorFns) return
1908
2374
  const disposers = []
@@ -1916,6 +2382,65 @@ window.__ModuleLoader__.load({
1916
2382
  { name: 'shell.overlay', id: 'cdex-session-watch', order: 4, label: '' },
1917
2383
  (props) => h(SessionWatch, props),
1918
2384
  )))
2385
+ // 消息区引用卡:观察宿主对话区,把带 [code UI] 指针的用户消息补挂引用卡(卸载时连卡移除)
2386
+ disposers.push(observeRefCards())
2387
+ // —— 路线A原型:@ 菜单行内引用 chip(宿主 ReferenceChipNode 装饰器)——
2388
+ // 注册官方触发源:输入框打 @ 弹菜单出现「code UI 引用」分组(与 @file 同菜单),
2389
+ // 选中即在光标处插入行内 chip;发送时宿主调 codec.serialize 把每颗 chip 在原位
2390
+ // 展开成模型文本(这里 = 与指针条目同格式的引用行)。chip 携带的引用不走自动
2391
+ // 附带(防重复),消耗仍随消息(consumePendingRefs 标记 used,记录保留)。
2392
+ // 已知原型局限:chip 在草稿里被删掉后 chipRefs 未同步,该引用本次不自动附带。
2393
+ // inputTriggers 服务由 dsh-client-ui-input-trigger 在 client 启动期注册,插件
2394
+ // client 可能先于它启动——ctx.get 会拿到 undefined 静默跳过,改用 inject 等待。
2395
+ if (!store.chipRefs) store.chipRefs = new Set()
2396
+ ctx.inject(['inputTriggers'], (scope) => {
2397
+ try {
2398
+ const inputTriggers = scope.inputTriggers
2399
+ if (!inputTriggers || typeof inputTriggers.registerSource !== 'function') return
2400
+ store.inputTriggers = inputTriggers
2401
+ disposers.push(ctx.effect(() => inputTriggers.registerSource({
2402
+ trigger: '@',
2403
+ name: 'code-ui',
2404
+ showGroupTitle: true,
2405
+ order: 100,
2406
+ candidates: (session, req) => {
2407
+ const pend = numberedPendingRefs()
2408
+ if (pend.length === 0) return Promise.resolve([])
2409
+ const query = String((req && req.query) || '').toLowerCase()
2410
+ return Promise.resolve(pend
2411
+ .filter((x) => !query || tagLabel(x.r).toLowerCase().indexOf(query) >= 0 || baseName(x.r.path).toLowerCase().indexOf(query) >= 0)
2412
+ .map((x) => ({
2413
+ name: (x.r.kind === 'translate' ? '🌐' : '') + x.n + ':' + tagLabel(x.r),
2414
+ description: x.r.path,
2415
+ icon: 'file',
2416
+ section: 'code UI 引用',
2417
+ value: x.r.id,
2418
+ })))
2419
+ },
2420
+ onPick: (arg) => {
2421
+ const r = store.references.find((x) => x.id === arg.candidate.value)
2422
+ if (!r) return undefined
2423
+ store.chipRefs.add(r.id)
2424
+ emit()
2425
+ // ref 字段封入完整引用数据(JSON):chip 自包含,serialize 不依赖 client store——
2426
+ // 硬刷新/重启后草稿恢复的 chip 仍能正确展开(对齐 @file 的 ref=mention 自包含设计)
2427
+ const label = chipLabelFor(r)
2428
+ 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 } }
2429
+ },
2430
+ codec: {
2431
+ clipboardText: (refJson) => {
2432
+ const r = parseChipRef(refJson)
2433
+ return r ? chipShortLabel(r) : String(refJson)
2434
+ },
2435
+ serialize: async (refJson) => {
2436
+ const r = parseChipRef(refJson)
2437
+ if (!r) return '(code UI 引用数据无效: ' + refJson + ')'
2438
+ return formatRefModelLine(r)
2439
+ },
2440
+ },
2441
+ }), 'code-ui: @ 引用源原型'))
2442
+ } catch (e) {}
2443
+ })
1919
2444
  // 切换按钮:按用户选择注册(与布局模式解耦,任意组合)
1920
2445
  if (toggleStyle === 'bar') {
1921
2446
  // 悬浮模式条:可拖动横条,两键切换 code UI / dsh UI