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
@@ -0,0 +1,222 @@
1
+ import { Context } from '@deepseek-ai/cordis';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { flushSync } from 'react-dom';
4
+ import { SlotRegistry } from '@fixture/registry';
5
+ import { createSlotRenderer } from '@fixture/renderer';
6
+ import { UserMessageNodeView } from '@fixture/user-message';
7
+ import { Reader } from '../../src/client/Reader.js';
8
+ import { createReaderStore } from '../../src/client/store.js';
9
+ import { OFFICIAL_SEATS, OFFICIAL_SLOTS, installOfficialSlots, officialChildren } from '../../src/client/official-slots.js';
10
+ import type { ReaderProps } from '../../src/client/types.js';
11
+
12
+ // Real Reader, real slot seats, and the host's user/steering renderer.
13
+ // Session text is the only synthetic part. Width is measured after layout.
14
+
15
+ const CASES = [
16
+ { name: 'short', width: 748, axis: '', text: '升级插件' },
17
+ { name: 'thirty', width: 748, axis: '', text: '测'.repeat(30) },
18
+ { name: 'long', width: 748, axis: '', text: '测'.repeat(80) },
19
+ { name: 'narrow', width: 480, axis: '', text: '测'.repeat(30) },
20
+ { name: 'wide-axis', width: 1000, axis: '1000px', text: '测'.repeat(80) },
21
+ ] as const;
22
+
23
+ function turnOf() {
24
+ return {
25
+ turn: 1, status: 'closed' as const, start: { seq: 1, time: 1 },
26
+ end: { time: 2, data: { reason: { kind: 'completed' } } }, data: new Map(), steps: [],
27
+ };
28
+ }
29
+
30
+ function messageNode(kind: 'user' | 'steering', text: string) {
31
+ const turn = turnOf();
32
+ return {
33
+ key: `${kind}-key`, kind, anchorSeq: kind === 'user' ? 1 : 2, visibility: 'visible' as const,
34
+ location: { kind: 'turn' as const, turn },
35
+ data: { seq: kind === 'user' ? 1 : 2, time: 1, content: [{ type: 'text', text }] },
36
+ };
37
+ }
38
+
39
+ function readerProps(text: string, renderSlot: ReaderProps['renderSlot'], renderSlotChain: ReaderProps['renderSlotChain']): ReaderProps {
40
+ const turn = turnOf();
41
+ const user = messageNode('user', text);
42
+ const steering = messageNode('steering', text);
43
+ user.location = { kind: 'turn', turn };
44
+ steering.location = { kind: 'turn', turn };
45
+ const nodes = new Map([[user.key, user], [steering.key, steering]]);
46
+ const chat = { order: [user.key, steering.key], nodes, timeline: { turns: new Map([[1, turn]]) } };
47
+ const session = {
48
+ running: false, openState: 'ready', hasMore: false, loadingOlder: false,
49
+ pendingSubmissions: [{ requestId: `pending-${text.length}`, text, time: 1, placement: 'transcript' }],
50
+ };
51
+ const store = createReaderStore().create(`session-${text.length}`);
52
+ store.actions.setMotion(false);
53
+ const source = <T,>(value: T) => ({ getSnapshot: () => value, subscribe: () => () => {} });
54
+ return {
55
+ sessionId: `session-${text.length}`,
56
+ useChat: (select: (snapshot: typeof chat) => unknown) => select(chat),
57
+ useSession: (select: (snapshot: typeof session) => unknown) => select(session),
58
+ useSessions: (select: (snapshot: { byId: Record<string, { cwd: string }> }) => unknown) => select({ byId: { [`session-${text.length}`]: { cwd: '/fixture' } } }),
59
+ useSessionStatus: (select: (snapshot: Map<string, unknown>) => unknown) => select(new Map()),
60
+ useStore: (select: (snapshot: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
61
+ actions: store.actions,
62
+ t: (key: string) => key,
63
+ loadImage: async () => ({ data: new Uint8Array(), mediaType: 'image/png' }),
64
+ officialImageLoader: Object.assign(async () => null, { peek: () => null }),
65
+ officialFileMentions: () => undefined,
66
+ officialPreviewFile: () => {},
67
+ officialHost: source({ home: '/fixture' }),
68
+ fillComposer: () => true,
69
+ openFile: () => {},
70
+ loadOlder: async () => {},
71
+ openView: () => {},
72
+ renderSlot,
73
+ renderSlotChain,
74
+ } as ReaderProps;
75
+ }
76
+
77
+ function NativeColumn({ renderSlot, kind, text }: {
78
+ renderSlot: ReaderProps['renderSlot'];
79
+ kind: 'user' | 'steering';
80
+ text: string;
81
+ }) {
82
+ const node = messageNode(kind, text);
83
+ const owner = {
84
+ cwd: '/fixture', openFile: () => {}, openSkill: () => {}, forkAt: () => {},
85
+ inspectCall: () => {}, loadImage: async () => null,
86
+ renderMessageImages: () => null, fileMentions: () => undefined, node,
87
+ };
88
+ return (
89
+ <div data-native={kind} style={{ width: '100%', maxWidth: 'var(--dsh-chat-content-width, 748px)', display: 'flex', flexDirection: 'column' }}>
90
+ <div style={{ minWidth: 0 }}>
91
+ {renderSlot(OFFICIAL_SLOTS.nodes, owner, {
92
+ entryKey: kind,
93
+ hookContext: { turnData: node.location.turn.data, disclosureReset: { getSnapshot: () => 0, subscribe: () => () => {}, set: () => {} } },
94
+ fallback: null,
95
+ })}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ function boxOf(root: ParentNode | null, kind: 'user' | 'steering' | 'pending') {
102
+ const scope = kind === 'pending'
103
+ ? root?.querySelector('[data-reader-pending-submission]')
104
+ : root?.querySelector(`[data-reader-key="${kind}-key"]`);
105
+ if (!scope) return null;
106
+ if (kind === 'pending') {
107
+ return [...scope.children].find(element => element instanceof HTMLElement && element.querySelector('button') === null) ?? null;
108
+ }
109
+ const slot = scope.querySelector('[data-slot]');
110
+ return slot?.firstElementChild?.firstElementChild?.firstElementChild ?? null;
111
+ }
112
+
113
+ function nativeBox(frame: ParentNode, kind: 'user' | 'steering') {
114
+ const column = frame.querySelector(`[data-native="${kind}"]`);
115
+ const slot = column?.querySelector('[data-slot]');
116
+ return slot?.firstElementChild?.firstElementChild?.firstElementChild ?? null;
117
+ }
118
+
119
+ function measure(element: Element | null) {
120
+ if (!element) return { w: 0, h: 0, font: '', line: '' };
121
+ const style = getComputedStyle(element);
122
+ const rect = element.getBoundingClientRect();
123
+ return {
124
+ w: Math.round(rect.width * 10) / 10,
125
+ h: Math.round(rect.height * 10) / 10,
126
+ font: style.fontSize,
127
+ line: style.lineHeight,
128
+ };
129
+ }
130
+
131
+ async function verify() {
132
+ const root = new Context();
133
+ await root.plugin(SlotRegistry);
134
+ let run = async () => {};
135
+ await root.plugin({ name: 'reader-bubble-width', inject: ['slots'], async apply(ctx: Context) {
136
+ const slots = ctx.slots as any;
137
+ slots.install(createSlotRenderer());
138
+ const source = <T,>(value: T) => ({ getSnapshot: () => value, subscribe: () => () => {} });
139
+ const binding = { key: 'session-width', ctx: new Context(), hooks: {}, keyedHooks: {}, props: { sessionId: 'session-width' } };
140
+ slots.installScope('session', {
141
+ current: source(binding), bindingSource: () => source(binding), renderArea: (_: unknown, props: { children?: unknown }) => props.children,
142
+ });
143
+ slots.installLocale({ ...source({}), bind: () => (key: string) => key });
144
+ const HostUser = (props: Record<string, unknown>) => (
145
+ <UserMessageNodeView
146
+ {...props as never}
147
+ t={(props.t as ((key: string) => string) | undefined) ?? ((key: string) => key)}
148
+ openFile={(props.openFile as (() => void) | undefined) ?? (() => {})}
149
+ openSkill={(props.openSkill as (() => void) | undefined) ?? (() => {})}
150
+ renderMessageImages={(props.renderMessageImages as (() => null) | undefined) ?? (() => null)}
151
+ />
152
+ );
153
+ slots.register({ name: 'root', children: {
154
+ 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: { hooks: {
155
+ turnData: (_: unknown, context: { turnData?: unknown }) => () => context.turnData,
156
+ disclosure: (_: unknown, context: { disclosureReset?: { getSnapshot(): number } }) => () => context.disclosureReset,
157
+ } } },
158
+ 'conversation.message.images': { kind: 'single', scope: 'session' },
159
+ 'fixture.reader': { kind: 'single', scope: 'session' },
160
+ } }, ({ SessionProvider, renderSlot }: any) => (
161
+ <SessionProvider>
162
+ {renderSlot('fixture.reader', {})}
163
+ {CASES.map(item => (
164
+ <section key={`native-${item.name}`} data-native-case={item.name} style={{ width: item.width, ['--dsh-chat-content-width' as string]: item.axis || undefined }}>
165
+ <NativeColumn renderSlot={renderSlot} kind="user" text={item.text} />
166
+ <NativeColumn renderSlot={renderSlot} kind="steering" text={item.text} />
167
+ </section>
168
+ ))}
169
+ </SessionProvider>
170
+ ));
171
+ slots.register({ name: 'fixture.reader', children: {
172
+ ...officialChildren(slots),
173
+ 'dsh-better-display.block': { kind: 'chain', scope: 'session' },
174
+ } }, ({ renderSlot, renderSlotChain }: any) => (
175
+ <>
176
+ {CASES.map(item => (
177
+ <section key={item.name} data-case={item.name} style={{ width: item.width, ['--dsh-chat-content-width' as string]: item.axis || undefined }}>
178
+ <div data-reader-mount>
179
+ <Reader {...readerProps(item.text, renderSlot, renderSlotChain)} />
180
+ </div>
181
+ </section>
182
+ ))}
183
+ </>
184
+ ));
185
+ slots.register({ name: 'conversation.chat.node', key: 'user', locale: 'chat' }, HostUser);
186
+ slots.register({ name: 'conversation.chat.node', key: 'steering', locale: 'chat' }, HostUser);
187
+ const stopMirror = installOfficialSlots(ctx);
188
+ run = async () => {
189
+ await new Promise(resolve => queueMicrotask(resolve));
190
+ const container = document.getElementById('app')!;
191
+ const reactRoot = createRoot(container);
192
+ flushSync(() => reactRoot.render(slots.renderSlot('root', {})));
193
+ for (const reader of container.querySelectorAll<HTMLElement>('[data-dsh-better-display]')) reader.style.padding = '0';
194
+ await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)));
195
+ const readerSlot = container.querySelector('[data-reader-key="user-key"] [data-slot]')?.getAttribute('data-slot') ?? '';
196
+ const nativeSlot = container.querySelector('[data-native="user"] [data-slot]')?.getAttribute('data-slot') ?? '';
197
+ const rows = CASES.map(item => {
198
+ const frame = container.querySelector(`[data-case="${item.name}"]`);
199
+ const nativeFrame = container.querySelector(`[data-native-case="${item.name}"]`);
200
+ const mount = frame?.querySelector('[data-reader-mount]') ?? null;
201
+ const user = measure(boxOf(mount, 'user'));
202
+ const steering = measure(boxOf(mount, 'steering'));
203
+ const pending = measure(boxOf(mount, 'pending'));
204
+ const nativeUser = measure(nativeFrame ? nativeBox(nativeFrame, 'user') : null);
205
+ const nativeSteering = measure(nativeFrame ? nativeBox(nativeFrame, 'steering') : null);
206
+ return {
207
+ name: item.name,
208
+ user, steering, pending, nativeUser, nativeSteering,
209
+ readerHtml: mount?.querySelector('[data-reader-key="user-key"]')?.innerHTML.slice(0, 400) ?? '',
210
+ };
211
+ });
212
+ stopMirror();
213
+ Object.assign(window, { readerBubbleResult: { readerSlot, nativeSlot, expectedReaderSlot: OFFICIAL_SEATS.nodes, expectedNativeSlot: OFFICIAL_SLOTS.nodes, rows } });
214
+ };
215
+ } });
216
+ await run();
217
+ }
218
+
219
+ verify().catch(error => {
220
+ console.error(error);
221
+ Object.assign(window, { readerBubbleResult: { error: `${error instanceof Error ? error.stack : String(error)}\n${document.getElementById('app')?.innerHTML.slice(0, 2000)}` } });
222
+ });
@@ -0,0 +1,66 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { test } from 'vitest';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { apply } from '../src/dsh-better-display.js';
9
+ import { defaultFlags, loadFlags } from '../src/flags.js';
10
+
11
+ test('readerEnabled defaults to true', () => {
12
+ assert.equal(defaultFlags.readerEnabled, true);
13
+ const file = JSON.parse(readFileSync(fileURLToPath(new URL('../flags.json', import.meta.url)), 'utf8')) as { readerEnabled: boolean };
14
+ assert.equal(file.readerEnabled, true);
15
+ assert.equal(loadFlags().readerEnabled, true);
16
+ assert.equal(loadFlags({ readerEnabled: false }).readerEnabled, false);
17
+ const dir = mkdtempSync(join(tmpdir(), 'bd-flags-'));
18
+ try {
19
+ const broken = join(dir, 'flags.json');
20
+ writeFileSync(broken, '{ "readerEnabled": "yes" }');
21
+ assert.equal(loadFlags(broken).readerEnabled, true);
22
+ assert.equal(loadFlags(join(dir, 'missing.json')).readerEnabled, true);
23
+ } finally {
24
+ rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ test('apply skips route registration when readerEnabled is false', () => {
29
+ let registered = 0;
30
+ let effects = 0;
31
+ apply({
32
+ webServer: {
33
+ register() {
34
+ registered += 1;
35
+ return () => undefined;
36
+ },
37
+ },
38
+ effect(run: () => void) {
39
+ effects += 1;
40
+ run();
41
+ },
42
+ } as never, { flags: { readerEnabled: false } });
43
+ assert.equal(registered, 0);
44
+ assert.equal(effects, 0);
45
+ assert.doesNotThrow(() => apply({} as never, { flags: { readerEnabled: true } }));
46
+ });
47
+
48
+ test('check-dead-flags fails when a flags.json key is unused under src', () => {
49
+ const script = fileURLToPath(new URL('../scripts/check-dead-flags.mjs', import.meta.url));
50
+ const ok = spawnSync(process.execPath, [script], { encoding: 'utf8' });
51
+ assert.equal(ok.status, 0, ok.stderr || ok.stdout);
52
+ const dir = mkdtempSync(join(tmpdir(), 'bd-dead-flags-'));
53
+ try {
54
+ const flags = join(dir, 'flags.json');
55
+ writeFileSync(flags, JSON.stringify({ readerEnabled: true, unusedFlag: false }));
56
+ const bad = spawnSync(process.execPath, [script], {
57
+ encoding: 'utf8',
58
+ env: { ...process.env, FLAGS_JSON: flags },
59
+ });
60
+ assert.notEqual(bad.status, 0);
61
+ assert.match(bad.stderr, /unusedFlag/);
62
+ assert.doesNotMatch(bad.stderr, /readerEnabled/);
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ });
@@ -1,28 +1,52 @@
1
- import test from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { collapseRows, containsNewUser, flowRows, FOLD_TIMING, retiringKeys } from '../src/client/fold-choreography.js';
4
4
  import { presentLiveTurn } from '../src/client/live-turn.js';
5
5
  import type { LiveStep } from '../src/client/live-turn.js';
6
6
  const reasoning = (n: number): LiveStep => ({ kind: 'reasoning', key: `r${n}`, nodeKey: `n${n}`, start: 0, blocks: [], step: n });
7
7
  const body: LiveStep = { kind: 'body', key: 'b1', nodeKey: 'n1', start: 1, blocks: [], step: 1 };
8
+ const tool: LiveStep = {
9
+ kind: 'tool',
10
+ key: 't1',
11
+ entry: {
12
+ kind: 'tool',
13
+ key: 't1',
14
+ callId: 'c1',
15
+ step: 0,
16
+ order: 0,
17
+ draft: { kind: 'tool-call', callId: 'c1', name: 'read', argsRaw: '{"path":"Reader.tsx"}' },
18
+ },
19
+ };
8
20
  const open = { status: 'open' } as const;
9
21
  test('first fold retains every visible step key and admits no incoming reasoning', () => {
10
- const before = presentLiveTurn([reasoning(1), body], open);
11
- const after = presentLiveTurn([reasoning(1), body, reasoning(2)], open);
12
- assert.deepEqual(retiringKeys(before, after, {}), ['r1', 'b1']);
22
+ const before = presentLiveTurn([reasoning(1), tool], open);
23
+ const after = presentLiveTurn([reasoning(1), tool, reasoning(2)], open);
24
+ assert.deepEqual(retiringKeys(before, after, {}), ['r1', 't1']);
13
25
  const rows = collapseRows(before, after);
14
- assert.deepEqual(rows.filter(x => x.kind === 'step').map(x => x.key), ['r1', 'b1']);
26
+ assert.deepEqual(rows.filter(x => x.kind === 'step').map(x => x.key), ['r1', 't1']);
15
27
  assert.equal(rows[0].kind, 'summary');
16
- assert.equal(rows[0].kind === 'summary' && rows[0].item.summary, '思考×0 · 输出×0');
28
+ assert.equal(rows[0].kind === 'summary' && rows[0].item.summary, '思考×0 · 工具×0');
17
29
  assert.equal(new Set(flowRows(after).map(row => row.key)).size, flowRows(after).length);
18
30
  });
19
31
  test('repeat fold retires only displayed open rows, retaining the same summary identity', () => {
20
- const before = presentLiveTurn([reasoning(1), body, reasoning(2)], open);
21
- const after = presentLiveTurn([reasoning(1), body, reasoning(2), reasoning(3)], open);
32
+ const before = presentLiveTurn([reasoning(1), tool, reasoning(2)], open);
33
+ const after = presentLiveTurn([reasoning(1), tool, reasoning(2), reasoning(3)], open);
22
34
  assert.deepEqual(retiringKeys(before, after, {}), ['r2']);
23
35
  assert.equal(collapseRows(before, after)[0].key, flowRows(before)[0].key);
24
36
  assert.deepEqual(retiringKeys(before, after, { 'live-fold:r1': true }), []);
25
37
  });
38
+ test('narration never enters a fold cell, so choreography never retires it', () => {
39
+ const before = presentLiveTurn([reasoning(1)], open);
40
+ const withNarration = presentLiveTurn([reasoning(1), body], open);
41
+ assert.deepEqual(retiringKeys(before, withNarration, {}), []);
42
+ assert.deepEqual(flowRows(withNarration).map(row => row.key), ['r1', 'b1']);
43
+
44
+ const after = presentLiveTurn([reasoning(1), body, tool, reasoning(2)], open);
45
+ assert.equal(after.some(item => item.kind === 'fold' && item.steps.some(step => step.kind === 'body')), false);
46
+ // The prior reasoning run folds (r1 retires), but narration itself never does.
47
+ assert.deepEqual(retiringKeys(withNarration, after, {}), ['r1']);
48
+ assert.ok(!retiringKeys(withNarration, after, {}).includes('b1'));
49
+ });
26
50
  test('user admission bypasses buffered choreography and preserves source ordering', () => {
27
51
  const before = presentLiveTurn([reasoning(1)], open);
28
52
  const user: LiveStep = { kind: 'user', key: 'u2', nodeKey: 'u2' };
@@ -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';
@@ -57,17 +57,22 @@ test('fold slider maps 0/1/2 and migrates older persist flags', () => {
57
57
  assert.equal(processOnlyFromIntensity(2), true);
58
58
  });
59
59
 
60
- test('autoFold false keeps every step open; autoFold true is current-main fold-on-next-reasoning', () => {
60
+ test('autoFold false keeps every step open; autoFold true folds only thinking/tool runs', () => {
61
61
  const steps = [reasoning(1), body, tool, reasoning(2)];
62
62
  const none = presentLiveTurn(steps, open, false);
63
63
  assert.equal(none.some(item => item.kind === 'fold'), false);
64
64
  assert.deepEqual(none.map(item => item.key), ['r1', 'b1', 't1', 'r2']);
65
65
 
66
+ // Narration stays open; each prior reasoning/tool run folds in its own place.
66
67
  const standard = presentLiveTurn(steps, open, true);
67
- assert.equal(standard[0]?.kind, 'fold');
68
- if (standard[0]?.kind !== 'fold') throw new Error('expected standard fold');
69
- assert.deepEqual(standard[0].steps.map(step => step.key), ['r1', 'b1', 't1']);
70
- assert.deepEqual(standard.filter(item => item.kind === 'open').map(item => item.key), ['r2']);
68
+ assert.deepEqual(standard.map(item => item.kind), ['fold', 'open', 'fold', 'open']);
69
+ assert.deepEqual(standard.filter(item => item.kind === 'open').map(item => item.key), ['b1', 'r2']);
70
+ const folds = standard.filter(item => item.kind === 'fold');
71
+ assert.equal(folds.length, 2);
72
+ if (folds[0]?.kind !== 'fold' || folds[1]?.kind !== 'fold') throw new Error('expected standard folds');
73
+ assert.deepEqual(folds[0].steps.map(step => step.key), ['r1']);
74
+ assert.deepEqual(folds[1].steps.map(step => step.key), ['t1']);
75
+ assert.equal(standard.some(item => item.kind === 'fold' && item.steps.some(step => step.kind === 'body')), false);
71
76
  });
72
77
 
73
78
  test('root reader store persists glass off and standard fold on dsh.reader.v1', () => {
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
- import { test } from 'node:test';
4
+ import { test } from 'vitest';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -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 { WordTimeline } from '../src/client/word-timeline.js';
4
4
 
5
5
  test('opening a long existing answer does not animate every historical word', () => {