dsh-better-display 0.3.6 → 0.3.8

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 (79) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/official-rendering-bridge.md +16 -0
  3. package/lib/client.js +564 -394
  4. package/lib/client.js.map +1 -1
  5. package/lib/dsh-better-display.js +139 -48
  6. package/lib/types/client/OfficialContent.d.ts.map +1 -1
  7. package/lib/types/client/OfficialContent.js +2 -1
  8. package/lib/types/client/OfficialContent.js.map +1 -1
  9. package/lib/types/client/Reader.d.ts.map +1 -1
  10. package/lib/types/client/Reader.js +96 -20
  11. package/lib/types/client/Reader.js.map +1 -1
  12. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  13. package/lib/types/client/ToolActivity.js +10 -5
  14. package/lib/types/client/ToolActivity.js.map +1 -1
  15. package/lib/types/client/chat-seat.d.ts +3 -0
  16. package/lib/types/client/chat-seat.d.ts.map +1 -0
  17. package/lib/types/client/chat-seat.js +9 -0
  18. package/lib/types/client/chat-seat.js.map +1 -0
  19. package/lib/types/client/deliverables.d.ts.map +1 -1
  20. package/lib/types/client/deliverables.js +2 -0
  21. package/lib/types/client/deliverables.js.map +1 -1
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/index.js +9 -1
  24. package/lib/types/client/index.js.map +1 -1
  25. package/lib/types/client/motion.d.ts.map +1 -1
  26. package/lib/types/client/motion.js +9 -1
  27. package/lib/types/client/motion.js.map +1 -1
  28. package/lib/types/client/official-slots.d.ts.map +1 -1
  29. package/lib/types/client/official-slots.js +6 -3
  30. package/lib/types/client/official-slots.js.map +1 -1
  31. package/lib/types/client/open-file.d.ts.map +1 -1
  32. package/lib/types/client/open-file.js +1 -6
  33. package/lib/types/client/open-file.js.map +1 -1
  34. package/lib/types/client/styles.d.ts +3 -0
  35. package/lib/types/client/styles.d.ts.map +1 -0
  36. package/lib/types/client/styles.js +25 -0
  37. package/lib/types/client/styles.js.map +1 -0
  38. package/lib/types/client/tool-activity.d.ts +2 -2
  39. package/lib/types/client/tool-activity.d.ts.map +1 -1
  40. package/lib/types/client/tool-activity.js +12 -7
  41. package/lib/types/client/tool-activity.js.map +1 -1
  42. package/lib/types/dsh-better-display.d.ts.map +1 -1
  43. package/lib/types/dsh-better-display.js +2 -39
  44. package/lib/types/dsh-better-display.js.map +1 -1
  45. package/lib/types/reveal.d.ts +12 -0
  46. package/lib/types/reveal.d.ts.map +1 -0
  47. package/lib/types/reveal.js +147 -0
  48. package/lib/types/reveal.js.map +1 -0
  49. package/lib/types/workspace-path.d.ts +3 -0
  50. package/lib/types/workspace-path.d.ts.map +1 -0
  51. package/lib/types/workspace-path.js +5 -0
  52. package/lib/types/workspace-path.js.map +1 -0
  53. package/package.json +1 -1
  54. package/src/client/OfficialContent.tsx +2 -1
  55. package/src/client/Reader.module.css +41 -9
  56. package/src/client/Reader.tsx +108 -32
  57. package/src/client/ToolActivity.tsx +9 -5
  58. package/src/client/chat-seat.ts +10 -0
  59. package/src/client/deliverables.ts +1 -0
  60. package/src/client/index.tsx +10 -1
  61. package/src/client/motion.tsx +8 -1
  62. package/src/client/official-slots.tsx +6 -3
  63. package/src/client/open-file.ts +2 -8
  64. package/src/client/styles.ts +22 -0
  65. package/src/client/tool-activity.ts +18 -7
  66. package/src/dsh-better-display.ts +2 -38
  67. package/src/reveal.ts +90 -0
  68. package/src/workspace-path.ts +4 -0
  69. package/tests/chat-flow-hook.test.ts +13 -0
  70. package/tests/fixtures/reader-official-users.tsx +170 -0
  71. package/tests/footer-precedence.test.ts +8 -0
  72. package/tests/reader-glass-css.test.ts +28 -0
  73. package/tests/reader-official-users.test.ts +81 -0
  74. package/tests/retiring-content.test.ts +137 -0
  75. package/tests/reveal.test.ts +128 -0
  76. package/tests/stock-install.test.ts +8 -0
  77. package/tests/styles.test.ts +48 -0
  78. package/tests/terminal-input-preview.test.ts +113 -0
  79. package/tsdown.config.ts +55 -0
@@ -17,6 +17,7 @@ import css from './Reader.module.css';
17
17
  const LABEL: Record<ToolPhase, string> = { preparing: '输入生成中', running: '执行中', returned: '已返回', succeeded: '已完成', failed: '失败', interrupted: '已中断' };
18
18
  const ICONS = { write: IconEditOutlineRegular, read: IconBrowseOutlineRegular, terminal: IconApiOutlineRegular, search: IconSearchOutlineRegular, web: IconSearchOutlineRegular, code: IconApiOutlineRegular, other: IconSparkleRegular } satisfies Record<ToolCategory, unknown>;
19
19
  const number = new Intl.NumberFormat('zh-CN');
20
+ const preparingTerminalLabels = { ...terminalBlockLabels, running: '正在生成命令' };
20
21
  const language = (path: string | undefined) => path?.split('.').at(-1);
21
22
  const duration = (ms: number) => ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} 秒`;
22
23
 
@@ -28,14 +29,17 @@ function generatedInput(content: string, target: string | undefined, preparing:
28
29
  </div>;
29
30
  }
30
31
 
31
- function InputView({ model, preparing, fillComposer }: { model: ReturnType<typeof activitySummary>; preparing: boolean; fillComposer: BlockRenderProps['fillComposer'] }) {
32
+ function InputView({ model, phase, fillComposer }: { model: ReturnType<typeof activitySummary>; phase: ToolPhase; fillComposer: BlockRenderProps['fillComposer'] }) {
33
+ const preparing = phase === 'preparing';
34
+ const inProgress = preparing || phase === 'running';
32
35
  if ((model.name === 'render_ui' || model.name === 'show_widget') && typeof model.args?.html === 'string') {
33
36
  return preparing
34
37
  ? <StreamingMcpAppPlaceholder title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} />
35
38
  : <McpAppFrame html={model.args.html as string} title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} fillComposer={fillComposer} />;
36
39
  }
37
40
  if (model.content) return generatedInput(model.content, model.target, preparing);
38
- if (model.command) return <div data-reader-tool-terminal><p className={css.toolDetailNote}>{preparing ? '正在生成命令 · 尚未执行' : '提交的命令'}</p><TerminalBlock command={model.command} cwd={model.cwd} labels={terminalBlockLabels} /></div>;
41
+ if (model.command) return <div data-reader-tool-terminal><p className={css.toolDetailNote}>{preparing ? '正在生成命令 · 尚未执行' : '提交的命令'}</p><TerminalBlock command={model.command} cwd={model.cwd} running={inProgress} labels={preparing ? preparingTerminalLabels : terminalBlockLabels} /></div>;
42
+ if (model.category === 'terminal' && preparing && model.argsState === 'partial') return <p className={css.toolDetailNote} data-reader-tool-command-pending>命令仍在生成,收到可识别的命令文本后会显示预览。</p>;
39
43
  return <JsonTree data={model.args} label={preparing ? '已收到的输入字段' : '工具输入'} labels={jsonTreeLabels} />;
40
44
  }
41
45
 
@@ -95,11 +99,11 @@ function ResultFallback({ entry, model, phase, ...render }: BlockRenderProps & {
95
99
  const block = entry.block;
96
100
  if (!block || !('kind' in block)) return <>
97
101
  <p className={css.toolDetailNote}>{phase === 'interrupted' ? '已中断,没有工具结果。已生成的输入仍可查看。' : phase === 'preparing' ? '模型正在生成工具输入,工具还未开始执行。' : '工具已开始执行,正在等待结果。'}</p>
98
- <InputView model={model} preparing={phase === 'preparing'} fillComposer={render.fillComposer} />
102
+ <InputView model={model} phase={phase} fillComposer={render.fillComposer} />
99
103
  </>;
100
104
  const meta = objectValue(block.meta);
101
105
  const text = block.content.filter(item => item.type === 'text').map(item => item.text).join('\n');
102
- if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model} preparing={false} fillComposer={render.fillComposer} /><pre className={css.toolRaw}>{text}</pre></>;
106
+ if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model} phase={phase} fillComposer={render.fillComposer} /><pre className={css.toolRaw}>{text}</pre></>;
103
107
 
104
108
  if (model.category === 'terminal') {
105
109
  const facts = executionFacts(block);
@@ -247,7 +251,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
247
251
  <div ref={panel} id={`${detailId}-panel`} className={css.toolPanel} role="tabpanel" aria-labelledby={`${detailId}-${tab}`} tabIndex={0}>
248
252
  {selected && <p className={css.toolDetailNote}>为保留选区,预览暂停更新;当前状态见卡片标题。</p>}
249
253
  {tab === 'result' && <ResultView {...render} {...preview} />}
250
- {tab === 'input' && <><InputView model={preview.model} preparing={preview.phase === 'preparing'} fillComposer={render.fillComposer} /><details className={css.detail}><summary>全部输入字段</summary><JsonTree data={preview.model.args} label="输入字段" labels={jsonTreeLabels} /></details></>}
254
+ {tab === 'input' && <><InputView model={preview.model} phase={preview.phase} fillComposer={render.fillComposer} /><details className={css.detail}><summary>全部输入字段</summary><JsonTree data={preview.model.args} label="输入字段" labels={jsonTreeLabels} /></details></>}
251
255
  {tab === 'raw' && <><p className={css.toolDetailNote}>完整记录 · 只读 · 不执行其中的代码</p><h4 className={css.toolRawLabel}>工具输入</h4><pre className={css.toolRaw}>{preview.model.raw || '输入尚未到达'}</pre>{rawResult && <><h4 className={css.toolRawLabel}>工具结果</h4><pre className={css.toolRaw}>{rawResult}</pre></>}</>}
252
256
  </div>
253
257
  </div>
@@ -0,0 +1,10 @@
1
+ export type ChatSeatKind = 'user' | 'steering';
2
+
3
+ export function chatSeatProps(kind: ChatSeatKind, key: string): Record<string, string> {
4
+ return {
5
+ 'data-chat-anchor-key': key,
6
+ 'data-chat-flow-key': key,
7
+ 'data-chat-node-key': key,
8
+ 'data-chat-flow-kind': kind,
9
+ };
10
+ }
@@ -23,6 +23,7 @@ export function basename(path: string): string {
23
23
  export function dirname(path: string): string {
24
24
  const normalized = path.replace(/[/\\]+$/, '');
25
25
  const at = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'));
26
+ if (at === 0 || (at === 2 && /^[A-Za-z]:/.test(normalized))) return normalized.slice(0, at + 1);
26
27
  return at === -1 ? '.' : normalized.slice(0, at) || '.';
27
28
  }
28
29
 
@@ -6,6 +6,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types';
6
6
  import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path';
7
7
  import * as workspacePathPkg from '@deepseek-ai/dsh-util-workspace-path';
8
8
  import { dirname } from './deliverables.js';
9
+ import { isAbsoluteWorkspacePath } from '../workspace-path.js';
10
+ import { ensureStyles } from './styles.js';
9
11
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client';
10
12
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
11
13
  import { Reader } from './Reader.js';
@@ -57,6 +59,9 @@ export { McpAppFrame } from './McpAppFrame.js';
57
59
  export const name = 'dsh-better-display-client';
58
60
  export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'remote', 'remote.session'];
59
61
 
62
+ // All CSS imports have injected their nodes before this module's body runs.
63
+ ensureStyles();
64
+
60
65
  export function apply(ctx: Context): void {
61
66
  const store = createReaderStore();
62
67
  // conversation.view is session-scoped, so session persist keys are
@@ -138,7 +143,7 @@ export function apply(ctx: Context): void {
138
143
  const res = await fetch('/better-display/reveal', {
139
144
  method: 'POST',
140
145
  headers: { 'Content-Type': 'application/json' },
141
- body: JSON.stringify({ path: targetPath }),
146
+ body: JSON.stringify({ path: targetPath, sessionId }),
142
147
  });
143
148
  if (res.ok) {
144
149
  const data = await res.json();
@@ -150,6 +155,10 @@ export function apply(ctx: Context): void {
150
155
 
151
156
  // 2. Fallback to official opener with parent directory
152
157
  const parentDir = dirname(targetPath);
158
+ if (!isAbsoluteWorkspacePath(parentDir)) {
159
+ console.warn('[dsh-better-display] reveal failed; refusing to open a relative parent:', targetPath);
160
+ return;
161
+ }
153
162
  const remote = ctx.remote as unknown as { session?: { openWorkspacePath: (arg: { path: string }) => Promise<{ ok: boolean; error?: { message: string } }> } } | undefined;
154
163
  const remoteSession = remote?.session
155
164
  ?? (ctx.get?.('remote.session') as unknown as { openWorkspacePath: (arg: { path: string }) => Promise<{ ok: boolean; error?: { message: string } }> } | undefined);
@@ -191,7 +191,14 @@ export function RetiringContent({ visible, children }: { visible: boolean; child
191
191
  if (!enabled || from < 1) { setPresent(false); return; }
192
192
  const next = element.animate([{ height: `${from}px`, opacity: 1 }, { height: '0px', opacity: 0 }], { duration: 220, easing: EASING, fill: 'both' });
193
193
  animation.current = next;
194
- next.onfinish = () => { if (animation.current === next) { animation.current = null; next.cancel(); setPresent(false); } };
194
+ const settle = () => { if (animation.current === next) { animation.current = null; next.cancel(); setPresent(false); } };
195
+ next.onfinish = settle;
196
+ const deadline = window.setTimeout(settle, 220 + 240);
197
+ return () => {
198
+ window.clearTimeout(deadline);
199
+ if (animation.current === next) animation.current = null;
200
+ next.cancel();
201
+ };
195
202
  }, [visible, enabled, focusHeld]);
196
203
  useEffect(() => () => animation.current?.cancel(), []);
197
204
  if (!visible && !present) return null;
@@ -57,8 +57,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
57
57
  }
58
58
 
59
59
  // Existing Reader presentations own these rows. All other node kinds, including
60
- // future registrations, reach the official renderer through the fallback seat.
61
- const READER_NODES = new Set(['user', 'steering', 'assistant-step', 'tool-call', 'turn-tail', 'turn-process']);
60
+ // future registrations, reach the official renderer through the mirror seat.
61
+ // 'user' and 'steering' are mirrored too, so third-party renderers registered
62
+ // on the official node slot (e.g. message-recall buttons) keep working inside
63
+ // the Reader instead of being silently dropped.
64
+ const READER_OWNED_NODES = new Set(['assistant-step', 'tool-call', 'turn-tail', 'turn-process']);
62
65
  const EXPECTED: Record<OfficialFamily, Spec> = {
63
66
  actions: { kind: 'list', scope: 'session' },
64
67
  tools: { kind: 'keyed', scope: 'session' },
@@ -198,7 +201,7 @@ export function installOfficialSlots(ctx: Context): () => void {
198
201
  throw new Error(`Official slot contract changed: ${source}`);
199
202
  }
200
203
  return mirrorOfficialSlot(slots, source, target, `dsh-better-display.official.${family}`,
201
- family === 'nodes' ? entry => !READER_NODES.has(entry.options.key ?? '') : undefined);
204
+ family === 'nodes' ? entry => !READER_OWNED_NODES.has(entry.options.key ?? '') : undefined);
202
205
  }));
203
206
  }
204
207
  } catch (error) {
@@ -1,3 +1,5 @@
1
+ import { isAbsoluteWorkspacePath } from '../workspace-path.js';
2
+
1
3
  export type DeliverableOpenMode = 'external' | 'sidebar';
2
4
 
3
5
  export interface OpenModeSnapshot {
@@ -22,14 +24,6 @@ export function isFolderOpenPath(path: string): boolean {
22
24
  return path === '.' || path === '';
23
25
  }
24
26
 
25
- function isWindowsStylePath(value: string): boolean {
26
- return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\');
27
- }
28
-
29
- function isAbsoluteWorkspacePath(path: string): boolean {
30
- return path.startsWith('/') || isWindowsStylePath(path);
31
- }
32
-
33
27
  function encodeSegment(segment: string): string {
34
28
  return encodeURIComponent(segment).replace(/%3A/gi, ':');
35
29
  }
@@ -0,0 +1,22 @@
1
+ const plugin = 'dsh-better-display';
2
+ const styles = new Map<string, string>();
3
+
4
+ /** Retain injected CSS across host cleanup and restore it before Reader paints. */
5
+ export function ensureStyles(): void {
6
+ if (typeof document === 'undefined') return;
7
+ const live = new Set<string>();
8
+ for (const tag of document.querySelectorAll<HTMLStyleElement>(`style[data-plugin="${plugin}"][data-plugin-css]`)) {
9
+ const id = tag.dataset.pluginCss;
10
+ if (id === undefined) continue;
11
+ styles.set(id, tag.textContent ?? '');
12
+ live.add(id);
13
+ }
14
+ for (const [id, text] of styles) {
15
+ if (live.has(id)) continue;
16
+ const tag = document.createElement('style');
17
+ tag.dataset.plugin = plugin;
18
+ tag.dataset.pluginCss = id;
19
+ tag.textContent = text;
20
+ document.head.appendChild(tag);
21
+ }
22
+ }
@@ -53,10 +53,15 @@ export function stringValue(record: Record<string, unknown> | null, ...keys: str
53
53
  return undefined;
54
54
  }
55
55
 
56
+ /** Top-level fields from complete or in-flight JSON tool arguments. */
57
+ type ParsedInputFields =
58
+ | { kind: 'complete'; fields: Record<string, unknown> }
59
+ | { kind: 'partial'; fields: Record<string, unknown> };
60
+
56
61
  /** Read only top-level JSON string values, including an unfinished final string.
57
62
  * 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 */ }
63
+ function parseInputFields(raw: string): ParsedInputFields {
64
+ try { return { kind: 'complete', fields: objectValue(JSON.parse(raw)) ?? {} }; } catch { /* an in-flight argument is normally incomplete */ }
60
65
  const fields: Record<string, unknown> = Object.create(null);
61
66
  const prefix = raw.slice(0, 262144);
62
67
  let depth = 0;
@@ -88,14 +93,19 @@ export function inputFields(raw: string): Record<string, unknown> {
88
93
  if (closed && /^\s*:/.test(prefix.slice(index + 1))) key = value;
89
94
  else if (key !== undefined) { fields[key] = value; key = undefined; }
90
95
  }
91
- return fields;
96
+ return { kind: 'partial', fields };
97
+ }
98
+
99
+ export function inputFields(raw: string): Record<string, unknown> {
100
+ return parseInputFields(raw).fields;
92
101
  }
93
102
 
103
+ /** Name and raw arguments of any call block, whether it has landed or is pending. */
94
104
  export function toolIdentity(entry: Pick<ToolActivityEntry, 'block' | 'draft'>) {
95
105
  const block = entry.block;
96
106
  return {
97
107
  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 ?? '',
108
+ raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : ('argsRaw' in block ? block.argsRaw : entry.draft?.argsRaw ?? '') : entry.draft?.argsRaw ?? '',
99
109
  };
100
110
  }
101
111
 
@@ -254,7 +264,7 @@ export function foldDiffHunks(steps: readonly { kind: string; entry?: ToolActivi
254
264
 
255
265
  export function activityPhase(entry: Pick<ToolActivityEntry, 'block' | 'draft'>, turnClosed = false): ToolPhase {
256
266
  if (!entry.block) return turnClosed ? 'interrupted' : 'preparing';
257
- if (!('kind' in entry.block)) return turnClosed ? 'interrupted' : 'running';
267
+ if (!('kind' in entry.block)) return turnClosed ? 'interrupted' : entry.block.phase === 'preparing' ? 'preparing' : 'running';
258
268
  // RC1 publishes canonical cancellation as an error result with a typed code.
259
269
  if (entry.block.error?.code === 'ABORTED' || entry.block.error?.code === 'interrupted') return 'interrupted';
260
270
  const facts = executionFacts(entry.block);
@@ -308,7 +318,8 @@ export function forgetCallClock(callId: string): void {
308
318
 
309
319
  export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'>) {
310
320
  const { name, raw } = toolIdentity(entry);
311
- const args = inputFields(raw);
321
+ const parsed = parseInputFields(raw);
322
+ const args = parsed.fields;
312
323
  const target = stringValue(args, 'file_path', 'path', 'filename', 'filePath');
313
324
  const command = stringValue(args, 'command', 'cmd', 'script');
314
325
  const description = stringValue(args, 'description');
@@ -331,7 +342,7 @@ export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'
331
342
  : category === 'web' ? name === 'web_search' ? '搜索网页' : '读取网页'
332
343
  : category === 'code' ? (description || '运行代码')
333
344
  : name;
334
- return { name, raw, args, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
345
+ return { name, raw, args, argsState: parsed.kind, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
335
346
  cwd: stringValue(args, 'workdir', 'cwd'), content: stringValue(args, 'content', 'new_string', 'newText', 'file_text') };
336
347
  }
337
348
 
@@ -1,8 +1,8 @@
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';
4
3
  import { scanGenerativeMcpappsStatus } from './skill-roots.js';
5
4
  import { skillsFromListResult, toPublicSkillStatus } from './skill-status.js';
5
+ import { createRevealHandler, revealRoots } from './reveal.js';
6
6
 
7
7
  declare module '@deepseek-ai/cordis' {
8
8
  interface Context {
@@ -39,43 +39,7 @@ export function apply(ctx: Context): void {
39
39
  const disposeReveal = ctx.webServer!.register({
40
40
  kind: 'exact',
41
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
- });
78
- },
42
+ handler: createRevealHandler({ roots: sessionId => revealRoots(ctx, sessionId) }),
79
43
  });
80
44
  const disposeSkill = ctx.webServer!.register({
81
45
  kind: 'exact',
package/src/reveal.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { spawn, type SpawnOptions } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import type { IncomingMessage, ServerResponse } from 'node:http';
4
+ import { posix, win32 } from 'node:path';
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ import { isAbsoluteWorkspacePath } from './workspace-path.js';
7
+
8
+ export async function revealRoots(ctx: Context, sessionId: string | undefined): Promise<readonly string[]> {
9
+ if (sessionId !== undefined) {
10
+ const sessions = ctx.get?.('sessions') as { get: (id: string) => { header: { cwd?: string } } | undefined } | undefined;
11
+ const cwd = sessions?.get(sessionId)?.header.cwd;
12
+ if (cwd) return [cwd];
13
+ const query = ctx.get?.('sessionQuery') as {
14
+ observeSession: (id: string, options: { projectionMode: 'none' }) => Promise<{ header: { cwd?: string } } & Disposable>;
15
+ } | undefined;
16
+ if (query) {
17
+ using observation = await query.observeSession(sessionId, { projectionMode: 'none' });
18
+ if (observation.header.cwd) return [observation.header.cwd];
19
+ }
20
+ }
21
+ const registry = ctx.get?.('workspaceRegistry') as {
22
+ list: () => readonly { path: string; sessionIds: readonly string[] }[];
23
+ } | undefined;
24
+ return (registry?.list() ?? [])
25
+ .filter(workspace => sessionId === undefined || workspace.sessionIds.includes(sessionId))
26
+ .map(workspace => workspace.path);
27
+ }
28
+
29
+ export function resolveRevealPath(path: string, roots: readonly string[], exists = existsSync): string {
30
+ if (isAbsoluteWorkspacePath(path)) return path;
31
+ // Drive-relative and root-relative Windows paths do not identify a workspace.
32
+ if (/^[A-Za-z]:|^\\/.test(path)) throw new Error('Path is not workspace-relative');
33
+ const matches = new Set<string>();
34
+ for (const root of roots) {
35
+ if (!isAbsoluteWorkspacePath(root)) continue;
36
+ const paths = /^[A-Za-z]:[/\\]|^\\\\/.test(root) ? win32 : posix;
37
+ const candidate = paths.resolve(root, path);
38
+ if (exists(candidate)) matches.add(candidate);
39
+ }
40
+ if (matches.size !== 1) throw new Error(matches.size === 0 ? 'Workspace path not found' : 'Workspace path is ambiguous');
41
+ return [...matches][0];
42
+ }
43
+
44
+ export function revealPath(
45
+ path: string,
46
+ platform = process.platform,
47
+ spawnProcess: (command: string, args: string[], options: SpawnOptions) => unknown = spawn,
48
+ ): void {
49
+ const options = { detached: true, stdio: 'ignore' } satisfies SpawnOptions;
50
+ if (platform === 'darwin') {
51
+ spawnProcess('open', ['-R', path], options);
52
+ } else if (platform === 'win32') {
53
+ spawnProcess('explorer.exe', [`/select,"${path}"`], { ...options, windowsVerbatimArguments: true });
54
+ } else {
55
+ spawnProcess('xdg-open', [path], options);
56
+ }
57
+ }
58
+
59
+ export function createRevealHandler({ roots, reveal = revealPath }: {
60
+ roots: (sessionId: string | undefined) => Promise<readonly string[]>;
61
+ reveal?: (path: string) => void;
62
+ }): (req: IncomingMessage, res: ServerResponse) => void {
63
+ return (req, res) => {
64
+ if (req.method !== 'POST') {
65
+ res.statusCode = 405;
66
+ res.end();
67
+ return;
68
+ }
69
+ let body = '';
70
+ req.on('data', chunk => { body += chunk; });
71
+ req.on('end', async () => {
72
+ res.setHeader('Content-Type', 'application/json');
73
+ try {
74
+ const data: unknown = JSON.parse(body);
75
+ const path = typeof data === 'object' && data !== null && 'path' in data && typeof data.path === 'string'
76
+ ? data.path.trim() : '';
77
+ if (!path) throw new Error('Empty path');
78
+ const sessionId = typeof data === 'object' && data !== null && 'sessionId' in data && typeof data.sessionId === 'string'
79
+ ? data.sessionId : undefined;
80
+ const targetPath = resolveRevealPath(path, isAbsoluteWorkspacePath(path) ? [] : await roots(sessionId));
81
+ reveal(targetPath);
82
+ res.statusCode = 200;
83
+ res.end(JSON.stringify({ ok: true }));
84
+ } catch (err) {
85
+ res.statusCode = 400;
86
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
87
+ }
88
+ });
89
+ };
90
+ }
@@ -0,0 +1,4 @@
1
+ /** Absolute paths accepted by both POSIX and Windows hosts. */
2
+ export function isAbsoluteWorkspacePath(path: string): boolean {
3
+ return /^(?:\/|[A-Za-z]:[/\\]|\\\\)/.test(path);
4
+ }
@@ -45,6 +45,19 @@ test('committed client bundle publishes data-chat-flow for git installs', () =>
45
45
  assert.match(compiled, /"data-chat-flow":\s*""/);
46
46
  });
47
47
 
48
+ test('Reader exposes rewind chat seats and pending steering markers', () => {
49
+ const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
50
+ const seat = readFileSync(resolve(root, 'src/client/chat-seat.ts'), 'utf8');
51
+ assert.match(seat, /data-chat-anchor-key/);
52
+ assert.match(seat, /data-chat-flow-key/);
53
+ assert.match(seat, /data-chat-node-key/);
54
+ assert.match(source, /data-pending-steering/);
55
+ assert.match(source, /data-pending-attachment-count/);
56
+ const client = readFileSync(resolve(root, 'lib/client.js'), 'utf8');
57
+ assert.match(client, /data-chat-anchor-key/);
58
+ assert.match(client, /data-pending-steering/);
59
+ });
60
+
48
61
  test('skin empty-state hide stays off when Reader mounts inside the scrollport', () => {
49
62
  const inspectOnly: AttrNode = {
50
63
  attrs: { 'data-conversation-scroll': '' },
@@ -0,0 +1,170 @@
1
+ import { Context } from '@deepseek-ai/cordis';
2
+ import * as React from 'react';
3
+ import * as Primitives from '@deepseek-ai/dsh-client-ui-primitives';
4
+ import { createRoot } from 'react-dom/client';
5
+ import { flushSync } from 'react-dom';
6
+ import { SlotRegistry } from '@fixture/registry';
7
+ import { createSlotRenderer } from '@fixture/renderer';
8
+ import { Reader } from '../../src/client/Reader.js';
9
+ import { createReaderStore } from '../../src/client/store.js';
10
+ import { installOfficialSlots, officialChildren, OFFICIAL_SEATS } from '../../src/client/official-slots.js';
11
+ import type { ReaderProps } from '../../src/client/types.js';
12
+
13
+ // Real Reader, registry, scope binding and slot renderer. Only session data is synthetic.
14
+ function equal(actual: unknown, expected: unknown) {
15
+ if (!Object.is(actual, expected)) throw new Error(`Expected ${String(expected)}, got ${String(actual)}`);
16
+ }
17
+ const assert = {
18
+ equal,
19
+ ok(value: unknown) { equal(Boolean(value), true); },
20
+ deepEqual(actual: unknown, expected: unknown) { equal(JSON.stringify(actual), JSON.stringify(expected)); },
21
+ };
22
+ async function verify() {
23
+ const root = new Context();
24
+ await root.plugin(SlotRegistry);
25
+ let run = async () => {};
26
+ await root.plugin({ name: 'reader-user-test', inject: ['slots'], async apply(ctx: Context) {
27
+ const slots = ctx.slots as any;
28
+ slots.install(createSlotRenderer());
29
+ const source = <T,>(value: T) => ({ getSnapshot: () => value, subscribe: () => () => {} });
30
+ const binding = { key: 'session-users', ctx: new Context(), hooks: {}, keyedHooks: {}, props: { sessionId: 'session-users' } };
31
+ slots.installScope('session', {
32
+ current: source(binding), bindingSource: () => source(binding), renderArea: (_: unknown, props: any) => props.children,
33
+ });
34
+ slots.installLocale({ ...source({}), bind: () => (key: string) => key });
35
+ const turn = { turn: 1, status: 'closed', start: { seq: 1, time: 1 },
36
+ end: { time: 2, data: { reason: { kind: 'completed' } } }, data: new Map(), steps: [] };
37
+ const nodes = new Map(['user', 'steering', 'hidden'].map((kind, index) => {
38
+ const key = `${kind}-key`;
39
+ return [key, { key, kind: kind === 'hidden' ? 'user' : kind, anchorSeq: index + 1,
40
+ visibility: kind === 'hidden' ? 'hidden' : 'visible', location: { kind: 'turn', turn },
41
+ data: { seq: index + 1, time: 1, content: [{ type: 'text', text: `${kind} content` }] } }];
42
+ }));
43
+ const chat = { order: [...nodes.keys()], nodes, timeline: { turns: new Map([[1, turn]]) } };
44
+ const session = { running: false, openState: 'ready', pendingSubmissions: [], hasMore: false, loadingOlder: false };
45
+ const store = createReaderStore().create('session-users');
46
+ const props = {
47
+ sessionId: 'session-users', useChat: (select: any) => select(chat),
48
+ useSession: (select: any) => select(session), useSessions: (select: any) => select({ byId: { 'session-users': { cwd: '/fixture' } } }),
49
+ useSessionStatus: (select: any) => select(new Map()), useStore: (select: any) => select(store.getSnapshot()), actions: store.actions,
50
+ t: (key: string) => key, loadImage: async () => ({ data: new Uint8Array(), mediaType: 'image/png' }),
51
+ officialImageLoader: Object.assign(async () => null, { peek: () => null }),
52
+ officialFileMentions: () => undefined, officialPreviewFile: () => {},
53
+ officialHost: source({ home: '/fixture' }), fillComposer: () => true,
54
+ openFile: () => {}, loadOlder: async () => {}, openView: () => {},
55
+ };
56
+ slots.register({ name: 'root', children: {
57
+ 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: { hooks: {
58
+ turnData: (_: unknown, context: any) => () => context.turnData,
59
+ disclosure: (_: unknown, context: any) => () => context.disclosureReset,
60
+ } } },
61
+ 'fixture.reader': { kind: 'single', scope: 'session' },
62
+ } }, ({ SessionProvider, renderSlot }: any) => <SessionProvider>{renderSlot('fixture.reader', {})}</SessionProvider>);
63
+ slots.register({ name: 'fixture.reader', children: { ...officialChildren(slots), 'dsh-better-display.block': { kind: 'chain', scope: 'session' } } }, ({ renderSlot, renderSlotChain }: any) =>
64
+ <Reader {...{ ...props, renderSlot, renderSlotChain } as ReaderProps} />);
65
+ const stopMirror = installOfficialSlots(ctx);
66
+ run = async () => {
67
+ const container = document.getElementById('app')!;
68
+ const reactRoot = createRoot(container);
69
+ const render = () => {
70
+ flushSync(() => reactRoot.render(slots.renderSlot('root', {})));
71
+ return container.innerHTML;
72
+ };
73
+ const count = (html: string, text: string) => html.split(text).length - 1;
74
+ const assertSeats = (html: string) => {
75
+ for (const kind of ['user', 'steering']) {
76
+ assert.equal(count(html, `data-reader-key="${kind}-key"`), 1);
77
+ assert.equal(count(html, `data-chat-anchor-key="${kind}-key"`), 1);
78
+ assert.equal(count(html, `data-chat-flow-key="${kind}-key"`), 1);
79
+ assert.equal(count(html, `data-chat-node-key="${kind}-key"`), 1);
80
+ assert.equal(count(html, `data-chat-flow-kind="${kind}"`), 1);
81
+ }
82
+ assert.equal(count(html, 'data-reader-anchor="true"'), 2);
83
+ assert.ok(!html.includes('hidden content'));
84
+ };
85
+
86
+ // The real renderSlot returns an outlet element for an unoccupied key.
87
+ let html = render();
88
+ assertSeats(html);
89
+ assert.equal(count(html, 'user content'), 1);
90
+ assert.equal(count(html, 'steering content'), 1);
91
+ assert.ok(html.includes('补充消息'));
92
+ const seen: any[] = [];
93
+ const official = ({ node, sessionId, injectedSession, useTurnData, useDisclosure }: any) => {
94
+ assert.equal(sessionId, 'session-users');
95
+ assert.equal(injectedSession, 'session-users');
96
+ assert.equal(useTurnData(), turn.data);
97
+ assert.equal(useDisclosure().getSnapshot(), 0);
98
+ assert.equal(node, nodes.get(node.key));
99
+ seen.push(node.kind);
100
+ return <div data-official-user={node.kind}>{node.data.content[0].text}<button>Official recall</button></div>;
101
+ };
102
+ const removeUser = slots.register({ name: 'conversation.chat.node', key: 'user',
103
+ inject: (sessionId: string) => ({ injectedSession: sessionId }), locale: 'chat' }, official);
104
+ const removeSteering = slots.register({ name: 'conversation.chat.node', key: 'steering',
105
+ inject: (sessionId: string) => ({ injectedSession: sessionId }), locale: 'chat' }, official);
106
+ const removeAssistant = slots.register({ name: 'conversation.chat.node', key: 'assistant-step' }, () => <span>Duplicate assistant</span>);
107
+ await new Promise(resolve => queueMicrotask(resolve));
108
+ assert.deepEqual(slots.entriesOfSlot(OFFICIAL_SEATS.nodes).map((entry: any) => entry.options.key).sort(), ['steering', 'user']);
109
+ html = render();
110
+ assertSeats(html);
111
+ assert.equal(count(html, 'Official recall'), 2);
112
+ assert.equal(count(html, 'user content'), 1);
113
+ assert.equal(count(html, 'steering content'), 1);
114
+ assert.deepEqual(seen, ['user', 'steering']);
115
+
116
+ // A winner intentionally returning null must keep its row hidden.
117
+ const removeWinner = slots.register({ name: 'conversation.chat.node', key: 'user', priority: -1 }, () => null);
118
+ await new Promise(resolve => queueMicrotask(resolve));
119
+ html = render();
120
+ assertSeats(html);
121
+ assert.equal(count(html, 'user content'), 0);
122
+ assert.equal(count(html, 'Official recall'), 1);
123
+ removeWinner();
124
+ await new Promise(resolve => queueMicrotask(resolve));
125
+ assert.equal(count(render(), 'Official recall'), 2);
126
+ removeUser(); removeSteering(); removeAssistant();
127
+ await new Promise(resolve => queueMicrotask(resolve));
128
+ html = render();
129
+ assertSeats(html);
130
+ assert.equal(count(html, 'user content'), 1);
131
+ assert.equal(count(html, 'steering content'), 1);
132
+ assert.equal(count(html, 'Official recall'), 0);
133
+ const module = Reflect.get(window, 'easyrewriteModule');
134
+ if (module) {
135
+ const plugin = module.factory((name: string) => {
136
+ if (name === 'react') return React;
137
+ if (name === '@deepseek-ai/dsh-client-ui-primitives') return Primitives;
138
+ throw new Error(`Unexpected easyrewrite dependency: ${name}`);
139
+ });
140
+ const disposers: Array<() => void> = [];
141
+ plugin.apply({
142
+ effect: (effect: () => () => void) => disposers.push(effect()),
143
+ slots: {
144
+ inject: (key: string, effect: () => () => void) => key === 'conversation.chat.node' ? effect() : () => {},
145
+ register: (options: object, component: unknown) => slots.register(options, component),
146
+ },
147
+ sessions: { list: source({ current: 'session-users' }), scope: () => ({ get: () => undefined }) },
148
+ workspaces: {},
149
+ });
150
+ await new Promise(resolve => queueMicrotask(resolve));
151
+ html = render();
152
+ assertSeats(html);
153
+ assert.equal(count(html, 'data-dsh-easyrewrite="user"'), 1);
154
+ assert.equal(count(html, 'data-dsh-easyrewrite="recall-key"'), 1);
155
+ assert.equal(count(html, 'user content'), 1);
156
+ assert.equal(count(html, 'steering content'), 1);
157
+ const recall = container.querySelector<HTMLButtonElement>('[data-dsh-easyrewrite="recall-key"]');
158
+ assert.ok(recall);
159
+ flushSync(() => recall!.click());
160
+ assert.equal(container.querySelectorAll('[data-dsh-easyrewrite="confirm-capsule"]').length, 1);
161
+ for (const dispose of disposers) dispose();
162
+ }
163
+ stopMirror();
164
+ reactRoot.unmount();
165
+ Object.assign(window, { readerUserResult: 'passed' });
166
+ };
167
+ } });
168
+ await run();
169
+ }
170
+ verify().catch(error => { console.error(error); Object.assign(window, { readerUserResult: `${error.stack}\n${document.getElementById('app')?.innerHTML}` }); });
@@ -21,6 +21,7 @@ const HOST_FOOTER_Z = 7;
21
21
 
22
22
  /** Lanes a scroll can clamp into the footer band. */
23
23
  const CLAMPED_LANES = [
24
+ '.turnStickyLanes',
24
25
  '.turnProcessSticky',
25
26
  '.liveFoldContainer',
26
27
  '.flowCell[data-flow-summary]',
@@ -85,6 +86,13 @@ test('the top toolbar lane stays at 7 and pins to the scrollport top', () => {
85
86
  assert.equal(declarations.get('top'), '0');
86
87
  });
87
88
 
89
+ test('official code block banners stay beneath Reader sticky lanes', () => {
90
+ const blockZ = Number(mergedDeclarations(css, '.root :global(.md-code-block)').get('z-index'));
91
+ const summaryZ = Number(mergedDeclarations(css, '.flowCell[data-flow-summary]').get('z-index'));
92
+ assert.ok(Number.isFinite(blockZ), 'Reader scopes official markdown code blocks into a stacking context');
93
+ assert.ok(blockZ < summaryZ, `code block banner context (${blockZ}) must stay below the summary lane (${summaryZ})`);
94
+ });
95
+
88
96
  test('the live status lane pins below the toolbar lane instead of over it', () => {
89
97
  const declarations = mergedDeclarations(css, '.turnProcessSticky');
90
98
  assert.match(