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
@@ -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,
@@ -53,10 +100,15 @@ export function stringValue(record: Record<string, unknown> | null, ...keys: str
53
100
  return undefined;
54
101
  }
55
102
 
103
+ /** Top-level fields from complete or in-flight JSON tool arguments. */
104
+ type ParsedInputFields =
105
+ | { kind: 'complete'; fields: Record<string, unknown> }
106
+ | { kind: 'partial'; fields: Record<string, unknown> };
107
+
56
108
  /** Read only top-level JSON string values, including an unfinished final string.
57
109
  * This never executes input or mistakes escaped/nested content for a path field. */
58
- export function inputFields(raw: string): Record<string, unknown> {
59
- try { return objectValue(JSON.parse(raw)) ?? {}; } catch { /* an in-flight argument is normally incomplete */ }
110
+ function parseInputFields(raw: string): ParsedInputFields {
111
+ try { return { kind: 'complete', fields: objectValue(JSON.parse(raw)) ?? {} }; } catch { /* an in-flight argument is normally incomplete */ }
60
112
  const fields: Record<string, unknown> = Object.create(null);
61
113
  const prefix = raw.slice(0, 262144);
62
114
  let depth = 0;
@@ -88,14 +140,19 @@ export function inputFields(raw: string): Record<string, unknown> {
88
140
  if (closed && /^\s*:/.test(prefix.slice(index + 1))) key = value;
89
141
  else if (key !== undefined) { fields[key] = value; key = undefined; }
90
142
  }
91
- return fields;
143
+ return { kind: 'partial', fields };
92
144
  }
93
145
 
146
+ export function inputFields(raw: string): Record<string, unknown> {
147
+ return parseInputFields(raw).fields;
148
+ }
149
+
150
+ /** Name and raw arguments of any call block, whether it has landed or is pending. */
94
151
  export function toolIdentity(entry: Pick<ToolActivityEntry, 'block' | 'draft'>) {
95
152
  const block = entry.block;
96
153
  return {
97
154
  name: block ? 'kind' in block ? block.call?.name ?? entry.draft?.name ?? '工具调用' : block.name : entry.draft?.name ?? '工具调用',
98
- raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : ('argsRaw' in block ? block.argsRaw : '') : entry.draft?.argsRaw ?? '',
155
+ raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : ('argsRaw' in block ? block.argsRaw : entry.draft?.argsRaw ?? '') : entry.draft?.argsRaw ?? '',
99
156
  };
100
157
  }
101
158
 
@@ -254,7 +311,7 @@ export function foldDiffHunks(steps: readonly { kind: string; entry?: ToolActivi
254
311
 
255
312
  export function activityPhase(entry: Pick<ToolActivityEntry, 'block' | 'draft'>, turnClosed = false): ToolPhase {
256
313
  if (!entry.block) return turnClosed ? 'interrupted' : 'preparing';
257
- if (!('kind' in entry.block)) return turnClosed ? 'interrupted' : 'running';
314
+ if (!('kind' in entry.block)) return turnClosed ? 'interrupted' : entry.block.phase === 'preparing' ? 'preparing' : 'running';
258
315
  // RC1 publishes canonical cancellation as an error result with a typed code.
259
316
  if (entry.block.error?.code === 'ABORTED' || entry.block.error?.code === 'interrupted') return 'interrupted';
260
317
  const facts = executionFacts(entry.block);
@@ -308,7 +365,8 @@ export function forgetCallClock(callId: string): void {
308
365
 
309
366
  export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'>) {
310
367
  const { name, raw } = toolIdentity(entry);
311
- const args = inputFields(raw);
368
+ const parsed = parseInputFields(raw);
369
+ const args = parsed.fields;
312
370
  const target = stringValue(args, 'file_path', 'path', 'filename', 'filePath');
313
371
  const command = stringValue(args, 'command', 'cmd', 'script');
314
372
  const description = stringValue(args, 'description');
@@ -331,7 +389,7 @@ export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'
331
389
  : category === 'web' ? name === 'web_search' ? '搜索网页' : '读取网页'
332
390
  : category === 'code' ? (description || '运行代码')
333
391
  : name;
334
- return { name, raw, args, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
392
+ return { name, raw, args, argsState: parsed.kind, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
335
393
  cwd: stringValue(args, 'workdir', 'cwd'), content: stringValue(args, 'content', 'new_string', 'newText', 'file_text') };
336
394
  }
337
395
 
@@ -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,6 +1,9 @@
1
- import { spawn } from 'node:child_process';
2
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
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';
4
7
  import { scanGenerativeMcpappsStatus } from './skill-roots.js';
5
8
  import { skillsFromListResult, toPublicSkillStatus } from './skill-status.js';
6
9
 
@@ -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,56 +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: async (req, res) => {
43
- if (req.method !== 'POST') {
44
- res.statusCode = 405;
45
- res.end();
46
- return;
47
- }
48
- let body = '';
49
- req.on('data', chunk => { body += chunk; });
50
- req.on('end', () => {
51
- try {
52
- const data = JSON.parse(body);
53
- const targetPath = typeof data.path === 'string' ? data.path.trim() : '';
54
- if (!targetPath) {
55
- res.statusCode = 400;
56
- res.end(JSON.stringify({ ok: false, error: 'Empty path' }));
57
- return;
58
- }
59
-
60
- if (process.platform === 'darwin') {
61
- // Exact file reveal in macOS Finder with selection highlight
62
- spawn('open', ['-R', targetPath], { detached: true, stdio: 'ignore' });
63
- } else if (process.platform === 'win32') {
64
- // Exact file selection in Windows Explorer
65
- spawn('explorer.exe', [`/select,${targetPath}`], { detached: true, stdio: 'ignore' });
66
- } else {
67
- spawn('xdg-open', [targetPath], { detached: true, stdio: 'ignore' });
68
- }
69
-
70
- res.setHeader('Content-Type', 'application/json');
71
- res.statusCode = 200;
72
- res.end(JSON.stringify({ ok: true }));
73
- } catch (err) {
74
- res.statusCode = 400;
75
- res.end(JSON.stringify({ ok: false, error: String(err) }));
76
- }
77
- });
70
+ path: ROUTE_REVEAL,
71
+ handler: (req, res) => {
72
+ beginRequest(req, res, ROUTE_REVEAL);
73
+ reveal(req, res);
78
74
  },
79
75
  });
80
76
  const disposeSkill = ctx.webServer!.register({
81
77
  kind: 'exact',
82
- path: '/better-display/skill-status',
78
+ path: ROUTE_SKILL_STATUS,
83
79
  handler: async (req, res) => {
80
+ const requestId = beginRequest(req, res, ROUTE_SKILL_STATUS);
84
81
  if (req.method !== 'GET') {
85
82
  res.statusCode = 405;
86
83
  res.end();
@@ -95,14 +92,32 @@ export function apply(ctx: Context): void {
95
92
  });
96
93
  writeJson(res, 200, toPublicSkillStatus(status));
97
94
  } catch (err) {
95
+ log.error('skill-status failed', {
96
+ requestId,
97
+ error: err instanceof Error ? err.message : String(err),
98
+ });
98
99
  writeJson(res, 500, { ok: false, error: String(err) });
99
100
  }
100
101
  },
101
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
+ });
102
116
  return () => {
103
117
  disposeReveal();
104
118
  disposeSkill();
119
+ disposeMetrics();
105
120
  };
106
- }, 'dsh-better-display: reveal and skill-status routes');
121
+ }, 'dsh-better-display: reveal, skill-status, and metrics routes');
107
122
  }
108
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
+ }
package/src/log.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { IncomingMessage } from 'node:http';
3
+
4
+ export const REDACTED = '[REDACTED]';
5
+
6
+ const REQUEST_ID_HEADER = 'x-request-id';
7
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
8
+
9
+ const SECRET_KEYS = new Set([
10
+ 'authorization',
11
+ 'proxyauthorization',
12
+ 'cookie',
13
+ 'setcookie',
14
+ 'token',
15
+ 'accesstoken',
16
+ 'refreshtoken',
17
+ 'idtoken',
18
+ 'apikey',
19
+ 'apisecret',
20
+ 'secret',
21
+ 'clientsecret',
22
+ 'password',
23
+ 'passwd',
24
+ 'credential',
25
+ 'credentials',
26
+ 'signature',
27
+ 'sig',
28
+ 'jwt',
29
+ 'bearer',
30
+ 'privatekey',
31
+ 'xapikey',
32
+ 'xauthtoken',
33
+ 'xaccesstoken',
34
+ 'auth',
35
+ ]);
36
+
37
+ export type LogFields = Record<string, unknown>;
38
+
39
+ export interface Logger {
40
+ info(msg: string, fields?: LogFields): void;
41
+ warn(msg: string, fields?: LogFields): void;
42
+ error(msg: string, fields?: LogFields): void;
43
+ }
44
+
45
+ export interface LoggerOptions {
46
+ readonly write?: (line: string) => void;
47
+ readonly base?: LogFields;
48
+ readonly clock?: () => Date;
49
+ }
50
+
51
+ function normalizeKey(key: string): string {
52
+ return key.toLowerCase().replace(/[^a-z0-9]/g, '');
53
+ }
54
+
55
+ function isSecretKey(key: string): boolean {
56
+ const normalized = normalizeKey(key);
57
+ if (SECRET_KEYS.has(normalized)) return true;
58
+ if (normalized === 'requestid' || normalized === 'xrequestid') return false;
59
+ return /(?:^|[^a-z])(?:token|secret|password|api[-_]?key)$/i.test(key);
60
+ }
61
+
62
+ function redactString(value: string): string {
63
+ const query = value.replace(/([?&#])([^&#=]+)=([^&#]*)/g, (match, sep: string, rawKey: string) => {
64
+ let key: string;
65
+ try {
66
+ key = decodeURIComponent(rawKey.replace(/\+/g, ' '));
67
+ } catch {
68
+ key = rawKey;
69
+ }
70
+ return isSecretKey(key) ? `${sep}${rawKey}=${REDACTED}` : match;
71
+ });
72
+ return query
73
+ .replace(/\b(Bearer|Basic)\s+\S+/gi, `$1 ${REDACTED}`)
74
+ .replace(
75
+ /\b((?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|client[_-]?secret|password|passwd|authorization|token|secret))(\s*[:=]\s*)([^\s&]+)/gi,
76
+ `$1$2${REDACTED}`,
77
+ );
78
+ }
79
+
80
+ export function redact(value: unknown): unknown {
81
+ return redactValue(undefined, value, 0, new WeakSet());
82
+ }
83
+
84
+ function redactValue(key: string | undefined, value: unknown, depth: number, seen: WeakSet<object>): unknown {
85
+ if (key !== undefined && isSecretKey(key)) return REDACTED;
86
+ if (depth > 8) return REDACTED;
87
+ if (typeof value === 'string') return redactString(value);
88
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null || value === undefined) return value;
89
+ if (typeof value !== 'object') return REDACTED;
90
+ if (value instanceof Date) return value.toISOString();
91
+ if (value instanceof Error) {
92
+ return redactValue(undefined, { name: value.name, message: value.message }, depth + 1, seen);
93
+ }
94
+ if (seen.has(value)) return REDACTED;
95
+ seen.add(value);
96
+ if (Array.isArray(value)) {
97
+ return value.map(item => redactValue(undefined, item, depth + 1, seen));
98
+ }
99
+ const output: Record<string, unknown> = {};
100
+ for (const [childKey, childValue] of Object.entries(value)) {
101
+ output[childKey] = redactValue(childKey, childValue, depth + 1, seen);
102
+ }
103
+ return output;
104
+ }
105
+
106
+ export function createLogger(options: LoggerOptions = {}): Logger {
107
+ const write = options.write ?? ((line: string) => {
108
+ console.log(line);
109
+ });
110
+ const clock = options.clock ?? (() => new Date());
111
+ const emit = (level: 'info' | 'warn' | 'error', msg: string, fields?: LogFields): void => {
112
+ const details = redact({ ...options.base, ...fields });
113
+ const record = {
114
+ ...(details !== null && typeof details === 'object' ? details as LogFields : {}),
115
+ time: clock().toISOString(),
116
+ level,
117
+ msg: redactString(msg),
118
+ };
119
+ try {
120
+ write(JSON.stringify(record));
121
+ } catch {
122
+ write(JSON.stringify({ time: record.time, level, msg: record.msg, error: 'log record was not serializable' }));
123
+ }
124
+ };
125
+ return {
126
+ info: (msg, fields) => emit('info', msg, fields),
127
+ warn: (msg, fields) => emit('warn', msg, fields),
128
+ error: (msg, fields) => emit('error', msg, fields),
129
+ };
130
+ }
131
+
132
+ export function requestIdOf(req: IncomingMessage): string {
133
+ const raw = req.headers[REQUEST_ID_HEADER];
134
+ const value = Array.isArray(raw) ? raw[0] : raw;
135
+ if (typeof value === 'string' && REQUEST_ID_PATTERN.test(value)) return value;
136
+ return randomUUID();
137
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,50 @@
1
+ export const ROUTE_REVEAL = '/better-display/reveal';
2
+ export const ROUTE_SKILL_STATUS = '/better-display/skill-status';
3
+ export const ROUTE_METRICS = '/better-display/metrics';
4
+
5
+ export const ROUTE_HITS = [ROUTE_REVEAL, ROUTE_SKILL_STATUS, ROUTE_METRICS] as const;
6
+
7
+ export interface RouteTiming {
8
+ count: number;
9
+ totalMs: number;
10
+ maxMs: number;
11
+ }
12
+
13
+ const hits = new Map<string, number>(ROUTE_HITS.map(route => [route, 0]));
14
+ const timings = new Map<string, RouteTiming>(ROUTE_HITS.map(route => [route, { count: 0, totalMs: 0, maxMs: 0 }]));
15
+
16
+ function snapshotHits(): Record<string, number> {
17
+ return Object.fromEntries(hits);
18
+ }
19
+
20
+ function snapshotTimings(): Record<string, RouteTiming> {
21
+ return Object.fromEntries([...timings].map(([route, timing]) => [route, { ...timing }]));
22
+ }
23
+
24
+ export function recordRouteHit(route: string): number {
25
+ const next = (hits.get(route) ?? 0) + 1;
26
+ hits.set(route, next);
27
+ return next;
28
+ }
29
+
30
+ export function recordRouteDuration(route: string, elapsedMs: number): RouteTiming {
31
+ const ms = Number.isFinite(elapsedMs) && elapsedMs > 0 ? Math.round(elapsedMs) : 0;
32
+ const current = timings.get(route) ?? { count: 0, totalMs: 0, maxMs: 0 };
33
+ const next = {
34
+ count: current.count + 1,
35
+ totalMs: current.totalMs + ms,
36
+ maxMs: Math.max(current.maxMs, ms),
37
+ };
38
+ timings.set(route, next);
39
+ return next;
40
+ }
41
+
42
+ export function metricsPayload(): {
43
+ counters: { dsh_better_display_route_hits_total: Record<string, number> };
44
+ timingsMs: Record<string, RouteTiming>;
45
+ } {
46
+ return {
47
+ counters: { dsh_better_display_route_hits_total: snapshotHits() },
48
+ timingsMs: snapshotTimings(),
49
+ };
50
+ }