dsh-better-display 0.3.8 → 0.3.9

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 (182) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/DESIGN.md +43 -3
  3. package/README.en.md +9 -1
  4. package/README.md +9 -1
  5. package/docs/dependency-update-policy.md +9 -0
  6. package/docs/extension-points.md +172 -0
  7. package/docs/official-rendering-bridge.md +48 -19
  8. package/docs/openapi.yaml +147 -0
  9. package/docs/runbooks/blank-reader.md +39 -0
  10. package/lib/client.js +1417 -490
  11. package/lib/client.js.map +1 -1
  12. package/lib/dsh-better-display.js +378 -144
  13. package/lib/types/client/AssistantNode.d.ts +20 -0
  14. package/lib/types/client/AssistantNode.d.ts.map +1 -0
  15. package/lib/types/client/AssistantNode.js +46 -0
  16. package/lib/types/client/AssistantNode.js.map +1 -0
  17. package/lib/types/client/Blocks.d.ts.map +1 -1
  18. package/lib/types/client/Blocks.js +33 -10
  19. package/lib/types/client/Blocks.js.map +1 -1
  20. package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
  21. package/lib/types/client/ChoreographedFlow.js +2 -0
  22. package/lib/types/client/ChoreographedFlow.js.map +1 -1
  23. package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
  24. package/lib/types/client/ClosedProcessSummary.js +3 -1
  25. package/lib/types/client/ClosedProcessSummary.js.map +1 -1
  26. package/lib/types/client/McpAppFrame.d.ts.map +1 -1
  27. package/lib/types/client/McpAppFrame.js +5 -11
  28. package/lib/types/client/McpAppFrame.js.map +1 -1
  29. package/lib/types/client/OfficialContent.d.ts +17 -3
  30. package/lib/types/client/OfficialContent.d.ts.map +1 -1
  31. package/lib/types/client/OfficialContent.js +33 -7
  32. package/lib/types/client/OfficialContent.js.map +1 -1
  33. package/lib/types/client/Reader.d.ts.map +1 -1
  34. package/lib/types/client/Reader.js +43 -71
  35. package/lib/types/client/Reader.js.map +1 -1
  36. package/lib/types/client/SettingsSection.d.ts.map +1 -1
  37. package/lib/types/client/SettingsSection.js +0 -5
  38. package/lib/types/client/SettingsSection.js.map +1 -1
  39. package/lib/types/client/StickyLane.d.ts +12 -0
  40. package/lib/types/client/StickyLane.d.ts.map +1 -1
  41. package/lib/types/client/StickyLane.js +78 -2
  42. package/lib/types/client/StickyLane.js.map +1 -1
  43. package/lib/types/client/ToolActivity.js.map +1 -1
  44. package/lib/types/client/index.d.ts +3 -1
  45. package/lib/types/client/index.d.ts.map +1 -1
  46. package/lib/types/client/index.js +6 -0
  47. package/lib/types/client/index.js.map +1 -1
  48. package/lib/types/client/live-turn.d.ts +17 -5
  49. package/lib/types/client/live-turn.d.ts.map +1 -1
  50. package/lib/types/client/live-turn.js +106 -76
  51. package/lib/types/client/live-turn.js.map +1 -1
  52. package/lib/types/client/motion.d.ts +26 -2
  53. package/lib/types/client/motion.d.ts.map +1 -1
  54. package/lib/types/client/motion.js +136 -10
  55. package/lib/types/client/motion.js.map +1 -1
  56. package/lib/types/client/official-slots.d.ts +9 -0
  57. package/lib/types/client/official-slots.d.ts.map +1 -1
  58. package/lib/types/client/official-slots.js +22 -7
  59. package/lib/types/client/official-slots.js.map +1 -1
  60. package/lib/types/client/projection.d.ts +15 -2
  61. package/lib/types/client/projection.d.ts.map +1 -1
  62. package/lib/types/client/projection.js +16 -4
  63. package/lib/types/client/projection.js.map +1 -1
  64. package/lib/types/client/rewind-cut.d.ts +131 -0
  65. package/lib/types/client/rewind-cut.d.ts.map +1 -0
  66. package/lib/types/client/rewind-cut.js +321 -0
  67. package/lib/types/client/rewind-cut.js.map +1 -0
  68. package/lib/types/client/text-transform.d.ts +93 -0
  69. package/lib/types/client/text-transform.d.ts.map +1 -0
  70. package/lib/types/client/text-transform.js +183 -0
  71. package/lib/types/client/text-transform.js.map +1 -0
  72. package/lib/types/client/timeline.d.ts.map +1 -1
  73. package/lib/types/client/timeline.js.map +1 -1
  74. package/lib/types/client/tool-activity.d.ts +9 -2
  75. package/lib/types/client/tool-activity.d.ts.map +1 -1
  76. package/lib/types/client/tool-activity.js +42 -2
  77. package/lib/types/client/tool-activity.js.map +1 -1
  78. package/lib/types/client/turn-slots.d.ts +29 -0
  79. package/lib/types/client/turn-slots.d.ts.map +1 -0
  80. package/lib/types/client/turn-slots.js +62 -0
  81. package/lib/types/client/turn-slots.js.map +1 -0
  82. package/lib/types/client/types.d.ts +18 -0
  83. package/lib/types/client/types.d.ts.map +1 -1
  84. package/lib/types/dsh-better-display.d.ts +4 -1
  85. package/lib/types/dsh-better-display.d.ts.map +1 -1
  86. package/lib/types/dsh-better-display.js +56 -7
  87. package/lib/types/dsh-better-display.js.map +1 -1
  88. package/lib/types/flags.d.ts +7 -0
  89. package/lib/types/flags.d.ts.map +1 -0
  90. package/lib/types/flags.js +37 -0
  91. package/lib/types/flags.js.map +1 -0
  92. package/lib/types/log.d.ts +17 -0
  93. package/lib/types/log.d.ts.map +1 -0
  94. package/lib/types/log.js +122 -0
  95. package/lib/types/log.js.map +1 -0
  96. package/lib/types/metrics.d.ts +18 -0
  97. package/lib/types/metrics.d.ts.map +1 -0
  98. package/lib/types/metrics.js +35 -0
  99. package/lib/types/metrics.js.map +1 -0
  100. package/package.json +36 -5
  101. package/scripts/changelog.mjs +20 -0
  102. package/scripts/check-agents-md.mjs +67 -0
  103. package/scripts/check-bundle-size.mjs +38 -0
  104. package/scripts/check-dead-flags.mjs +55 -0
  105. package/scripts/check-todo-debt.mjs +69 -0
  106. package/scripts/security-audit-report.mjs +92 -0
  107. package/scripts/verify-commentary-visible.mjs +627 -0
  108. package/src/client/AssistantNode.tsx +72 -0
  109. package/src/client/Blocks.tsx +49 -10
  110. package/src/client/ChoreographedFlow.tsx +3 -1
  111. package/src/client/ClosedProcessSummary.tsx +3 -1
  112. package/src/client/McpAppFrame.tsx +5 -13
  113. package/src/client/OfficialContent.tsx +47 -9
  114. package/src/client/Reader.module.css +68 -18
  115. package/src/client/Reader.tsx +36 -76
  116. package/src/client/SettingsSection.tsx +1 -7
  117. package/src/client/StickyLane.tsx +81 -2
  118. package/src/client/ToolActivity.tsx +1 -1
  119. package/src/client/index.tsx +8 -1
  120. package/src/client/live-turn.ts +100 -69
  121. package/src/client/motion.tsx +135 -7
  122. package/src/client/official-slots.tsx +23 -7
  123. package/src/client/projection.ts +16 -4
  124. package/src/client/rewind-cut.ts +340 -0
  125. package/src/client/text-transform.ts +265 -0
  126. package/src/client/timeline.ts +0 -2
  127. package/src/client/tool-activity.ts +49 -2
  128. package/src/client/turn-slots.ts +80 -0
  129. package/src/client/types.ts +19 -0
  130. package/src/dsh-better-display.ts +58 -7
  131. package/src/flags.ts +40 -0
  132. package/src/log.ts +137 -0
  133. package/src/metrics.ts +50 -0
  134. package/tests/browser-commentary.tsx +191 -0
  135. package/tests/chat-flow-hook.test.ts +1 -1
  136. package/tests/cloud-gate.test.ts +1 -1
  137. package/tests/context-tool-update.test.ts +1 -1
  138. package/tests/conversation-scroll.test.ts +1 -1
  139. package/tests/deliverables.test.ts +1 -1
  140. package/tests/entry.test.ts +1 -1
  141. package/tests/fixtures/reader-lane-owners.tsx +46 -0
  142. package/tests/fixtures/reader-official-users.tsx +64 -0
  143. package/tests/fixtures/reader-theme-overlap.html +80 -0
  144. package/tests/fixtures/reader-user-bubble.tsx +222 -0
  145. package/tests/flags.test.ts +66 -0
  146. package/tests/fold-choreography.test.ts +32 -8
  147. package/tests/fold-intensity.test.ts +11 -6
  148. package/tests/footer-precedence.test.ts +1 -1
  149. package/tests/history-word-regression.test.ts +1 -1
  150. package/tests/live-turn.test.ts +125 -54
  151. package/tests/log.test.ts +99 -0
  152. package/tests/mcp-apps.test.ts +1 -1
  153. package/tests/message-chrome.test.ts +1 -1
  154. package/tests/metrics.test.ts +52 -0
  155. package/tests/official-slots.test.ts +41 -2
  156. package/tests/open-file.test.ts +1 -1
  157. package/tests/pending-submission.test.ts +12 -1
  158. package/tests/pinned-selection.test.ts +16 -0
  159. package/tests/platform-media.test.ts +1 -1
  160. package/tests/projection.test.ts +66 -3
  161. package/tests/reader-glass-css.test.ts +1 -1
  162. package/tests/reader-official-users.test.ts +11 -1
  163. package/tests/reader-sticky-surface.test.ts +377 -0
  164. package/tests/reader-theme-lanes.test.ts +54 -0
  165. package/tests/reader-theme-overlap.test.ts +151 -0
  166. package/tests/reader-user-bubble.test.ts +137 -0
  167. package/tests/reasoning-follow.test.ts +1 -1
  168. package/tests/retiring-content.test.ts +98 -6
  169. package/tests/reveal.test.ts +3 -3
  170. package/tests/rewind-cut.test.ts +1090 -0
  171. package/tests/settings-copy.test.ts +1 -1
  172. package/tests/skill-status.test.ts +1 -1
  173. package/tests/stock-install.test.ts +1 -1
  174. package/tests/stream-buffer.test.ts +1 -1
  175. package/tests/styles.test.ts +30 -3
  176. package/tests/terminal-input-preview.test.ts +1 -1
  177. package/tests/text-transform.test.ts +272 -0
  178. package/tests/timeline.test.ts +1 -1
  179. package/tests/tool-clock.test.ts +1 -1
  180. package/tests/tool-diff.test.ts +1 -1
  181. package/tests/waiting-clock.test.ts +1 -1
  182. package/tests/word-timeline.test.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { dirname, resolve } from 'node:path';
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { dirname, resolve } from 'node:path';
5
- import { test } from 'node:test';
5
+ import { test } from 'vitest';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import semver from 'semver';
8
8
 
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
2
+ import { test } from 'vitest';
3
3
  import { StreamBuffer, STREAM_TIMING } from '../src/client/stream-buffer.js';
4
4
 
5
5
  test('a transport burst becomes multiple bounded, monotonically growing frames', () => {
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
2
+ import { test } from 'vitest';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { ensureStyles } from '../src/client/styles.js';
5
5
 
@@ -9,7 +9,7 @@ test('ensureStyles tolerates module evaluation without a document', () => {
9
9
 
10
10
  test('ensureStyles restores removed plugin CSS and remains idempotent', t => {
11
11
  const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
12
- t.after(() => {
12
+ t.onTestFinished(() => {
13
13
  if (descriptor) Object.defineProperty(globalThis, 'document', descriptor);
14
14
  else Reflect.deleteProperty(globalThis, 'document');
15
15
  });
@@ -44,5 +44,32 @@ test('CSS is cached after module injection and restored before mount layout effe
44
44
  const index = readFileSync(new URL('../src/client/index.tsx', import.meta.url), 'utf8');
45
45
  const reader = readFileSync(new URL('../src/client/Reader.tsx', import.meta.url), 'utf8');
46
46
  assert.match(index, /\nensureStyles\(\);/);
47
- assert.match(reader, /useInsertionEffect\(\(\) => \{ ensureStyles\(\); \}, \[\]\)/);
47
+ // Whitespace-tolerant: the source is formatted by the project's own toolchain,
48
+ // which may break the effect body onto its own line.
49
+ assert.match(reader, /useInsertionEffect\(\(\) => \{[\s\S]{0,40}?ensureStyles\(\);[\s\S]{0,20}?\}, \[\]\)/);
50
+ });
51
+
52
+ test('user cluster fills the column so the host cap resolves once', () => {
53
+ const css = readFileSync(new URL('../src/client/Reader.module.css', import.meta.url), 'utf8');
54
+ const cluster = css.match(/\.userCluster\s*\{[^}]*\}/)?.[0] ?? '';
55
+ assert.match(cluster, /width:\s*100%/);
56
+ assert.match(cluster, /max-width:\s*100%/);
57
+ assert.doesNotMatch(cluster, /0\.702/);
58
+ assert.doesNotMatch(cluster, /525px/);
59
+ assert.doesNotMatch(css, /525px/);
60
+ // A direct-child cap misses the display:contents slot. The host row is the
61
+ // slot's child; capping that grandchild would replace .userStack's own 82%.
62
+ assert.doesNotMatch(css, /\.userCluster\s*>\s*\*/);
63
+ assert.doesNotMatch(css, /\[data-slot="conversation\.chat\.node"\]/);
64
+ assert.match(css, /\.userCluster > \[data-slot\] > \* \{[^}]*align-self:\s*stretch/);
65
+ assert.match(css, /\.userCluster > \[data-slot\] > \* \{[^}]*width:\s*100%/);
66
+ assert.doesNotMatch(css, /\.userCluster > \[data-slot\] > \* > \*/);
67
+ const user = css.match(/\.user\s*\{[^}]*\}/)?.[0] ?? '';
68
+ assert.match(user, /max-width:\s*min\(calc\(var\(--dsh-chat-content-width,\s*748px\)\s*\*\s*0\.702\),\s*82%\)/);
69
+ assert.match(user, /font-size:\s*var\(--dsh-content-font-size,\s*14px\)/);
70
+ assert.match(user, /line-height:\s*calc\(22px \+ var\(--dsh-content-font-delta,\s*0px\)\)/);
71
+ assert.match(user, /font-weight:\s*400/);
72
+ assert.match(user, /white-space:\s*pre-wrap/);
73
+ assert.match(user, /border-radius:\s*var\(--dsw-radius-xl,\s*22px\)/);
74
+ assert.doesNotMatch(css, /\.user\s*\{\s*max-width:\s*90%/);
48
75
  });
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import type { PreparingToolCall, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
4
4
  import { activityPhase, activitySummary } from '../src/client/tool-activity.ts';
@@ -0,0 +1,272 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'vitest';
3
+ import { StreamBuffer } from '../src/client/stream-buffer.js';
4
+ import {
5
+ activeTextTransform,
6
+ createTextTransformRegistry,
7
+ displayStreaming,
8
+ installTextTransformRegistry,
9
+ nextDisplayCursor,
10
+ originalAnswerText,
11
+ readDisplayText,
12
+ readerBlockOwner,
13
+ type DisplayTextRequest,
14
+ } from '../src/client/text-transform.js';
15
+ import type { ReaderBlockOwner } from '../src/client/types.js';
16
+ import { WordTimeline } from '../src/client/word-timeline.js';
17
+
18
+ const reasoning = (text: string, streaming = true, blockKey = 'block-1'): DisplayTextRequest => ({
19
+ text, streaming, blockKey, kind: 'reasoning', source: 'assistant',
20
+ });
21
+
22
+ test('no transformer leaves the source text and the streaming flag untouched', () => {
23
+ const registry = createTextTransformRegistry();
24
+ const request = reasoning('Hello world', true);
25
+ const shown = readDisplayText(registry, request);
26
+ assert.equal(shown.text, request.text);
27
+ assert.equal(shown.final, false);
28
+ assert.equal(shown.motionRevision, 0);
29
+ assert.equal(shown.transformRevision, 0);
30
+ assert.equal(readDisplayText(undefined, request).text, 'Hello world');
31
+ assert.equal(displayStreaming(true, shown, false), true);
32
+ assert.equal(displayStreaming(false, { final: true }, false), false);
33
+ assert.equal(registry.has('reasoning'), false);
34
+ assert.equal(registry.has('body'), false);
35
+ });
36
+
37
+ test('a sync transformer rewrites the display string and leaves the block alone', () => {
38
+ const registry = createTextTransformRegistry();
39
+ const source = 'Hello';
40
+ const block = { kind: 'text' as const, text: source };
41
+ registry.register('body', {
42
+ read: request => ({ text: request.text.toUpperCase(), final: !request.streaming }),
43
+ });
44
+ const streaming = readDisplayText(registry, { text: source, streaming: true, blockKey: 'a', kind: 'body', source: 'assistant' });
45
+ const settled = readDisplayText(registry, { text: source, streaming: false, blockKey: 'a', kind: 'body', source: 'assistant' });
46
+ assert.equal(streaming.text, 'HELLO');
47
+ assert.equal(streaming.final, false);
48
+ assert.equal(displayStreaming(true, streaming, true), true);
49
+ assert.equal(settled.text, 'HELLO');
50
+ assert.equal(settled.final, true);
51
+ assert.equal(displayStreaming(false, settled, true), false);
52
+ assert.equal(source, 'Hello');
53
+ assert.equal(block.text, 'Hello');
54
+ assert.equal(originalAnswerText([block, { kind: 'reasoning', text: 'secret thought' }]), 'Hello');
55
+ });
56
+
57
+ test('an async append bumps the transform revision without restarting the reveal', () => {
58
+ const registry = createTextTransformRegistry();
59
+ let shown = 'Hello';
60
+ const listeners = new Set<() => void>();
61
+ registry.register('reasoning', {
62
+ read: () => ({ text: shown, final: false }),
63
+ subscribe(listener) {
64
+ listeners.add(listener);
65
+ return () => { listeners.delete(listener); };
66
+ },
67
+ });
68
+ const request = reasoning('thinking starts', true);
69
+ const first = registry.read(request);
70
+ assert.equal(first.text, 'Hello');
71
+ assert.equal(first.transformRevision, 0);
72
+ assert.equal(first.motionRevision, 0);
73
+
74
+ const buffer = new StreamBuffer('');
75
+ const timeline = new WordTimeline();
76
+ timeline.begin('', true, 0, 0);
77
+ const paint = (text: string, now: number) => {
78
+ buffer.update(text, now, { finished: false });
79
+ buffer.advance(now + 120);
80
+ timeline.begin(buffer.visible, true, buffer.revision, now + 120);
81
+ };
82
+ paint(first.text, 100);
83
+ assert.equal(buffer.revision, 0);
84
+ assert.ok(buffer.visible.length > 0);
85
+ const generation = timeline.generation;
86
+ const prefixBorn = timeline.bornAt(0);
87
+ const visible = buffer.visible;
88
+ assert.notEqual(prefixBorn, null);
89
+
90
+ shown = 'Hello there';
91
+ for (const listener of listeners) listener();
92
+ const second = registry.read(request);
93
+ assert.equal(second.text, 'Hello there');
94
+ assert.equal(second.motionRevision, first.motionRevision);
95
+ assert.equal(second.transformRevision, first.transformRevision + 1);
96
+ paint(second.text, 400);
97
+ assert.equal(buffer.revision, 0);
98
+ assert.ok(buffer.visible.startsWith(visible));
99
+ assert.ok(buffer.visible.length > visible.length);
100
+ assert.equal(timeline.generation, generation);
101
+ assert.equal(timeline.bornAt(0), prefixBorn);
102
+
103
+ // The transform counter is a different clock. Feeding it to the timeline
104
+ // settles the paragraph; the reader does not do that.
105
+ const trap = new WordTimeline();
106
+ trap.begin('', true, 0, 0);
107
+ trap.begin(visible, true, first.motionRevision, 220);
108
+ const trapGeneration = trap.generation;
109
+ trap.begin(second.text, true, second.transformRevision, 520);
110
+ assert.notEqual(trap.generation, trapGeneration);
111
+ });
112
+
113
+ test('a replaced translation settles on the new string instead of replaying from empty', () => {
114
+ const registry = createTextTransformRegistry();
115
+ let shown = 'Hello there';
116
+ registry.register('reasoning', {
117
+ read: () => ({ text: shown, final: false }),
118
+ });
119
+ const request = reasoning('source', true);
120
+ const first = registry.read(request);
121
+ const buffer = new StreamBuffer('');
122
+ buffer.update(first.text, 100);
123
+ buffer.advance(220);
124
+ assert.equal(buffer.visible, 'Hello there');
125
+ const timeline = new WordTimeline();
126
+ timeline.begin('', true, buffer.revision, 0);
127
+ timeline.begin(buffer.visible, true, buffer.revision, 220);
128
+ const generation = timeline.generation;
129
+
130
+ shown = '你好世界';
131
+ const second = registry.read(request);
132
+ assert.equal(second.motionRevision, first.motionRevision + 1);
133
+ assert.ok(second.transformRevision > first.transformRevision);
134
+ buffer.update(second.text, 400);
135
+ assert.equal(buffer.visible, '你好世界');
136
+ assert.notEqual(buffer.visible, '');
137
+ assert.equal(buffer.revision, 1);
138
+ timeline.begin(buffer.visible, true, buffer.revision, 400);
139
+ assert.notEqual(timeline.generation, generation);
140
+ assert.equal(timeline.bornAt(0), null);
141
+ });
142
+
143
+ test('the block owner carries presentation, holdFormatting, and liveText', () => {
144
+ const block = { kind: 'reasoning' as const, text: 'original' };
145
+ const owner = readerBlockOwner(block as ReaderBlockOwner['block'], true, 'assistant', {
146
+ startedAt: 42, interrupted: false, liveText: true,
147
+ }, false);
148
+ assert.equal(owner.block, block);
149
+ assert.equal(owner.streaming, true);
150
+ assert.equal(owner.source, 'assistant');
151
+ assert.deepEqual(owner.presentation, { startedAt: 42, interrupted: false, liveText: true });
152
+ assert.equal(owner.holdFormatting, false);
153
+ assert.equal(owner.liveText, true);
154
+ assert.equal(owner.block.text, 'original');
155
+ const bare: ReaderBlockOwner = { block: owner.block, streaming: false, source: 'user' };
156
+ assert.equal(bare.presentation, undefined);
157
+ assert.equal(bare.holdFormatting, undefined);
158
+ assert.equal(bare.liveText, undefined);
159
+ });
160
+
161
+ test('unregistering restores the original text and the install pointer nests', () => {
162
+ const registry = createTextTransformRegistry();
163
+ const dispose = registry.register(['reasoning', 'body'], {
164
+ read: request => request.text.toUpperCase(),
165
+ });
166
+ assert.equal(registry.has('reasoning'), true);
167
+ assert.equal(registry.has('body'), true);
168
+ assert.equal(readDisplayText(registry, reasoning('Ab')).text, 'AB');
169
+ dispose();
170
+ assert.equal(registry.has('reasoning'), false);
171
+ assert.equal(readDisplayText(registry, reasoning('Ab')).text, 'Ab');
172
+
173
+ const outer = createTextTransformRegistry();
174
+ const restoreOuter = installTextTransformRegistry(outer);
175
+ try {
176
+ assert.equal(activeTextTransform(), outer);
177
+ const inner = createTextTransformRegistry();
178
+ const restoreInner = installTextTransformRegistry(inner);
179
+ try {
180
+ assert.equal(activeTextTransform(), inner);
181
+ } finally {
182
+ restoreInner();
183
+ }
184
+ assert.equal(activeTextTransform(), outer);
185
+ } finally {
186
+ restoreOuter();
187
+ }
188
+ assert.equal(activeTextTransform(), undefined);
189
+ });
190
+
191
+ test('a transformer notify reaches reader subscribers, and release drops the cursor', () => {
192
+ const registry = createTextTransformRegistry();
193
+ let shown = 'Hello';
194
+ let notify = () => {};
195
+ registry.register('reasoning', {
196
+ read: () => ({ text: shown, final: false }),
197
+ subscribe(listener) {
198
+ notify = listener;
199
+ return () => {};
200
+ },
201
+ });
202
+ let seen = 0;
203
+ const stop = registry.subscribe('reasoning', () => { seen += 1; });
204
+ const first = registry.read(reasoning('source'));
205
+ assert.equal(first.text, 'Hello');
206
+ assert.equal(first.transformRevision, 0);
207
+ shown = 'Hello there';
208
+ notify();
209
+ assert.equal(seen, 1);
210
+ const next = registry.read(reasoning('source'));
211
+ assert.equal(next.text, 'Hello there');
212
+ assert.equal(next.motionRevision, 0);
213
+ assert.equal(next.transformRevision, 1);
214
+ stop();
215
+ registry.release('reasoning', 'block-1');
216
+ shown = 'Hello there friend';
217
+ const restarted = registry.read(reasoning('source'));
218
+ assert.equal(restarted.transformRevision, 0);
219
+ assert.equal(restarted.motionRevision, 0);
220
+ });
221
+
222
+ test('an extending display string keeps the motion revision', () => {
223
+ const first = nextDisplayCursor(undefined, { text: 'Hello', final: false });
224
+ const second = nextDisplayCursor(first, { text: 'Hello world', final: false });
225
+ const same = nextDisplayCursor(second, { text: 'Hello world', final: false });
226
+ assert.equal(second.motionRevision, first.motionRevision);
227
+ assert.equal(second.transformRevision, 1);
228
+ assert.equal(same, second);
229
+ });
230
+
231
+ test('a throwing transformer falls back to the original text and recovers without a remount', () => {
232
+ const registry = createTextTransformRegistry();
233
+ let broken = true;
234
+ let notify = () => {};
235
+ const warn = console.warn;
236
+ console.warn = () => {};
237
+ try {
238
+ registry.register('reasoning', {
239
+ subscribe(listener) { notify = listener; return () => {}; },
240
+ read(request) {
241
+ if (broken) throw new Error('engine down');
242
+ return { text: request.text.toUpperCase(), final: !request.streaming };
243
+ },
244
+ });
245
+ const first = registry.read(reasoning('hello', true));
246
+ assert.equal(first.text, 'hello');
247
+ assert.equal(first.final, false);
248
+ broken = false;
249
+ notify();
250
+ const recovered = registry.read(reasoning('hello', true));
251
+ assert.equal(recovered.text, 'HELLO');
252
+ } finally {
253
+ console.warn = warn;
254
+ }
255
+ });
256
+
257
+ test('a transformer whose subscribe throws still registers and reads', () => {
258
+ const registry = createTextTransformRegistry();
259
+ const warn = console.warn;
260
+ console.warn = () => {};
261
+ try {
262
+ const off = registry.register('body', {
263
+ subscribe() { throw new Error('no events'); },
264
+ read(request) { return request.text.toUpperCase(); },
265
+ });
266
+ assert.equal(registry.read({ text: 'ok', streaming: false, blockKey: 'b', kind: 'body', source: 'assistant' }).text, 'OK');
267
+ off();
268
+ assert.equal(registry.has('body'), false);
269
+ } finally {
270
+ console.warn = warn;
271
+ }
272
+ });
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { mergeTimelineItems } from '../src/client/timeline.ts';
4
4
 
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { callStartTime, forgetCallClock, runningClockBase } from '../src/client/tool-activity.ts';
4
4
  import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { callDiffHunks, DIFF_FALLBACK_TOOLS, diffTotals } from '../src/client/tool-activity.ts';
4
4
  import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
@@ -1,4 +1,4 @@
1
- import test from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { HANDOVER_KINDS, handsBackToModel, WAIT_AFTER, waitingAnchor } from '../src/client/waiting-clock.js';
4
4
 
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
2
+ import { test } from 'vitest';
3
3
  import type { Nodes } from 'mdast';
4
4
  import { WORD_MOTION, WordTimeline } from '../src/client/word-timeline.js';
5
5
  import { IncrementalMarkdownParser } from '../src/client/markdown/incremental.js';