dsh-better-display 0.3.10 → 0.3.12
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 +15 -0
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/docs/extension-points.md +36 -0
- package/lib/client.js +2556 -2565
- package/lib/client.js.map +1 -1
- package/lib/types/client/Blocks.js +4 -4
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +3 -0
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/composer-seat.d.ts +24 -0
- package/lib/types/client/composer-seat.d.ts.map +1 -0
- package/lib/types/client/composer-seat.js +23 -0
- package/lib/types/client/composer-seat.js.map +1 -0
- package/package.json +2 -2
- package/src/client/Blocks.tsx +5 -5
- package/src/client/Reader.module.css +11 -0
- package/src/client/Reader.tsx +3 -0
- package/src/client/composer-seat.ts +28 -0
- package/tests/reader-composer-seat.test.ts +61 -0
- package/tests/reader-official-users.test.ts +1 -1
- package/tests/reader-theme-overlap.test.ts +1 -1
- package/tests/reasoning-block-key.test.ts +129 -0
- package/tsdown.config.ts +12 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
3
|
+
import { test, vi } from 'vitest';
|
|
4
|
+
import { Blocks } from '../src/client/Blocks.js';
|
|
5
|
+
import type { BlockRenderProps } from '../src/client/types.js';
|
|
6
|
+
import {
|
|
7
|
+
createTextTransformRegistry,
|
|
8
|
+
installTextTransformRegistry,
|
|
9
|
+
type DisplayTextRequest,
|
|
10
|
+
} from '../src/client/text-transform.js';
|
|
11
|
+
|
|
12
|
+
// Inspect the real component paths without a browser or Harness runtime. The
|
|
13
|
+
// fixed useId represents one mounted Blocks instance, not a remount test.
|
|
14
|
+
vi.mock('react', async importOriginal => {
|
|
15
|
+
const react = await importOriginal<typeof import('react')>();
|
|
16
|
+
return {
|
|
17
|
+
...react,
|
|
18
|
+
useId: () => ':opaque-mounted-reader:',
|
|
19
|
+
useEffect: () => {},
|
|
20
|
+
useMemo: (factory: () => unknown) => factory(),
|
|
21
|
+
useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({
|
|
25
|
+
JsonBlock: () => null, MarkdownText: () => null, writeClipboard: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('../src/client/streaming.js', () => ({
|
|
28
|
+
useStreamingText: (text: string) => ({ text, pending: false, reveal: false, revision: 0 }),
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('../src/client/word-motion.js', () => ({
|
|
31
|
+
MotionMarkdown: () => null, MotionPlainText: () => null,
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('../src/client/McpAppFrame.js', async () => {
|
|
34
|
+
const { createContext } = await import('react');
|
|
35
|
+
return { ComposerFillContext: createContext(undefined), McpAppFrame: () => null };
|
|
36
|
+
});
|
|
37
|
+
vi.mock('../src/client/TurnMetrics.js', () => ({ TurnMetrics: () => null }));
|
|
38
|
+
|
|
39
|
+
type Element = ReactElement<Record<string, unknown>>;
|
|
40
|
+
type ReasoningBlock = { readonly kind: 'reasoning'; readonly text: string };
|
|
41
|
+
|
|
42
|
+
function reasoningRoots(blocks: readonly ReasoningBlock[], streaming = false): Element[] {
|
|
43
|
+
const roots: Element[] = [];
|
|
44
|
+
const renderSlotChain = ((_name: string, _owner: unknown, options: { fallback: ReactNode }) => {
|
|
45
|
+
let element = options.fallback as Element;
|
|
46
|
+
// ReadingReasoning -> [TransformedReasoning ->] ReasoningStream -> div.
|
|
47
|
+
while (typeof element.type === 'function') {
|
|
48
|
+
element = (element.type as (props: Record<string, unknown>) => Element)(element.props);
|
|
49
|
+
}
|
|
50
|
+
assert.equal(element.type, 'div');
|
|
51
|
+
assert.equal(element.props['data-reader-text-kind'], 'reasoning');
|
|
52
|
+
roots.push(element);
|
|
53
|
+
return element;
|
|
54
|
+
}) as BlockRenderProps['renderSlotChain'];
|
|
55
|
+
const renderBlocks = (Blocks as unknown as { type: (props: unknown) => ReactNode }).type;
|
|
56
|
+
renderBlocks({ blocks, streaming, renderSlotChain });
|
|
57
|
+
return roots;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const marker = (root: Element) => root.props['data-reader-reasoning-block-key'];
|
|
61
|
+
const shownText = (root: Element) => (root.props.children as Element).props.text;
|
|
62
|
+
|
|
63
|
+
for (const mode of ['no registry', 'body only', 'translated', 'original fallback'] as const) {
|
|
64
|
+
test(`reasoning roots retain their own opaque keys and source text: ${mode}`, () => {
|
|
65
|
+
const registry = mode === 'no registry' ? undefined : createTextTransformRegistry();
|
|
66
|
+
const requests: DisplayTextRequest[] = [];
|
|
67
|
+
if (mode === 'body only') registry!.register('body', { read: () => 'unused body transform' });
|
|
68
|
+
if (mode === 'translated' || mode === 'original fallback') {
|
|
69
|
+
registry!.register('reasoning', {
|
|
70
|
+
read(request) {
|
|
71
|
+
requests.push(request);
|
|
72
|
+
return mode === 'translated' ? 'translated display' : undefined;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const restore = installTextTransformRegistry(registry);
|
|
77
|
+
const blocks = Object.freeze([
|
|
78
|
+
Object.freeze({ kind: 'reasoning' as const, text: 'identical original' }),
|
|
79
|
+
Object.freeze({ kind: 'reasoning' as const, text: 'identical original' }),
|
|
80
|
+
]);
|
|
81
|
+
try {
|
|
82
|
+
const roots = reasoningRoots(blocks);
|
|
83
|
+
const keys = roots.map(marker);
|
|
84
|
+
assert.equal(keys.length, 2);
|
|
85
|
+
assert.ok(keys.every(key => typeof key === 'string' && key.length > 0));
|
|
86
|
+
assert.equal(new Set(keys).size, 2, 'identical text must not merge block ownership');
|
|
87
|
+
assert.deepEqual(roots.map(shownText), [
|
|
88
|
+
mode === 'translated' ? 'translated display' : 'identical original',
|
|
89
|
+
mode === 'translated' ? 'translated display' : 'identical original',
|
|
90
|
+
]);
|
|
91
|
+
assert.equal(requests.length, mode === 'translated' || mode === 'original fallback' ? 2 : 0);
|
|
92
|
+
if (requests.length) {
|
|
93
|
+
assert.deepEqual(requests.map(request => request.blockKey), keys);
|
|
94
|
+
assert.ok(requests.every(request => request.text === 'identical original'));
|
|
95
|
+
}
|
|
96
|
+
assert.deepEqual(blocks.map(block => block.text), ['identical original', 'identical original']);
|
|
97
|
+
const grown = reasoningRoots(blocks.map(block => ({ ...block, text: `${block.text} grows` })), true);
|
|
98
|
+
assert.deepEqual(grown.map(marker), keys, 'streaming growth must preserve each mounted block key');
|
|
99
|
+
} finally {
|
|
100
|
+
restore();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('marker keeps the existing mounted key across source growth, translated display and original override', () => {
|
|
106
|
+
const registry = createTextTransformRegistry();
|
|
107
|
+
const requests: DisplayTextRequest[] = [];
|
|
108
|
+
let original = false;
|
|
109
|
+
registry.register('reasoning', {
|
|
110
|
+
read(request) {
|
|
111
|
+
requests.push(request);
|
|
112
|
+
return original ? undefined : { text: `translated: ${request.text}`, final: !request.streaming };
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const restore = installTextTransformRegistry(registry);
|
|
116
|
+
try {
|
|
117
|
+
const first = reasoningRoots([{ kind: 'reasoning', text: 'hello' }], true)[0];
|
|
118
|
+
const grown = reasoningRoots([{ kind: 'reasoning', text: 'hello world' }], true)[0];
|
|
119
|
+
original = true;
|
|
120
|
+
const settled = reasoningRoots([{ kind: 'reasoning', text: 'hello world' }])[0];
|
|
121
|
+
assert.deepEqual([marker(grown), marker(settled)], [marker(first), marker(first)]);
|
|
122
|
+
assert.deepEqual(requests.map(request => request.blockKey), [marker(first), marker(first), marker(first)]);
|
|
123
|
+
assert.equal(shownText(first), 'translated: hello');
|
|
124
|
+
assert.equal(shownText(grown), 'translated: hello world');
|
|
125
|
+
assert.equal(shownText(settled), 'hello world');
|
|
126
|
+
} finally {
|
|
127
|
+
restore();
|
|
128
|
+
}
|
|
129
|
+
});
|
package/tsdown.config.ts
CHANGED
|
@@ -30,11 +30,20 @@ const portableOutput: TsdownPlugin = {
|
|
|
30
30
|
generateBundle(_options, output) {
|
|
31
31
|
const client = output['client.js'];
|
|
32
32
|
if (client?.type !== 'chunk') this.error('client.js was not emitted');
|
|
33
|
+
const root = resolve('.').replaceAll('\\', '/').replace(/\/$/u, '');
|
|
34
|
+
// Rolldown prints `// \0dshx-css-module:<id>`. Older builds used `//#region`.
|
|
35
|
+
// Rewrite either spelling to a package-relative POSIX path before the
|
|
36
|
+
// absolute-path guard, so a checkout path cannot survive into lib/client.js.
|
|
33
37
|
client.code = client.code.replace(
|
|
34
|
-
/^([ \t]
|
|
35
|
-
|
|
38
|
+
/^([ \t]*\/\/(?:#region )? ?)\\0dshx-css-module:([^\r\n]*?)(\r?)$/gmu,
|
|
39
|
+
(_match, prefix: string, rawPath: string, ending: string) => {
|
|
40
|
+
let cssPath = rawPath.replaceAll('\\', '/');
|
|
41
|
+
const rootPrefix = `${root}/`;
|
|
42
|
+
if (cssPath.startsWith(rootPrefix)) cssPath = cssPath.slice(rootPrefix.length);
|
|
43
|
+
return `${prefix}\\0dshx-css-module:${cssPath}${ending}`;
|
|
44
|
+
},
|
|
36
45
|
);
|
|
37
|
-
if (
|
|
46
|
+
if (/^[ \t]*\/\/(?:#region )? ?\\0dshx-css-module:(?:\/|[A-Za-z]:[\\/])/mu.test(client.code)) {
|
|
38
47
|
this.error('client.js contains a non-portable CSS module path');
|
|
39
48
|
}
|
|
40
49
|
},
|