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,265 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import type { ReaderBlockOwner, ReaderBlockPresentation } from './types.js';
3
+
4
+ /**
5
+ * Cordis service name. Another client plugin lists this in `inject` and
6
+ * registers a transformer. The reading view paints the result. The session
7
+ * block, transcript, and fold inputs stay on the original text.
8
+ */
9
+ export const TEXT_TRANSFORM_SERVICE = 'dsh-better-display.text-transform';
10
+
11
+ export type DisplayTextKind = 'reasoning' | 'body';
12
+
13
+ export interface DisplayTextRequest {
14
+ /** Original block text. Registrants must not write it back onto the block. */
15
+ text: string;
16
+ streaming: boolean;
17
+ /** Opaque id of this mounted block. Stable until the block unmounts. */
18
+ blockKey: string;
19
+ kind: DisplayTextKind;
20
+ /** Assistant body and reasoning are the usual targets. Tool prose uses `tool`. */
21
+ source?: ReaderBlockOwner['source'];
22
+ }
23
+
24
+ export interface DisplayText {
25
+ text: string;
26
+ /**
27
+ * True when this string is the settled display for the current source.
28
+ * False keeps the live reveal open, including after the model has stopped
29
+ * but a translation batch is still outstanding.
30
+ */
31
+ final: boolean;
32
+ }
33
+
34
+ /**
35
+ * Sync `read` on every paint, plus `subscribe` when a later batch changes
36
+ * what `read` returns. `subscribe` may be called once per kind and must
37
+ * accept more than one listener.
38
+ */
39
+ export interface DisplayTextTransformer {
40
+ read(request: DisplayTextRequest): DisplayText | string | undefined;
41
+ subscribe?(listener: () => void): () => void;
42
+ }
43
+
44
+ export interface DisplayCursor extends DisplayText {
45
+ /**
46
+ * Increments when the displayed string or its `final` bit changes.
47
+ * This is not the reveal clock. Passing it to the word timeline as the
48
+ * motion revision replays or snaps the paragraph.
49
+ */
50
+ transformRevision: number;
51
+ /**
52
+ * Reveal identity. Stays put when the new string extends the previous
53
+ * display string, so an appended translation batch continues the fade.
54
+ * Increments only when the string is replaced, which settles the new
55
+ * text instead of typing it again from the first grapheme.
56
+ */
57
+ motionRevision: number;
58
+ }
59
+
60
+ export interface TextTransformRegistry {
61
+ register(kind: DisplayTextKind | readonly DisplayTextKind[], transformer: DisplayTextTransformer): () => void;
62
+ has(kind: DisplayTextKind): boolean;
63
+ read(request: DisplayTextRequest): DisplayCursor;
64
+ subscribe(kind: DisplayTextKind, listener: () => void): () => void;
65
+ release(kind: DisplayTextKind, blockKey: string): void;
66
+ }
67
+
68
+ const KINDS = new Set<DisplayTextKind>(['reasoning', 'body']);
69
+
70
+ function isKind(value: string): value is DisplayTextKind {
71
+ return KINDS.has(value as DisplayTextKind);
72
+ }
73
+
74
+ function reportTransformError(error: unknown): void {
75
+ console.warn('[dsh-better-display] text transform failed; showing original text', error);
76
+ }
77
+
78
+ function passthrough(request: DisplayTextRequest): DisplayCursor {
79
+ return { text: request.text, final: !request.streaming, transformRevision: 0, motionRevision: 0 };
80
+ }
81
+
82
+ function normalize(value: DisplayText | string | undefined, request: DisplayTextRequest): DisplayText {
83
+ if (typeof value === 'string') return { text: value, final: !request.streaming };
84
+ if (!value || typeof value.text !== 'string') return { text: request.text, final: !request.streaming };
85
+ return { text: value.text, final: typeof value.final === 'boolean' ? value.final : !request.streaming };
86
+ }
87
+
88
+ /**
89
+ * Appends keep the motion revision. A replacement gets a new one so the
90
+ * timeline treats the swapped string as already settled.
91
+ */
92
+ export function nextDisplayCursor(previous: DisplayCursor | undefined, display: DisplayText): DisplayCursor {
93
+ if (!previous) return { ...display, transformRevision: 0, motionRevision: 0 };
94
+ if (previous.text === display.text && previous.final === display.final) return previous;
95
+ const appended = display.text.startsWith(previous.text);
96
+ return {
97
+ text: display.text,
98
+ final: display.final,
99
+ transformRevision: previous.transformRevision + 1,
100
+ motionRevision: appended ? previous.motionRevision : previous.motionRevision + 1,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Streaming flag fed to the reveal. With no transformer this is the model's
106
+ * flag. A transformer that reports `final: !streaming` produces the same flag.
107
+ */
108
+ export function displayStreaming(sourceStreaming: boolean, shown: Pick<DisplayCursor, 'final'>, active: boolean): boolean {
109
+ return active ? !shown.final : sourceStreaming;
110
+ }
111
+
112
+ /** No registry, or none for this kind: the source string, and no cursor write. */
113
+ export function readDisplayText(registry: TextTransformRegistry | undefined, request: DisplayTextRequest): DisplayCursor {
114
+ if (!registry?.has(request.kind)) return passthrough(request);
115
+ return registry.read(request);
116
+ }
117
+
118
+ interface StackEntry {
119
+ transformer: DisplayTextTransformer;
120
+ unsubscribe?: () => void;
121
+ }
122
+
123
+ class TextTransformRegistryImpl implements TextTransformRegistry {
124
+ private readonly stacks = new Map<DisplayTextKind, StackEntry[]>();
125
+ private readonly listeners = new Map<DisplayTextKind, Set<() => void>>();
126
+ private readonly cursors = new Map<string, DisplayCursor>();
127
+
128
+ register(kind: DisplayTextKind | readonly DisplayTextKind[], transformer: DisplayTextTransformer): () => void {
129
+ const requested = [...new Set(Array.isArray(kind) ? kind : [kind])];
130
+ for (const item of requested) {
131
+ if (!isKind(item)) throw new Error(`Unknown display text kind: ${String(item)}`);
132
+ }
133
+ const bound: { kind: DisplayTextKind; entry: StackEntry }[] = [];
134
+ for (const item of requested) {
135
+ const entry: StackEntry = { transformer };
136
+ try {
137
+ entry.unsubscribe = transformer.subscribe?.(() => { this.emit(item); });
138
+ } catch (error) {
139
+ reportTransformError(error);
140
+ }
141
+ const stack = this.stacks.get(item) ?? [];
142
+ stack.push(entry);
143
+ this.stacks.set(item, stack);
144
+ bound.push({ kind: item, entry });
145
+ this.emit(item);
146
+ }
147
+ return () => {
148
+ for (const { kind: item, entry } of bound) {
149
+ try { entry.unsubscribe?.(); } catch (error) { reportTransformError(error); }
150
+ const stack = this.stacks.get(item);
151
+ if (!stack) continue;
152
+ const next = stack.filter(candidate => candidate !== entry);
153
+ if (next.length > 0) this.stacks.set(item, next);
154
+ else this.stacks.delete(item);
155
+ this.emit(item);
156
+ }
157
+ };
158
+ }
159
+
160
+ has(kind: DisplayTextKind): boolean {
161
+ return (this.stacks.get(kind)?.length ?? 0) > 0;
162
+ }
163
+
164
+ read(request: DisplayTextRequest): DisplayCursor {
165
+ const transformer = this.stacks.get(request.kind)?.at(-1)?.transformer;
166
+ if (!transformer) return passthrough(request);
167
+ let display: DisplayText;
168
+ try {
169
+ display = normalize(transformer.read(request), request);
170
+ } catch (error) {
171
+ // A broken transformer must never hide the original text. Show it and
172
+ // ask again on the next text change or notify, so a recovered
173
+ // transformer takes over without a remount.
174
+ reportTransformError(error);
175
+ display = { text: request.text, final: !request.streaming };
176
+ }
177
+ const key = `${request.kind}\u0000${request.blockKey}`;
178
+ const next = nextDisplayCursor(this.cursors.get(key), display);
179
+ this.cursors.set(key, next);
180
+ return next;
181
+ }
182
+
183
+ subscribe(kind: DisplayTextKind, listener: () => void): () => void {
184
+ const set = this.listeners.get(kind) ?? new Set();
185
+ set.add(listener);
186
+ this.listeners.set(kind, set);
187
+ return () => { set.delete(listener); };
188
+ }
189
+
190
+ release(kind: DisplayTextKind, blockKey: string): void {
191
+ this.cursors.delete(`${kind}\u0000${blockKey}`);
192
+ }
193
+
194
+ private emit(kind: DisplayTextKind): void {
195
+ const set = this.listeners.get(kind);
196
+ if (!set) return;
197
+ for (const listener of [...set]) listener();
198
+ }
199
+ }
200
+
201
+ export function createTextTransformRegistry(): TextTransformRegistry {
202
+ return new TextTransformRegistryImpl();
203
+ }
204
+
205
+ let active: TextTransformRegistry | undefined;
206
+
207
+ /** Points the reading view at the registry this client plugin provides. */
208
+ export function installTextTransformRegistry(registry: TextTransformRegistry | undefined): () => void {
209
+ const previous = active;
210
+ active = registry;
211
+ return () => { active = previous; };
212
+ }
213
+
214
+ export function activeTextTransform(): TextTransformRegistry | undefined {
215
+ return active;
216
+ }
217
+
218
+ /**
219
+ * Subscribe while mounted. The reveal components call this only after
220
+ * `has(kind)`, so an empty registry adds no hook and no listener.
221
+ */
222
+ export function useDisplayText(registry: TextTransformRegistry, request: DisplayTextRequest): DisplayCursor {
223
+ const [epoch, setEpoch] = useState(0);
224
+ const { text, streaming, blockKey, kind, source } = request;
225
+ useEffect(() => registry.subscribe(kind, () => { setEpoch(value => value + 1); }), [registry, kind]);
226
+ useEffect(() => () => { registry.release(kind, blockKey); }, [registry, kind, blockKey]);
227
+ return useMemo(
228
+ () => registry.read({ text, streaming, blockKey, kind, source }),
229
+ [registry, text, streaming, blockKey, kind, source, epoch],
230
+ );
231
+ }
232
+
233
+ /** Chain-slot owner. Extra fields are optional so older callers still type-check. */
234
+ export function readerBlockOwner(
235
+ block: ReaderBlockOwner['block'],
236
+ streaming: boolean,
237
+ source: ReaderBlockOwner['source'],
238
+ presentation: ReaderBlockPresentation,
239
+ holdFormatting: boolean,
240
+ ): ReaderBlockOwner {
241
+ return {
242
+ block,
243
+ streaming,
244
+ source,
245
+ presentation: {
246
+ startedAt: presentation.startedAt,
247
+ interrupted: presentation.interrupted,
248
+ liveText: presentation.liveText,
249
+ },
250
+ holdFormatting,
251
+ liveText: presentation.liveText,
252
+ };
253
+ }
254
+
255
+ /** Copy actions read this. Display text never replaces it. */
256
+ export function originalAnswerText(blocks: readonly { readonly kind: string; readonly text?: string }[]): string {
257
+ return blocks.flatMap(block => block.kind === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n\n');
258
+ }
259
+
260
+ declare module '@deepseek-ai/cordis' {
261
+ interface Context {
262
+ /** Display-only reader text transforms. Present after this client plugin applies. */
263
+ 'dsh-better-display.text-transform'?: TextTransformRegistry;
264
+ }
265
+ }
@@ -1,5 +1,3 @@
1
- import type { TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client';
2
-
3
1
  export interface TimelineItem {
4
2
  readonly turn: number;
5
3
  readonly prompt: string;
@@ -2,6 +2,7 @@ import type { AssistantBlock, ToolCallBlock, TurnLocation } from '@deepseek-ai/d
2
2
  import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
3
3
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client';
4
4
  import type { ReaderGroup } from './projection.js';
5
+ import { anchorSeqOf, cutContains, rewindCutSpans, type CutChatNode } from './rewind-cut.js';
5
6
 
6
7
  export type ToolDraft = Extract<AssistantBlock, { kind: 'tool-call' }>;
7
8
  export type ToolPhase = 'preparing' | 'running' | 'returned' | 'succeeded' | 'failed' | 'interrupted';
@@ -12,11 +13,52 @@ export interface ToolActivityEntry {
12
13
  }
13
14
  export type ReaderFlowEntry = ToolActivityEntry | { kind: 'node'; key: string; nodeKey: string; order: number };
14
15
 
15
- /** Public Step data includes tool-only model output before a chat node exists. */
16
- export function readerFlow(group: ReaderGroup, turn: TurnLocation | undefined, get: (key: string) => ChatConversationViewNode | undefined): ReaderFlowEntry[] {
16
+ /**
17
+ * callId of a tool-call chat node. Drafts for a call whose node was cut must
18
+ * not be reintroduced after `group.keys` has already dropped that node.
19
+ */
20
+ function toolCallId(node: ChatConversationViewNode): string | undefined {
21
+ const callId = (node.data as { root?: { callId?: unknown } } | null)?.root?.callId;
22
+ return typeof callId === 'string' && callId.length > 0 ? callId : undefined;
23
+ }
24
+
25
+ /** callIds whose tool-call node sits inside a rewind cut. */
26
+ function cutToolCallIds(
27
+ order: readonly string[],
28
+ get: (key: string) => ChatConversationViewNode | undefined,
29
+ spans: readonly { readonly start: number; readonly end: number }[],
30
+ ): Set<string> {
31
+ const ids = new Set<string>();
32
+ for (const key of order) {
33
+ const node = get(key);
34
+ if (node?.kind !== 'tool-call') continue;
35
+ const seq = anchorSeqOf(node as CutChatNode);
36
+ if (seq === undefined || !cutContains(spans, seq)) continue;
37
+ const callId = toolCallId(node);
38
+ if (callId !== undefined) ids.add(callId);
39
+ }
40
+ return ids;
41
+ }
42
+
43
+ /**
44
+ * Public Step data includes tool-only model output before a chat node exists.
45
+ *
46
+ * `sourceOrder` is the unfiltered chat order. Drafts that belong to a cut step,
47
+ * or to a call whose tool-call node was cut, are not put back. A draft that
48
+ * still has no tool-call node, on a step outside the cut, stays — that is the
49
+ * in-flight call the host has not published a node for yet.
50
+ */
51
+ export function readerFlow(
52
+ group: ReaderGroup,
53
+ turn: TurnLocation | undefined,
54
+ get: (key: string) => ChatConversationViewNode | undefined,
55
+ sourceOrder?: readonly string[],
56
+ ): ReaderFlowEntry[] {
17
57
  const flow: ReaderFlowEntry[] = [];
18
58
  const calls = new Map<string, ToolActivityEntry>();
19
59
  const assistantOrder = new Map<number, number>();
60
+ const spans = sourceOrder === undefined ? [] : rewindCutSpans({ order: sourceOrder, nodes: { get } });
61
+ const cutCalls = spans.length === 0 || sourceOrder === undefined ? undefined : cutToolCallIds(sourceOrder, get, spans);
20
62
  for (const key of group.keys) {
21
63
  const node = get(key);
22
64
  if (!node || node.visibility === 'hidden') continue;
@@ -31,10 +73,15 @@ export function readerFlow(group: ReaderGroup, turn: TurnLocation | undefined, g
31
73
  }
32
74
  for (const step of turn?.steps ?? []) {
33
75
  const data = step.data.get('assistant-step');
76
+ const stepSeq = step.start?.seq;
77
+ const stepCut = typeof stepSeq === 'number' && spans.length > 0 && cutContains(spans, stepSeq);
34
78
  let index = 0;
35
79
  for (const draft of data?.blocks ?? []) {
36
80
  if (draft.kind !== 'tool-call' || !draft.callId) continue;
37
81
  const previous = calls.get(draft.callId);
82
+ // A visible tool row still receives its draft. A new row is the streaming
83
+ // case — unless this step, or this call's tool-call node, was cut.
84
+ if (!previous && (stepCut || cutCalls?.has(draft.callId) === true)) continue;
38
85
  calls.set(draft.callId, previous ? { ...previous, draft } : {
39
86
  kind: 'tool', key: `reader-tool:${draft.callId}`, callId: draft.callId, step: step.step, draft,
40
87
  order: (assistantOrder.get(step.step) ?? step.start?.seq ?? 0) + .01 + index++ / 10000,
@@ -0,0 +1,80 @@
1
+ /** One turn lane: `live` while it is still in the visible order. */
2
+ export interface TurnSlot<T extends { readonly key: string }> {
3
+ readonly group: T;
4
+ readonly live: boolean;
5
+ }
6
+
7
+ /**
8
+ * Keep live turns in the incoming `groups` order, and keep a departing turn
9
+ * next to the live neighbour that preceded it.
10
+ *
11
+ * A rewind removes turn keys from the visible order. Unmounting them in that
12
+ * same commit snaps the transcript, so the lane retains the last group object
13
+ * until the collapse finishes. A re-render, a stream tick, or a reconnect that
14
+ * still contains the same keys only replaces the group object and leaves
15
+ * `live` true — those updates must not start a collapse. A key that returns
16
+ * is live again where `groups` places it. Loading older history therefore
17
+ * stays in transcript order instead of appending the older turns after the
18
+ * ones already on screen. A departing slot is inserted immediately after the
19
+ * nearest preceding slot that is still live, or at the front when it had none,
20
+ * so several departing neighbours keep their relative order.
21
+ *
22
+ * Returns `current` itself when nothing changed, so a React state update can
23
+ * bail out on `Object.is`.
24
+ */
25
+ export function reconcileTurnSlots<T extends { readonly key: string }>(
26
+ current: readonly TurnSlot<T>[],
27
+ groups: readonly T[],
28
+ ): readonly TurnSlot<T>[] {
29
+ const incoming = new Map(groups.map(group => [group.key, group]));
30
+ const next: TurnSlot<T>[] = groups.map(group => ({ group, live: true }));
31
+ for (const slot of current) {
32
+ if (incoming.has(slot.group.key)) continue;
33
+ insertDeparting(next, { group: slot.group, live: false }, precedingLiveKey(current, incoming, slot.group.key));
34
+ }
35
+ const same =
36
+ next.length === current.length &&
37
+ next.every(
38
+ (slot, index) => slot.live === current[index]!.live && slot.group === current[index]!.group,
39
+ );
40
+ return same ? current : next;
41
+ }
42
+
43
+ /** Nearest preceding slot that is still in the incoming order, if any. */
44
+ function precedingLiveKey<T extends { readonly key: string }>(
45
+ current: readonly TurnSlot<T>[],
46
+ incoming: ReadonlyMap<string, T>,
47
+ key: string,
48
+ ): string | undefined {
49
+ const index = current.findIndex(slot => slot.group.key === key);
50
+ for (let cursor = index - 1; cursor >= 0; cursor--) {
51
+ const candidate = current[cursor]!.group.key;
52
+ if (incoming.has(candidate)) return candidate;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ /**
58
+ * Place a departing slot after `anchorKey`, or at the front when the slot had
59
+ * no preceding live neighbour. Slots that share an anchor keep `current` order
60
+ * because each one is inserted at the end of the departing run after that anchor.
61
+ */
62
+ function insertDeparting<T extends { readonly key: string }>(
63
+ next: TurnSlot<T>[],
64
+ slot: TurnSlot<T>,
65
+ anchorKey: string | undefined,
66
+ ): void {
67
+ if (anchorKey === undefined) {
68
+ const firstLive = next.findIndex(item => item.live);
69
+ next.splice(firstLive < 0 ? next.length : firstLive, 0, slot);
70
+ return;
71
+ }
72
+ const anchorIndex = next.findIndex(item => item.group.key === anchorKey);
73
+ if (anchorIndex < 0) {
74
+ next.unshift(slot);
75
+ return;
76
+ }
77
+ let insertAt = anchorIndex + 1;
78
+ while (insertAt < next.length && next[insertAt]?.live === false) insertAt++;
79
+ next.splice(insertAt, 0, slot);
80
+ }
@@ -10,10 +10,29 @@ import type { OfficialSeat } from './official-slots.js';
10
10
  import type { OpenFileOptions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
11
11
  import type {} from '@deepseek-ai/dsh-client-ui-tool/client';
12
12
 
13
+ /**
14
+ * Display inputs the Reader already computed for this block.
15
+ * Older callers may omit the object. Registrants must tolerate that.
16
+ */
17
+ export interface ReaderBlockPresentation {
18
+ /** Assistant step time, when the Reader has one. */
19
+ startedAt?: number;
20
+ /** True when this step was interrupted. */
21
+ interrupted?: boolean;
22
+ /** True when this block may run the live reveal. */
23
+ liveText?: boolean;
24
+ }
25
+
13
26
  export interface ReaderBlockOwner {
14
27
  block: AssistantBlock;
15
28
  streaming: boolean;
16
29
  source: 'assistant' | 'user' | 'tool';
30
+ /** Viewport and motion inputs. Absent on owners built before this field existed. */
31
+ presentation?: ReaderBlockPresentation;
32
+ /** Selection is holding Markdown formatting in place. */
33
+ holdFormatting?: boolean;
34
+ /** Same value as `presentation.liveText`, repeated so a registrant can read it directly. */
35
+ liveText?: boolean;
17
36
  }
18
37
 
19
38
  declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -1,8 +1,11 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
+ import { loadFlags, type Flags } from './flags.js';
4
+ import { createLogger, requestIdOf } from './log.js';
5
+ import { metricsPayload, recordRouteDuration, recordRouteHit, ROUTE_METRICS, ROUTE_REVEAL, ROUTE_SKILL_STATUS } from './metrics.js';
6
+ import { createRevealHandler, revealRoots } from './reveal.js';
3
7
  import { scanGenerativeMcpappsStatus } from './skill-roots.js';
4
8
  import { skillsFromListResult, toPublicSkillStatus } from './skill-status.js';
5
- import { createRevealHandler, revealRoots } from './reveal.js';
6
9
 
7
10
  declare module '@deepseek-ai/cordis' {
8
11
  interface Context {
@@ -19,6 +22,8 @@ declare module '@deepseek-ai/cordis' {
19
22
  export const name = 'dsh-better-display';
20
23
  export const inject = ['webServer'];
21
24
 
25
+ const log = createLogger({ base: { plugin: name } });
26
+
22
27
  function writeJson(res: ServerResponse, status: number, body: unknown): void {
23
28
  res.setHeader('Content-Type', 'application/json');
24
29
  res.statusCode = status;
@@ -31,20 +36,48 @@ function skillLister(ctx: Context): (() => Promise<readonly { readonly name?: st
31
36
  return async () => skillsFromListResult(await skills.list!({}));
32
37
  }
33
38
 
34
- export function apply(ctx: Context): void {
35
- console.log('[my-plugins/dsh-better-display] loaded');
39
+ function beginRequest(req: IncomingMessage, res: ServerResponse, route: string): string {
40
+ const requestId = requestIdOf(req);
41
+ res.setHeader('X-Request-ID', requestId);
42
+ recordRouteHit(route);
43
+ const started = performance.now();
44
+ res.on('finish', () => {
45
+ recordRouteDuration(route, performance.now() - started);
46
+ });
47
+ log.info('http request', {
48
+ requestId,
49
+ route,
50
+ method: req.method ?? '',
51
+ url: req.url ?? '',
52
+ headers: req.headers,
53
+ });
54
+ return requestId;
55
+ }
56
+
57
+ export function apply(ctx: Context, options?: { readonly flags?: Flags }): void {
58
+ const flags = options?.flags ?? loadFlags();
59
+ log.info('[my-plugins/dsh-better-display] loaded', { readerEnabled: flags.readerEnabled });
60
+ if (!flags.readerEnabled) {
61
+ log.info('route registration skipped', { readerEnabled: false });
62
+ return;
63
+ }
36
64
 
37
65
  if (ctx.webServer) {
38
66
  ctx.effect(() => {
67
+ const reveal = createRevealHandler({ roots: sessionId => revealRoots(ctx, sessionId) });
39
68
  const disposeReveal = ctx.webServer!.register({
40
69
  kind: 'exact',
41
- path: '/better-display/reveal',
42
- handler: createRevealHandler({ roots: sessionId => revealRoots(ctx, sessionId) }),
70
+ path: ROUTE_REVEAL,
71
+ handler: (req, res) => {
72
+ beginRequest(req, res, ROUTE_REVEAL);
73
+ reveal(req, res);
74
+ },
43
75
  });
44
76
  const disposeSkill = ctx.webServer!.register({
45
77
  kind: 'exact',
46
- path: '/better-display/skill-status',
78
+ path: ROUTE_SKILL_STATUS,
47
79
  handler: async (req, res) => {
80
+ const requestId = beginRequest(req, res, ROUTE_SKILL_STATUS);
48
81
  if (req.method !== 'GET') {
49
82
  res.statusCode = 405;
50
83
  res.end();
@@ -59,14 +92,32 @@ export function apply(ctx: Context): void {
59
92
  });
60
93
  writeJson(res, 200, toPublicSkillStatus(status));
61
94
  } catch (err) {
95
+ log.error('skill-status failed', {
96
+ requestId,
97
+ error: err instanceof Error ? err.message : String(err),
98
+ });
62
99
  writeJson(res, 500, { ok: false, error: String(err) });
63
100
  }
64
101
  },
65
102
  });
103
+ const disposeMetrics = ctx.webServer!.register({
104
+ kind: 'exact',
105
+ path: ROUTE_METRICS,
106
+ handler: (req, res) => {
107
+ beginRequest(req, res, ROUTE_METRICS);
108
+ if (req.method !== 'GET') {
109
+ res.statusCode = 405;
110
+ res.end();
111
+ return;
112
+ }
113
+ writeJson(res, 200, metricsPayload());
114
+ },
115
+ });
66
116
  return () => {
67
117
  disposeReveal();
68
118
  disposeSkill();
119
+ disposeMetrics();
69
120
  };
70
- }, 'dsh-better-display: reveal and skill-status routes');
121
+ }, 'dsh-better-display: reveal, skill-status, and metrics routes');
71
122
  }
72
123
  }
package/src/flags.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /** Startup kill switch loaded from flags.json. This is not the reader glass setting. */
6
+ export interface Flags {
7
+ readonly readerEnabled: boolean;
8
+ }
9
+
10
+ export const defaultFlags: Flags = { readerEnabled: true };
11
+
12
+ function locateFlags(start = dirname(fileURLToPath(import.meta.url))): string | undefined {
13
+ let dir = start;
14
+ for (let depth = 0; depth < 6; depth += 1) {
15
+ const candidate = resolve(dir, 'flags.json');
16
+ if (existsSync(candidate)) return candidate;
17
+ const parent = dirname(dir);
18
+ if (parent === dir) return undefined;
19
+ dir = parent;
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ function normalize(value: unknown): Flags {
25
+ const record = value !== null && typeof value === 'object' ? value as { readerEnabled?: unknown } : {};
26
+ return {
27
+ readerEnabled: typeof record.readerEnabled === 'boolean' ? record.readerEnabled : defaultFlags.readerEnabled,
28
+ };
29
+ }
30
+
31
+ export function loadFlags(source?: string | Flags): Flags {
32
+ if (source !== undefined && typeof source !== 'string') return normalize(source);
33
+ try {
34
+ const path = typeof source === 'string' ? source : locateFlags();
35
+ if (path === undefined || !existsSync(path)) return { ...defaultFlags };
36
+ return normalize(JSON.parse(readFileSync(path, 'utf8')) as unknown);
37
+ } catch {
38
+ return { ...defaultFlags };
39
+ }
40
+ }