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.
- package/CHANGELOG.md +11 -0
- package/DESIGN.md +43 -3
- package/README.en.md +9 -1
- package/README.md +9 -1
- package/docs/dependency-update-policy.md +9 -0
- package/docs/extension-points.md +172 -0
- package/docs/official-rendering-bridge.md +48 -19
- package/docs/openapi.yaml +147 -0
- package/docs/runbooks/blank-reader.md +39 -0
- package/lib/client.js +1417 -490
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +378 -144
- package/lib/types/client/AssistantNode.d.ts +20 -0
- package/lib/types/client/AssistantNode.d.ts.map +1 -0
- package/lib/types/client/AssistantNode.js +46 -0
- package/lib/types/client/AssistantNode.js.map +1 -0
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +33 -10
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
- package/lib/types/client/ChoreographedFlow.js +2 -0
- package/lib/types/client/ChoreographedFlow.js.map +1 -1
- package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
- package/lib/types/client/ClosedProcessSummary.js +3 -1
- package/lib/types/client/ClosedProcessSummary.js.map +1 -1
- package/lib/types/client/McpAppFrame.d.ts.map +1 -1
- package/lib/types/client/McpAppFrame.js +5 -11
- package/lib/types/client/McpAppFrame.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +17 -3
- package/lib/types/client/OfficialContent.d.ts.map +1 -1
- package/lib/types/client/OfficialContent.js +33 -7
- package/lib/types/client/OfficialContent.js.map +1 -1
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +43 -71
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/SettingsSection.js +0 -5
- package/lib/types/client/SettingsSection.js.map +1 -1
- package/lib/types/client/StickyLane.d.ts +12 -0
- package/lib/types/client/StickyLane.d.ts.map +1 -1
- package/lib/types/client/StickyLane.js +78 -2
- package/lib/types/client/StickyLane.js.map +1 -1
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/index.d.ts +3 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +6 -0
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/live-turn.d.ts +17 -5
- package/lib/types/client/live-turn.d.ts.map +1 -1
- package/lib/types/client/live-turn.js +106 -76
- package/lib/types/client/live-turn.js.map +1 -1
- package/lib/types/client/motion.d.ts +26 -2
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +136 -10
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +9 -0
- package/lib/types/client/official-slots.d.ts.map +1 -1
- package/lib/types/client/official-slots.js +22 -7
- package/lib/types/client/official-slots.js.map +1 -1
- package/lib/types/client/projection.d.ts +15 -2
- package/lib/types/client/projection.d.ts.map +1 -1
- package/lib/types/client/projection.js +16 -4
- package/lib/types/client/projection.js.map +1 -1
- package/lib/types/client/rewind-cut.d.ts +131 -0
- package/lib/types/client/rewind-cut.d.ts.map +1 -0
- package/lib/types/client/rewind-cut.js +321 -0
- package/lib/types/client/rewind-cut.js.map +1 -0
- package/lib/types/client/text-transform.d.ts +93 -0
- package/lib/types/client/text-transform.d.ts.map +1 -0
- package/lib/types/client/text-transform.js +183 -0
- package/lib/types/client/text-transform.js.map +1 -0
- package/lib/types/client/timeline.d.ts.map +1 -1
- package/lib/types/client/timeline.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +9 -2
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +42 -2
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/turn-slots.d.ts +29 -0
- package/lib/types/client/turn-slots.d.ts.map +1 -0
- package/lib/types/client/turn-slots.js +62 -0
- package/lib/types/client/turn-slots.js.map +1 -0
- package/lib/types/client/types.d.ts +18 -0
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/dsh-better-display.d.ts +4 -1
- package/lib/types/dsh-better-display.d.ts.map +1 -1
- package/lib/types/dsh-better-display.js +56 -7
- package/lib/types/dsh-better-display.js.map +1 -1
- package/lib/types/flags.d.ts +7 -0
- package/lib/types/flags.d.ts.map +1 -0
- package/lib/types/flags.js +37 -0
- package/lib/types/flags.js.map +1 -0
- package/lib/types/log.d.ts +17 -0
- package/lib/types/log.d.ts.map +1 -0
- package/lib/types/log.js +122 -0
- package/lib/types/log.js.map +1 -0
- package/lib/types/metrics.d.ts +18 -0
- package/lib/types/metrics.d.ts.map +1 -0
- package/lib/types/metrics.js +35 -0
- package/lib/types/metrics.js.map +1 -0
- package/package.json +36 -5
- package/scripts/changelog.mjs +20 -0
- package/scripts/check-agents-md.mjs +67 -0
- package/scripts/check-bundle-size.mjs +38 -0
- package/scripts/check-dead-flags.mjs +55 -0
- package/scripts/check-todo-debt.mjs +69 -0
- package/scripts/security-audit-report.mjs +92 -0
- package/scripts/verify-commentary-visible.mjs +627 -0
- package/src/client/AssistantNode.tsx +72 -0
- package/src/client/Blocks.tsx +49 -10
- package/src/client/ChoreographedFlow.tsx +3 -1
- package/src/client/ClosedProcessSummary.tsx +3 -1
- package/src/client/McpAppFrame.tsx +5 -13
- package/src/client/OfficialContent.tsx +47 -9
- package/src/client/Reader.module.css +68 -18
- package/src/client/Reader.tsx +36 -76
- package/src/client/SettingsSection.tsx +1 -7
- package/src/client/StickyLane.tsx +81 -2
- package/src/client/ToolActivity.tsx +1 -1
- package/src/client/index.tsx +8 -1
- package/src/client/live-turn.ts +100 -69
- package/src/client/motion.tsx +135 -7
- package/src/client/official-slots.tsx +23 -7
- package/src/client/projection.ts +16 -4
- package/src/client/rewind-cut.ts +340 -0
- package/src/client/text-transform.ts +265 -0
- package/src/client/timeline.ts +0 -2
- package/src/client/tool-activity.ts +49 -2
- package/src/client/turn-slots.ts +80 -0
- package/src/client/types.ts +19 -0
- package/src/dsh-better-display.ts +58 -7
- package/src/flags.ts +40 -0
- package/src/log.ts +137 -0
- package/src/metrics.ts +50 -0
- package/tests/browser-commentary.tsx +191 -0
- package/tests/chat-flow-hook.test.ts +1 -1
- package/tests/cloud-gate.test.ts +1 -1
- package/tests/context-tool-update.test.ts +1 -1
- package/tests/conversation-scroll.test.ts +1 -1
- package/tests/deliverables.test.ts +1 -1
- package/tests/entry.test.ts +1 -1
- package/tests/fixtures/reader-lane-owners.tsx +46 -0
- package/tests/fixtures/reader-official-users.tsx +64 -0
- package/tests/fixtures/reader-theme-overlap.html +80 -0
- package/tests/fixtures/reader-user-bubble.tsx +222 -0
- package/tests/flags.test.ts +66 -0
- package/tests/fold-choreography.test.ts +32 -8
- package/tests/fold-intensity.test.ts +11 -6
- package/tests/footer-precedence.test.ts +1 -1
- package/tests/history-word-regression.test.ts +1 -1
- package/tests/live-turn.test.ts +125 -54
- package/tests/log.test.ts +99 -0
- package/tests/mcp-apps.test.ts +1 -1
- package/tests/message-chrome.test.ts +1 -1
- package/tests/metrics.test.ts +52 -0
- package/tests/official-slots.test.ts +41 -2
- package/tests/open-file.test.ts +1 -1
- package/tests/pending-submission.test.ts +12 -1
- package/tests/pinned-selection.test.ts +16 -0
- package/tests/platform-media.test.ts +1 -1
- package/tests/projection.test.ts +66 -3
- package/tests/reader-glass-css.test.ts +1 -1
- package/tests/reader-official-users.test.ts +11 -1
- package/tests/reader-sticky-surface.test.ts +377 -0
- package/tests/reader-theme-lanes.test.ts +54 -0
- package/tests/reader-theme-overlap.test.ts +151 -0
- package/tests/reader-user-bubble.test.ts +137 -0
- package/tests/reasoning-follow.test.ts +1 -1
- package/tests/retiring-content.test.ts +98 -6
- package/tests/reveal.test.ts +3 -3
- package/tests/rewind-cut.test.ts +1090 -0
- package/tests/settings-copy.test.ts +1 -1
- package/tests/skill-status.test.ts +1 -1
- package/tests/stock-install.test.ts +1 -1
- package/tests/stream-buffer.test.ts +1 -1
- package/tests/styles.test.ts +30 -3
- package/tests/terminal-input-preview.test.ts +1 -1
- package/tests/text-transform.test.ts +272 -0
- package/tests/timeline.test.ts +1 -1
- package/tests/tool-clock.test.ts +1 -1
- package/tests/tool-diff.test.ts +1 -1
- package/tests/waiting-clock.test.ts +1 -1
- 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
|
+
}
|
package/src/client/timeline.ts
CHANGED
|
@@ -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
|
-
/**
|
|
16
|
-
|
|
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
|
+
}
|
package/src/client/types.ts
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
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:
|
|
42
|
-
handler:
|
|
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:
|
|
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
|
|
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
|
+
}
|