dsh-better-display 0.3.7 → 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 (208) hide show
  1. package/CHANGELOG.md +22 -1
  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 +59 -18
  8. package/docs/openapi.yaml +147 -0
  9. package/docs/runbooks/blank-reader.md +39 -0
  10. package/lib/client.js +1629 -586
  11. package/lib/client.js.map +1 -1
  12. package/lib/dsh-better-display.js +535 -57
  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 +76 -73
  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.d.ts.map +1 -1
  44. package/lib/types/client/ToolActivity.js +10 -5
  45. package/lib/types/client/ToolActivity.js.map +1 -1
  46. package/lib/types/client/deliverables.d.ts.map +1 -1
  47. package/lib/types/client/deliverables.js +2 -0
  48. package/lib/types/client/deliverables.js.map +1 -1
  49. package/lib/types/client/index.d.ts +3 -1
  50. package/lib/types/client/index.d.ts.map +1 -1
  51. package/lib/types/client/index.js +15 -1
  52. package/lib/types/client/index.js.map +1 -1
  53. package/lib/types/client/live-turn.d.ts +17 -5
  54. package/lib/types/client/live-turn.d.ts.map +1 -1
  55. package/lib/types/client/live-turn.js +106 -76
  56. package/lib/types/client/live-turn.js.map +1 -1
  57. package/lib/types/client/motion.d.ts +26 -2
  58. package/lib/types/client/motion.d.ts.map +1 -1
  59. package/lib/types/client/motion.js +144 -10
  60. package/lib/types/client/motion.js.map +1 -1
  61. package/lib/types/client/official-slots.d.ts +9 -0
  62. package/lib/types/client/official-slots.d.ts.map +1 -1
  63. package/lib/types/client/official-slots.js +22 -4
  64. package/lib/types/client/official-slots.js.map +1 -1
  65. package/lib/types/client/open-file.d.ts.map +1 -1
  66. package/lib/types/client/open-file.js +1 -6
  67. package/lib/types/client/open-file.js.map +1 -1
  68. package/lib/types/client/projection.d.ts +15 -2
  69. package/lib/types/client/projection.d.ts.map +1 -1
  70. package/lib/types/client/projection.js +16 -4
  71. package/lib/types/client/projection.js.map +1 -1
  72. package/lib/types/client/rewind-cut.d.ts +131 -0
  73. package/lib/types/client/rewind-cut.d.ts.map +1 -0
  74. package/lib/types/client/rewind-cut.js +321 -0
  75. package/lib/types/client/rewind-cut.js.map +1 -0
  76. package/lib/types/client/styles.d.ts +3 -0
  77. package/lib/types/client/styles.d.ts.map +1 -0
  78. package/lib/types/client/styles.js +25 -0
  79. package/lib/types/client/styles.js.map +1 -0
  80. package/lib/types/client/text-transform.d.ts +93 -0
  81. package/lib/types/client/text-transform.d.ts.map +1 -0
  82. package/lib/types/client/text-transform.js +183 -0
  83. package/lib/types/client/text-transform.js.map +1 -0
  84. package/lib/types/client/timeline.d.ts.map +1 -1
  85. package/lib/types/client/timeline.js.map +1 -1
  86. package/lib/types/client/tool-activity.d.ts +11 -4
  87. package/lib/types/client/tool-activity.d.ts.map +1 -1
  88. package/lib/types/client/tool-activity.js +54 -9
  89. package/lib/types/client/tool-activity.js.map +1 -1
  90. package/lib/types/client/turn-slots.d.ts +29 -0
  91. package/lib/types/client/turn-slots.d.ts.map +1 -0
  92. package/lib/types/client/turn-slots.js +62 -0
  93. package/lib/types/client/turn-slots.js.map +1 -0
  94. package/lib/types/client/types.d.ts +18 -0
  95. package/lib/types/client/types.d.ts.map +1 -1
  96. package/lib/types/dsh-better-display.d.ts +4 -1
  97. package/lib/types/dsh-better-display.d.ts.map +1 -1
  98. package/lib/types/dsh-better-display.js +55 -43
  99. package/lib/types/dsh-better-display.js.map +1 -1
  100. package/lib/types/flags.d.ts +7 -0
  101. package/lib/types/flags.d.ts.map +1 -0
  102. package/lib/types/flags.js +37 -0
  103. package/lib/types/flags.js.map +1 -0
  104. package/lib/types/log.d.ts +17 -0
  105. package/lib/types/log.d.ts.map +1 -0
  106. package/lib/types/log.js +122 -0
  107. package/lib/types/log.js.map +1 -0
  108. package/lib/types/metrics.d.ts +18 -0
  109. package/lib/types/metrics.d.ts.map +1 -0
  110. package/lib/types/metrics.js +35 -0
  111. package/lib/types/metrics.js.map +1 -0
  112. package/lib/types/reveal.d.ts +12 -0
  113. package/lib/types/reveal.d.ts.map +1 -0
  114. package/lib/types/reveal.js +147 -0
  115. package/lib/types/reveal.js.map +1 -0
  116. package/lib/types/workspace-path.d.ts +3 -0
  117. package/lib/types/workspace-path.d.ts.map +1 -0
  118. package/lib/types/workspace-path.js +5 -0
  119. package/lib/types/workspace-path.js.map +1 -0
  120. package/package.json +36 -5
  121. package/scripts/changelog.mjs +20 -0
  122. package/scripts/check-agents-md.mjs +67 -0
  123. package/scripts/check-bundle-size.mjs +38 -0
  124. package/scripts/check-dead-flags.mjs +55 -0
  125. package/scripts/check-todo-debt.mjs +69 -0
  126. package/scripts/security-audit-report.mjs +92 -0
  127. package/scripts/verify-commentary-visible.mjs +627 -0
  128. package/src/client/AssistantNode.tsx +72 -0
  129. package/src/client/Blocks.tsx +49 -10
  130. package/src/client/ChoreographedFlow.tsx +3 -1
  131. package/src/client/ClosedProcessSummary.tsx +3 -1
  132. package/src/client/McpAppFrame.tsx +5 -13
  133. package/src/client/OfficialContent.tsx +47 -9
  134. package/src/client/Reader.module.css +103 -21
  135. package/src/client/Reader.tsx +83 -88
  136. package/src/client/SettingsSection.tsx +1 -7
  137. package/src/client/StickyLane.tsx +81 -2
  138. package/src/client/ToolActivity.tsx +10 -6
  139. package/src/client/deliverables.ts +1 -0
  140. package/src/client/index.tsx +18 -2
  141. package/src/client/live-turn.ts +100 -69
  142. package/src/client/motion.tsx +142 -7
  143. package/src/client/official-slots.tsx +23 -4
  144. package/src/client/open-file.ts +2 -8
  145. package/src/client/projection.ts +16 -4
  146. package/src/client/rewind-cut.ts +340 -0
  147. package/src/client/styles.ts +22 -0
  148. package/src/client/text-transform.ts +265 -0
  149. package/src/client/timeline.ts +0 -2
  150. package/src/client/tool-activity.ts +67 -9
  151. package/src/client/turn-slots.ts +80 -0
  152. package/src/client/types.ts +19 -0
  153. package/src/dsh-better-display.ts +57 -42
  154. package/src/flags.ts +40 -0
  155. package/src/log.ts +137 -0
  156. package/src/metrics.ts +50 -0
  157. package/src/reveal.ts +90 -0
  158. package/src/workspace-path.ts +4 -0
  159. package/tests/browser-commentary.tsx +191 -0
  160. package/tests/chat-flow-hook.test.ts +1 -1
  161. package/tests/cloud-gate.test.ts +1 -1
  162. package/tests/context-tool-update.test.ts +1 -1
  163. package/tests/conversation-scroll.test.ts +1 -1
  164. package/tests/deliverables.test.ts +1 -1
  165. package/tests/entry.test.ts +1 -1
  166. package/tests/fixtures/reader-lane-owners.tsx +46 -0
  167. package/tests/fixtures/reader-official-users.tsx +234 -0
  168. package/tests/fixtures/reader-theme-overlap.html +80 -0
  169. package/tests/fixtures/reader-user-bubble.tsx +222 -0
  170. package/tests/flags.test.ts +66 -0
  171. package/tests/fold-choreography.test.ts +32 -8
  172. package/tests/fold-intensity.test.ts +11 -6
  173. package/tests/footer-precedence.test.ts +9 -1
  174. package/tests/history-word-regression.test.ts +1 -1
  175. package/tests/live-turn.test.ts +125 -54
  176. package/tests/log.test.ts +99 -0
  177. package/tests/mcp-apps.test.ts +1 -1
  178. package/tests/message-chrome.test.ts +1 -1
  179. package/tests/metrics.test.ts +52 -0
  180. package/tests/official-slots.test.ts +41 -2
  181. package/tests/open-file.test.ts +1 -1
  182. package/tests/pending-submission.test.ts +12 -1
  183. package/tests/pinned-selection.test.ts +16 -0
  184. package/tests/platform-media.test.ts +1 -1
  185. package/tests/projection.test.ts +66 -3
  186. package/tests/reader-glass-css.test.ts +28 -0
  187. package/tests/reader-official-users.test.ts +91 -0
  188. package/tests/reader-sticky-surface.test.ts +377 -0
  189. package/tests/reader-theme-lanes.test.ts +54 -0
  190. package/tests/reader-theme-overlap.test.ts +151 -0
  191. package/tests/reader-user-bubble.test.ts +137 -0
  192. package/tests/reasoning-follow.test.ts +1 -1
  193. package/tests/retiring-content.test.ts +229 -0
  194. package/tests/reveal.test.ts +128 -0
  195. package/tests/rewind-cut.test.ts +1090 -0
  196. package/tests/settings-copy.test.ts +1 -1
  197. package/tests/skill-status.test.ts +1 -1
  198. package/tests/stock-install.test.ts +9 -1
  199. package/tests/stream-buffer.test.ts +1 -1
  200. package/tests/styles.test.ts +75 -0
  201. package/tests/terminal-input-preview.test.ts +113 -0
  202. package/tests/text-transform.test.ts +272 -0
  203. package/tests/timeline.test.ts +1 -1
  204. package/tests/tool-clock.test.ts +1 -1
  205. package/tests/tool-diff.test.ts +1 -1
  206. package/tests/waiting-clock.test.ts +1 -1
  207. package/tests/word-timeline.test.ts +1 -1
  208. package/tsdown.config.ts +49 -1
@@ -1,10 +1,10 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import type { AssistantChatData, ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
4
4
  import type { AssistantBlock, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client';
5
5
  import { assistantSegments, processExpanded } from '../src/client/projection.ts';
6
6
  import type { TurnBoundary } from '../src/client/projection.ts';
7
- import { foldSummary, presentLiveTurn, segmentLiveTurn, splitChain } from '../src/client/live-turn.ts';
7
+ import { planProcessSeams, presentLiveTurn, segmentLiveTurn } from '../src/client/live-turn.ts';
8
8
  import type { LiveStep } from '../src/client/live-turn.ts';
9
9
  import { readerFlow } from '../src/client/tool-activity.ts';
10
10
 
@@ -31,69 +31,96 @@ function kinds(items: ReturnType<typeof presentLiveTurn>) {
31
31
  return items.map(item => item.kind === 'fold' ? ['fold', item.summary, item.steps.map(step => step.key)] : [item.kind, item.key]);
32
32
  }
33
33
 
34
- test('body or tool alone never folds, including after the first reasoning', () => {
35
- assert.equal(splitChain([reasoning('1')]).fold, null);
36
- assert.equal(splitChain([reasoning('1'), body('2')]).fold, null);
37
- assert.equal(splitChain([reasoning('1'), body('2'), tool('3')]).fold, null);
38
- assert.equal(splitChain([body('2'), tool('3')]).fold, null);
39
- assert.equal(splitChain([other('ctx'), reasoning('1'), body('2')]).fold, null);
34
+ test('narration is never folded; earlier process runs collapse even across narration', () => {
35
+ const beforeSecondReasoning = presentLiveTurn([reasoning('1'), body('2')], open);
36
+ assert.deepEqual(kinds(beforeSecondReasoning), [['open', '1'], ['open', '2']]);
37
+
38
+ // The first reasoning run folds in place before the narration; the narration
39
+ // itself stays open, and the latest reasoning stays open.
40
+ const afterSecondReasoning = presentLiveTurn([reasoning('1'), body('2'), reasoning('3')], open);
41
+ assert.deepEqual(kinds(afterSecondReasoning), [
42
+ ['fold', '思考×1', ['1']],
43
+ ['open', '2'],
44
+ ['open', '3'],
45
+ ]);
46
+
47
+ const mixed = presentLiveTurn([reasoning('1'), tool('2'), body('3'), reasoning('4'), tool('5'), reasoning('6')], open);
48
+ assert.deepEqual(kinds(mixed), [
49
+ ['fold', '思考×1 · 工具×1', ['1', '2']],
50
+ ['open', '3'],
51
+ ['fold', '思考×1 · 工具×1', ['4', '5']],
52
+ ['open', '6'],
53
+ ]);
54
+ assert.equal(mixed.some(item => item.kind === 'fold' && item.steps.some(step => step.kind === 'body')), false);
55
+ for (const item of mixed) {
56
+ if (item.kind === 'fold') assert.equal(item.summary.includes('输出'), false);
57
+ }
40
58
  });
41
59
 
42
- test('a second reasoning folds every prior step into one box, then a third folds everything before it', () => {
43
- const first = splitChain([reasoning('1'), body('2'), tool('3')]);
44
- assert.equal(first.fold, null);
45
- assert.deepEqual(first.open.map(step => step.key), ['1', '2', '3']);
46
-
47
- const second = splitChain([reasoning('1'), body('2'), tool('3'), reasoning('4')]);
48
- assert.deepEqual(second.fold?.map(step => step.key), ['1', '2', '3']);
49
- assert.deepEqual(second.open.map(step => step.key), ['4']);
50
- assert.equal(foldSummary(second.fold!), '思考×1 · 输出×1 · 工具×1');
51
- if (second.fold?.[0]?.kind !== 'reasoning') throw new Error('folded reasoning missing');
52
- assert.equal(second.fold[0].blocks[0]?.kind === 'reasoning' && second.fold[0].blocks[0].text, '思考 1');
53
- assert.equal(foldSummary(second.fold).includes('思考 1'), false);
54
-
55
- const withTool = splitChain([reasoning('1'), body('2'), tool('3'), reasoning('4'), tool('5')]);
56
- assert.deepEqual(withTool.fold?.map(step => step.key), ['1', '2', '3']);
57
- assert.deepEqual(withTool.open.map(step => step.key), ['4', '5']);
58
-
59
- const third = splitChain([reasoning('1'), body('2'), tool('3'), reasoning('4'), tool('5'), reasoning('6')]);
60
- assert.deepEqual(third.fold?.map(step => step.key), ['1', '2', '3', '4', '5']);
61
- assert.deepEqual(third.open.map(step => step.key), ['6']);
62
- assert.equal(foldSummary(third.fold!), '思考×2 · 输出×1 · 工具×2');
63
-
64
- const latest = splitChain([reasoning('1'), body('2'), tool('3'), reasoning('4'), tool('5'), reasoning('6'), body('7')]);
65
- assert.deepEqual(latest.fold?.map(step => step.key), ['1', '2', '3', '4', '5']);
66
- assert.deepEqual(latest.open.map(step => step.key), ['6', '7']);
60
+ test('a second reasoning folds the foldable run before it, never narration', () => {
61
+ const first = presentLiveTurn([reasoning('1'), tool('2')], open);
62
+ assert.deepEqual(kinds(first), [['open', '1'], ['open', '2']]);
63
+
64
+ const second = presentLiveTurn([reasoning('1'), tool('2'), reasoning('3')], open);
65
+ assert.deepEqual(kinds(second), [['fold', '思考×1 · 工具×1', ['1', '2']], ['open', '3']]);
66
+ if (second[0]?.kind !== 'fold') throw new Error('missing fold');
67
+ if (second[0].steps[0]?.kind !== 'reasoning') throw new Error('folded reasoning missing');
68
+ assert.equal(second[0].steps[0].blocks[0]?.kind === 'reasoning' && second[0].steps[0].blocks[0].text, '思考 1');
69
+ assert.equal(second[0].summary.includes('思考 1'), false);
70
+
71
+ const grown = presentLiveTurn([reasoning('1'), tool('2'), reasoning('3'), tool('4'), reasoning('5')], open);
72
+ if (grown[0]?.kind !== 'fold') throw new Error('missing grown fold');
73
+ assert.deepEqual(grown[0].steps.map(step => step.key), ['1', '2', '3', '4']);
74
+ assert.deepEqual(grown.filter(item => item.kind === 'open').map(item => item.key), ['5']);
75
+ assert.equal(grown[0].summary, '思考×2 · 工具×2');
76
+
77
+ const narrationBetween = presentLiveTurn([reasoning('1'), body('2'), reasoning('3'), tool('4'), reasoning('5')], open);
78
+ assert.deepEqual(kinds(narrationBetween), [
79
+ ['fold', '思考×1', ['1']],
80
+ ['open', '2'],
81
+ ['fold', '思考×1 · 工具×1', ['3', '4']],
82
+ ['open', '5'],
83
+ ]);
67
84
  });
68
85
 
69
86
  test('fold presentation keeps prior step keys so the reader can collapse the same process fragments in place', () => {
70
- const chain = [reasoning('1'), body('2'), tool('3')];
87
+ const chain = [reasoning('1'), tool('2')];
71
88
  const before = presentLiveTurn(chain, open);
72
- assert.deepEqual(before.map(item => item.key), ['1', '2', '3']);
89
+ assert.deepEqual(before.map(item => item.key), ['1', '2']);
73
90
 
74
- const second = presentLiveTurn([...chain, reasoning('4')], open);
91
+ const second = presentLiveTurn([...chain, reasoning('3')], open);
75
92
  assert.equal(second[0]?.kind, 'fold');
76
93
  if (second[0]?.kind !== 'fold') throw new Error('missing fold');
77
94
  assert.equal(second[0].key, 'live-fold:1');
78
95
  assert.deepEqual(second[0].steps.map(step => step.key), before.map(item => item.key));
79
- assert.deepEqual(second.filter(item => item.kind === 'open').map(item => item.key), ['4']);
96
+ assert.deepEqual(second.filter(item => item.kind === 'open').map(item => item.key), ['3']);
80
97
 
81
- const third = presentLiveTurn([...chain, reasoning('4'), tool('5'), reasoning('6')], open);
98
+ const third = presentLiveTurn([...chain, reasoning('3'), tool('4'), reasoning('5')], open);
82
99
  if (third[0]?.kind !== 'fold') throw new Error('missing grown fold');
83
100
  assert.equal(third[0].key, 'live-fold:1');
84
- assert.deepEqual(third[0].steps.map(step => step.key), ['1', '2', '3', '4', '5']);
101
+ assert.deepEqual(third[0].steps.map(step => step.key), ['1', '2', '3', '4']);
85
102
  });
86
103
 
87
- test('presentLiveTurn keeps a single prior box rather than one fold per step', () => {
88
- const items = presentLiveTurn([reasoning('1'), body('2'), tool('3'), reasoning('4'), tool('5'), reasoning('6'), body('7')], open);
104
+ test('presentLiveTurn keeps one fold box per foldable run and never hides narration', () => {
105
+ const items = presentLiveTurn([reasoning('1'), tool('2'), reasoning('3'), tool('4'), reasoning('5')], open);
89
106
  const folds = items.filter(item => item.kind === 'fold');
90
107
  assert.equal(folds.length, 1);
91
108
  if (folds[0]?.kind !== 'fold') throw new Error('missing fold');
92
- assert.equal(folds[0].steps.length, 5);
93
- assert.deepEqual(items.filter(item => item.kind === 'open').map(item => item.key), ['6', '7']);
109
+ assert.equal(folds[0].steps.length, 4);
110
+ assert.deepEqual(items.filter(item => item.kind === 'open').map(item => item.key), ['5']);
111
+
112
+ const withNarration = presentLiveTurn([reasoning('1'), tool('2'), body('3'), reasoning('4'), tool('5'), reasoning('6')], open);
113
+ const narratedFolds = withNarration.filter(item => item.kind === 'fold');
114
+ assert.equal(narratedFolds.length, 2);
115
+ assert.deepEqual(withNarration.filter(item => item.kind === 'open').map(item => item.key), ['3', '6']);
116
+ assert.deepEqual(narratedFolds.map(fold => fold.kind === 'fold' ? fold.steps.map(step => step.key) : []), [['1', '2'], ['4', '5']]);
117
+ for (const fold of narratedFolds) {
118
+ if (fold.kind !== 'fold') continue;
119
+ assert.equal(fold.steps.some(step => step.kind === 'body'), false);
120
+ }
94
121
  });
95
122
 
96
- test('mid-turn user insert resets the chain; only the next reasoning folds post-insert priors', () => {
123
+ test('mid-turn user insert resets the chain; narration stays open across the reset', () => {
97
124
  const before = presentLiveTurn([reasoning('1'), body('2'), tool('3'), user('insert'), body('4'), tool('5')], open);
98
125
  assert.deepEqual(kinds(before), [
99
126
  ['open', '1'], ['open', '2'], ['open', '3'],
@@ -105,28 +132,30 @@ test('mid-turn user insert resets the chain; only the next reasoning folds post-
105
132
  assert.deepEqual(kinds(after), [
106
133
  ['open', '1'], ['open', '2'], ['open', '3'],
107
134
  ['user', 'insert'],
108
- ['fold', '输出×1 · 工具×1', ['4', '5']],
135
+ ['open', '4'],
136
+ ['fold', '工具×1', ['5']],
109
137
  ['open', '6'],
110
138
  ]);
111
139
  const folds = after.filter(item => item.kind === 'fold');
112
140
  assert.equal(folds.length, 1);
113
141
  if (folds[0]?.kind !== 'fold') throw new Error('missing insert fold');
114
- assert.ok(!folds[0].steps.some(step => ['1', '2', '3', 'insert'].includes(step.key)));
142
+ assert.ok(!folds[0].steps.some(step => ['1', '2', '3', '4', 'insert'].includes(step.key)));
115
143
  });
116
144
 
117
- test('each chain has at most one fold box; a later chain does not split priors into per-step folds', () => {
145
+ test('every prior process run folds in its own place; narration stays open between runs', () => {
118
146
  const items = presentLiveTurn([
119
- reasoning('1'), body('2'), tool('3'), reasoning('4'),
120
- user('insert'),
121
- body('5'), tool('6'), reasoning('7'),
147
+ reasoning('1'), tool('2'), reasoning('3'),
148
+ body('4'),
149
+ tool('5'), reasoning('6'), tool('7'), reasoning('8'),
122
150
  ], open);
123
151
  const folds = items.filter(item => item.kind === 'fold');
124
152
  assert.equal(folds.length, 2);
125
- if (folds[0]?.kind !== 'fold' || folds[1]?.kind !== 'fold') throw new Error('expected one box per chain');
153
+ if (folds[0]?.kind !== 'fold' || folds[1]?.kind !== 'fold') throw new Error('expected one box per run');
126
154
  assert.deepEqual(folds[0].steps.map(step => step.key), ['1', '2', '3']);
127
- assert.deepEqual(folds[1].steps.map(step => step.key), ['5', '6']);
128
- assert.equal(folds[0].summary, '思考×1 · 输出×1 · 工具×1');
129
- assert.equal(folds[1].summary, '输出×1 · 工具×1');
155
+ assert.deepEqual(folds[1].steps.map(step => step.key), ['5', '6', '7']);
156
+ assert.equal(folds[0].summary, '思考×2 · 工具×1');
157
+ assert.equal(folds[1].summary, '思考×1 · 工具×2');
158
+ assert.deepEqual(items.filter(item => item.kind === 'open').map(item => item.key), ['4', '8']);
130
159
  });
131
160
 
132
161
  test('successful turn close disables live fold so the existing final-answer rules still apply', () => {
@@ -216,3 +245,45 @@ test('disabling auto-fold keeps all steps open as plain streaming steps', () =>
216
245
  assert.deepEqual(items.map(item => item.key), ['1', '2', '3', '4', '5', '6']);
217
246
  assert.deepEqual(items.map(item => item.kind), ['open', 'open', 'open', 'open', 'open', 'open']);
218
247
  });
248
+
249
+ /** Issue #73: body, then a process run, then another body, then a later reasoning. */
250
+ function issue73Steps(): LiveStep[] {
251
+ return [
252
+ body('body1'),
253
+ reasoning('r1'), reasoning('r2'),
254
+ tool('t1'), tool('t2'), tool('t3'),
255
+ other('rec1'),
256
+ body('body2'),
257
+ reasoning('rLatest'),
258
+ ];
259
+ }
260
+
261
+ test('open turn does not seam a run the live fold already summarises (#73)', () => {
262
+ const steps = issue73Steps();
263
+ const live = presentLiveTurn(steps, open, true);
264
+ const folds = live.filter(item => item.kind === 'fold');
265
+ assert.equal(folds.length, 1);
266
+ if (folds[0]?.kind !== 'fold') throw new Error('missing live fold');
267
+ assert.equal(folds[0].summary, '思考×2 · 工具×3 · 记录×1');
268
+ assert.deepEqual(folds[0].steps.map(step => step.key), ['r1', 'r2', 't1', 't2', 't3', 'rec1']);
269
+
270
+ const seams = planProcessSeams(steps, live, false);
271
+ assert.equal(seams.before.has('body2'), false);
272
+ assert.equal(seams.after.has('body1'), false);
273
+ assert.equal(seams.before.size, 0);
274
+ assert.equal(seams.after.size, 0);
275
+ });
276
+
277
+ test('closed turn keeps the seam between narration blocks and the previous body spacing (#73)', () => {
278
+ const steps = issue73Steps();
279
+ const live = presentLiveTurn(steps, completed, true);
280
+ assert.equal(live.some(item => item.kind === 'fold'), false);
281
+
282
+ const seams = planProcessSeams(steps, live, false);
283
+ assert.equal(seams.before.get('body2'), '思考×2 · 工具×3 · 记录×1');
284
+ assert.equal(seams.after.has('body1'), true);
285
+
286
+ const expanded = planProcessSeams(steps, live, true);
287
+ assert.equal(expanded.before.size, 0);
288
+ assert.equal(expanded.after.size, 0);
289
+ });
@@ -0,0 +1,99 @@
1
+ import assert from 'node:assert/strict';
2
+ import { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { Socket } from 'node:net';
4
+ import { test } from 'vitest';
5
+ import { createLogger, redact, REDACTED, requestIdOf } from '../src/log.js';
6
+ import { apply } from '../src/dsh-better-display.js';
7
+ import { ROUTE_REVEAL } from '../src/metrics.js';
8
+
9
+ const SECRET = 's3cr3t-token-value';
10
+
11
+ test('logger redacts authorization headers, tokens, and secret-looking query values', () => {
12
+ const lines: string[] = [];
13
+ const logger = createLogger({ write: line => lines.push(line), clock: () => new Date('2026-10-20T00:00:00.000Z') });
14
+ logger.info('http request', {
15
+ headers: {
16
+ authorization: `Bearer ${SECRET}`,
17
+ 'x-api-key': SECRET,
18
+ 'x-request-id': 'req-1',
19
+ accept: 'application/json',
20
+ },
21
+ url: `/better-display/skill-status?cwd=/work&token=${SECRET}&api_key=${SECRET}&password=${SECRET}`,
22
+ token: SECRET,
23
+ access_token: SECRET,
24
+ });
25
+ const record = JSON.parse(lines[0] ?? '{}') as {
26
+ level: string;
27
+ msg: string;
28
+ time: string;
29
+ headers: { authorization: string; 'x-api-key': string; 'x-request-id': string; accept: string };
30
+ url: string;
31
+ token: string;
32
+ access_token: string;
33
+ };
34
+ const text = lines.join('\n');
35
+ assert.equal(record.level, 'info');
36
+ assert.equal(record.msg, 'http request');
37
+ assert.equal(record.time, '2026-10-20T00:00:00.000Z');
38
+ assert.equal(record.headers.authorization, REDACTED);
39
+ assert.equal(record.headers['x-api-key'], REDACTED);
40
+ assert.equal(record.token, REDACTED);
41
+ assert.equal(record.access_token, REDACTED);
42
+ assert.equal(record.headers['x-request-id'], 'req-1');
43
+ assert.equal(record.headers.accept, 'application/json');
44
+ assert.match(record.url, /cwd=\/work/);
45
+ assert.match(record.url, /token=\[REDACTED\]/);
46
+ assert.match(record.url, /api_key=\[REDACTED\]/);
47
+ assert.match(record.url, /password=\[REDACTED\]/);
48
+ assert.equal(text.includes(SECRET), false);
49
+ assert.deepEqual(redact({ authorization: `Bearer ${SECRET}`, accept: 'application/json' }), {
50
+ authorization: REDACTED,
51
+ accept: 'application/json',
52
+ });
53
+ });
54
+
55
+ test('request ids keep a safe X-Request-ID and replace anything else', () => {
56
+ const kept = requestIdOf({ headers: { 'x-request-id': 'req.trace_1:part-a' } } as IncomingMessage);
57
+ assert.equal(kept, 'req.trace_1:part-a');
58
+ const firstOfMany = requestIdOf({ headers: { 'x-request-id': ['batch-7', 'batch-8'] } } as IncomingMessage);
59
+ assert.equal(firstOfMany, 'batch-7');
60
+ const generated = requestIdOf({ headers: { 'x-request-id': 'bad id\ninjected' } } as IncomingMessage);
61
+ assert.notEqual(generated, 'bad id\ninjected');
62
+ assert.match(generated, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
63
+ assert.match(requestIdOf({ headers: {} } as IncomingMessage), /^[0-9a-f-]{36}$/);
64
+ });
65
+
66
+ test('reveal handler logs the request id and does not log the secret query', () => {
67
+ const lines: string[] = [];
68
+ const original = console.log;
69
+ console.log = (line?: unknown) => { lines.push(String(line)); };
70
+ try {
71
+ const routes: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }[] = [];
72
+ apply({
73
+ webServer: {
74
+ register(route: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }) {
75
+ routes.push(route);
76
+ return () => undefined;
77
+ },
78
+ },
79
+ effect(run: () => void) { run(); },
80
+ } as never, { flags: { readerEnabled: true } });
81
+ const reveal = routes.find(route => route.path === ROUTE_REVEAL);
82
+ assert.ok(reveal);
83
+ const req = new IncomingMessage(new Socket());
84
+ req.method = 'GET';
85
+ req.url = `${ROUTE_REVEAL}?cwd=/work&token=${SECRET}`;
86
+ req.headers = { authorization: `Bearer ${SECRET}`, 'x-request-id': 'req-reveal-1' };
87
+ const res = new ServerResponse(req);
88
+ res.end = (() => res) as ServerResponse['end'];
89
+ reveal.handler(req, res);
90
+ assert.equal(res.statusCode, 405);
91
+ assert.equal(res.getHeader('X-Request-ID'), 'req-reveal-1');
92
+ const text = lines.join('\n');
93
+ assert.match(text, /\[my-plugins\/dsh-better-display\] loaded/);
94
+ assert.match(text, /req-reveal-1/);
95
+ assert.equal(text.includes(SECRET), false);
96
+ } finally {
97
+ console.log = original;
98
+ }
99
+ });
@@ -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 { Code } from 'mdast';
4
4
  import {
5
5
  isMcpAppCodeBlock,
@@ -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 { formatMessageClock, formatRanFor, formatRunDuration } from '../src/client/message-chrome.ts';
4
4
 
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { Socket } from 'node:net';
4
+ import { test } from 'vitest';
5
+ import { apply } from '../src/dsh-better-display.js';
6
+ import { metricsPayload, recordRouteDuration, recordRouteHit, ROUTE_METRICS, ROUTE_REVEAL, ROUTE_SKILL_STATUS } from '../src/metrics.js';
7
+
8
+ test('route hit counter increments', () => {
9
+ const before = metricsPayload();
10
+ const hitsBefore = before.counters.dsh_better_display_route_hits_total[ROUTE_REVEAL] ?? 0;
11
+ const totalBefore = before.timingsMs[ROUTE_REVEAL]?.totalMs ?? 0;
12
+ const next = recordRouteHit(ROUTE_REVEAL);
13
+ recordRouteDuration(ROUTE_REVEAL, 12.4);
14
+ const after = metricsPayload();
15
+ assert.equal(next, hitsBefore + 1);
16
+ assert.equal(after.counters.dsh_better_display_route_hits_total[ROUTE_REVEAL], hitsBefore + 1);
17
+ assert.equal(after.timingsMs[ROUTE_REVEAL]?.totalMs, totalBefore + 12);
18
+ assert.ok((after.timingsMs[ROUTE_REVEAL]?.maxMs ?? 0) >= 12);
19
+ });
20
+
21
+ test('GET /better-display/metrics returns the incremented route counter', () => {
22
+ const routes: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }[] = [];
23
+ apply({
24
+ webServer: {
25
+ register(route: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }) {
26
+ routes.push(route);
27
+ return () => undefined;
28
+ },
29
+ },
30
+ effect(run: () => void) { run(); },
31
+ } as never, { flags: { readerEnabled: true } });
32
+ assert.deepEqual(routes.map(route => route.path).sort(), [ROUTE_METRICS, ROUTE_REVEAL, ROUTE_SKILL_STATUS].sort());
33
+ const metrics = routes.find(route => route.path === ROUTE_METRICS);
34
+ assert.ok(metrics);
35
+ const before = metricsPayload().counters.dsh_better_display_route_hits_total[ROUTE_METRICS] ?? 0;
36
+ const req = new IncomingMessage(new Socket());
37
+ req.method = 'GET';
38
+ req.url = ROUTE_METRICS;
39
+ req.headers = { 'x-request-id': 'req-metrics-1' };
40
+ const res = new ServerResponse(req);
41
+ let body = '';
42
+ res.end = ((chunk?: string) => {
43
+ body = chunk ?? '';
44
+ return res;
45
+ }) as ServerResponse['end'];
46
+ metrics.handler(req, res);
47
+ assert.equal(res.statusCode, 200);
48
+ assert.equal(res.getHeader('content-type'), 'application/json');
49
+ assert.equal(res.getHeader('X-Request-ID'), 'req-metrics-1');
50
+ const parsed = JSON.parse(body) as { counters: { dsh_better_display_route_hits_total: Record<string, number> } };
51
+ assert.equal(parsed.counters.dsh_better_display_route_hits_total[ROUTE_METRICS], before + 1);
52
+ });
@@ -1,8 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
- import test from 'node:test';
2
+ import { test } from 'vitest';
3
3
  import { memo } from 'react';
4
4
  import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots';
5
- import { mirrorOfficialSlot, readerTailMatch, type CompositionRegistry } from '../src/client/official-slots.js';
5
+ import { acceptMirroredChatNode, mirrorOfficialSlot, readerTailMatch, type CompositionRegistry } from '../src/client/official-slots.js';
6
6
 
7
7
  function registry(kind: 'keyed' | 'chain' | 'list' = 'keyed') {
8
8
  const core = new SlotCore();
@@ -96,6 +96,45 @@ test('new registrations do not remount existing cards; winner changes and unload
96
96
  assert.equal(core.entriesOfSlot('reader').length, 0);
97
97
  });
98
98
 
99
+ test('a lower-priority assistant-step replacement is mirrored and the official renderer is not', () => {
100
+ const { core, slots, register } = registry();
101
+ const official = memo(() => null);
102
+ const foreign = memo(() => null);
103
+ const tool = memo(() => null);
104
+ const user = memo(() => null);
105
+ register({ name: 'source', key: 'assistant-step' }, official);
106
+ register({ name: 'source', key: 'assistant-step', priority: -10 }, foreign);
107
+ register({ name: 'source', key: 'tool-call', priority: -10 }, tool);
108
+ register({ name: 'source', key: 'turn-process' }, memo(() => null));
109
+ register({ name: 'source', key: 'user' }, user);
110
+ const winner = core.entriesOfSlot('source').find(entry => entry.options.key === 'assistant-step');
111
+ assert.ok(winner);
112
+ assert.equal(acceptMirroredChatNode(winner), true);
113
+ assert.equal(winner.component, foreign);
114
+ const stop = mirrorOfficialSlot(slots, 'source', 'reader', 'reader.children', acceptMirroredChatNode);
115
+ const mirrored = core.entriesOfSlot('reader');
116
+ assert.deepEqual(mirrored.map(entry => entry.options.key).sort(), ['assistant-step', 'user']);
117
+ assert.equal(mirrored.find(entry => entry.options.key === 'assistant-step')?.component, foreign);
118
+ assert.equal(mirrored.find(entry => entry.options.key === 'assistant-step')?.options.priority, -10);
119
+ assert.equal(mirrored.some(entry => entry.component === official || entry.component === tool), false);
120
+ stop();
121
+ assert.equal(core.entriesOfSlot('reader').length, 0);
122
+ assert.equal(core.entriesOfSlot('source').find(entry => entry.options.key === 'assistant-step')?.component, foreign);
123
+ });
124
+
125
+ test('the official assistant-step priority stays on the source slot', () => {
126
+ const { core, slots, register } = registry();
127
+ const official = memo(() => null);
128
+ register({ name: 'source', key: 'assistant-step', priority: 0 }, official);
129
+ const entry = core.entriesOfSlot('source')[0];
130
+ assert.ok(entry);
131
+ assert.equal(acceptMirroredChatNode(entry), false);
132
+ const stop = mirrorOfficialSlot(slots, 'source', 'reader', 'reader.children', acceptMirroredChatNode);
133
+ assert.equal(core.entriesOfSlot('reader').length, 0);
134
+ assert.equal(core.entriesOfSlot('source')[0]?.component, official);
135
+ stop();
136
+ });
137
+
99
138
  test('partial setup rolls back its own registrations on a nested contract failure', () => {
100
139
  const { core, slots, register } = registry();
101
140
  register({ name: 'source', key: 'nested', children: { detail: { kind: 'single', scope: 'session' } } }, () => null);
@@ -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 { readFileSync } from 'node:fs';
4
4
  import { dirname, resolve } from 'node:path';
@@ -35,6 +35,17 @@ test('pendingSubmissions that is not an array is treated as empty', () => {
35
35
  assert.deepEqual(asReadonlyArray([{ requestId: 'a' }]), [{ requestId: 'a' }]);
36
36
  });
37
37
 
38
+ test('pending submission bubble keeps the host font metrics', () => {
39
+ const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
40
+ const css = readFileSync(resolve(root, 'src/client/Reader.module.css'), 'utf8');
41
+ assert.match(source, /data-reader-pending-submission[\s\S]*className=\{css\.user\}/);
42
+ const user = css.match(/\.user\s*\{[^}]*\}/)?.[0] ?? '';
43
+ assert.match(user, /font-size:\s*var\(--dsh-content-font-size,\s*14px\)/);
44
+ assert.match(user, /line-height:\s*calc\(22px \+ var\(--dsh-content-font-delta,\s*0px\)\)/);
45
+ assert.match(user, /font-weight:\s*400/);
46
+ assert.match(user, /white-space:\s*pre-wrap/);
47
+ });
48
+
38
49
  test('Reader and committed client bundle do not read unguarded submission.images.length', () => {
39
50
  const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
40
51
  assert.match(source, /pendingSubmissionImages/);
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
5
+
6
+ describe('pinned selection', () => {
7
+ it('covers every Reader body marker, so final formatting keeps a live selection', () => {
8
+ const reader = [read('../src/client/Reader.tsx'), read('../src/client/AssistantNode.tsx')].join('\n');
9
+ const motion = read('../src/client/motion.tsx');
10
+ const selector = /usePinnedSelection\([^)]*selector = '([^']+)'/.exec(motion)?.[1] ?? '';
11
+ const markers = ['data-reader-answer', 'data-reader-body'].filter(marker => new RegExp(`<article[^>]*\\b${marker}\\b`).test(reader));
12
+ expect(markers.length).toBeGreaterThan(0);
13
+ for (const marker of markers) expect(selector).toContain(`[${marker}]`);
14
+ expect(selector).toContain('[data-reader-process]');
15
+ });
16
+ });
@@ -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 { localPathMediaUrl } from '../src/client/platform-media.js';
4
4
 
@@ -1,8 +1,9 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import type { AssistantBlock, ToolCallBlock, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client';
4
4
  import type { AssistantChatData, ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
5
5
  import { assistantSegments, boundaryOf, forkAnchorSeq, groupNodes, hasProcessContent, hasVisibleBody, isEarlierNarration, processChoiceKey, processExpanded, terminalLabel, toolFailed } from '../src/client/projection.ts';
6
+ import { visibleChatKeys } from '../src/client/rewind-cut.ts';
6
7
  import type { TurnBoundary } from '../src/client/projection.ts';
7
8
  import { activityPhase, activitySummary, inputFields, readerFlow } from '../src/client/tool-activity.ts';
8
9
 
@@ -70,14 +71,15 @@ test('reading during a run does not keep the completed process open; reopening a
70
71
  assert.equal(processExpanded(choices[processChoiceKey('turn:2', completed)], completed), false);
71
72
  });
72
73
 
73
- test('body-only steps do not advertise empty thinking, while real reasoning and earlier progress remain accessible', () => {
74
+ test('body-only steps never advertise process content, even when they are earlier narration', () => {
74
75
  const node = (data: AssistantChatData) => ({ kind: 'assistant-step', visibility: 'visible', data }) as ChatConversationViewNode;
75
76
  const closing = node(assistant({ step: 2 }));
76
77
  assert.equal(hasProcessContent(closing, active), false);
77
78
  assert.equal(hasProcessContent(closing, completed), false);
78
79
  assert.equal(hasProcessContent(node(assistant({ step: 2, blocks: [{ kind: 'reasoning', text: '' }, text] })), completed), false);
79
80
  assert.equal(hasProcessContent(node(assistant({ step: 2, blocks: [{ kind: 'reasoning', text: '真实思考' }, text] })), active), true);
80
- assert.equal(hasProcessContent(node(assistant({ step: 1 })), completed), true);
81
+ // Earlier narration stays visible, so it is not a thinking/process disclosure.
82
+ assert.equal(hasProcessContent(node(assistant({ step: 1 })), completed), false);
81
83
  assert.equal(hasProcessContent({ ...closing, visibility: 'hidden' }, completed), false);
82
84
  });
83
85
 
@@ -175,6 +177,67 @@ test('a nonzero terminal exit is a failure even when the tool transport is non-e
175
177
  assert.equal(activityPhase({}, true), 'interrupted');
176
178
  });
177
179
 
180
+ test('a supplementary rewind does not restore a tool draft from a cut step', () => {
181
+ const liveDraft = { kind: 'tool-call' as const, callId: 'live-tool', name: 'bash', argsRaw: '{"command":"pwd"}' };
182
+ const earlyCutDraft = { kind: 'tool-call' as const, callId: 'early-cut', name: 'bash', argsRaw: '{"command":"echo EARLY"}' };
183
+ const cutDraft = { kind: 'tool-call' as const, callId: 'cut-tool', name: 'bash', argsRaw: '{"command":"echo WITHDRAWN_TOOL"}' };
184
+ const orphanDraft = { kind: 'tool-call' as const, callId: 'orphan-cut', name: 'bash', argsRaw: '{"command":"echo ORPHAN"}' };
185
+ const turn = {
186
+ turn: 1,
187
+ steps: [
188
+ { step: 0, start: { seq: 2 }, data: new Map([['assistant-step', { blocks: [liveDraft, earlyCutDraft] }]]) },
189
+ { step: 1, start: { seq: 4 }, data: new Map([['assistant-step', { blocks: [cutDraft, orphanDraft] }]]) },
190
+ ],
191
+ } as unknown as TurnLocation;
192
+ const rows = [
193
+ { key: 'user', kind: 'user', anchorSeq: 1, visibility: 'visible', location: { kind: 'turn', turn }, data: {} },
194
+ { key: 'supplement', kind: 'steering', anchorSeq: 3, visibility: 'visible', location: { kind: 'turn', turn }, data: {} },
195
+ { key: 'early-tool', kind: 'tool-call', anchorSeq: 4, visibility: 'visible', location: { kind: 'turn', turn }, data: { root: { callId: 'early-cut', name: 'bash', argsRaw: '{}' } } },
196
+ { key: 'tool', kind: 'tool-call', anchorSeq: 5, visibility: 'visible', location: { kind: 'turn', turn }, data: { root: { callId: 'cut-tool', name: 'bash', argsRaw: '{}' } } },
197
+ { key: 'cmd', kind: 'command', anchorSeq: 6, visibility: 'visible', location: { kind: 'none' }, data: { name: 'rewind-plugin', args: '@3 chat', outcome: { kind: 'success', sourceEventSeq: 7 } } },
198
+ ] as unknown as ChatConversationViewNode[];
199
+ const byKey = new Map(rows.map(node => [node.key, node]));
200
+ const order = rows.map(node => node.key);
201
+ const visible = visibleChatKeys({ order, nodes: byKey });
202
+ assert.deepEqual([...visible], ['user']);
203
+ const group = groupNodes(visible, key => byKey.get(key))[0];
204
+ if (!group) throw new Error('missing turn group');
205
+ const mainKeys = byKey.get(group.keys[0] ?? '')?.kind === 'user' ? group.keys.slice(1) : group.keys;
206
+ const flow = readerFlow({ ...group, keys: mainKeys }, turn, key => byKey.get(key), order);
207
+ const toolKeys = flow.filter(entry => entry.kind === 'tool').map(entry => entry.key);
208
+ assert.deepEqual(toolKeys, ['reader-tool:live-tool']);
209
+
210
+ const hiddenDraft = { kind: 'tool-call' as const, callId: 'hidden-call', name: 'write', argsRaw: '{"file_path":"/work/view.html"}' };
211
+ const hiddenTurn = {
212
+ turn: 2,
213
+ steps: [{ step: 0, start: { seq: 1 }, data: new Map([['assistant-step', { blocks: [hiddenDraft] }]]) }],
214
+ } as unknown as TurnLocation;
215
+ const hiddenNode = {
216
+ key: 'hidden-tool', kind: 'tool-call', anchorSeq: 2, visibility: 'hidden',
217
+ location: { kind: 'turn', turn: hiddenTurn },
218
+ data: { root: { callId: 'hidden-call', name: 'write', argsRaw: '{}' } },
219
+ } as unknown as ChatConversationViewNode;
220
+ const hiddenOrder = ['hidden-tool'];
221
+ const hiddenFlow = readerFlow(
222
+ { key: 'turn:2', turn: 2, keys: [] },
223
+ hiddenTurn,
224
+ key => key === hiddenNode.key ? hiddenNode : undefined,
225
+ hiddenOrder,
226
+ );
227
+ assert.equal(hiddenFlow[0]?.kind === 'tool' && hiddenFlow[0].key, 'reader-tool:hidden-call');
228
+
229
+ const previewRows = [
230
+ { key: 'user', kind: 'user', anchorSeq: 1, visibility: 'visible', location: { kind: 'turn', turn }, data: {} },
231
+ { key: 'preview', kind: 'command', anchorSeq: 8, visibility: 'visible', location: { kind: 'none' }, data: { name: 'rewind-plugin', args: 'preview @3 both', outcome: { kind: 'success', sourceEventSeq: 9 } } },
232
+ ] as unknown as ChatConversationViewNode[];
233
+ const previewByKey = new Map(previewRows.map(node => [node.key, node]));
234
+ const previewOrder = previewRows.map(node => node.key);
235
+ const previewFlow = readerFlow({ ...group, keys: mainKeys }, turn, key => previewByKey.get(key) ?? byKey.get(key), previewOrder);
236
+ const previewTools = previewFlow.filter(entry => entry.kind === 'tool').map(entry => entry.key);
237
+ assert.ok(previewTools.includes('reader-tool:cut-tool'));
238
+ assert.ok(previewTools.includes('reader-tool:live-tool'));
239
+ });
240
+
178
241
  test('fork anchor prefers the durable closing seq and never yields a missing anchor', () => {
179
242
  assert.equal(forkAnchorSeq([{ seq: 42 }]), 42);
180
243
  assert.equal(forkAnchorSeq([null, undefined, {}, { seq: '42' as unknown as number }, { seq: 7 }]), 7);