dsh-superinput 0.7.0 → 0.12.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.
@@ -7,6 +7,17 @@ function createPromptsClient(require) {
7
7
  const STYLE_ID = 'dsh-superinput-prompts-style'
8
8
  const ENDPOINT = '/api/dsh-superinput/prompts'
9
9
 
10
+ /* 划选引用:官方 reference chip + Trae 规格。
11
+ * CHIP_MARK 是不可见分隔符(U+2063),作为标签前缀用于 CSS 精确选中
12
+ * 「我们插入的 chip」而不波及官方 @file chip;视觉与文本内容无影响。 */
13
+ const SELECTION_SOURCE = 'superinput-selection'
14
+ const SELECTION_RULE = 'dsh-superinput-selection'
15
+ const CHIP_MARK = '\u2063'
16
+ const CONTEXT_MAX = 8
17
+ const CONTEXT_TEXT_MAX = 4000
18
+ const CONTEXT_COMMENT_MAX = 600
19
+ const STORAGE_KEY = 'dsh-superinput-selection-items'
20
+
10
21
  const CSS = `
11
22
  .dsh-cp-btn {
12
23
  display: inline-flex;
@@ -107,17 +118,154 @@ function createPromptsClient(require) {
107
118
  .dsh-cp-action:hover { color: var(--dsw-alias-label-primary); }
108
119
  .dsh-cp-action.primary { color: #fff; background: #3a83f7; border-color: transparent; }
109
120
  .dsh-cp-action.primary:hover { background: #4f8ef9; }
110
- .dsh-cp-selmenu { display: flex; flex-direction: row; align-items: center; gap: 2px; padding: 3px; border-radius: var(--dsw-radius-lg, 12px);
111
- background: var(--dsw-specific-menu, rgba(67, 69, 74, 0.85));
112
- backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
113
- --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
114
- box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px var(--dsw-elevation-stroke-color), 0 3px 8px 0 rgba(0, 0, 0, 0.1));
121
+ /* 四选项横条:完全官方样式——令牌与几何照抄官方 QuoteSelection 按钮
122
+ (.U4oBDW_action:floating-fill 底 / 999px 胶囊 / panel 阴影 / xs-13 字), */
123
+ .dsh-cp-selmenu { display: flex; flex-direction: row; align-items: center; gap: 2px; padding: 3px; border-radius: 999px;
124
+ background: var(--dsw-alias-button-floating-fill, rgba(116, 126, 148, .24));
125
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l3, rgba(255, 255, 255, .1));
126
+ box-shadow: var(--dsw-elevation-panel, 0 0 0 .5px var(--dsw-elevation-stroke-color), 0 2px 6px rgba(0, 0, 0, .12));
115
127
  }
116
- .dsh-cp-selitem { border: none; background: transparent; text-align: left; padding: 5px 10px; font-size: 12px; line-height: 18px;
117
- color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.92)); border-radius: var(--dsw-radius-sm, 6px); cursor: pointer; white-space: nowrap; }
118
- .dsh-cp-selitem:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.07)); }
128
+ .dsh-cp-selitem { border: none; background: transparent; text-align: center; padding: 5px 10px; font-size: 13px; line-height: 18px;
129
+ color: var(--dsw-alias-label-primary); border-radius: 999px; cursor: pointer; white-space: nowrap;
130
+ font-family: var(--dsw-font-family, inherit); letter-spacing: -.02em; }
131
+ .dsh-cp-selitem:hover, .dsh-cp-selitem:focus-visible { background: var(--dsw-alias-button-floating-hover, rgba(116, 126, 148, .36)); }
132
+ /* 官方浮动「引用文本」pill 由横条里的同名功能取代(同一 insertQuote API),隐藏避免双浮层 */
133
+ [class*="U4oBDW_action"] { display: none !important; }
119
134
  .dsh-cp-toast { padding: 5px 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary, #fff);
120
135
  background: rgba(50, 50, 54, 0.95); border-radius: 8px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); }
136
+ /* ---- 划选引用 chip:Trae comment-mention 规格(ai-modules-chat/index.css)----
137
+ 规格:高 22px / 圆角 --dsw-radius-sm(8) / business 浅色底 + business 主色文字 /
138
+ 12px 500 / max-width 240;左图标在 hover 时换成 ×(Trae remove-mention-icon
139
+ 同义)。颜色全部走 dsh 设计令牌,明暗主题各自生效,不再写死 Trae 的灰/蓝。 */
140
+ [contenteditable] span[title^="${CHIP_MARK}"] {
141
+ box-sizing: border-box;
142
+ display: inline-flex;
143
+ align-items: baseline;
144
+ gap: 4px;
145
+ max-width: min(80%, 300px);
146
+ padding: 0;
147
+ border: none;
148
+ border-radius: 0;
149
+ background: none;
150
+ color: var(--dsw-alias-state-business-primary, #4176e6);
151
+ font-family: var(--dsw-font-family, inherit);
152
+ font-size: 14px;
153
+ line-height: 20px;
154
+ vertical-align: baseline;
155
+ cursor: pointer;
156
+ overflow: hidden;
157
+ transition: background .14s ease;
158
+ }
159
+ [contenteditable] span[title^="${CHIP_MARK}"]:hover {
160
+ color: var(--dsw-alias-state-business-primary, #4176e6);
161
+ }
162
+ /* 隐藏宿主默认前缀(@ / file 图标),换官方同款 ❝ 引用标记(hover 变 ×) */
163
+ [contenteditable] span[title^="${CHIP_MARK}"] > span:first-child { display: none; }
164
+ [contenteditable] span[title^="${CHIP_MARK}"]::before {
165
+ content: '';
166
+ flex: 0 0 14px;
167
+ width: 14px;
168
+ height: 14px;
169
+ align-self: center;
170
+ margin-right: 2px;
171
+ background: currentColor;
172
+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
173
+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
174
+ }
175
+ [contenteditable] span[title^="${CHIP_MARK}"]:hover::before {
176
+ content: '×';
177
+ background: none;
178
+ -webkit-mask: none;
179
+ mask: none;
180
+ color: var(--dsw-alias-label-primary, currentColor);
181
+ font-size: 14px;
182
+ line-height: 14px;
183
+ text-align: center;
184
+ }
185
+ /* 消息正文里的同款 chip(user-text 规则节点;不在 contenteditable 里) */
186
+ .dsh-si-msgchip { box-sizing: border-box; display: inline-flex; align-items: baseline; gap: 4px; max-width: min(80%, 300px);
187
+ padding: 0; border: none; border-radius: 0; background: none;
188
+ color: var(--dsw-alias-state-business-primary, #4176e6);
189
+ font-family: var(--dsw-font-family, inherit); font-size: 14px; line-height: 20px;
190
+ vertical-align: baseline; overflow: hidden; user-select: none; }
191
+ .dsh-si-msgchip.interactive { cursor: pointer; }
192
+ .dsh-si-msgchip-icon { flex: 0 0 14px; width: 14px; height: 14px; margin-right: 2px; opacity: .8; background: currentColor;
193
+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
194
+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat; }
195
+ .dsh-si-msgchip-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
196
+ /* 详情弹窗:dsh 原生 Dialog 观感(layer-1 表面 / 16 圆角 / 突出投影 / 标准按钮) */
197
+ .dsh-si-mask { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center;
198
+ padding: 24px; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, .24)); }
199
+ .dsh-si-dialog { box-sizing: border-box; width: min(480px, 100%); max-height: min(600px, 82vh); display: flex; flex-direction: column; gap: 12px;
200
+ padding: 16px; border-radius: var(--dsw-radius-lg, 16px); color: var(--dsw-alias-label-primary, #e8eefa); overflow-y: auto;
201
+ font-family: var(--dsw-font-family, inherit);
202
+ background: var(--dsw-alias-bg-layer-1, #232324);
203
+ border: .5px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .08));
204
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l4, rgba(255, 255, 255, .2));
205
+ box-shadow: var(--dsw-elevation-prominent, 0 0 0 .5px var(--dsw-elevation-stroke-color), 0 8px 28px rgba(0, 0, 0, .28)); }
206
+ .dsh-si-dialog-head { display: flex; align-items: center; gap: 8px; }
207
+ .dsh-si-dialog-title { flex: 1 1 auto; min-width: 0; font-size: 16px; font-weight: 500; line-height: 24px;
208
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
209
+ .dsh-si-dialog-x { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs, 4px); cursor: pointer;
210
+ background: transparent; color: var(--dsw-alias-label-tertiary, #8a9096); font-size: 15px; line-height: 1; }
211
+ .dsh-si-dialog-x:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
212
+ .dsh-si-dialog-quote { box-sizing: border-box; flex: 1 1 auto; min-height: 40px; max-height: 240px; overflow: auto; padding: 10px 12px; border-radius: var(--dsw-radius-sm, 8px);
213
+ background: var(--dsw-alias-bg-module-platform, rgba(255, 255, 255, .06));
214
+ border-left: 2px solid var(--dsw-alias-state-business-primary, #3a83f7);
215
+ font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
216
+ .dsh-si-dialog-label { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, #8a9096); }
217
+ .dsh-si-dialog-detail { font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
218
+ .dsh-si-dialog-comment { box-sizing: border-box; width: 100%; min-height: 64px; max-height: 160px; resize: vertical; padding: 8px 10px;
219
+ font-size: 14px; line-height: 22px; font-family: inherit; color: var(--dsw-alias-label-primary);
220
+ background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, .06)); border: .5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .12));
221
+ border-radius: var(--dsw-radius-sm, 8px); outline: none; }
222
+ .dsh-si-dialog-comment:focus {
223
+ border-color: var(--dsw-alias-state-business-primary, #3a83f7);
224
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary, #3a83f7) 16%, transparent);
225
+ }
226
+ .dsh-si-dialog-foot { display: flex; align-items: center; gap: 8px; }
227
+ .dsh-si-dialog-spacer { flex: 1 1 auto; }
228
+ .dsh-si-dialog-btn { height: 30px; padding: 0 14px; font-size: 13px; font-weight: 500; line-height: 20px; cursor: pointer;
229
+ color: var(--dsw-alias-label-primary); background: var(--dsw-alias-button-elevated-fill, transparent);
230
+ border: .5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .12)); border-radius: var(--dsw-radius-sm, 8px); }
231
+ .dsh-si-dialog-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
232
+ .dsh-si-dialog-btn.ghost { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary); }
233
+ .dsh-si-dialog-btn.ghost:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
234
+ .dsh-si-dialog-btn.primary { color: var(--dsw-alias-label-primary-foreground, #fff);
235
+ background: var(--dsw-alias-button-primary-fill, #3a83f7); border-color: transparent; }
236
+ .dsh-si-dialog-btn.primary:hover { background: var(--dsw-alias-button-primary-hover, #4f8ef9); }
237
+ .dsh-si-dialog-btn.danger { background: transparent; border-color: transparent; color: var(--dsw-alias-state-error-primary, #f87171); }
238
+ .dsh-si-dialog-btn.danger:hover { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(248, 113, 113, .12)); }
239
+ /* 划选评论编辑器:照抄 Trae CommentEditorCard(solo-lite 7920 包源码)——
240
+ previewCommentCardContainer(菜单底/1px 中性描边/radius 8/双层阴影)内嵌
241
+ commentEditorCard(320px/padding 8/gap 4 列布局);无标题无预览无取消钮,
242
+ 单行 = 透明自增 textarea(13px/20px,max-height 140)+ ✓ 主按钮;Enter 提交、
243
+ Shift+Enter 换行、Esc/点外取消。 */
244
+ .dsh-si-comment-editor { position: fixed; z-index: 1300; box-sizing: border-box; width: 320px; padding: 8px;
245
+ display: flex; flex-direction: column; gap: 4px;
246
+ border-radius: var(--dsw-radius-sm, 8px); color: var(--dsw-alias-label-primary);
247
+ font-family: var(--dsw-font-family, inherit); background: var(--dsw-specific-menu, #202124);
248
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
249
+ /* specific-menu 是半透明底:必须配官方毛玻璃,否则透出下层文字(官方弹卡成对规则) */
250
+ backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
251
+ -webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
252
+ box-shadow: 0 2px 6px rgba(0, 0, 0, .19), 0 .5px rgba(255, 255, 255, .06), inset 0 .5px rgba(255, 255, 255, .06);
253
+ overflow: hidden;
254
+ transition: width .25s cubic-bezier(.4, 0, .2, 1), height .25s cubic-bezier(.4, 0, .2, 1);
255
+ visibility: hidden; }
256
+ .dsh-si-comment-row { display: flex; align-items: center; gap: 8px; }
257
+ .dsh-si-comment-input { flex: 1; min-width: 0; box-sizing: border-box; background: none; border: none; outline: none; resize: none;
258
+ color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-family, inherit);
259
+ font-size: 13px; font-weight: 400; line-height: 20px; max-height: 140px;
260
+ padding: 0 8px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .16)) transparent; }
261
+ .dsh-si-comment-input::placeholder { color: var(--dsw-alias-label-tertiary); }
262
+ .dsh-si-comment-submit { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
263
+ border: none; border-radius: 6px; cursor: pointer;
264
+ background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, .12)); color: var(--dsw-alias-label-primary); }
265
+ .dsh-si-comment-submit:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .2)); }
266
+ .dsh-si-comment-submit:disabled { opacity: .5; cursor: default; }
267
+ /* 点击标签跳源时对源元素的闪烁高亮。 */
268
+ .dsh-cp-flash { outline: 2px solid var(--dsw-alias-state-business-primary, #3a83f7) !important; outline-offset: 1px; border-radius: 3px; transition: outline-color .3s ease; }
121
269
  `
122
270
 
123
271
  function ensureStyle() {
@@ -149,6 +297,195 @@ function createPromptsClient(require) {
149
297
  } catch { /* ignore */ }
150
298
  }
151
299
 
300
+ /* ------------------------------------------------------------------ */
301
+ /* 划选引用:输入框内联 chip(官方 reference source)+ /context 同步 */
302
+ /* ------------------------------------------------------------------ */
303
+
304
+ const selectionItems = new Map() // id -> item(发送后归档,供消息正文的 chip 渲染)
305
+ const selectionSyncTimers = new Map()
306
+ const selectionActions = new Map() // sessionId -> InputActions
307
+ const selectionDetailSubs = new Set()
308
+ let selectionDetailRequest = null
309
+ let selectionSessions = null // ClientSessions 服务(取每会话 binding.ctx 发输入事件)
310
+ let selectionIdSeq = 0
311
+ let selectionStorageTimer = null
312
+
313
+ const tokenOf = (id) => `[选段#${id}]`
314
+ const previewOf = (text, max = 220) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
315
+
316
+ /** 选区矩形兜底:菜单给的 rect 失效时退回锚点元素/默认位置。 */
317
+ const normalizeSelectionRect = (rect, anchorEl) => {
318
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n)
319
+ if (rect && finite(rect.left) && finite(rect.top) && finite(rect.right) && finite(rect.bottom)) return rect
320
+ const el = anchorEl instanceof Element ? anchorEl : null
321
+ if (el) {
322
+ const r = el.getBoundingClientRect()
323
+ return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }
324
+ }
325
+ return { left: 16, top: 80, right: 16, bottom: 80, width: 0, height: 0 }
326
+ }
327
+
328
+ /** chip 标签:Trae 是 `N. 选段文本 · 评论`(评论内联在标签里)。
329
+ * 有评论时把选段压到 ~14 字(Trae 的 text-content 也只占 50%),
330
+ * 否则 240px 的 chip 会把评论整段挤没。 */
331
+ const chipTextOf = (seq, text, comment) => {
332
+ const quote = String(text ?? '').replace(/\s+/g, ' ').trim()
333
+ const note = String(comment ?? '').replace(/\s+/g, ' ').trim()
334
+ if (!note) return `${seq}. ${quote}`
335
+ const head = quote.length > 14 ? `${quote.slice(0, 14)}…` : quote
336
+ return `${seq}. ${head} · ${note.slice(0, 40)}`
337
+ }
338
+ const chipLabelOf = (item) => `${CHIP_MARK}${chipTextOf(item.seq, item.text, item.comment)}`
339
+
340
+ /**
341
+ * 某条 chip 在 detect 坐标里的跨度。occurrences 的 offset 是 clipboard
342
+ * 坐标(chip 展开成 clipboardText),detect 坐标里每条 chip 只占 1 个
343
+ * U+FFFC——把前面所有 chip 的 (length-1) 扣掉即得。
344
+ */
345
+ function chipSpanOf(occurrences, ref, draftRev) {
346
+ const own = (occurrences ?? []).find((occ) => occ.source === SELECTION_SOURCE && occ.ref === ref)
347
+ if (!own) return null
348
+ let start = own.offset
349
+ for (const occ of occurrences) if (occ.offset < own.offset) start -= occ.length - 1
350
+ return { start, end: start + 1, draftRev }
351
+ }
352
+
353
+ /** 当前草稿里真实存在的条目,按草稿里的先后顺序(Trae 的编号顺序)。 */
354
+ function liveSelectionItems(sessionId, occurrences) {
355
+ if (!sessionId) return []
356
+ const seen = new Set()
357
+ const live = []
358
+ for (const occ of occurrences ?? []) {
359
+ if (occ.source !== SELECTION_SOURCE || seen.has(occ.ref)) continue
360
+ seen.add(occ.ref)
361
+ const item = selectionItems.get(occ.ref)
362
+ if (item && item.sessionId === sessionId) live.push(item)
363
+ }
364
+ return live
365
+ }
366
+
367
+ async function postContext(sessionId, items) {
368
+ if (!sessionId) return
369
+ try {
370
+ await fetch(`${ENDPOINT}/context`, {
371
+ method: 'POST',
372
+ headers: { 'content-type': 'application/json' },
373
+ body: JSON.stringify({
374
+ sessionId,
375
+ items: items.map(({ text, comment }) => ({
376
+ text: String(text ?? '').slice(0, CONTEXT_TEXT_MAX),
377
+ comment: String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX),
378
+ })),
379
+ }),
380
+ keepalive: true,
381
+ })
382
+ } catch { /* 网络失败时服务端保留上一份,不打断输入 */ }
383
+ }
384
+
385
+ /** 服务端 /context 同步:150ms 防抖;发送意图用 immediate flush。 */
386
+ function syncSelectionContext(sessionId, items, immediate) {
387
+ if (!sessionId) return
388
+ const previous = selectionSyncTimers.get(sessionId)
389
+ if (previous) clearTimeout(previous)
390
+ const send = () => {
391
+ selectionSyncTimers.delete(sessionId)
392
+ void postContext(sessionId, items)
393
+ }
394
+ if (immediate) send()
395
+ else selectionSyncTimers.set(sessionId, setTimeout(send, 150))
396
+ }
397
+
398
+ /** 落 sessionStorage:消息正文的 chip 在刷新后仍有文本/评论可渲染。 */
399
+ function persistSelectionItems() {
400
+ if (typeof sessionStorage === 'undefined' || selectionStorageTimer) return
401
+ selectionStorageTimer = setTimeout(() => {
402
+ selectionStorageTimer = null
403
+ try {
404
+ const snapshot = [...selectionItems.values()].slice(-120).map((item) => ({
405
+ id: item.id,
406
+ sessionId: item.sessionId,
407
+ seq: item.seq,
408
+ text: item.text,
409
+ comment: item.comment,
410
+ sent: item.sent === true,
411
+ at: item.at,
412
+ }))
413
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
414
+ } catch { /* 配额/隐私模式:忽略 */ }
415
+ }, 400)
416
+ }
417
+
418
+ function loadSelectionItems() {
419
+ if (typeof sessionStorage === 'undefined') return
420
+ try {
421
+ const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '[]')
422
+ if (!Array.isArray(saved)) return
423
+ for (const raw of saved) {
424
+ if (!raw?.id || !raw?.text) continue
425
+ const id = String(raw.id)
426
+ if (selectionItems.has(id)) continue
427
+ const sessionId = String(raw.sessionId ?? '')
428
+ const seq = Number(raw.seq) || 0
429
+ selectionItems.set(id, {
430
+ id, sessionId, seq,
431
+ text: String(raw.text),
432
+ comment: String(raw.comment ?? ''),
433
+ anchor: null,
434
+ sent: raw.sent === true,
435
+ archived: true, // 历史条目:chip 已不在草稿里,只用于消息正文展示
436
+ at: Number(raw.at) || Date.now(),
437
+ })
438
+ }
439
+ } catch { /* 存档损坏:忽略 */ }
440
+ }
441
+
442
+ /** 走会话作用域的 slash/input-insert-reference 事件把 chip 插进官方编辑器。 */
443
+ function applySelectionInsert(sessionId, item, span) {
444
+ if (!span) return false
445
+ const binding = selectionSessions?.binding?.(sessionId)
446
+ const actx = binding?.ctx
447
+ if (!actx) return false
448
+ const request = {
449
+ reference: {
450
+ source: SELECTION_SOURCE,
451
+ ref: item.id,
452
+ label: chipLabelOf(item),
453
+ clipboardText: tokenOf(item.id),
454
+ },
455
+ span,
456
+ }
457
+ try {
458
+ if (typeof actx.bail === 'function') {
459
+ try { return actx.bail(actx, 'slash/input-insert-reference', request) === true }
460
+ catch { /* 有些版本只接受 (name, payload) */ }
461
+ try { return actx.bail('slash/input-insert-reference', request) === true }
462
+ catch { return false }
463
+ }
464
+ if (typeof actx.emit === 'function') {
465
+ actx.emit(actx, 'slash/input-insert-reference', request)
466
+ return true
467
+ }
468
+ } catch { return false }
469
+ return false
470
+ }
471
+
472
+ /** 删除一条 chip:用 insertText('') 替换它的 detect 跨度(空文本即删除)。 */
473
+ function applySelectionRemove(sessionId, item, span) {
474
+ if (!span) return false
475
+ const actions = selectionActions.get(sessionId)
476
+ if (typeof actions?.insertText !== 'function') return false
477
+ try { return actions.insertText('', span) === true } catch { return false }
478
+ }
479
+
480
+ function requestSelectionDetail(sessionId, id, focus) {
481
+ selectionDetailRequest = { sessionId, id, focus: focus === true }
482
+ for (const listener of selectionDetailSubs) {
483
+ try { listener() } catch { /* ignore */ }
484
+ }
485
+ }
486
+
487
+ loadSelectionItems()
488
+
152
489
  /** 找出正文里的 {{变量}},去重保序。 */
153
490
  function varsOf(text) {
154
491
  const found = []
@@ -237,84 +574,53 @@ function createPromptsClient(require) {
237
574
  if (hideTimer) clearTimeout(hideTimer)
238
575
  hideTimer = setTimeout(hide, 1800)
239
576
  }
240
- const copySel = async (text) => {
241
- hide()
242
- try {
243
- await navigator.clipboard?.writeText(text)
244
- toast('已复制')
245
- } catch {
246
- toast('复制失败')
247
- }
248
- }
577
+ // 评论编辑器在 React 侧保存后要提示,复用这个文档级 toast。
578
+ window.__dshSuperinputToast = toast
249
579
 
250
- /** 把选中文字追加进输入框草稿(草稿桥是 enhancer 客户端挂的全局)。 */
251
- const addToDraft = (text) => {
252
- hide()
253
- try {
254
- const set = window.__dshSuperinputSetDraft
255
- if (!set) { toast('草稿桥未就绪,刷新后重试'); return }
256
- const current = String(window.__dshSuperinputDraft?.() ?? '')
257
- set(current ? `${current}\n${text}` : text)
258
- toast('已添加到输入框')
259
- } catch {
260
- toast('添加失败')
261
- }
262
- }
263
-
264
- const save = async (text) => {
265
- const title = text.replace(/\s+/g, ' ').trim().slice(0, 40)
266
- hide()
267
- try {
268
- const res = await fetch('/api/dsh-superinput/prompts/save', {
269
- method: 'POST',
270
- headers: { 'content-type': 'application/json' },
271
- body: JSON.stringify({ title, body: text }),
272
- })
273
- const data = res.ok ? await res.json() : null
274
- toast(data?.ok ? '已存为模板' : '保存失败')
275
- } catch {
276
- toast('保存失败')
277
- }
278
- }
279
-
280
- // 选区变化驱动(dblclick 选词在 mouseup 之后才成形,selectionchange 才是可靠信号)
580
+ /* 四选项横条(用户拍板恢复 0.8.x 形态):复制 / 引用文本(官方
581
+ * insertQuote 机制)/ 添加评论(Trae 评论卡 → chip)/ 存为模板。
582
+ * 官方自己的浮动「引用文本」pill 用 CSS 隐藏——横条里的「引用文本」
583
+ * 调同一个官方 API,避免双浮层。 */
281
584
  let showTimer = 0
282
585
  document.addEventListener('selectionchange', () => {
283
586
  try {
284
587
  if (showTimer) { clearTimeout(showTimer); showTimer = 0 }
285
588
  const selection = window.getSelection?.()
286
589
  const text = String(selection ?? '').trim()
287
- if (!text || text.length < 4 || !selection.rangeCount) { hide(); return }
590
+ if (!text || !selection.rangeCount) { hide(); return }
288
591
  showTimer = setTimeout(() => {
289
592
  try {
290
593
  const sel = window.getSelection?.()
291
594
  const current = String(sel ?? '').trim()
292
- if (!current || current.length < 4 || !sel.rangeCount) { hide(); return }
595
+ if (!current || !sel.rangeCount) { hide(); return }
293
596
  const node = sel.anchorNode
294
597
  const el = node instanceof Element ? node : node?.parentElement
295
- // 只收对话正文:跳过输入区和我们自己的弹层
598
+ // 只收对话正文:跳过输入区、我们自己的弹层,以及侧栏/设置等非对话区域
296
599
  if (!el || el.closest('[data-dsh-superinput-portal]') || el.closest('[contenteditable="true"], textarea, input')) { hide(); return }
600
+ if (!el.closest('[data-chat-flow], [data-slot="conversation.chat.flow"]')) { hide(); return }
297
601
  const rect = sel.getRangeAt(0).getBoundingClientRect()
298
602
  if (!rect || (rect.width === 0 && rect.height === 0)) { hide(); return }
299
603
  const container = ensureHost()
300
- // 横排菜单(复制 / 添加到对话 / 存为模板 ≈210px):左缘对齐选区,右侧防溢出
301
- container.style.left = `${Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 226))}px`
604
+ // 横排四项 ≈320px:左缘对齐选区,右侧防溢出
605
+ container.style.left = `${Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 330))}px`
302
606
  container.style.top = `${Math.max(8, rect.top - 44)}px`
303
607
  container.style.transform = ''
304
608
  container.style.display = 'block'
305
609
  const menu = document.createElement('div')
306
610
  menu.className = 'dsh-cp-selmenu'
307
- menu.addEventListener('mousedown', (e) => e.stopPropagation())
611
+ // 阻止默认聚焦/清选区:否则 mousedown 触发 selectionchange,菜单会在 click 前被隐藏
612
+ menu.addEventListener('mousedown', (e) => { e.stopPropagation(); e.preventDefault() })
308
613
  const mkItem = (label, action) => {
309
614
  const b = document.createElement('button')
310
615
  b.className = 'dsh-cp-selitem'
311
616
  b.textContent = label
312
- b.addEventListener('click', () => { void action(current) })
617
+ b.addEventListener('click', () => { void action(current, el, rect) })
313
618
  return b
314
619
  }
315
620
  menu.append(
316
621
  mkItem('复制', copySel),
317
- mkItem('添加到对话', addToDraft),
622
+ mkItem('引用文本', quoteToComposer),
623
+ mkItem('添加评论', (text2) => openComment(text2, el, rect)),
318
624
  mkItem('存为模板', save))
319
625
  container.replaceChildren(menu)
320
626
  } catch { /* ignore */ }
@@ -328,6 +634,128 @@ function createPromptsClient(require) {
328
634
  document.addEventListener('selectionchange', () => {
329
635
  if (!String(window.getSelection?.() ?? '').trim()) hide()
330
636
  })
637
+
638
+ const copySel = async (text) => {
639
+ hide()
640
+ if (!navigator.clipboard?.writeText) { toast('当前环境不支持复制'); return }
641
+ try {
642
+ await navigator.clipboard.writeText(text)
643
+ toast('已复制')
644
+ } catch {
645
+ toast('复制失败')
646
+ }
647
+ }
648
+
649
+ /** 官方「引用文本」同款:inputActions.insertQuote 插 Markdown blockquote。
650
+ * plain 模式限制下返回 false 时,退回草稿桥手写 `> ` 前缀(发送后官方
651
+ * quoteRule 一样渲染成引用样式)。 */
652
+ const quoteToComposer = (text) => {
653
+ hide()
654
+ const insert = window.__dshSuperinputInsertQuote
655
+ if (typeof insert === 'function' && insert(text) === true) { toast('已引用到输入框'); return }
656
+ try {
657
+ const set = window.__dshSuperinputSetDraft
658
+ if (!set) { toast('输入框桥未就绪,刷新后重试'); return }
659
+ const block = text.split('\n').map((line) => `> ${line}`).join('\n')
660
+ const current = String(window.__dshSuperinputDraft?.() ?? '')
661
+ set(current ? `${current}\n${block}` : block)
662
+ toast('已引用到输入框')
663
+ } catch {
664
+ toast('引用失败')
665
+ }
666
+ }
667
+
668
+ /** Trae 的「添加评论」:在选区旁弹评论卡,保存后才插 chip(带评论)。
669
+ * rect 是选区矩形,React 侧用它把卡片锚在选区附近。 */
670
+ const openComment = (text, anchorEl, rect) => {
671
+ hide()
672
+ const open = window.__dshSuperinputCommentContext
673
+ if (typeof open !== 'function') { toast('评论编辑器未就绪,刷新后重试'); return }
674
+ let status = 'failed'
675
+ try {
676
+ status = open(text, anchorEl, {
677
+ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
678
+ width: rect.width, height: rect.height,
679
+ })
680
+ } catch { status = 'failed' }
681
+ if (status === 'full') toast(`最多添加 ${CONTEXT_MAX} 段对话内容`)
682
+ else if (status === 'empty') toast('选段为空')
683
+ else if (status === 'failed') toast('打开评论编辑器失败')
684
+ }
685
+
686
+ const save = async (text) => {
687
+ const title = text.replace(/\s+/g, ' ').trim().slice(0, 40)
688
+ hide()
689
+ try {
690
+ const res = await fetch('/api/dsh-superinput/prompts/save', {
691
+ method: 'POST',
692
+ headers: { 'content-type': 'application/json' },
693
+ body: JSON.stringify({ title, body: text }),
694
+ })
695
+ const data = res.ok ? await res.json() : null
696
+ toast(data?.ok ? '已存为模板' : '保存失败')
697
+ } catch {
698
+ toast('保存失败')
699
+ }
700
+ }
701
+
702
+ /* Trae dragDropMention 等价:把对话里划选的文字拖进输入框,落成引用 chip。
703
+ * dragstart 时对选区打自定义 dataTransfer 标记 + stash 文本;dragover 放行
704
+ * composer;drop 拦截宿主的纯文本插入,走 __dshSuperinputDropContext 插 chip。 */
705
+ let dragSelection = null // { text, anchorEl }
706
+ document.addEventListener('dragstart', (event) => {
707
+ try {
708
+ const selection = window.getSelection?.()
709
+ const text = String(selection ?? '').trim()
710
+ const node = selection?.anchorNode
711
+ const el = node instanceof Element ? node : node?.parentElement
712
+ if (!text || !el?.closest?.('[data-chat-flow], [data-slot="conversation.chat.flow"]')
713
+ || el.closest?.('[contenteditable="true"], textarea, input')) { dragSelection = null; return }
714
+ dragSelection = { text: text.slice(0, CONTEXT_TEXT_MAX), anchorEl: el }
715
+ try { event.dataTransfer?.setData('application/x-dsh-selection', '1') } catch { /* 标记失败只影响识别 */ }
716
+ } catch { dragSelection = null }
717
+ }, true)
718
+ document.addEventListener('dragover', (event) => {
719
+ try {
720
+ if (!dragSelection) return
721
+ const target = event.target
722
+ if (!(target instanceof Element) || !target.closest?.('[contenteditable="true"]')) return
723
+ event.preventDefault()
724
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
725
+ } catch { /* ignore */ }
726
+ }, true)
727
+ document.addEventListener('drop', (event) => {
728
+ try {
729
+ if (!dragSelection) return
730
+ const target = event.target
731
+ if (!(target instanceof Element) || !target.closest?.('[contenteditable="true"]')) return
732
+ event.preventDefault()
733
+ event.stopPropagation()
734
+ const { text, anchorEl } = dragSelection
735
+ dragSelection = null
736
+ // 尽力把光标挪到落点:宿主 captureInsertion 读到哪就是哪。
737
+ try {
738
+ const range = typeof document.caretRangeFromPoint === 'function'
739
+ ? document.caretRangeFromPoint(event.clientX, event.clientY)
740
+ : null
741
+ if (range) {
742
+ window.getSelection()?.removeAllRanges()
743
+ window.getSelection()?.addRange(range)
744
+ }
745
+ } catch { /* 定位失败就落原 caret */ }
746
+ const insert = window.__dshSuperinputDropContext ?? window.__dshSuperinputAddContext
747
+ if (typeof insert === 'function') {
748
+ const status = insert(text, anchorEl)
749
+ const toast = window.__dshSuperinputToast
750
+ if (typeof toast === 'function') {
751
+ if (status === 'added') toast('已添加到对话')
752
+ else if (status === 'dup') toast('该选段已在列表中')
753
+ else if (status === 'full') toast(`最多添加 ${CONTEXT_MAX} 段对话内容`)
754
+ }
755
+ }
756
+ } catch { /* ignore */ }
757
+ }, true)
758
+ document.addEventListener('dragend', () => { dragSelection = null }, true)
331
759
  }
332
760
 
333
761
  function PromptsPanel({ anchor, onClose, onSyncSlash }) {
@@ -623,6 +1051,525 @@ function createPromptsClient(require) {
623
1051
  render() { return this.state.crashed ? null : this.props.children }
624
1052
  }
625
1053
 
1054
+ /** 详情弹窗:Trae 点 chip 打开的**只读** Dialog(引用全文 + 评论 + 跳转原文)。
1055
+ * 评论不在这个弹窗里编辑——Trae 的评论在选区旁的 previewCommentCard 输入;
1056
+ * 这里只展示结果(与 ai-modules-chat 的 mention-info-modal 同构)。 */
1057
+ function SelectionDetail({ title, text, comment, onNavigate, onClose }) {
1058
+ const maskRef = React.useRef(null)
1059
+
1060
+ /* 桌面壳(Electron)里 body 会变成 0×0 的 fixed 包含块:`inset: 0` 的遮罩
1061
+ * 缩在左下角,弹窗被裁掉。打开后量一次遮罩实际矩形,若没有铺满视口,
1062
+ * 就按测量偏移把遮罩校正回视口原点并铺满——不依赖宿主沿用哪种 portal。 */
1063
+ React.useEffect(() => {
1064
+ const mask = maskRef.current
1065
+ if (!mask || typeof window === 'undefined') return undefined
1066
+ let corrected = false
1067
+ const fix = () => {
1068
+ const vw = window.innerWidth
1069
+ const vh = window.innerHeight
1070
+ if (!corrected) {
1071
+ const rect = mask.getBoundingClientRect()
1072
+ if (rect.width >= vw * 0.8 && rect.height >= vh * 0.8) return
1073
+ corrected = true
1074
+ mask.style.position = 'fixed'
1075
+ mask.style.inset = 'auto'
1076
+ mask.style.left = `${-rect.left}px`
1077
+ mask.style.top = `${-rect.top}px`
1078
+ mask.style.display = 'flex'
1079
+ mask.style.alignItems = 'center'
1080
+ mask.style.justifyContent = 'center'
1081
+ report(`selection dialog: corrected mask ${Math.round(rect.width)}x${Math.round(rect.height)}@${Math.round(rect.left)},${Math.round(rect.top)} to viewport ${vw}x${vh}`)
1082
+ }
1083
+ mask.style.width = `${vw}px`
1084
+ mask.style.height = `${vh}px`
1085
+ mask.style.boxSizing = 'border-box'
1086
+ }
1087
+ fix()
1088
+ window.addEventListener('resize', fix)
1089
+ return () => window.removeEventListener('resize', fix)
1090
+ }, [])
1091
+
1092
+ return h('div', {
1093
+ ref: maskRef,
1094
+ className: 'dsh-si-mask',
1095
+ onMouseDown: (event) => { event.stopPropagation(); onClose?.() },
1096
+ },
1097
+ h('div', {
1098
+ className: 'dsh-si-dialog',
1099
+ onMouseDown: (event) => event.stopPropagation(),
1100
+ onClick: (event) => event.stopPropagation(),
1101
+ },
1102
+ h('div', { className: 'dsh-si-dialog-head' },
1103
+ h('span', { className: 'dsh-si-dialog-title' }, title),
1104
+ h('button', { className: 'dsh-si-dialog-x', 'aria-label': '关闭', onClick: () => onClose?.() }, '×')),
1105
+ h('div', { className: 'dsh-si-dialog-quote' }, text),
1106
+ comment ? h(React.Fragment, null,
1107
+ h('div', { className: 'dsh-si-dialog-label' }, '评论'),
1108
+ h('div', { className: 'dsh-si-dialog-detail' }, comment)) : null,
1109
+ h('div', { className: 'dsh-si-dialog-foot' },
1110
+ onNavigate ? h('button', { className: 'dsh-si-dialog-btn', onClick: onNavigate }, '跳转原文') : null,
1111
+ h('span', { className: 'dsh-si-dialog-spacer' }),
1112
+ h('button', { className: 'dsh-si-dialog-btn ghost', onClick: () => onClose?.() }, '关闭')))
1113
+ )
1114
+ }
1115
+
1116
+ /** 划选评论编辑器:照抄 Trae CommentEditorCard——无标题/无预览/无取消钮,
1117
+ * 单行 = 透明自增 textarea + ✓ 按钮;Enter 提交、Shift+Enter 换行、
1118
+ * Esc/点外取消;IME 组合期不提交。锚在选区旁,不遮罩。 */
1119
+ function SelectionCommentEditor({ draft, onSave, onCancel }) {
1120
+ const [value, setValue] = React.useState(draft.comment ?? '')
1121
+ const cardRef = React.useRef(null)
1122
+ const inputRef = React.useRef(null)
1123
+ const valueRef = React.useRef(value)
1124
+ React.useEffect(() => { valueRef.current = value }, [value])
1125
+
1126
+ // textarea 自增高(Trae 同款:height auto → scrollHeight,上限 140)
1127
+ React.useEffect(() => {
1128
+ const el = inputRef.current
1129
+ if (!el) return
1130
+ el.style.height = 'auto'
1131
+ el.style.height = `${Math.min(el.scrollHeight, 140)}px`
1132
+ }, [value])
1133
+
1134
+ React.useEffect(() => {
1135
+ const card = cardRef.current
1136
+ if (!card || typeof window === 'undefined') return
1137
+ const vw = window.innerWidth
1138
+ const vh = window.innerHeight
1139
+ const margin = 8
1140
+ const width = Math.min(320, vw - margin * 2)
1141
+ card.style.width = `${width}px`
1142
+ const height = card.getBoundingClientRect().height || 44
1143
+ const rect = draft.rect ?? {}
1144
+ let left = Math.min(Math.max(margin, (rect.left ?? 16) + 20), Math.max(margin, vw - width - margin))
1145
+ let top = (rect.bottom ?? 80) + 8
1146
+ if (top + height > vh - margin) top = Math.max(margin, (rect.top ?? 80) - height - 8)
1147
+ // 先放到期望位置;桌面壳若把 fixed 包含块变成 0×0,再按实际偏差校正。
1148
+ card.style.visibility = 'hidden'
1149
+ card.style.left = `${left}px`
1150
+ card.style.top = `${top}px`
1151
+ const actual = card.getBoundingClientRect()
1152
+ const dx = actual.left - left
1153
+ const dy = actual.top - top
1154
+ if (Math.abs(dx) > 1) card.style.left = `${left - dx}px`
1155
+ if (Math.abs(dy) > 1) card.style.top = `${top - dy}px`
1156
+ card.style.visibility = 'visible'
1157
+ const textarea = card.querySelector('textarea')
1158
+ if (textarea) {
1159
+ textarea.focus()
1160
+ textarea.setSelectionRange?.(textarea.value.length, textarea.value.length)
1161
+ }
1162
+ }, [draft])
1163
+
1164
+ React.useEffect(() => {
1165
+ const onDown = (event) => {
1166
+ if (cardRef.current?.contains(event.target)) return
1167
+ onCancel?.()
1168
+ }
1169
+ document.addEventListener('mousedown', onDown, true)
1170
+ return () => document.removeEventListener('mousedown', onDown, true)
1171
+ }, [onCancel])
1172
+
1173
+ const submit = () => {
1174
+ if (!value.trim()) return
1175
+ onSave?.(value)
1176
+ }
1177
+
1178
+ return createPortal(h('div', {
1179
+ ref: cardRef,
1180
+ className: 'dsh-si-comment-editor',
1181
+ onMouseDown: (event) => event.stopPropagation(),
1182
+ onClick: (event) => event.stopPropagation(),
1183
+ },
1184
+ h('div', { className: 'dsh-si-comment-row' },
1185
+ h('textarea', {
1186
+ ref: inputRef,
1187
+ className: 'dsh-si-comment-input',
1188
+ rows: 1,
1189
+ value,
1190
+ placeholder: '输入你的评论...',
1191
+ 'aria-label': '输入你的评论...',
1192
+ onInput: (event) => setValue(event.target.value),
1193
+ onKeyDown: (event) => {
1194
+ if (event.nativeEvent?.isComposing || event.nativeEvent?.keyCode === 229) return
1195
+ event.stopPropagation()
1196
+ if (event.key === 'Escape') { event.preventDefault(); onCancel?.() }
1197
+ else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
1198
+ event.preventDefault()
1199
+ submit()
1200
+ }
1201
+ },
1202
+ }),
1203
+ h('button', {
1204
+ className: 'dsh-si-comment-submit',
1205
+ title: draft.comment ? '保存' : '评论到对话',
1206
+ 'aria-label': draft.comment ? '保存' : '评论到对话',
1207
+ disabled: !value.trim(),
1208
+ onClick: submit,
1209
+ },
1210
+ h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', 'aria-hidden': true },
1211
+ h('path', {
1212
+ d: 'M13.5 4.5L6.5 11.5L2.5 7.5',
1213
+ fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8',
1214
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
1215
+ }))))
1216
+ ), document.body)
1217
+ }
1218
+
1219
+ /** 划选引用桥:把「添加到对话」插成输入框内的官方 reference chip。
1220
+ * Trae 的引用是编辑器里的内联节点(conversation-selection-mention-symbol),
1221
+ * dsh 对外只开放 reference chip 这一条内联通道——这里注册自定义 source,
1222
+ * 用 slash/input-insert-reference 落 chip,并按 Trae 规格重着色。
1223
+ * 输入框上方不再有独立 pill 行。 */
1224
+ function SelectionContextBridge({ session, useInput, inputActions }) {
1225
+ const sessionId = session?.sessionId === undefined ? '' : String(session.sessionId)
1226
+ const draftRev = useInput((state) => state?.draftRev ?? 0)
1227
+ const occurrences = useInput((state) => state?.occurrences)
1228
+ const phase = useInput((state) => state?.phase)
1229
+ const snapshotRef = React.useRef({ draftRev, occurrences })
1230
+ const actionsRef = React.useRef(inputActions)
1231
+ const previousPhase = React.useRef(phase)
1232
+ const [, setVersion] = React.useState(0)
1233
+ const [dialog, setDialog] = React.useState(null)
1234
+ const [editor, setEditor] = React.useState(null)
1235
+ const bump = () => setVersion((value) => value + 1)
1236
+
1237
+ React.useEffect(() => {
1238
+ snapshotRef.current = { draftRev, occurrences }
1239
+ actionsRef.current = inputActions
1240
+ }, [draftRev, occurrences, inputActions])
1241
+
1242
+ React.useEffect(() => {
1243
+ if (!sessionId || !inputActions) return undefined
1244
+ selectionActions.set(sessionId, inputActions)
1245
+ return () => {
1246
+ if (selectionActions.get(sessionId) === inputActions) selectionActions.delete(sessionId)
1247
+ }
1248
+ }, [sessionId, inputActions])
1249
+
1250
+ /** 发送意图:把当前 chip 立即同步给服务端(pre-step 读取),并标记发送。 */
1251
+ const flushForSend = () => {
1252
+ if (!sessionId) return
1253
+ const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
1254
+ if (live.length === 0) return
1255
+ syncSelectionContext(sessionId, live, true)
1256
+ for (const item of live) item.sent = true
1257
+ persistSelectionItems()
1258
+ }
1259
+
1260
+ /** 插入/更新一条选段 chip;comment === undefined 表示「不改已有评论」。 */
1261
+ const insertSelection = (text, anchorEl, comment) => {
1262
+ const clean = String(text ?? '').trim().slice(0, CONTEXT_TEXT_MAX)
1263
+ if (!clean) return { status: 'empty' }
1264
+ const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
1265
+ const existing = live.find((entry) => entry.text === clean)
1266
+ if (existing) {
1267
+ if (comment !== undefined) {
1268
+ const next = String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim()
1269
+ if (next !== (existing.comment ?? '')) {
1270
+ existing.comment = next
1271
+ const span = chipSpanOf(snapshotRef.current.occurrences, existing.id, snapshotRef.current.draftRev)
1272
+ if (span) applySelectionInsert(sessionId, existing, span)
1273
+ bump()
1274
+ syncSelectionContext(sessionId, liveSelectionItems(sessionId, snapshotRef.current.occurrences), false)
1275
+ persistSelectionItems()
1276
+ }
1277
+ }
1278
+ return { status: 'dup', id: existing.id, item: existing }
1279
+ }
1280
+ if (live.length >= CONTEXT_MAX) return { status: 'full' }
1281
+ const actions = actionsRef.current
1282
+ if (typeof actions?.captureInsertion !== 'function') return { status: 'failed' }
1283
+ let span
1284
+ try {
1285
+ const insertion = actions.captureInsertion()
1286
+ // 折叠到选区末尾:只插入、不替换用户已选中的草稿文字。
1287
+ span = { start: insertion.end, end: insertion.end, draftRev: insertion.draftRev }
1288
+ } catch { return { status: 'failed' } }
1289
+ // 显示序号按当前输入框里的条目数重排(Trae 每个草稿从 1 开始)。
1290
+ const seq = live.reduce((max, entry) => Math.max(max, entry.seq ?? 0), 0) + 1
1291
+ const id = `${Date.now().toString(36)}${(++selectionIdSeq).toString(36)}`
1292
+ const item = {
1293
+ id, sessionId, seq,
1294
+ text: clean,
1295
+ comment: comment === undefined ? '' : String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim(),
1296
+ anchor: anchorEl instanceof Element ? new WeakRef(anchorEl) : null,
1297
+ sent: false,
1298
+ archived: false,
1299
+ at: Date.now(),
1300
+ }
1301
+ if (!applySelectionInsert(sessionId, item, span)) return { status: 'failed' }
1302
+ selectionItems.set(id, item)
1303
+ bump()
1304
+ syncSelectionContext(sessionId, [...live, item], false)
1305
+ persistSelectionItems()
1306
+ return { status: 'added', id, item }
1307
+ }
1308
+
1309
+ /** 删除一条选段:chip + 条目 + 服务端同步(hover × / 编辑器移除共用)。 */
1310
+ const dropSelectionItem = (item) => {
1311
+ if (!item) return
1312
+ const span = chipSpanOf(snapshotRef.current.occurrences, item.id, snapshotRef.current.draftRev)
1313
+ if (span) applySelectionRemove(item.sessionId, item, span)
1314
+ selectionItems.delete(item.id)
1315
+ bump()
1316
+ syncSelectionContext(item.sessionId, liveSelectionItems(item.sessionId, snapshotRef.current.occurrences), false)
1317
+ persistSelectionItems()
1318
+ }
1319
+
1320
+ React.useEffect(() => {
1321
+ const was = previousPhase.current
1322
+ previousPhase.current = phase
1323
+ if (was !== 'submitting' && phase === 'submitting') flushForSend()
1324
+ }, [phase, sessionId])
1325
+
1326
+ // 发送按钮 / Enter 比 phase 更早、更可靠;window capture 先于宿主监听。
1327
+ React.useEffect(() => {
1328
+ if (!sessionId) return undefined
1329
+ const onClick = (event) => {
1330
+ const target = event.target
1331
+ if (target?.closest?.('button[aria-label*="发送消息"], button[aria-label*="Send"], button[aria-label*="send"]')) flushForSend()
1332
+ }
1333
+ const onKey = (event) => {
1334
+ if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
1335
+ if (!event.target?.closest?.('[contenteditable="true"]')) return
1336
+ flushForSend()
1337
+ }
1338
+ window.addEventListener('click', onClick, true)
1339
+ window.addEventListener('keydown', onKey, true)
1340
+ return () => {
1341
+ window.removeEventListener('click', onClick, true)
1342
+ window.removeEventListener('keydown', onKey, true)
1343
+ }
1344
+ }, [sessionId])
1345
+
1346
+ // 草稿里的 chip 被键盘删掉 / 发送后清空:以 occurrences 为真相修剪条目。
1347
+ React.useEffect(() => {
1348
+ if (!sessionId) return
1349
+ const refs = new Set()
1350
+ for (const occ of occurrences ?? []) if (occ.source === SELECTION_SOURCE) refs.add(occ.ref)
1351
+ let changed = false
1352
+ for (const [id, item] of selectionItems) {
1353
+ if (item.sessionId !== sessionId) continue
1354
+ if (item.archived) {
1355
+ // 草稿恢复出的历史 chip:重新激活,参与同步
1356
+ if (refs.has(id)) { item.archived = false; item.sent = false; changed = true }
1357
+ continue
1358
+ }
1359
+ if (refs.has(id)) continue
1360
+ if (item.sent) { item.archived = true; changed = true; continue }
1361
+ selectionItems.delete(id)
1362
+ changed = true
1363
+ }
1364
+ if (changed) bump()
1365
+ syncSelectionContext(sessionId, liveSelectionItems(sessionId, occurrences), false)
1366
+ persistSelectionItems()
1367
+ }, [occurrences, sessionId])
1368
+
1369
+ // 详情弹窗请求(chip 点击 → source.openReference;添加成功后也会主动打开)。
1370
+ React.useEffect(() => {
1371
+ if (!sessionId) return undefined
1372
+ const listener = () => {
1373
+ const request = selectionDetailRequest
1374
+ if (!request || request.sessionId !== sessionId) return
1375
+ selectionDetailRequest = null
1376
+ const item = selectionItems.get(request.id)
1377
+ if (!item) return
1378
+ setEditor(null)
1379
+ setDialog({ id: request.id })
1380
+ }
1381
+ selectionDetailSubs.add(listener)
1382
+ listener()
1383
+ return () => { selectionDetailSubs.delete(listener) }
1384
+ }, [sessionId])
1385
+
1386
+ // Trae 的两个入口(previewCommentCard):
1387
+ // - 添加到对话:先插 chip,再把选区旁的卡片原位切成评论编辑器(addThenComment);
1388
+ // - 评论到对话:先弹评论卡片,保存后才插 chip(带评论)。
1389
+ React.useEffect(() => {
1390
+ if (!sessionId) return undefined
1391
+ const add = (text, anchorEl, rect) => {
1392
+ const result = insertSelection(text, anchorEl, undefined)
1393
+ if (result.status === 'added' || result.status === 'dup') {
1394
+ const item = result.item ?? (result.id ? selectionItems.get(result.id) : null)
1395
+ if (item) {
1396
+ // Trae addThenComment:chips 已经进输入框,卡片原位切换为评论编辑。
1397
+ setDialog(null)
1398
+ setEditor({
1399
+ id: item.id,
1400
+ text: item.text,
1401
+ comment: item.comment ?? '',
1402
+ anchorEl: anchorEl instanceof Element ? anchorEl : null,
1403
+ rect: normalizeSelectionRect(rect, anchorEl),
1404
+ })
1405
+ }
1406
+ }
1407
+ return result.status
1408
+ }
1409
+ const openComment = (text, anchorEl, rect) => {
1410
+ const clean = String(text ?? '').trim().slice(0, CONTEXT_TEXT_MAX)
1411
+ if (!clean) return 'empty'
1412
+ const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
1413
+ const existing = live.find((entry) => entry.text === clean)
1414
+ if (!existing && live.length >= CONTEXT_MAX) return 'full'
1415
+ setDialog(null)
1416
+ setEditor({
1417
+ id: existing?.id ?? null,
1418
+ text: clean,
1419
+ comment: existing?.comment ?? '',
1420
+ anchorEl: anchorEl instanceof Element ? anchorEl : null,
1421
+ rect: normalizeSelectionRect(rect, anchorEl),
1422
+ })
1423
+ return 'opened'
1424
+ }
1425
+ window.__dshSuperinputAddContext = add
1426
+ window.__dshSuperinputCommentContext = openComment
1427
+ // Trae dragDropMention 同义:拖放进来的引用只插 chip,不弹评论卡
1428
+ //(拖拽是快速引用手势;要评论走划选菜单的「评论到对话」)。
1429
+ window.__dshSuperinputDropContext = (text, anchorEl) => insertSelection(text, anchorEl, undefined)
1430
+ return () => {
1431
+ if (window.__dshSuperinputAddContext === add) delete window.__dshSuperinputAddContext
1432
+ if (window.__dshSuperinputCommentContext === openComment) delete window.__dshSuperinputCommentContext
1433
+ if (window.__dshSuperinputDropContext === insertSelection) delete window.__dshSuperinputDropContext
1434
+ }
1435
+ }, [sessionId])
1436
+
1437
+ // chip 悬停时左侧图标变 ×(Trae remove-mention-icon);点左侧 20px 移除。
1438
+ React.useEffect(() => {
1439
+ if (!sessionId) return undefined
1440
+ const onClick = (event) => {
1441
+ const target = event.target
1442
+ const chip = target?.closest?.('span[title]')
1443
+ if (!chip || !chip.closest?.('[contenteditable="true"]')) return
1444
+ if (!String(chip.title || '').startsWith(CHIP_MARK)) return
1445
+ const rect = chip.getBoundingClientRect()
1446
+ if (event.clientX > rect.left + 20 || !chip.matches(':hover')) return
1447
+ const label = String(chip.title)
1448
+ const item = [...selectionItems.values()].find((entry) => entry.sessionId === sessionId && chipLabelOf(entry) === label)
1449
+ if (!item) return
1450
+ event.preventDefault()
1451
+ event.stopPropagation()
1452
+ dropSelectionItem(item)
1453
+ }
1454
+ document.addEventListener('click', onClick, true)
1455
+ return () => document.removeEventListener('click', onClick, true)
1456
+ }, [sessionId])
1457
+
1458
+ const navigateTo = (item) => {
1459
+ try {
1460
+ const el = item.anchor?.deref?.()
1461
+ if (el?.isConnected) {
1462
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1463
+ el.classList.add('dsh-cp-flash')
1464
+ setTimeout(() => el.classList.remove('dsh-cp-flash'), 1600)
1465
+ }
1466
+ } catch { /* 锚点失效:静默降级 */ }
1467
+ }
1468
+
1469
+ /** 评论卡片保存:已有条目更新评论,新条目插 chip(带评论)。 */
1470
+ const saveEditorComment = (comment) => {
1471
+ const draft = editor
1472
+ if (!draft) return
1473
+ const next = String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim()
1474
+ const existing = draft.id ? selectionItems.get(draft.id) : null
1475
+ if (existing) {
1476
+ if (next !== (existing.comment ?? '')) {
1477
+ existing.comment = next
1478
+ const span = chipSpanOf(snapshotRef.current.occurrences, existing.id, snapshotRef.current.draftRev)
1479
+ if (span) applySelectionInsert(existing.sessionId, existing, span)
1480
+ bump()
1481
+ syncSelectionContext(existing.sessionId, liveSelectionItems(existing.sessionId, snapshotRef.current.occurrences), false)
1482
+ persistSelectionItems()
1483
+ window.__dshSuperinputToast?.('已保存评论')
1484
+ }
1485
+ } else {
1486
+ const result = insertSelection(draft.text, draft.anchorEl, next)
1487
+ if (result.status === 'added' || result.status === 'dup') window.__dshSuperinputToast?.('已添加到对话')
1488
+ else if (result.status === 'full') window.__dshSuperinputToast?.(`最多添加 ${CONTEXT_MAX} 段对话内容`)
1489
+ else if (result.status === 'failed') window.__dshSuperinputToast?.('保存失败')
1490
+ }
1491
+ setEditor(null)
1492
+ }
1493
+
1494
+ const dialogItem = dialog ? selectionItems.get(dialog.id) : null
1495
+ const editorNode = editor ? h(SelectionCommentEditor, {
1496
+ key: `comment:${editor.id ?? 'new'}:${editor.text}`,
1497
+ draft: editor,
1498
+ onSave: saveEditorComment,
1499
+ onCancel: () => setEditor(null),
1500
+ }) : null
1501
+ const detailNode = dialogItem ? createPortal(h(SelectionDetail, {
1502
+ title: `${dialogItem.seq}. ${previewOf(dialogItem.text, 48)}`,
1503
+ text: dialogItem.text,
1504
+ comment: dialogItem.comment,
1505
+ onNavigate: dialogItem.anchor ? () => navigateTo(dialogItem) : null,
1506
+ onClose: () => setDialog(null),
1507
+ }), document.body) : null
1508
+ if (!editorNode && !detailNode) return null
1509
+ return h(React.Fragment, null, editorNode, detailNode)
1510
+ }
1511
+
1512
+ /** 消息正文的选段 chip:user-text 规则把 token 换成同款标签(Trae 消息里的
1513
+ * mention chip 同义)。刷新后条目由 sessionStorage 恢复。 */
1514
+ const selectionUserTextRule = {
1515
+ id: SELECTION_RULE,
1516
+ order: 5,
1517
+ match(text, from) {
1518
+ const re = /\[选段#([0-9a-z]+)\]/g
1519
+ re.lastIndex = from
1520
+ const found = re.exec(text)
1521
+ if (!found) return undefined
1522
+ const item = selectionItems.get(found[1])
1523
+ if (!item) return undefined
1524
+ return {
1525
+ id: SELECTION_RULE,
1526
+ start: found.index,
1527
+ end: found.index + found[0].length,
1528
+ data: { id: item.id, seq: item.seq, text: item.text, comment: item.comment },
1529
+ }
1530
+ },
1531
+ }
1532
+
1533
+ function SelectionMessageChip({ node, interactive }) {
1534
+ const data = node?.data ?? {}
1535
+ const [open, setOpen] = React.useState(false)
1536
+ const label = chipTextOf(data.seq, data.text, data.comment)
1537
+ return h(React.Fragment, null,
1538
+ h('span', {
1539
+ className: `dsh-si-msgchip${interactive ? ' interactive' : ''}`,
1540
+ title: data.text,
1541
+ onClick: interactive ? () => setOpen(true) : undefined,
1542
+ },
1543
+ h('span', { className: 'dsh-si-msgchip-icon', 'aria-hidden': true }),
1544
+ h('span', { className: 'dsh-si-msgchip-label' }, label)),
1545
+ open ? createPortal(h(SelectionDetail, {
1546
+ title: label,
1547
+ text: data.text,
1548
+ comment: data.comment,
1549
+ onClose: () => setOpen(false),
1550
+ }), document.body) : null)
1551
+ }
1552
+
1553
+ /** 草稿桥:划选「添加评论」、模板插入、/ 命令插入、横条「引用文本」共用。
1554
+ * 挂 conversation.input.dock——该槽位会注入 useInput / inputActions;
1555
+ * composer.dock 不注入这些 props。片段模块移除时桥被一并删掉,这里补回。 */
1556
+ function PromptsDraftBridge({ useInput, inputActions }) {
1557
+ const draft = useInput((state) => state?.draft)
1558
+ React.useEffect(() => {
1559
+ window.__dshSuperinputDraft = () => (typeof draft === 'string' ? draft : '')
1560
+ if (typeof inputActions?.setDraft === 'function') {
1561
+ window.__dshSuperinputSetDraft = (value) => {
1562
+ try { inputActions.setDraft(value) } catch { /* ignore */ }
1563
+ }
1564
+ }
1565
+ // 官方「引用文本」同款 API(0.2.2-rc.1 起可用):plain 模式下插 blockquote
1566
+ window.__dshSuperinputInsertQuote = (text) => {
1567
+ try { return typeof inputActions?.insertQuote === 'function' && inputActions.insertQuote(text) === true } catch { return false }
1568
+ }
1569
+ }, [draft, inputActions])
1570
+ return null
1571
+ }
1572
+
626
1573
  function PromptsButton({ onSyncSlash }) {
627
1574
  const [open, setOpen] = React.useState(false)
628
1575
  const ref = React.useRef(null)
@@ -636,7 +1583,7 @@ function createPromptsClient(require) {
636
1583
  }
637
1584
 
638
1585
  return {
639
- inject: ['slots'],
1586
+ inject: ['slots', 'sessions', 'uiConversation'],
640
1587
  apply(ctx) {
641
1588
  ensureStyle()
642
1589
  installSelectionSaver()
@@ -693,6 +1640,70 @@ function createPromptsClient(require) {
693
1640
  id: 'dsh-superinput-prompts-btn',
694
1641
  order: 4,
695
1642
  }, (props) => h(PromptsBoundary, null, h(PromptsButton, { ...props, onSyncSlash: syncSlash }))))
1643
+
1644
+ /* 划选引用的官方 input source:codec 决定 chip 的粘贴文本与送模文本;
1645
+ * candidates 恒空——只是借 @ 触发器的注册位拿到一个合法的自定义
1646
+ * source(官方 chip 插入/序列化按 source 名路由),不往 @ 菜单加条目。 */
1647
+ const selectionSource = {
1648
+ trigger: '@',
1649
+ name: SELECTION_SOURCE,
1650
+ showGroupTitle: false,
1651
+ async candidates() { return [] },
1652
+ onPick() { return undefined },
1653
+ openReference(session, reference) {
1654
+ const targetSession = session?.sessionId === undefined ? '' : String(session.sessionId)
1655
+ const ref = String(reference?.ref ?? '')
1656
+ if (!targetSession || !selectionItems.has(ref)) return false
1657
+ requestSelectionDetail(targetSession, ref, false)
1658
+ return true
1659
+ },
1660
+ codec: {
1661
+ clipboardText: (ref) => tokenOf(ref),
1662
+ serialize: async (ref) => tokenOf(ref),
1663
+ },
1664
+ }
1665
+ ctx.inject(['inputTriggers'], (scope) => {
1666
+ try {
1667
+ const service = scope.get('inputTriggers')
1668
+ ctx.effect(() => service.registerSource(selectionSource), 'dsh-superinput: selection source')
1669
+ } catch (error) {
1670
+ report(`selection source register failed: ${error?.message ?? error}`)
1671
+ }
1672
+ })
1673
+ ctx.inject(['sessions'], (scope) => {
1674
+ selectionSessions = scope.get('sessions')
1675
+ })
1676
+
1677
+ // 消息正文里的同款 chip:user-text 规则 + 节点渲染槽位。
1678
+ ctx.inject(['uiConversation'], (scope) => {
1679
+ try {
1680
+ const ui = scope.get('uiConversation')
1681
+ if (typeof ui?.userText?.register === 'function') {
1682
+ ctx.effect(() => ui.userText.register(selectionUserTextRule), 'dsh-superinput: selection user-text rule')
1683
+ }
1684
+ } catch (error) {
1685
+ report(`selection user-text rule failed: ${error?.message ?? error}`)
1686
+ }
1687
+ })
1688
+ ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({
1689
+ name: 'conversation.user.text.node',
1690
+ key: SELECTION_RULE,
1691
+ priority: 20,
1692
+ }, SelectionMessageChip))
1693
+
1694
+ // 草稿桥挂 input.dock(该槽位注入 useInput / inputActions)
1695
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
1696
+ name: 'conversation.input.dock',
1697
+ id: 'dsh-superinput-prompts-bridge',
1698
+ order: 96,
1699
+ }, PromptsDraftBridge))
1700
+
1701
+ // 划选引用桥:chip 已内联在编辑器里,这个槽位只提供入口与详情弹窗。
1702
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
1703
+ name: 'conversation.input.dock',
1704
+ id: 'dsh-superinput-selection-context',
1705
+ order: 97,
1706
+ }, (props) => h(PromptsBoundary, null, h(SelectionContextBridge, props))))
696
1707
  },
697
1708
  }
698
1709
  }