dsh-better-display 0.3.7 → 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 (69) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/docs/official-rendering-bridge.md +12 -0
  3. package/lib/client.js +221 -105
  4. package/lib/client.js.map +1 -1
  5. package/lib/dsh-better-display.js +297 -53
  6. package/lib/types/client/Reader.d.ts.map +1 -1
  7. package/lib/types/client/Reader.js +43 -12
  8. package/lib/types/client/Reader.js.map +1 -1
  9. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  10. package/lib/types/client/ToolActivity.js +10 -5
  11. package/lib/types/client/ToolActivity.js.map +1 -1
  12. package/lib/types/client/deliverables.d.ts.map +1 -1
  13. package/lib/types/client/deliverables.js +2 -0
  14. package/lib/types/client/deliverables.js.map +1 -1
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/index.js +9 -1
  17. package/lib/types/client/index.js.map +1 -1
  18. package/lib/types/client/motion.d.ts.map +1 -1
  19. package/lib/types/client/motion.js +9 -1
  20. package/lib/types/client/motion.js.map +1 -1
  21. package/lib/types/client/official-slots.d.ts.map +1 -1
  22. package/lib/types/client/official-slots.js +6 -3
  23. package/lib/types/client/official-slots.js.map +1 -1
  24. package/lib/types/client/open-file.d.ts.map +1 -1
  25. package/lib/types/client/open-file.js +1 -6
  26. package/lib/types/client/open-file.js.map +1 -1
  27. package/lib/types/client/styles.d.ts +3 -0
  28. package/lib/types/client/styles.d.ts.map +1 -0
  29. package/lib/types/client/styles.js +25 -0
  30. package/lib/types/client/styles.js.map +1 -0
  31. package/lib/types/client/tool-activity.d.ts +2 -2
  32. package/lib/types/client/tool-activity.d.ts.map +1 -1
  33. package/lib/types/client/tool-activity.js +12 -7
  34. package/lib/types/client/tool-activity.js.map +1 -1
  35. package/lib/types/dsh-better-display.d.ts.map +1 -1
  36. package/lib/types/dsh-better-display.js +2 -39
  37. package/lib/types/dsh-better-display.js.map +1 -1
  38. package/lib/types/reveal.d.ts +12 -0
  39. package/lib/types/reveal.d.ts.map +1 -0
  40. package/lib/types/reveal.js +147 -0
  41. package/lib/types/reveal.js.map +1 -0
  42. package/lib/types/workspace-path.d.ts +3 -0
  43. package/lib/types/workspace-path.d.ts.map +1 -0
  44. package/lib/types/workspace-path.js +5 -0
  45. package/lib/types/workspace-path.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/client/Reader.module.css +41 -9
  48. package/src/client/Reader.tsx +56 -21
  49. package/src/client/ToolActivity.tsx +9 -5
  50. package/src/client/deliverables.ts +1 -0
  51. package/src/client/index.tsx +10 -1
  52. package/src/client/motion.tsx +8 -1
  53. package/src/client/official-slots.tsx +6 -3
  54. package/src/client/open-file.ts +2 -8
  55. package/src/client/styles.ts +22 -0
  56. package/src/client/tool-activity.ts +18 -7
  57. package/src/dsh-better-display.ts +2 -38
  58. package/src/reveal.ts +90 -0
  59. package/src/workspace-path.ts +4 -0
  60. package/tests/fixtures/reader-official-users.tsx +170 -0
  61. package/tests/footer-precedence.test.ts +8 -0
  62. package/tests/reader-glass-css.test.ts +28 -0
  63. package/tests/reader-official-users.test.ts +81 -0
  64. package/tests/retiring-content.test.ts +137 -0
  65. package/tests/reveal.test.ts +128 -0
  66. package/tests/stock-install.test.ts +8 -0
  67. package/tests/styles.test.ts +48 -0
  68. package/tests/terminal-input-preview.test.ts +113 -0
  69. package/tsdown.config.ts +49 -1
@@ -0,0 +1,137 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { runInNewContext } from 'node:vm';
6
+ import ts from 'typescript';
7
+
8
+ const source = readFileSync(new URL('../src/client/motion.tsx', import.meta.url), 'utf8');
9
+ const retiring = source.slice(source.indexOf('export function RetiringContent('), source.indexOf('// DOM-only behavior:'));
10
+ const compiled = ts.transpileModule(`const EASING = 'cubic-bezier(.22,1,.36,1)';\n${retiring}`, {
11
+ fileName: 'retiring.tsx',
12
+ compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
13
+ }).outputText;
14
+
15
+ // Drive the component's real effect with deterministic hooks and a compositor
16
+ // that never finishes unless the test explicitly sends onfinish.
17
+ function mount() {
18
+ let cursor = 0;
19
+ let enabled = true;
20
+ const hooks: unknown[] = [];
21
+ const effects = new Map<number, { deps: unknown[]; cleanup?: () => void }>();
22
+ let pending: Array<{ index: number; run: () => (() => void) | void; deps: unknown[] }> = [];
23
+ let timerId = 0;
24
+ const timers = new Map<number, { run: () => void; duration: number }>();
25
+ const animations: Array<{ onfinish?: () => void; cancelled: number }> = [];
26
+ const element = {
27
+ contains: () => false,
28
+ getBoundingClientRect: () => ({ height: 64 }),
29
+ animate: (_frames: unknown, options: { duration: number; fill: string }) => {
30
+ assert.equal(options.duration, 220);
31
+ assert.equal(options.fill, 'both');
32
+ const animation = { cancelled: 0, cancel() { this.cancelled++; } };
33
+ animations.push(animation);
34
+ return animation;
35
+ },
36
+ };
37
+ const effect = (run: () => (() => void) | void, deps: unknown[]) => {
38
+ const index = cursor++;
39
+ const previous = effects.get(index);
40
+ if (!previous || deps.some((value, at) => value !== previous.deps[at])) pending.push({ index, run, deps });
41
+ };
42
+ const exports: { RetiringContent?: (props: { visible: boolean; children: string }) => unknown } = {};
43
+ runInNewContext(compiled, {
44
+ exports,
45
+ require: createRequire(import.meta.url),
46
+ css: { retiringContent: 'retiring' },
47
+ StreamMotionContext: {},
48
+ document: { activeElement: null },
49
+ window: {
50
+ setTimeout: (run: () => void, duration: number) => { timers.set(++timerId, { run, duration }); return timerId; },
51
+ clearTimeout: (id: number) => { timers.delete(id); },
52
+ },
53
+ useContext: () => ({ enabled }),
54
+ useRef: (initial: unknown) => {
55
+ const index = cursor++;
56
+ if (!(index in hooks)) hooks[index] = { current: initial };
57
+ return hooks[index];
58
+ },
59
+ useState: (initial: unknown) => {
60
+ const index = cursor++;
61
+ if (!(index in hooks)) hooks[index] = initial;
62
+ return [hooks[index], (value: unknown) => { hooks[index] = value; }];
63
+ },
64
+ useLayoutEffect: effect,
65
+ useEffect: effect,
66
+ });
67
+ assert.equal(typeof exports.RetiringContent, 'function');
68
+ const render = (visible: boolean) => {
69
+ cursor = 0;
70
+ pending = [];
71
+ const result = exports.RetiringContent!({ visible, children: 'narration' }) as { ref: { current: unknown } } | null;
72
+ if (result) result.ref.current = element;
73
+ for (const { index, run, deps } of pending) {
74
+ effects.get(index)?.cleanup?.();
75
+ effects.set(index, { deps, cleanup: run() ?? undefined });
76
+ }
77
+ return result;
78
+ };
79
+ render(true);
80
+ return {
81
+ render, timers, animations,
82
+ disableMotion: () => { enabled = false; },
83
+ unmount: () => { for (const effect of effects.values()) effect.cleanup?.(); },
84
+ deadline: () => {
85
+ const [id, timer] = [...timers][0] ?? [];
86
+ assert.ok(timer, 'retiring animation must have a deadline');
87
+ assert.equal(timer.duration, 460);
88
+ timers.delete(id);
89
+ timer.run();
90
+ },
91
+ };
92
+ }
93
+
94
+ test('retiring narration unmounts on its deadline when onfinish never fires', () => {
95
+ const view = mount();
96
+ assert.ok(view.render(false));
97
+ view.deadline();
98
+ assert.equal(view.animations[0].cancelled, 1);
99
+ assert.equal(view.render(false), null);
100
+ view.unmount();
101
+ });
102
+
103
+ test('normal finish settles once and removes the deadline on cleanup', () => {
104
+ const view = mount();
105
+ view.render(false);
106
+ view.animations[0].onfinish?.();
107
+ view.animations[0].onfinish?.();
108
+ assert.equal(view.animations[0].cancelled, 1);
109
+ assert.equal(view.render(false), null);
110
+ view.unmount();
111
+ assert.equal(view.timers.size, 0);
112
+ });
113
+
114
+ test('visibility reversal and unmount cancel the deadline and release animation fill', () => {
115
+ const view = mount();
116
+ view.render(false);
117
+ const staleFinish = view.animations[0].onfinish;
118
+ assert.ok(view.render(true));
119
+ assert.equal(view.timers.size, 0);
120
+ assert.equal(view.animations[0].cancelled, 1);
121
+ staleFinish?.();
122
+ assert.ok(view.render(true));
123
+ view.render(false);
124
+ view.unmount();
125
+ assert.equal(view.timers.size, 0);
126
+ assert.equal(view.animations[1].cancelled, 1);
127
+ });
128
+
129
+ test('disabled motion retires immediately without an animation or timer', () => {
130
+ const view = mount();
131
+ view.disableMotion();
132
+ view.render(false);
133
+ assert.equal(view.render(false), null);
134
+ assert.equal(view.animations.length, 0);
135
+ assert.equal(view.timers.size, 0);
136
+ view.unmount();
137
+ });
@@ -0,0 +1,128 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { IncomingMessage, ServerResponse } from 'node:http';
5
+ import { Socket } from 'node:net';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import { createRevealHandler, resolveRevealPath, revealPath, revealRoots } from '../src/reveal.js';
10
+ import { isAbsoluteWorkspacePath } from '../src/workspace-path.js';
11
+ import { dirname } from '../src/client/deliverables.js';
12
+
13
+ test('absolute parent predicate accepts POSIX, drive and UNC roots only', () => {
14
+ for (const path of ['/', '/repo', 'C:\\', 'C:/', 'D:\\5- AI\\docs', '\\\\server\\share']) {
15
+ assert.equal(isAbsoluteWorkspacePath(path), true, path);
16
+ }
17
+ for (const path of ['', '.', 'docs', './docs', '../docs', 'C:', 'C:docs', '\\docs']) {
18
+ assert.equal(isAbsoluteWorkspacePath(path), false, path);
19
+ }
20
+ assert.equal(dirname('/a.txt'), '/');
21
+ assert.equal(dirname('C:\\a.txt'), 'C:\\');
22
+ assert.equal(dirname('C:/a.txt'), 'C:/');
23
+ assert.equal(isAbsoluteWorkspacePath(dirname('docs/a.txt')), false);
24
+ });
25
+
26
+ async function workspace(t: { after: (cleanup: () => Promise<void>) => void }) {
27
+ const root = await mkdtemp(join(tmpdir(), 'bd-reveal-'));
28
+ t.after(() => rm(root, { recursive: true, force: true }));
29
+ await mkdir(join(root, 'docs'));
30
+ await writeFile(join(root, 'docs', 'report.md'), 'report');
31
+ return root;
32
+ }
33
+
34
+ test('relative resolution uses an existing full path, rejects missing and ambiguous matches', async t => {
35
+ const root = await workspace(t);
36
+ const other = await workspace(t);
37
+ assert.equal(resolveRevealPath('docs/report.md', [root]), join(root, 'docs', 'report.md'));
38
+ assert.equal(resolveRevealPath('docs/report.md', ['/missing-root', root, root]), join(root, 'docs', 'report.md'));
39
+ assert.throws(() => resolveRevealPath('docs/missing.md', [root]), /not found/);
40
+ assert.throws(() => resolveRevealPath('docs/report.md', []), /not found/);
41
+ assert.throws(() => resolveRevealPath('docs/report.md', [root, other]), /ambiguous/);
42
+ assert.equal(resolveRevealPath('/absolute/file', []), '/absolute/file');
43
+ });
44
+
45
+ test('relative resolution preserves Windows drive and UNC spelling', () => {
46
+ const exists = (path: string) => ['D:\\5- AI\\docs\\report.md', '\\\\server\\share\\docs\\report.md'].includes(path);
47
+ assert.equal(resolveRevealPath('docs/report.md', ['D:\\5- AI'], exists), 'D:\\5- AI\\docs\\report.md');
48
+ assert.equal(resolveRevealPath('docs/report.md', ['\\\\server\\share'], exists), '\\\\server\\share\\docs\\report.md');
49
+ assert.throws(() => resolveRevealPath('C:report.md', ['D:\\5- AI'], exists), /not workspace-relative/);
50
+ assert.throws(() => resolveRevealPath('\\report.md', ['D:\\5- AI'], exists), /not workspace-relative/);
51
+ });
52
+
53
+ function context(services: Record<string, unknown>): Context {
54
+ return { get: (name: string) => services[name] } as unknown as Context;
55
+ }
56
+
57
+ test('host roots use only the target live session or its registered workspace', async () => {
58
+ const ctx = context({
59
+ sessions: { get: (id: string) => id === 'target' ? { header: { cwd: '/target' } } : undefined },
60
+ workspaceRegistry: { list: () => [
61
+ { path: '/other', sessionIds: ['other'] },
62
+ { path: '/cold', sessionIds: ['cold'] },
63
+ ] },
64
+ });
65
+ assert.deepEqual(await revealRoots(ctx, 'target'), ['/target']);
66
+ assert.deepEqual(await revealRoots(ctx, 'cold'), ['/cold']);
67
+ assert.deepEqual(await revealRoots(ctx, 'unknown'), []);
68
+ assert.deepEqual(await revealRoots(ctx, undefined), ['/other', '/cold']);
69
+ });
70
+
71
+ test('cold session roots are read without projections and the observation is disposed', async () => {
72
+ let disposed = false;
73
+ const ctx = context({ sessionQuery: {
74
+ observeSession: async (id: string, options: unknown) => {
75
+ assert.equal(id, 'cold');
76
+ assert.deepEqual(options, { projectionMode: 'none' });
77
+ return { header: { cwd: '/cold' }, [Symbol.dispose]: () => { disposed = true; } };
78
+ },
79
+ } });
80
+ assert.deepEqual(await revealRoots(ctx, 'cold'), ['/cold']);
81
+ assert.equal(disposed, true);
82
+ });
83
+
84
+ async function request(handler: ReturnType<typeof createRevealHandler>, body: string, method = 'POST') {
85
+ const req = new IncomingMessage(new Socket());
86
+ req.method = method;
87
+ const res = new ServerResponse(req);
88
+ const response = new Promise<{ status: number; body: string }>(resolve => {
89
+ res.end = (chunk: string) => {
90
+ resolve({ status: res.statusCode, body: chunk ?? '' });
91
+ return res;
92
+ };
93
+ });
94
+ handler(req, res);
95
+ req.emit('data', body);
96
+ req.emit('end');
97
+ return response;
98
+ }
99
+
100
+ test('reveal route resolves a session-relative request and never opens a missing path', async t => {
101
+ const root = await workspace(t);
102
+ const opened: string[] = [];
103
+ const handler = createRevealHandler({
104
+ roots: async sessionId => { assert.equal(sessionId, 'target'); return [root]; },
105
+ reveal: path => { opened.push(path); },
106
+ });
107
+ assert.deepEqual(await request(handler, JSON.stringify({ path: 'docs/report.md', sessionId: 'target' })), {
108
+ status: 200, body: '{"ok":true}',
109
+ });
110
+ assert.equal((await request(handler, JSON.stringify({ path: 'docs/missing.md', sessionId: 'target' }))).status, 400);
111
+ assert.deepEqual(opened, [join(root, 'docs', 'report.md')]);
112
+ assert.equal((await request(handler, '{}')).status, 400);
113
+ assert.equal((await request(handler, 'invalid')).status, 400);
114
+ assert.equal((await request(handler, '', 'GET')).status, 405);
115
+ });
116
+
117
+ test('absolute requests bypass root lookup and Explorer receives a quoted verbatim selection', async () => {
118
+ const path = 'D:\\5- AI\\docs\\report.md';
119
+ const calls: unknown[] = [];
120
+ const handler = createRevealHandler({
121
+ roots: async () => { throw new Error('absolute requests do not need roots'); },
122
+ reveal: target => revealPath(target, 'win32', (...args) => { calls.push(args); }),
123
+ });
124
+ assert.equal((await request(handler, JSON.stringify({ path }))).status, 200);
125
+ assert.deepEqual(calls, [['explorer.exe', ['/select,"D:\\5- AI\\docs\\report.md"'], {
126
+ detached: true, stdio: 'ignore', windowsVerbatimArguments: true,
127
+ }]]);
128
+ });
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { dirname, resolve } from 'node:path';
4
5
  import { test } from 'node:test';
@@ -63,6 +64,13 @@ test('commits compiled lib entries and does not require a prepare script', () =>
63
64
  assert.doesNotMatch(clientJs, /submission\.images\.length/);
64
65
  });
65
66
 
67
+ test('compiled host entry loads in Node without a TypeScript loader', () => {
68
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval',
69
+ "const plugin = await import('./lib/dsh-better-display.js'); if (plugin.name !== 'dsh-better-display' || typeof plugin.apply !== 'function') process.exit(1);",
70
+ ], { cwd: root, encoding: 'utf8' });
71
+ assert.equal(result.status, 0, result.stderr || String(result.error ?? 'host entry did not load'));
72
+ });
73
+
66
74
  test('README leads with the official stock one-liner and names pnpm', () => {
67
75
  for (const name of ['README.md', 'README.en.md']) {
68
76
  const text = readFileSync(resolve(root, name), 'utf8');
@@ -0,0 +1,48 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import { ensureStyles } from '../src/client/styles.js';
5
+
6
+ test('ensureStyles tolerates module evaluation without a document', () => {
7
+ ensureStyles();
8
+ });
9
+
10
+ test('ensureStyles restores removed plugin CSS and remains idempotent', t => {
11
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
12
+ t.after(() => {
13
+ if (descriptor) Object.defineProperty(globalThis, 'document', descriptor);
14
+ else Reflect.deleteProperty(globalThis, 'document');
15
+ });
16
+ const reader = { dataset: { plugin: 'dsh-better-display', pluginCss: 'dsh-better-display/Reader.module.css' }, textContent: '.reader{height:auto}' };
17
+ const fold = { dataset: { plugin: 'dsh-better-display', pluginCss: 'dsh-better-display/Fold.module.css' }, textContent: '.fold{overflow:hidden}' };
18
+ const foreign = { dataset: { plugin: 'other', pluginCss: 'other/Fold.module.css' }, textContent: '.other{}' };
19
+ const tags = [reader, fold, foreign];
20
+ const document = {
21
+ querySelectorAll: (selector: string) => {
22
+ assert.equal(selector, 'style[data-plugin="dsh-better-display"][data-plugin-css]');
23
+ return tags.filter(tag => tag.dataset.plugin === 'dsh-better-display');
24
+ },
25
+ createElement: (name: string) => { assert.equal(name, 'style'); return { dataset: {}, textContent: '' }; },
26
+ head: { appendChild: (tag: typeof reader) => { tags.push(tag); } },
27
+ };
28
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
29
+ ensureStyles();
30
+ assert.equal(tags.length, 3);
31
+ tags.splice(0, 2);
32
+ ensureStyles();
33
+ assert.deepEqual(tags, [foreign, reader, fold]);
34
+ ensureStyles();
35
+ assert.equal(tags.length, 3);
36
+ tags[1].textContent = '.reader{height:100px}';
37
+ ensureStyles();
38
+ tags.splice(1, 1);
39
+ ensureStyles();
40
+ assert.equal(tags.find(tag => tag.dataset.pluginCss === reader.dataset.pluginCss)?.textContent, '.reader{height:100px}');
41
+ });
42
+
43
+ test('CSS is cached after module injection and restored before mount layout effects', () => {
44
+ const index = readFileSync(new URL('../src/client/index.tsx', import.meta.url), 'utf8');
45
+ const reader = readFileSync(new URL('../src/client/Reader.tsx', import.meta.url), 'utf8');
46
+ assert.match(index, /\nensureStyles\(\);/);
47
+ assert.match(reader, /useInsertionEffect\(\(\) => \{ ensureStyles\(\); \}, \[\]\)/);
48
+ });
@@ -0,0 +1,113 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import type { PreparingToolCall, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
4
+ import { activityPhase, activitySummary } from '../src/client/tool-activity.ts';
5
+ import type { ToolDraft } from '../src/client/tool-activity.ts';
6
+
7
+ function draft(name: string, argsRaw: string): ToolDraft {
8
+ return { kind: 'tool-call', callId: 'terminal-preview', name, argsRaw };
9
+ }
10
+
11
+ test('terminal drafts expose a pending placeholder state until a command field appears', () => {
12
+ for (const name of ['bash', 'pwsh']) {
13
+ const early = activitySummary({ draft: draft(name, '{"comman') });
14
+ assert.equal(early.category, 'terminal');
15
+ assert.equal(early.argsState, 'partial');
16
+ assert.equal(early.command, undefined);
17
+
18
+ const partial = activitySummary({ draft: draft(name, '{"command":"Get-C') });
19
+ assert.equal(partial.argsState, 'partial');
20
+ assert.equal(partial.command, 'Get-C');
21
+ }
22
+ });
23
+
24
+ test('complete Bash and PowerShell arguments remain available in full', () => {
25
+ for (const name of ['bash', 'pwsh']) {
26
+ const values = {
27
+ command: name === 'pwsh' ? 'Get-ChildItem -Force' : 'printf "ready\\n"',
28
+ workdir: '/work/project',
29
+ description: 'List project files',
30
+ timeout_ms: 4000,
31
+ };
32
+ const model = activitySummary({ draft: draft(name, JSON.stringify(values)) });
33
+ assert.equal(model.argsState, 'complete');
34
+ assert.equal(model.command, values.command);
35
+ assert.deepEqual(model.args, values);
36
+ }
37
+ });
38
+
39
+ test('partial escaped and multiline commands keep their authored text', () => {
40
+ const argsRaw = '{"command":"printf \\"first\\nsecond';
41
+ const model = activitySummary({ draft: draft('bash', argsRaw) });
42
+ assert.equal(model.argsState, 'partial');
43
+ assert.equal(model.command, 'printf "first\nsecond');
44
+
45
+ const multiline = 'printf "first line\\nsecond line"\necho 你好';
46
+ const complete = activitySummary({ draft: draft('bash', JSON.stringify({ command: multiline, workdir: '/tmp' })) });
47
+ assert.equal(complete.command, multiline);
48
+ assert.equal(complete.args.workdir, '/tmp');
49
+ });
50
+
51
+ test('complete empty and non-command arguments are not mistaken for partial commands', () => {
52
+ const empty = activitySummary({ draft: draft('bash', '{}') });
53
+ assert.equal(empty.argsState, 'complete');
54
+ assert.deepEqual(empty.args, {});
55
+ assert.equal(empty.command, undefined);
56
+
57
+ const read = activitySummary({ draft: draft('read', '{"path":"/tmp/readme.md"}') });
58
+ assert.equal(read.argsState, 'complete');
59
+ assert.equal(read.category, 'read');
60
+ assert.equal(read.command, undefined);
61
+ assert.equal(read.args.path, '/tmp/readme.md');
62
+ });
63
+
64
+ test('the activity lifecycle distinguishes draft, dispatched call, and result', () => {
65
+ const argsRaw = '{"command":"echo ready"}';
66
+ const pending = draft('bash', argsRaw);
67
+ const running: RunningToolCall = {
68
+ phase: 'start', callId: pending.callId, name: pending.name, turn: 1, step: 0, time: 1000, argsRaw, subCalls: [],
69
+ };
70
+ const result: ToolResultNode = {
71
+ kind: 'tool-result', seq: 2, time: 2000, callId: pending.callId,
72
+ call: { name: pending.name, argsRaw }, callTime: 1000,
73
+ content: [{ type: 'text', text: 'ready\n[exit code: 0]' }], isError: false, meta: { exitCode: 0 }, subCalls: [],
74
+ };
75
+ assert.equal(activityPhase({ draft: pending }), 'preparing');
76
+ assert.equal(activityPhase({ block: running }), 'running');
77
+ assert.equal(activityPhase({ block: result }), 'succeeded');
78
+ });
79
+
80
+ test('a canonical preparing call remains preparing until dispatch', () => {
81
+ for (const name of ['bash', 'pwsh']) {
82
+ const block: PreparingToolCall = {
83
+ phase: 'preparing', callId: 'preparing-head', name, turn: 1, step: 0, time: 1000, subCalls: [],
84
+ };
85
+ assert.equal(activityPhase({ block }), 'preparing');
86
+ assert.equal(activityPhase({ block }, true), 'interrupted');
87
+ const model = activitySummary({ block });
88
+ assert.equal(model.name, name);
89
+ assert.equal(model.argsState, 'partial');
90
+ assert.equal(model.raw, '');
91
+ assert.equal(model.command, undefined);
92
+ }
93
+ });
94
+
95
+ test('a canonical preparing head retains progressively generated draft arguments', () => {
96
+ for (const name of ['bash', 'pwsh']) {
97
+ const block: PreparingToolCall = {
98
+ phase: 'preparing', callId: 'preparing-head', name, turn: 1, step: 0, time: 1000, subCalls: [],
99
+ };
100
+ for (const raw of ['{"comman', '{"command":"echo ready']) {
101
+ const pending = { ...draft(name, raw), callId: block.callId };
102
+ const model = activitySummary({ block, draft: pending });
103
+ assert.equal(model.raw, raw);
104
+ assert.equal(model.argsState, 'partial');
105
+ assert.equal(model.command, raw === '{"comman' ? undefined : 'echo ready');
106
+ }
107
+ const dispatched: RunningToolCall = { ...block, phase: 'start', argsRaw: '{"command":"echo sent"}' };
108
+ const model = activitySummary({ block: dispatched, draft: draft(name, '{"command":"stale') });
109
+ assert.equal(activityPhase({ block: dispatched }), 'running');
110
+ assert.equal(model.command, 'echo sent');
111
+ assert.equal(model.argsState, 'complete');
112
+ }
113
+ });
package/tsdown.config.ts CHANGED
@@ -40,12 +40,60 @@ const portableOutput: TsdownPlugin = {
40
40
  },
41
41
  };
42
42
 
43
+ /**
44
+ * Guard against the 0.3.7 regression class: the host entry must not ship a
45
+ * relative specifier that cannot resolve inside the published tarball.
46
+ *
47
+ * The specific failure was `import ... from "./skill-roots.ts"` — a path to the
48
+ * TypeScript source that is never emitted next to the entry, so the host died
49
+ * with ERR_MODULE_NOT_FOUND on every start while the market still reported the
50
+ * plugin as installed. A build-time assertion turns that silent publish-time
51
+ * mistake into a red build.
52
+ */
53
+ const hostPortableOutput: TsdownPlugin = {
54
+ name: 'dsh-better-display-host-portable-output',
55
+ generateBundle(_options, output) {
56
+ for (const [fileName, chunk] of Object.entries(output)) {
57
+ if (chunk.type !== 'chunk') continue;
58
+ const offenders = [
59
+ // Relative specifiers that still point at TS sources.
60
+ ...chunk.code.matchAll(/from\s*["'](\.[^"']*\.ts)["']/g),
61
+ // Relative specifiers reaching into node_modules-written shapes.
62
+ ...chunk.code.matchAll(/from\s*["'](\.\.?\/[^"']*node_modules\/[^"']*)["']/g),
63
+ ].map((match) => match[1]);
64
+ if (offenders.length > 0) {
65
+ this.error(
66
+ `${fileName} contains relative specifiers that will not resolve in the published package: ` +
67
+ `${[...new Set(offenders)].join(', ')}. Relative imports must be bundled, not externalised ` +
68
+ `(see the neverBundle note above).`,
69
+ );
70
+ }
71
+ }
72
+ },
73
+ };
74
+
43
75
  export default bundle.map((config) => {
44
76
  if (config.name === 'dsh-better-display') {
45
77
  const { external: _external, ...hostConfig } = config;
46
78
  return {
47
79
  ...hostConfig,
48
- deps: { ...hostConfig.deps, neverBundle: (specifier: string) => !specifier.startsWith('.') },
80
+ plugins: [
81
+ ...(Array.isArray(hostConfig.plugins)
82
+ ? hostConfig.plugins
83
+ : hostConfig.plugins === undefined
84
+ ? []
85
+ : [hostConfig.plugins]),
86
+ hostPortableOutput,
87
+ ],
88
+ // NEVER re-introduce a resolved-id predicate here. tsdown may hand this
89
+ // callback an already-resolved absolute path, in which case a
90
+ // `!specifier.startsWith('.')` test is true for EVERY module and the
91
+ // relative source imports get externalised instead of bundled — the
92
+ // host entry then ships as `import ... from "./skill-roots.ts"`, which
93
+ // no longer exists in the package (0.3.7 regression, ERR_MODULE_NOT_FOUND).
94
+ // `true` keeps the documented semantics: bare npm specifiers stay
95
+ // external "as written", relative imports keep being bundled.
96
+ deps: { ...hostConfig.deps, neverBundle: true },
49
97
  };
50
98
  }
51
99
  if (config.name !== 'dsh-better-display/client') return config;