dsh-config-manager 0.1.73 → 0.1.76

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.
Files changed (49) hide show
  1. package/lib/client.d.ts +8 -4
  2. package/lib/client.js +814 -682
  3. package/lib/core/rollback-ledger.d.ts +29 -3
  4. package/lib/core/rollback-ledger.js +125 -7
  5. package/lib/core/rollback-point.d.ts +221 -3
  6. package/lib/core/rollback-point.js +790 -153
  7. package/lib/index.d.ts +1 -1
  8. package/lib/index.js +1 -1
  9. package/lib/sync/messages.d.ts +1 -0
  10. package/lib/sync/messages.js +3 -0
  11. package/lib/sync/sync-engine.js +56 -7
  12. package/lib/ui/i18n.d.ts +1 -1
  13. package/lib/ui/i18n.js +2 -2
  14. package/lib/utils/bundle-scan.d.ts +5 -3
  15. package/lib/utils/bundle-scan.js +5 -3
  16. package/lib/utils/line-diff.d.ts +7 -1
  17. package/lib/utils/line-diff.js +8 -2
  18. package/package.json +1 -1
  19. package/src/client/chat/ChatEntryView.tsx +38 -9
  20. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  21. package/src/client/chat/chat-entry-view.test.ts +164 -1
  22. package/src/client/chat/chat-entry-view.ts +127 -10
  23. package/src/client/chat/chat-node-registration.ts +40 -6
  24. package/src/client/common/DiffTable.tsx +12 -23
  25. package/src/client/common/host-primitives.ts +78 -0
  26. package/src/client/config-manager.module.css +33 -12
  27. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  28. package/src/client/index.ts +7 -1
  29. package/src/client/library/RestorePlanView.tsx +0 -1
  30. package/src/client/locales.ts +0 -2
  31. package/src/client/recovery/RollbackDiffPane.tsx +13 -6
  32. package/src/client/recovery/recovery-locales.ts +2 -4
  33. package/src/client/recovery/rollback-file-changes.test.ts +37 -1
  34. package/src/client/sync/PushPreviewCard.tsx +57 -0
  35. package/src/client/sync/SyncPanel.tsx +2 -37
  36. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  37. package/src/client/sync/sync-push-preview.test.ts +14 -0
  38. package/src/client/sync/sync-view.ts +9 -1
  39. package/src/core/rollback-ledger.ts +123 -7
  40. package/src/core/rollback-point.test.ts +592 -17
  41. package/src/core/rollback-point.ts +861 -155
  42. package/src/index.ts +1 -1
  43. package/src/sync/messages.ts +3 -0
  44. package/src/sync/sync-engine.ts +55 -7
  45. package/src/sync/sync-preview-push.test.ts +158 -0
  46. package/src/ui/i18n.ts +2 -2
  47. package/src/utils/bundle-scan.ts +5 -3
  48. package/src/utils/line-diff.test.ts +12 -0
  49. package/src/utils/line-diff.ts +9 -3
@@ -42,6 +42,9 @@ import {
42
42
  planChatEntryAttachments,
43
43
  planChatNodeRegistration,
44
44
  projectChatEntryText,
45
+ projectChatEntryTextWithHost,
46
+ chatNodeUserOccupant,
47
+ CHAT_NODE_YIELD_WARN_PREFIX,
45
48
  resetChatPointCache,
46
49
  shouldRetryChatPoint,
47
50
  CHAT_POINT_LOOKUP_WINDOW_MS,
@@ -55,7 +58,7 @@ import {
55
58
  retryChatPointUntilHit,
56
59
  type ChatPointWindowState,
57
60
  } from './chat-point-window.ts'
58
- import { declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
61
+ import { chatNodeOccupantOf, declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
59
62
  import { en as chatEn, zh as chatZh } from './chat-locales.ts'
60
63
  import type { RollbackListResult, RollbackPointPort, RollbackPointSummary } from '../../ui/rollback-view.ts'
61
64
 
@@ -110,6 +113,52 @@ test('registration plan: a keyed declaration registers; anything else is skipped
110
113
  assert.deepEqual(planChatNodeRegistration('list'), { kind: 'skip', reason: 'slot-kind-mismatch', declared: 'list' })
111
114
  })
112
115
 
116
+ /**
117
+ * 让位判据(2026-10-15,跨插件冲突):keyed cell「最低的渲染」——别人 priority 更低时我们必定轮不到渲染,
118
+ * 所以**先读占用再决定注册**:更低 → 让位并告警(不抢格、不静默);相同(只可能是残留的自己)→ 照常注册
119
+ * (真撞上由 try/catch 兜住);宿主那条 priority 0 → 我们 -1 本来就要遮蔽它,当然注册。
120
+ */
121
+ test('registration plan: yields when another renderer already owns the user cell at a LOWER priority', () => {
122
+ const host = { priority: 0, registrant: null }
123
+ const oursOwn = { priority: -1, registrant: 'dsh-config-manager' }
124
+ const theirs = { priority: -2, registrant: '@someone/other-rewind' }
125
+ assert.deepEqual(planChatNodeRegistration('keyed', host), { kind: 'register' })
126
+ assert.deepEqual(planChatNodeRegistration('keyed', oursOwn), { kind: 'register' })
127
+ assert.deepEqual(planChatNodeRegistration('keyed', theirs), {
128
+ kind: 'skip',
129
+ reason: 'shadowed',
130
+ declared: 'keyed',
131
+ occupant: theirs,
132
+ })
133
+ // 形态不符的判据优先于占用(按 keyed 参数注册本来就会抛)
134
+ assert.equal(planChatNodeRegistration('list', theirs).kind, 'skip')
135
+ })
136
+
137
+ test('occupant reader: reads the winning entry per cell from entriesOfSlot shape; anything else is null', () => {
138
+ const entry = (key: string, priority?: number, registrant?: string) => ({
139
+ options: { key, ...(priority === undefined ? {} : { priority }) },
140
+ ...(registrant === undefined ? {} : { registrant }),
141
+ })
142
+ assert.equal(chatNodeUserOccupant(null), null)
143
+ assert.equal(chatNodeUserOccupant({}), null)
144
+ assert.equal(chatNodeUserOccupant([]), null)
145
+ assert.deepEqual(chatNodeUserOccupant([entry('agent-teams', -5)]), null, 'other keys are not our cell')
146
+ // 缺省 priority = 0(宿主缺省),registrant 读不到如实回 null(绝不猜)
147
+ assert.deepEqual(chatNodeUserOccupant([entry('user')]), { priority: 0, registrant: null })
148
+ // 多条同 key:取最低的那条(= 会被渲染的那条)
149
+ assert.deepEqual(
150
+ chatNodeUserOccupant([entry('user', 0, 'host'), entry('user', -2, 'other')]),
151
+ { priority: -2, registrant: 'other' },
152
+ )
153
+ // 非数值 priority 当成缺省 0;空 registrant 归 null
154
+ assert.deepEqual(chatNodeUserOccupant([entry('user', Number.NaN, '')]), { priority: 0, registrant: null })
155
+ // registrant 也可能挂在 options 上(宿主两个位置都读过)
156
+ assert.deepEqual(chatNodeUserOccupant([{ options: { key: 'user', priority: -3, registrant: 'x' } }]), {
157
+ priority: -3,
158
+ registrant: 'x',
159
+ })
160
+ })
161
+
113
162
  test('register shell: keyed declaration registers exactly once; non-keyed declaration is skipped, not thrown', () => {
114
163
  const warn: string[] = []
115
164
  let called = 0
@@ -136,6 +185,31 @@ test('register shell: keyed declaration registers exactly once; non-keyed declar
136
185
  assert.equal(declaredSlotKind({ spec: () => { throw new Error('boom') } }), null)
137
186
  })
138
187
 
188
+ test('register shell: yields (does NOT register) when a lower-priority renderer owns the user cell', () => {
189
+ const warn: string[] = []
190
+ let called = 0
191
+ const disposer = registerChatNodeEntry({
192
+ probe: {
193
+ spec: () => ({ kind: 'keyed', scope: 'session' }),
194
+ entriesOfSlot: () => [{ options: { key: 'user', priority: -2 }, registrant: '@someone/other-rewind' }],
195
+ },
196
+ register: () => { called += 1; return () => {} },
197
+ warn: (message) => { warn.push(message) },
198
+ })
199
+ assert.equal(called, 0, 'we must not fight for a cell another renderer already owns at a lower priority')
200
+ assert.equal(typeof disposer, 'function')
201
+ assert.ok(warn.some((line) => line.startsWith(CHAT_NODE_YIELD_WARN_PREFIX)), 'the yield must be logged, never silent')
202
+ assert.ok(warn.some((line) => line.includes('-2') && line.includes('@someone/other-rewind')))
203
+ })
204
+
205
+ test('occupant probe: missing entriesOfSlot / a throwing probe / a foreign shape all mean “nobody occupies it”', () => {
206
+ assert.equal(chatNodeOccupantOf(undefined), null)
207
+ assert.equal(chatNodeOccupantOf({ spec: () => ({ kind: 'keyed' }) }), null, 'old hosts expose no entriesOfSlot')
208
+ assert.equal(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => { throw new Error('boom') } }), null)
209
+ assert.equal(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => ({ not: 'an array' }) }), null)
210
+ assert.deepEqual(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => [{ options: { key: 'user', priority: 0 } }] }), { priority: 0, registrant: null })
211
+ })
212
+
139
213
  test('register shell: a throwing register() is contained (never takes the plugin down)', () => {
140
214
  const warn: string[] = []
141
215
  const disposer = registerChatNodeEntry({
@@ -204,6 +278,95 @@ test('样式纪律:icon 入口与预览文件行只用既有 token/类,未
204
278
  }
205
279
  })
206
280
 
281
+ /**
282
+ * 底色必须跟随宿主(2026-10-15 用户反馈「浅色主题下用户消息气泡看不见」)。
283
+ *
284
+ * 背景:接管 user 渲染器后我们自复刻那条气泡,底色曾写 --cm-surface-2(= --dsw-alias-bg-layer-2)——
285
+ * 浅色主题下它 = #fff,而页面底 --dsw-alias-bg-base 也是 #fff ⇒ 与页面同色(对比度 0);
286
+ * 宿主真机用的是 --dsw-specific-bubble(浅色 = --dsw-static-deepseek-50 = #edf3fe)。
287
+ * 深色下两者恰好同值,所以这条差距当时被误判成「可接受的近似档」。
288
+ */
289
+ test('气泡底色跟随宿主的 --dsw-specific-bubble(不得再用 --cm-surface-2 近似)', () => {
290
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
291
+ // ① 定义行:宿主 token + **可见**兜底(无兜底的 var() 在宿主删档时会整条失效 → transparent;
292
+ // 兜成 bg-layer-2 也没用 —— 浅色下它同样是 #fff,气泡照样消失)
293
+ const defs = cssSheet.match(/--cm-chat-bubble:\s*var\(--dsw-specific-bubble,\s*color-mix\(in srgb, var\(--cm-text-1\) 12%, transparent\)\)/g) ?? []
294
+ assert.ok(defs.length >= 2, '--cm-chat-bubble 必须在 §1 body 与 .chatEntryScope 各定义一次(且兜底是可见色),实际 ' + String(defs.length) + ' 处')
295
+ assert.equal(
296
+ (cssSheet.match(/--cm-chat-bubble:[^;]*--dsw-alias-bg-layer-2/g) ?? []).length,
297
+ 0,
298
+ '兜底不得用 --dsw-alias-bg-layer-2(浅色下 = #fff ⇒ 等于没兜底)',
299
+ )
300
+ // ② 消费点:.chatBubble 只写 --cm-*(三层单向依赖),且不得再回到 --cm-surface-2
301
+ const bubble = cssSheet.match(/\.chatBubble\s*\{[^}]*\}/)?.[0] ?? ''
302
+ assert.match(bubble, /background:\s*var\(--cm-chat-bubble\)/, '.chatBubble 的底色必须消费 --cm-chat-bubble')
303
+ assert.doesNotMatch(bubble, /--cm-surface-2|--dsw-/, '.chatBubble 不得再用 --cm-surface-2 近似、也不得裸写 --dsw-*')
304
+ // ③ 作用域重声明:聊天流不在插件设置页子树内
305
+ const scope = cssSheet.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
306
+ assert.match(scope, /--cm-chat-bubble:\s*var\(--dsw-specific-bubble/, '.chatEntryScope 必须就地重声明 --cm-chat-bubble')
307
+ })
308
+
309
+ /** 文件卡描边同轮对齐(2026-10-15):宿主 = `.5px solid --dsw-alias-border-l2`,我们此前用更淡的 border-l1。 */
310
+ test('文件卡描边与宿主同档:.5px + --cm-line-2(不是更淡的 --cm-line-1)', () => {
311
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
312
+ // 注释里会出现 token 名(解释为什么改),负向断言必须先剥注释 —— 与 css-token-guard.test.ts 同法
313
+ const card = cssSheet.replace(/\/\*[\s\S]*?\*\//g, '').match(/\.chatFileCard\s*\{[^}]*\}/)?.[0] ?? ''
314
+ assert.match(card, /border:\s*0\.5px solid var\(--cm-line-2\)/, '文件卡描边必须与宿主一致:.5px + --cm-line-2')
315
+ assert.doesNotMatch(card, /--cm-line-1/, '文件卡描边不得再退回更淡的 --cm-line-1')
316
+ const scope = cssSheet.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
317
+ assert.match(scope, /--cm-line-2:\s*var\(--dsw-alias-border-l2\)/, '.chatEntryScope 必须就地重声明 --cm-line-2')
318
+ })
319
+
320
+ /**
321
+ * 宿主正文投影(2026-10-15):气泡正文优先交给宿主自己的 projectUserText(会话引用/技能 → chip)。
322
+ * 四条回落判据必须逐条可证 —— 任一条不成立都回落我们自己的纯文本投影(绝不空白、绝不抛)。
323
+ */
324
+ test('host text projection: four fallback criteria, and every argument goes through verbatim', () => {
325
+ const calls: unknown[][] = []
326
+ // 返回值用字符串(合法的 ReactNode)—— 纯层只要求「非空节点」,chip 的真实形状由宿主决定
327
+ const projector = (text: string, labels: readonly string[], skills: readonly string[], kind: string, references?: { openFile?: unknown; openSkill?: unknown }): string => {
328
+ calls.push([text, labels, skills, kind, references])
329
+ return '[chip]'
330
+ }
331
+ const options = { sessionLabels: ['会话甲'], skillNames: ['i-have-adhd'], openFile: 'of', openSkill: 'os' }
332
+ assert.deepEqual(projectChatEntryTextWithHost(projector, 'raw', options), '[chip]')
333
+ assert.deepEqual(calls[0], ['raw', ['会话甲'], ['i-have-adhd'], 'skill', { openFile: 'of', openSkill: 'os' }])
334
+ // ① 没有投影器(老宿主模块表里没有 ui-primitives)
335
+ assert.equal(projectChatEntryTextWithHost(null, 'raw', options), null)
336
+ assert.equal(projectChatEntryTextWithHost(undefined, 'raw', options), null)
337
+ // ② 原文为空
338
+ assert.equal(projectChatEntryTextWithHost(projector, '', options), null)
339
+ // ③ 投影自己抛错
340
+ assert.equal(projectChatEntryTextWithHost(() => { throw new Error('boom') }, 'raw', options), null)
341
+ // ④ 投影拒绝了这段文本(null / undefined / false)
342
+ assert.equal(projectChatEntryTextWithHost(() => null, 'raw', options), null)
343
+ assert.equal(projectChatEntryTextWithHost(() => undefined, 'raw', options), null)
344
+ assert.equal(projectChatEntryTextWithHost(() => false, 'raw', options), null)
345
+ // 缺省选项也要把完整参数交给投影器(空数组 + 空打开器),绝不缺参
346
+ calls.length = 0
347
+ assert.deepEqual(projectChatEntryTextWithHost(projector, 'x'), '[chip]')
348
+ assert.deepEqual(calls[0], ['x', [], [], 'skill', { openFile: undefined, openSkill: undefined }])
349
+ })
350
+
351
+ test('bubble keeps the UNprojected text for the host projector and the projected text as the fallback', () => {
352
+ const view = buildChatEntryBubble([{ type: 'text', text: '看这条 @[会话甲](dsh-session:s9) 引用' }])
353
+ assert.equal(view.text, '看这条 会话甲 引用')
354
+ assert.equal(view.rawText, '看这条 @[会话甲](dsh-session:s9) 引用')
355
+ // 多个文本块:两边都按 content 顺序拼接(非文本块不参与拼接)
356
+ const multi = buildChatEntryBubble([
357
+ { type: 'text', text: 'A' },
358
+ { type: 'image', attachment: { id: 'i' } },
359
+ { type: 'text', text: 'B' },
360
+ ])
361
+ assert.equal(multi.text, 'AB')
362
+ assert.equal(multi.rawText, 'AB')
363
+ // 怪形状:字符串 content 两边都有值;空 content 两边都是空串(不编造)
364
+ assert.deepEqual([buildChatEntryBubble('hi').text, buildChatEntryBubble('hi').rawText], ['hi', 'hi'])
365
+ const empty = buildChatEntryBubble(null)
366
+ assert.equal(empty.text, '')
367
+ assert.equal(empty.rawText, '')
368
+ })
369
+
207
370
  /* ------------------------------------------------------------ ⑤ 回合号只认可核实字段 */
208
371
 
209
372
  test('turn number: only node.location.kind turn|step counts (same read as the host turnTail)', () => {
@@ -45,20 +45,63 @@ export const CHAT_NODE_USER_KEY = 'user'
45
45
  */
46
46
  export const CHAT_NODE_PRIORITY = -1
47
47
 
48
+ /**
49
+ * 同一个 cell(key=user)当前**会被渲染的那一条**(= priority 最小的那一条)。
50
+ *
51
+ * 为什么要读它(2026-10-15,跨插件冲突排查的落地):`conversation.chat.node` 是 keyed 遮蔽位,
52
+ * 「最低的渲染」——别的插件若也用 key=user 且 priority 比我们更低(≤ -2),它会赢而我们**静默失效**
53
+ * (框架不报错)。所以我们在注册**之前**读一次占用:赢家比我们更低就让位并告警,不去抢。
54
+ */
55
+ export interface ChatNodeOccupant {
56
+ /** 该条的 priority(宿主缺省 0)。 */
57
+ priority: number
58
+ /** 诊断用标识(宿主用 fiber 名盖的戳);读不到回 null,**绝不猜**。 */
59
+ registrant: string | null
60
+ }
61
+
62
+ /** 从 `entriesOfSlot('conversation.chat.node')` 的返回值里读 user cell 的赢家(形状不符一律 null)。 */
63
+ export function chatNodeUserOccupant(entries: unknown): ChatNodeOccupant | null {
64
+ if (!Array.isArray(entries)) return null
65
+ let winner: ChatNodeOccupant | null = null
66
+ for (const entry of entries) {
67
+ const record = asRecord(entry)
68
+ const options = asRecord(record?.['options'])
69
+ if (options === null || options['key'] !== CHAT_NODE_USER_KEY) continue
70
+ const raw = options['priority']
71
+ const priority = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
72
+ const label = record?.['registrant'] ?? options['registrant']
73
+ const registrant = typeof label === 'string' && label !== '' ? label : null
74
+ if (winner === null || priority < winner.priority) winner = { priority, registrant }
75
+ }
76
+ return winner
77
+ }
78
+
48
79
  /**
49
80
  * 槽位登记计划:**只在 0.2 的 keyed 形态时接管**(2026-10-12:入口只剩「每条用户消息」一种形态,
50
81
  * 旧的「整回合」档与偏好门已随 chat-entry-pref / chat-entry-mount 一起删除)。
51
82
  *
52
83
  * 跳过必须是**失败软化**(只丢这一个入口,不动本插件其余槽位、不动宿主启动):
53
84
  * · `slot-kind-mismatch`:0.1.x 的 ui-chat 把同一 key 声明成别的形态(对照 turnTail 的
54
- * list/chain 双形态实测),按 keyed 参数(key/priority)注册会抛。
85
+ * list/chain 双形态实测),按 keyed 参数(key/priority)注册会抛;
86
+ * · `shadowed`(2026-10-15):user cell 已被**更低优先级**的条目占住 —— 我们注册也永远轮不到渲染
87
+ * (「最低的渲染」),不如让位并如实告警(不静默、也不去抢别人的格子)。
88
+ *
89
+ * 注意 `occupant.priority === CHAT_NODE_PRIORITY` **不算遮蔽**:同一 cell 同 priority 只可能有一条
90
+ * (再注册会抛,由登记处的 try/catch 兜住),所以那时该试的是注册而不是让位。
55
91
  */
56
92
  export type ChatNodeRegistrationPlan =
57
93
  | { kind: 'register' }
58
94
  | { kind: 'skip'; reason: 'slot-kind-mismatch'; declared: string | null }
95
+ | { kind: 'skip'; reason: 'shadowed'; declared: string | null; occupant: ChatNodeOccupant }
59
96
 
60
- export function planChatNodeRegistration(declaredKind: string | null): ChatNodeRegistrationPlan {
97
+ export function planChatNodeRegistration(
98
+ declaredKind: string | null,
99
+ occupant: ChatNodeOccupant | null = null,
100
+ ): ChatNodeRegistrationPlan {
61
101
  if (declaredKind !== 'keyed') return { kind: 'skip', reason: 'slot-kind-mismatch', declared: declaredKind }
102
+ if (occupant !== null && occupant.priority < CHAT_NODE_PRIORITY) {
103
+ return { kind: 'skip', reason: 'shadowed', declared: declaredKind, occupant }
104
+ }
62
105
  return { kind: 'register' }
63
106
  }
64
107
 
@@ -151,6 +194,11 @@ export type ChatEntryAttachment =
151
194
  export interface ChatEntryBubbleView {
152
195
  /** 文本块拼接结果(宿主 `texts.join('')`,逐字保留换行与空白;会话引用的线格式已投影成 label)。 */
153
196
  text: string
197
+ /**
198
+ * **未投影**的文本拼接(会话线格式原样保留)—— 只给宿主 `projectUserText` 用:
199
+ * 它自己认 `@[label](dsh-session:…)` 并把引用/技能渲染成 chip;我们自己的 `text` 是它的兜底。
200
+ */
201
+ rawText: string
154
202
  /** **有序附件列表**:渲染层必须按这个顺序输出(宿主不区分「先图片后文件」)。 */
155
203
  attachments: ChatEntryAttachment[]
156
204
  /** 图片附件(= `attachments` 里的 image 项,顺序一致;用于计数与「无渲染位」提示)。 */
@@ -244,11 +292,12 @@ export function formatChatEntryClock(time: number | null): string | null {
244
292
  export const CHAT_SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
245
293
 
246
294
  /**
247
- * 正文的**最小文本投影**:把会话引用线格式换成它的人类可读 label。
295
+ * 正文的**最小文本投影**(宿主投影不可用时的兜底):把会话引用线格式换成它的人类可读 label。
248
296
  *
249
- * 宿主做的是「换成 chip 节点」(`projectUserText`,还会把裸 `@name` 也装饰成 chip、把引用汇总成
250
- * 一行 referenceSummary);我们**不做 chip**(ui-primitives 包私有、不可导入,见 G-39 已追认差距),
251
- * 但**绝不把线格式原样打给用户看** —— 那是机器的编码,正常人读不了(t4 复核 D5,medium)。
297
+ * 2026-10-15 起:气泡正文**优先走宿主自己的** `projectUserText`(见 `projectChatEntryTextWithHost`
298
+ * —— 它能把会话引用与技能渲染成 chip,并在给了打开器时可点开);本函数是**兜底与纯文本路径**
299
+ * (复制按钮、错误边界降级、老宿主没有原语包时)的唯一实现:任何情况下都**不把线格式原样打给用户看**
300
+ * (那是机器的编码,正常人读不了 —— t4 复核 D5,medium)。
252
301
  * 与宿主一致的边界:只认 `dsh-session:` 的括号形式;`g` flag 下逐处替换,正则自身 lastIndex 置零。
253
302
  */
254
303
  export function projectChatEntryText(text: string): string {
@@ -257,6 +306,61 @@ export function projectChatEntryText(text: string): string {
257
306
  return text.replace(CHAT_SESSION_WIRE_RE, '$1')
258
307
  }
259
308
 
309
+ /**
310
+ * 宿主 `projectUserText` 的**可注入面**(宽类型:本仓库不引用 ui-primitives 的类型,也不 import 它)。
311
+ * 返回值是不透明 ReactNode(chip 节点等);缺省 null = 这台宿主没有该能力。
312
+ */
313
+ export type ChatEntryTextProjector = (
314
+ text: string,
315
+ sessionLabels: readonly string[],
316
+ slashNames: readonly string[],
317
+ slashKind: string,
318
+ references?: { openFile?: unknown; openSkill?: unknown },
319
+ ) => ReactNode
320
+
321
+ /** 宿主投影的入参(全部来自节点 data 与 owner props;缺省即空)。 */
322
+ export interface ChatEntryHostTextOptions {
323
+ /** `data.referenceLabels`(引用会话的 label 列表)。 */
324
+ sessionLabels?: readonly string[]
325
+ /** `data.skillNames`(宿主把它们渲染成 chip —— 我们此前一个元素都不产出)。 */
326
+ skillNames?: readonly string[]
327
+ /** 宿主给的文件 / 技能打开器(给了才能点开 chip;没有就只显示 chip 外观)。 */
328
+ openFile?: unknown
329
+ openSkill?: unknown
330
+ }
331
+
332
+ /**
333
+ * 用**宿主自己**的 `projectUserText` 投影正文(2026-10-15:按生态既有做法补齐 G-39 差距①)。
334
+ *
335
+ * 实证(为什么这条现在成立):ui-primitives 由 DSH shell 的模块表作为 static module seed 预置,
336
+ * 插件 bundle 的 `require` 能命中 —— 第三方插件 dsh-reedit 就是这么拿 `projectUserText` 的,
337
+ * 本仓的 `RollbackFileIcon` 也早就这样拿 `FileTypeIcon`。**只能帧内惰取**(顶层 import 在老宿主
338
+ * 会打挂整个浏览器半加载),入口在 `common/host-primitives.ts`。
339
+ *
340
+ * 四条判据缺一不可(本函数**绝不抛**,任一条不满足就回 null ⇒ 调用方回落 `projectChatEntryText`):
341
+ * ① 有投影器(老宿主没有原语包 → null);② 原文非空;
342
+ * ③ 投影不抛错;④ 投影返回**非空节点**(null/undefined/false = 宿主拒绝了这段文本)。
343
+ *
344
+ * 为什么投影要当参数传进来:纯层与单测都不认识模块表 —— 抽成参数后,上面四条判据能逐条覆盖。
345
+ */
346
+ export function projectChatEntryTextWithHost(
347
+ projector: ChatEntryTextProjector | null | undefined,
348
+ rawText: string,
349
+ options: ChatEntryHostTextOptions = {},
350
+ ): ReactNode | null {
351
+ if (typeof projector !== 'function') return null
352
+ if (rawText === '') return null
353
+ try {
354
+ const node = projector(rawText, options.sessionLabels ?? [], options.skillNames ?? [], 'skill', {
355
+ openFile: options.openFile,
356
+ openSkill: options.openSkill,
357
+ })
358
+ return node === null || node === undefined || node === false ? null : node
359
+ } catch {
360
+ return null
361
+ }
362
+ }
363
+
260
364
  /**
261
365
  * 扩展名 → 文件卡上的短徽标:宿主 `fileExtension` + 宿主那行 meta 的同一截断
262
366
  * (`fileExtension(name).toUpperCase().slice(0, 8)`);后缀为空(无点 / 点在末尾)回 null。
@@ -310,12 +414,13 @@ export function fileSizeTextOf(bytes: number | null): string | null {
310
414
  export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
311
415
  if (typeof content === 'string') {
312
416
  const text = projectChatEntryText(content)
313
- return { text, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
417
+ return { text, rawText: content, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
314
418
  }
315
419
  if (!Array.isArray(content)) {
316
- return { text: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
420
+ return { text: '', rawText: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
317
421
  }
318
422
  const texts: string[] = []
423
+ const rawTexts: string[] = []
319
424
  const attachments: ChatEntryAttachment[] = []
320
425
  const raw: ChatEntryRawBlock[] = []
321
426
  content.forEach((block, index) => {
@@ -323,6 +428,8 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
323
428
  const type = record?.['type']
324
429
  if (type === 'text' && typeof record?.['text'] === 'string') {
325
430
  // 宿主把这条文本交给 projectUserText(会话引用渲染成 chip);我们做**最小文本投影**:线格式 → label
431
+ // (rawTexts 同时留一份未投影原文,宿主投影可用时优先用它 —— 见 ChatEntryBubbleView.rawText)
432
+ rawTexts.push(record['text'] as string)
326
433
  texts.push(projectChatEntryText(record['text'] as string))
327
434
  return
328
435
  }
@@ -347,10 +454,11 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
347
454
  raw.push({ id: String(index), payload: block })
348
455
  })
349
456
  const text = texts.join('')
457
+ const rawText = rawTexts.join('')
350
458
  // images / files 是 attachments 的**派生视图**(不独立积累,避免两处真相与顺序漂移)
351
459
  const images = attachments.flatMap((item) => (item.kind === 'image' ? [item.image] : []))
352
460
  const files = attachments.flatMap((item) => (item.kind === 'file' ? [item.file] : []))
353
- return { text, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
461
+ return { text, rawText, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
354
462
  }
355
463
 
356
464
  /** 渲染计划的一项:图片带 owner 载荷,文件带名称/扩展名/体积文本。 */
@@ -517,7 +625,10 @@ export interface ChatEntryView {
517
625
  bubble: ChatEntryBubbleView
518
626
  /** 动作行的时间文本(null = 节点没给可用的时间戳)。 */
519
627
  clock: string | null
520
- /** 宿主 data 的另外两个字段(我们**不**接线到 ui-primitives,渲染层如实记成差距)。 */
628
+ /**
629
+ * 宿主 data 的另外两个字段:`referenceLabels` 进引用汇总行;两者都作为 `projectUserText`
630
+ * 的入参传给**宿主的**正文投影(2026-10-15 起 —— 此前它们只是「如实记下的差距」)。
631
+ */
521
632
  referenceLabels: string[]
522
633
  skillNames: string[]
523
634
  }
@@ -600,6 +711,12 @@ export function rawBlockText(payload: unknown): string {
600
711
  /** 错误边界的 ASCII 诊断前缀(console.* 实参不触发 i18n 守卫;界面文案一律在字典里)。 */
601
712
  export const CHAT_ENTRY_WARN_PREFIX = '[config-manager] chat user node renderer degraded to plain text: '
602
713
 
714
+ /**
715
+ * 让位告警的 ASCII 前缀(诊断用,绝不当作界面文案)—— 见 `planChatNodeRegistration` 的 `shadowed`:
716
+ * 别的插件用更低优先级占住了 key=user 时,我们**不注册**(注册也永远轮不到渲染),只留这条日志。
717
+ */
718
+ export const CHAT_NODE_YIELD_WARN_PREFIX = '[config-manager] chat user node entry yielded: another renderer owns key "user" at a lower priority: '
719
+
603
720
  /* ------------------------------------------------------------ 回滚点惰取(按会话缓存 + 在途去重) */
604
721
 
605
722
  /** 缓存 TTL:同一会话里的所有按钮共用一次列表读取;跨分钟自然过期(列表变化由宿主写,非热点)。 */
@@ -9,11 +9,23 @@
9
9
  * (`chat-entry-pref` / `chat-entry-mount`)与回合尾入口(`conversation.chat.turnTail`)一并删除 ——
10
10
  * 入口恒为每条用户消息气泡里的那一颗,于是这里不再有偏好门,也不再需要「切档时重新评估」的订阅。
11
11
  */
12
- import { CHAT_NODE_SLOT, planChatNodeRegistration } from './chat-entry-view.ts'
12
+ import {
13
+ CHAT_NODE_SLOT,
14
+ CHAT_NODE_YIELD_WARN_PREFIX,
15
+ chatNodeUserOccupant,
16
+ planChatNodeRegistration,
17
+ type ChatNodeOccupant,
18
+ } from './chat-entry-view.ts'
13
19
 
14
- /** 槽位声明形态探测面(`ctx.slots`):只读 spec,宽类型读回 kind。 */
20
+ /** 槽位探测面(`ctx.slots`):读声明的 kind,以及该 cell 当前会被渲染的那一条。 */
15
21
  export interface ChatNodeSlotProbe {
22
+ /** 只读 spec(宽类型读回 kind)。 */
16
23
  spec: (key: string) => unknown
24
+ /**
25
+ * 每个 cell 当前会被渲染的那一条(SlotRegistry.entriesOfSlot)。
26
+ * **可选**:老宿主没有这个方法(那时不做占用探测,照常注册)。
27
+ */
28
+ entriesOfSlot?: (key: string) => unknown
17
29
  }
18
30
 
19
31
  /** 诊断(ASCII,与界面文案无关;console.* 实参不触发 i18n 守卫)。 */
@@ -33,12 +45,24 @@ export function declaredSlotKind(probe: ChatNodeSlotProbe | undefined | null): s
33
45
  }
34
46
  }
35
47
 
48
+ /** user cell 的当前赢家(没有探测面 / 形状不符 / 探测抛错都回 null —— 探测失败不当成「有人占」)。 */
49
+ export function chatNodeOccupantOf(probe: ChatNodeSlotProbe | undefined | null): ChatNodeOccupant | null {
50
+ if (probe === undefined || probe === null || typeof probe.entriesOfSlot !== 'function') return null
51
+ try {
52
+ return chatNodeUserOccupant(probe.entriesOfSlot(CHAT_NODE_SLOT))
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
36
58
  /**
37
59
  * 登记「接管 conversation.chat.node / user」的渲染器。
38
60
  *
39
- * 两道门(顺序即优先级):
61
+ * 三道门(顺序即优先级):
40
62
  * ① 声明形态不是 `keyed`(0.1.x 的 ui-chat 契约不同)→ 跳过(按 keyed 参数注册会抛);
41
- * ② 注册本身抛错 → 吞掉并告警(只丢这一个入口)。
63
+ * ② **user cell 已被更低优先级占住**(别的插件也在接管用户消息,2026-10-15)→ 让位并如实告警:
64
+ * 注册也永远轮不到渲染(keyed cell「最低的渲染」),不抢别人的格子、也不静默;
65
+ * ③ 注册本身抛错 → 吞掉并告警(只丢这一个入口)。
42
66
  *
43
67
  * @returns disposer;跳过 / 失败时是 no-op。
44
68
  */
@@ -51,8 +75,18 @@ export function registerChatNodeEntry(options: {
51
75
  }): () => void {
52
76
  const warn = options.warn ?? warnChatNode
53
77
  const declared = declaredSlotKind(options.probe)
54
- if (planChatNodeRegistration(declared).kind === 'skip') {
55
- warn('[config-manager] chat user node entry skipped: slot declaration is ' + (declared === null ? 'missing' : '"' + declared + '"') + ' (expected "keyed")')
78
+ const plan = planChatNodeRegistration(declared, chatNodeOccupantOf(options.probe))
79
+ if (plan.kind === 'skip') {
80
+ if (plan.reason === 'shadowed') {
81
+ warn(
82
+ CHAT_NODE_YIELD_WARN_PREFIX +
83
+ 'priority ' + String(plan.occupant.priority) +
84
+ (plan.occupant.registrant === null ? '' : ' (registered by ' + plan.occupant.registrant + ')') +
85
+ ' — entry not registered',
86
+ )
87
+ } else {
88
+ warn('[config-manager] chat user node entry skipped: slot declaration is ' + (declared === null ? 'missing' : '"' + declared + '"') + ' (expected "keyed")')
89
+ }
56
90
  return () => {}
57
91
  }
58
92
  try {
@@ -13,18 +13,20 @@
13
13
  * · 内容栏只在增删行上着色(`.diffCellAdd` / `.diffCellDel`,12% 混色),上下文行不上色;
14
14
  * · 两栏之间一条竖分割线(块头与正文都要有)。
15
15
  *
16
+ * 规模:**全部画出来,不截断**(2026-10-15 用户:「为什么最多只能加载 400 行?我需要能完整的加载」)。
17
+ * 到这里的 hunks 已经是宿主给的**全部**内容(单文件字节上限在宿主侧:回滚预览 1 MiB、恢复计划 maxSideBytes),
18
+ * 渲染层再砍一刀只会让同一份数据在界面上少半截,而用户没有任何办法看到剩下的
19
+ *(2026-10-14 起那道「最多 400 行对」的上限已删除,连文案一起去掉)。
20
+ *
16
21
  * 分工:行对对齐 / 块头 / 行号列宽 / 行数统计全来自 `src/ui/diff-view.ts`(纯函数、node 单测);本组件只画。
17
22
  * 安全:单元格文本是磁盘 / 宿主原文 ⇒ 展示前**必须**过调用方传进来的 sanitize(本该是 redact)。
18
23
  */
19
24
  import { Fragment } from 'react'
20
25
  import { redact } from '../../security/redaction.ts'
21
- import { countSideRows, diffLineCounts, maxLineDigits, toSideBySide, type SideBySideRow } from '../../ui/diff-view.ts'
26
+ import { diffLineCounts, maxLineDigits, toSideBySide, type SideBySideRow } from '../../ui/diff-view.ts'
22
27
  import type { DiffHunk } from '../../utils/line-diff.ts'
23
28
  import css from '../config-manager.module.css'
24
29
 
25
- /** 单文件最多渲染多少行对:diff 上限 6000 行,全量渲染会卡住弹窗(数据完整,只是没画出来)。 */
26
- export const DIFF_ROW_CAP = 400
27
-
28
30
  export interface DiffTableProps {
29
31
  hunks: readonly DiffHunk[]
30
32
  /** 左侧列头(before = 当前) */
@@ -36,12 +38,8 @@ export interface DiffTableProps {
36
38
  * 调用方**已在别处写过这份路径**(恢复计划行、聊天预览的路径行)时**不要传** —— 同一路径不画两遍。
37
39
  */
38
40
  title?: string
39
- /** 被渲染上限截断时的说明文案(由调用方按自己的字典出话;缺省 = 不提示) */
40
- truncatedNotice?: (shown: number, total: number) => string
41
41
  /** 单元格文本安全化(**缺省就是 redact**:调用方只能换成别的脱敏器,换不成不脱敏) */
42
42
  sanitize?: (text: string) => string
43
- /** 渲染上限(缺省 DIFF_ROW_CAP) */
44
- rowCap?: number
45
43
  }
46
44
 
47
45
  /** 左侧单元格:成对修改 / 纯删除 → 红底;上下文 → 中性。 */
@@ -65,17 +63,13 @@ function rightNoClass(kind: SideBySideRow['kind']): string {
65
63
  }
66
64
 
67
65
  /** 左右双栏逐行对照(文件头 + 表头 + 块头 + 行号 + 单元格)。 */
68
- export function DiffTable({ hunks, beforeLabel, afterLabel, title, truncatedNotice, sanitize, rowCap = DIFF_ROW_CAP }: DiffTableProps) {
66
+ export function DiffTable({ hunks, beforeLabel, afterLabel, title, sanitize }: DiffTableProps) {
69
67
  const cell = sanitize ?? ((text: string): string => redact(text))
70
68
  const groups = toSideBySide(hunks)
71
- const total = countSideRows(groups)
72
69
  const counts = diffLineCounts(hunks)
73
70
  // 行号列宽 = 实际出现的最大行号位数(1–3/4/5/6+ 位四档 ch),内容列平分剩余空间
74
71
  const digits = maxLineDigits(hunks)
75
72
  const noColClass = digits <= 3 ? css.diffNoColW3 : digits === 4 ? css.diffNoColW4 : digits === 5 ? css.diffNoColW5 : css.diffNoColW6
76
- let rendered = 0
77
- const capped = total > rowCap
78
- const notice = capped && truncatedNotice !== undefined ? truncatedNotice(rowCap, total) : null
79
73
  return (
80
74
  <>
81
75
  {title !== undefined && (
@@ -88,7 +82,6 @@ export function DiffTable({ hunks, beforeLabel, afterLabel, title, truncatedNoti
88
82
  </span>
89
83
  </div>
90
84
  )}
91
- {notice !== null && <div className={css.hint}>{notice}</div>}
92
85
  <div className={css.diffScroll}>
93
86
  <table className={css.diffTable}>
94
87
  <colgroup>
@@ -104,17 +97,14 @@ export function DiffTable({ hunks, beforeLabel, afterLabel, title, truncatedNoti
104
97
  </tr>
105
98
  </thead>
106
99
  <tbody>
107
- {groups.map((group) => {
108
- const rows = capped ? group.rows.slice(0, Math.max(0, rowCap - rendered)) : group.rows
109
- rendered += rows.length
110
- return (
111
- <Fragment key={group.header}>
100
+ {groups.map((group) => (
101
+ <Fragment key={group.header}>
112
102
  {/* 块头:两栏各一格、同一段文本(编辑器式单文件 diff 的观感),中间那条竖线由 CSS 补 */}
113
103
  <tr className={css.diffHunkRow}>
114
104
  <td colSpan={2}>{group.header}</td>
115
105
  <td colSpan={2}>{group.header}</td>
116
106
  </tr>
117
- {rows.map((row, index) => (
107
+ {group.rows.map((row, index) => (
118
108
  <tr key={group.header + '-' + String(index)}>
119
109
  <td className={leftNoClass(row.kind)}>{row.left?.oldNo ?? ''}</td>
120
110
  <td className={leftCellClass(row.kind)}>{row.left === null ? '' : cell(row.left.text)}</td>
@@ -122,9 +112,8 @@ export function DiffTable({ hunks, beforeLabel, afterLabel, title, truncatedNoti
122
112
  <td className={rightCellClass(row.kind)}>{row.right === null ? '' : cell(row.right.text)}</td>
123
113
  </tr>
124
114
  ))}
125
- </Fragment>
126
- )
127
- })}
115
+ </Fragment>
116
+ ))}
128
117
  </tbody>
129
118
  </table>
130
119
  </div>