dsh-edit-turn 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -277,6 +277,26 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
277
277
 
278
278
  ### 更新日志
279
279
 
280
+ **0.5.8** —— 复制交给**平台写入器这一层**兜底:不管按下走哪条路,落到剪贴板的都是改写后的文本。
281
+
282
+ - **0.5.7 没修好**:那一版把"回答按下"从按钮节点挪到了文档级捕获监听(按行匹配,节点被重画也不影响),但实测仍然复制出原文。说明这次按下**根本没有经过本插件能拦到的按钮**——具体是哪个环节,从外部看不出来(没有可用的浏览器来读真实 DOM)。
283
+
284
+ - **所以换到更低的层**:`navigator.clipboard.writeText` 是**所有**复制都要经过的唯一关口——宿主自己的 `writeClipboard` 原语内部就是调它。本插件把这一层包起来:写入的文本如果正是某条被改写消息的**原文**(按行登记,空白归一化后比较),就换成改写后的文本;其余写入一字不动。
285
+
286
+ - **登记表放在页级**(`globalThis` 上的一个 Symbol),不是模块变量:实时重载会生成新的 bundle,而**旧 bundle 装的包装器还在写入路径上**(包装本身是幂等的)。两份 Map 会让包装器读到一个没人填的登记表。
287
+
288
+ - 新增测试:宿主**自己**写它那份原文(不经过本插件的按钮接管)时,落地的是改写后的文本;与改写无关的复制原样通过。测试台补了 `navigator.clipboard` 与 `childNodes`。
289
+
290
+ **0.5.7** —— 改写过的行,复制按钮被宿主重画之后仍然交出改写后的文本。
291
+
292
+ - **问题**:编辑过一条消息(提示词走官方 surface 替换),界面上显示的是改写后的文本,但点那一排的**复制**按钮,粘出来的是**改写前**的原文。
293
+
294
+ - **原因**:宿主自己的复制按钮复制的是"这一行被画出来时的那条消息",所以改写过的行需要由本插件来回答这次按下。原来的做法是**在那个按钮节点上**装一个捕获期监听——只要节点还在就有效。但动作条是宿主的 React 画的,任何一次重渲染都可能换掉那个按钮节点(时钟、悬停、流式更新都会触发),监听就跟着旧节点一起没了;于是这次按下落到宿主自己的处理上,复制出原文。同一排的删除 / 重跑 / 编辑不受影响,因为它们本来就是本插件自己注入的节点。
295
+
296
+ - **修法**:改由**文档级捕获期监听**按"行"来回答——命中的是 `button.closest('[data-chat-flow-key]')` 那一行,取的是本插件维护的"这一行该交出的文本",与按钮是不是同一个节点无关。捕获期在 React 根节点之前,所以宿主自己的处理根本看不到这次按下;本插件自己注入的按钮按标记排除,不会被误当成复制。
297
+
298
+ - 新增测试:**宿主重渲染后换上全新的复制按钮**(旧节点上没有任何本插件的标记),按下它仍然交出改写后的文本,且宿主自己的处理拿不到这次按下。
299
+
280
300
  **0.5.6** —— 裁剪工具改为"先验后写":用宿主自己的校验器验,验不过就不落盘。
281
301
 
282
302
  - **校验不能自己写一份**:日志格式的规则全在宿主里,而且分三层——格式层(`dsh-session-format-v3-to-v4`)、会话层(`dsh-session` 的逐条准入与表面折叠)、投影层(`dsh-agent-loop` 的 inbox 拼接)。这次做裁剪时自己按理解写校验,结果是**连续八次被宿主拒收**:`title messageSeqs must name an earlier event`、`delivery throughSeq must precede its marker`、`system/message does not match an open turn and step`、`request/header is outside an open turn`、`turn/start does not open the expected turn`、`compaction shadowedSeqs do not name an exact current surface span`、`format v4 event N is not dense`、`successful compaction/end requires one summary`。
package/lib/client.js CHANGED
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
45
45
  const RERUN_PREFIX = '/dsh-rerun-turn'
46
46
 
47
47
  /** Keep in sync with package.json and lib/index.js. */
48
- const PLUGIN_VERSION = '0.5.6'
48
+ const PLUGIN_VERSION = '0.5.8'
49
49
 
50
50
  // --- copy -----------------------------------------------------------------
51
51
 
@@ -1937,6 +1937,39 @@ window.__ModuleLoader__.load({
1937
1937
  const COPY_TIMER = 'dshetCopyTimer'
1938
1938
  /** The host draws its own check for a second; this is the same second. */
1939
1939
  const COPY_FEEDBACK_MS = 1000
1940
+ /** 标记:这一页的剪贴板写入已经被本半部包过一次(重载会复用同一个 page)。 */
1941
+ const CLIPBOARD_WRAPPED = Symbol.for('dshetClipboardWrapped')
1942
+ /** 页级登记表:重载后新 bundle 与旧包装器共用同一份。 */
1943
+ const COPY_REGISTRY = Symbol.for('dshetCopyRegistry')
1944
+ /**
1945
+ * Row key -> the text that row's copy button must hand over.
1946
+ *
1947
+ * The host's copy button copies the message the row was drawn for, so on a row
1948
+ * this plugin rewrote it would copy the wording the user just replaced. The
1949
+ * press is answered from a document-level listener keyed by ROW rather than by
1950
+ * button node: the host re-renders its action bar, and a listener planted on
1951
+ * the button it drew goes away with that node (the button it draws next is a
1952
+ * different one).
1953
+ */
1954
+ /**
1955
+ * Row key -> the text that row's copy must hand over, and the wording the host
1956
+ * would hand over instead.
1957
+ *
1958
+ * Both live on the PAGE rather than in this module: a live reload builds a new
1959
+ * bundle with new maps, and the clipboard wrapper the previous bundle installed
1960
+ * is still the one in the write path (wrapping is idempotent, by design). Two
1961
+ * maps would leave that wrapper reading a registry nobody fills.
1962
+ */
1963
+ const copyRegistry = (() => {
1964
+ const page = typeof globalThis !== 'undefined' ? globalThis : {}
1965
+ const existing = page[COPY_REGISTRY]
1966
+ if (existing !== undefined && existing !== null && existing.text instanceof Map) return existing
1967
+ const created = { text: new Map(), host: new Map() }
1968
+ page[COPY_REGISTRY] = created
1969
+ return created
1970
+ })()
1971
+ const rowCopyText = copyRegistry.text
1972
+ const rowHostText = copyRegistry.host
1940
1973
 
1941
1974
  /**
1942
1975
  * A button another plugin put in this bar.
@@ -2000,7 +2033,94 @@ window.__ModuleLoader__.load({
2000
2033
  * host's own clipboard writer the button keeps the host's behaviour too: a
2001
2034
  * takeover that cannot copy is worse than none.
2002
2035
  */
2036
+ /**
2037
+ * The wording the host's own copy button would hand over for this row.
2038
+ *
2039
+ * It is the message the row was drawn for - the pre-edit text - and it is read
2040
+ * off the row itself, because that is where it still is: this plugin hides the
2041
+ * row's own bubble and stands its revision in front of it.
2042
+ *
2043
+ * @param {Element} row - the row.
2044
+ * @returns {string|null} the host's text, or null when it cannot be read.
2045
+ */
2046
+ function hostCopyTextOf(row) {
2047
+ try {
2048
+ const bubble = ownRevisionBubble(row)
2049
+ let text = ''
2050
+ const children = row.childNodes
2051
+ if (children === undefined || children === null) return null
2052
+ for (const child of children) {
2053
+ if (bubble !== null && child === bubble) continue
2054
+ if (typeof child.textContent === 'string') text += child.textContent
2055
+ }
2056
+ const normalized = text.replace(/\s+/g, ' ').trim()
2057
+ return normalized === '' ? null : normalized
2058
+ } catch {
2059
+ return null
2060
+ }
2061
+ }
2062
+
2063
+ /**
2064
+ * The wording that must actually land on the clipboard.
2065
+ *
2066
+ * The host's copy button copies the message its row was drawn for, so on a row
2067
+ * this plugin rewrote it hands over the pre-edit text. The press itself is
2068
+ * answered earlier when this half can find the button, but the action bar is the
2069
+ * host's own React and it may draw a button this half never marked - so the last
2070
+ * say belongs to the write itself, which no button and no handler can bypass.
2071
+ *
2072
+ * @param {string} text - what the platform is about to write.
2073
+ * @returns {string|null} the text to write instead, or null to leave it alone.
2074
+ */
2075
+ function rewriteCopiedText(text) {
2076
+ const wanted = text.replace(/\s+/g, ' ').trim()
2077
+ if (wanted === '') return null
2078
+ for (const [key, edited] of rowCopyText) {
2079
+ if (typeof edited !== 'string') continue
2080
+ if (edited.replace(/\s+/g, ' ').trim() === wanted) return null
2081
+ const hostText = rowHostText.get(key)
2082
+ if (typeof hostText === 'string' && (hostText === wanted || hostText.includes(wanted))) return edited
2083
+ }
2084
+ return null
2085
+ }
2086
+
2087
+ /**
2088
+ * Wrap the platform's clipboard writer, once per page.
2089
+ *
2090
+ * `navigator.clipboard.writeText` is the single choke point every copy goes
2091
+ * through - the host's own `writeClipboard` primitive calls it, and so does a
2092
+ * browser's native copy of a selection.
2093
+ *
2094
+ * @returns {() => void} undo.
2095
+ */
2096
+ function installClipboardRewrite() {
2097
+ const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined
2098
+ if (clipboard === undefined || clipboard === null || typeof clipboard.writeText !== 'function') return () => {}
2099
+ if (clipboard.writeText[CLIPBOARD_WRAPPED] === true) return () => {}
2100
+ const native = clipboard.writeText.bind(clipboard)
2101
+ const wrapped = (value) => {
2102
+ const rewritten = rewriteCopiedText(String(value ?? ''))
2103
+ return rewritten === null ? native(value) : native(rewritten)
2104
+ }
2105
+ wrapped[CLIPBOARD_WRAPPED] = true
2106
+ clipboard.writeText = wrapped
2107
+ return () => {
2108
+ if (clipboard.writeText === wrapped) clipboard.writeText = native
2109
+ }
2110
+ }
2111
+
2003
2112
  function takeOverRowCopy(row, text, t) {
2113
+ // Registered before the button lookup on purpose: the handover has to work
2114
+ // even when this pass cannot find the button to mark (the next one may, and
2115
+ // the document-level press answers from this table either way).
2116
+ {
2117
+ const key = row.getAttribute('data-chat-flow-key')
2118
+ if (typeof key === 'string' && key !== '') {
2119
+ rowCopyText.set(key, text)
2120
+ const hostText = hostCopyTextOf(row)
2121
+ if (hostText !== null) rowHostText.set(key, hostText)
2122
+ }
2123
+ }
2004
2124
  const button = nativeCopyButton(row, t)
2005
2125
  if (button === null) return
2006
2126
  const write = primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === 'function'
@@ -2129,6 +2249,11 @@ window.__ModuleLoader__.load({
2129
2249
 
2130
2250
  /** The copy handover of a row, wherever the last pass left it. */
2131
2251
  function releaseRowCopy(row) {
2252
+ const key = row.getAttribute('data-chat-flow-key')
2253
+ if (typeof key === 'string' && key !== '') {
2254
+ rowCopyText.delete(key)
2255
+ rowHostText.delete(key)
2256
+ }
2132
2257
  const button = nativeCopyButton(row)
2133
2258
  if (button !== null) releaseCopyButton(button)
2134
2259
  }
@@ -4773,10 +4898,53 @@ window.__ModuleLoader__.load({
4773
4898
  controller.load()
4774
4899
  }, [controller])
4775
4900
 
4901
+ // The last say on what lands: whatever route the press takes, the write
4902
+ // itself goes through here.
4903
+ react.useEffect(() => installClipboardRewrite(), [])
4904
+
4776
4905
  // Pressing outside the editor is the one signal that means "the user
4777
4906
  // left". Blur cannot be that signal: a host re-render replaces the node
4778
4907
  // (blur without a related target) and a save temporarily disables it -
4779
4908
  // both used to drop the caret for good.
4909
+ // The press that matters: the host's own copy button on a row this plugin
4910
+ // rewrote. Capture phase, so the host's handler (which listens at the React
4911
+ // root, below the document) never sees the press; by row key, so a button
4912
+ // the host re-rendered is still answered.
4913
+ react.useEffect(() => {
4914
+ if (typeof document.addEventListener !== 'function' || typeof document.removeEventListener !== 'function') {
4915
+ return undefined
4916
+ }
4917
+ const onCopyPress = (event) => {
4918
+ const target = event.target
4919
+ if (target === null || target === undefined || typeof target.closest !== 'function') return
4920
+ const button = target.closest('button')
4921
+ if (button === null) return
4922
+ // Our own buttons sit in the same bar: theirs is not this press.
4923
+ if (pluginOwned(button)) return
4924
+ const row = button.closest('[data-chat-flow-key]')
4925
+ if (row === null) return
4926
+ const key = row.getAttribute('data-chat-flow-key')
4927
+ if (typeof key !== 'string' || key === '') return
4928
+ const text = rowCopyText.get(key)
4929
+ if (typeof text !== 'string') return
4930
+ const write = primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === 'function'
4931
+ ? primitives.writeClipboard
4932
+ : null
4933
+ if (write === null) return
4934
+ if (typeof event.preventDefault === 'function') event.preventDefault()
4935
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
4936
+ if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation()
4937
+ Promise.resolve(write(text)).then(
4938
+ (ok) => {
4939
+ if (ok === true) flashRowCopy(button, typeof t === 'function' ? t('action.copied') : null)
4940
+ },
4941
+ () => {},
4942
+ )
4943
+ }
4944
+ document.addEventListener('click', onCopyPress, true)
4945
+ return () => document.removeEventListener('click', onCopyPress, true)
4946
+ }, [t])
4947
+
4780
4948
  react.useEffect(() => {
4781
4949
  if (typeof document.addEventListener !== 'function' || typeof document.removeEventListener !== 'function') {
4782
4950
  return undefined
package/lib/index.js CHANGED
@@ -78,7 +78,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
78
78
  export const name = PLUGIN_ID
79
79
 
80
80
  /** Keep in sync with package.json and lib/client.js. */
81
- export const PLUGIN_VERSION = '0.5.6'
81
+ export const PLUGIN_VERSION = '0.5.8'
82
82
 
83
83
  // A ring of the last few requests, answering "did the browser half even ask, and
84
84
  // what did it get?" without needing its console. Read-only diagnostics: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",