sh3-core 0.13.3 → 0.14.0

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 (66) hide show
  1. package/dist/api.d.ts +3 -0
  2. package/dist/api.js +3 -0
  3. package/dist/app/store/StoreView.svelte +15 -4
  4. package/dist/app/store/permissionConfirm.js +1 -2
  5. package/dist/app/store/storeApp.js +0 -1
  6. package/dist/app/store/storeShard.svelte.js +9 -18
  7. package/dist/app/store/storeTypes.d.ts +21 -0
  8. package/dist/app/store/storeTypes.js +33 -0
  9. package/dist/app/store/storeTypes.test.d.ts +1 -0
  10. package/dist/app/store/storeTypes.test.js +41 -0
  11. package/dist/app/store/updatePackage.test.js +1 -1
  12. package/dist/app/store/verbs.test.js +20 -17
  13. package/dist/host.js +2 -0
  14. package/dist/migrations/mode-id-rename.d.ts +9 -0
  15. package/dist/migrations/mode-id-rename.js +39 -0
  16. package/dist/migrations/mode-id-rename.test.d.ts +1 -0
  17. package/dist/migrations/mode-id-rename.test.js +52 -0
  18. package/dist/overlays/FloatFrame.svelte +18 -1
  19. package/dist/overlays/float.d.ts +12 -0
  20. package/dist/overlays/float.js +16 -0
  21. package/dist/overlays/float.test.js +97 -2
  22. package/dist/overlays/modal.js +1 -0
  23. package/dist/overlays/modal.test.js +17 -0
  24. package/dist/overlays/parentHost.d.ts +1 -0
  25. package/dist/overlays/parentHost.js +15 -0
  26. package/dist/overlays/parentHost.test.d.ts +1 -0
  27. package/dist/overlays/parentHost.test.js +39 -0
  28. package/dist/overlays/popup.js +1 -0
  29. package/dist/overlays/popup.test.js +19 -0
  30. package/dist/shell-shard/Terminal.svelte +85 -8
  31. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  32. package/dist/shell-shard/contract.d.ts +65 -0
  33. package/dist/shell-shard/contract.js +11 -0
  34. package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
  35. package/dist/shell-shard/dispatch-custom.test.js +104 -0
  36. package/dist/shell-shard/dispatch.d.ts +14 -1
  37. package/dist/shell-shard/dispatch.js +58 -5
  38. package/dist/shell-shard/modes/builtin.d.ts +2 -2
  39. package/dist/shell-shard/modes/builtin.js +8 -8
  40. package/dist/shell-shard/modes/prefs.js +1 -1
  41. package/dist/shell-shard/modes/prefs.test.js +13 -13
  42. package/dist/shell-shard/modes/registry.test.js +13 -13
  43. package/dist/shell-shard/output.d.ts +3 -0
  44. package/dist/shell-shard/output.js +75 -0
  45. package/dist/shell-shard/output.test.d.ts +1 -0
  46. package/dist/shell-shard/output.test.js +54 -0
  47. package/dist/shell-shard/registerShellMode.d.ts +13 -0
  48. package/dist/shell-shard/registerShellMode.js +14 -0
  49. package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
  50. package/dist/shell-shard/registerShellMode.test.js +19 -0
  51. package/dist/shell-shard/shellShard.svelte.js +8 -1
  52. package/dist/shell-shard/terminal-dispatch.test.js +9 -9
  53. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +11 -51
  54. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +2 -4
  55. package/dist/shell-shard/toolbar/slots.test.js +6 -6
  56. package/dist/shell-shard/verbs/index.js +2 -0
  57. package/dist/shell-shard/verbs/mode.d.ts +2 -0
  58. package/dist/shell-shard/verbs/mode.js +28 -0
  59. package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
  60. package/dist/shell-shard/verbs/mode.test.js +43 -0
  61. package/dist/verbs/types.d.ts +11 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/package.json +1 -1
  65. package/dist/app/store/InstalledView.svelte +0 -255
  66. package/dist/app/store/InstalledView.svelte.d.ts +0 -3
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { floatManager, __resetFloatManagerForTest, bindFloatStore } from './float';
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { floatManager, __resetFloatManagerForTest, bindFloatStore, getFloatParentHost, } from './float';
3
3
  import { layoutStore } from '../layout/store.svelte';
4
4
  describe('floatManager', () => {
5
5
  beforeEach(() => {
@@ -80,6 +80,49 @@ describe('floatManager', () => {
80
80
  expect(f.content.type).toBe('tabs');
81
81
  });
82
82
  });
83
+ describe('floatManager — anchor-aware parent host', () => {
84
+ beforeEach(() => {
85
+ __resetFloatManagerForTest();
86
+ });
87
+ afterEach(() => {
88
+ document.body.innerHTML = '';
89
+ });
90
+ function makeOverlayHost(kind) {
91
+ const host = document.createElement('div');
92
+ host.dataset.shellOverlayHost = kind;
93
+ const anchor = document.createElement('button');
94
+ host.appendChild(anchor);
95
+ document.body.appendChild(host);
96
+ return { host, anchor };
97
+ }
98
+ it('getFloatParentHost is undefined when no anchor was passed', () => {
99
+ const id = floatManager.open('test:view', { dismissable: true });
100
+ expect(getFloatParentHost(id)).toBeUndefined();
101
+ });
102
+ it('getFloatParentHost is undefined when the anchor lives outside any overlay host', () => {
103
+ const anchor = document.createElement('button');
104
+ document.body.appendChild(anchor);
105
+ const id = floatManager.open('test:view', { dismissable: true, anchor });
106
+ expect(getFloatParentHost(id)).toBeUndefined();
107
+ });
108
+ it('getFloatParentHost returns the enclosing host for a dismissable+anchored float', () => {
109
+ const { host, anchor } = makeOverlayHost('modal');
110
+ const id = floatManager.open('test:view', { dismissable: true, anchor });
111
+ expect(getFloatParentHost(id)).toBe(host);
112
+ });
113
+ it('getFloatParentHost is undefined for non-dismissable floats even with an anchor', () => {
114
+ const { anchor } = makeOverlayHost('modal');
115
+ const id = floatManager.open('test:view', { anchor });
116
+ expect(getFloatParentHost(id)).toBeUndefined();
117
+ });
118
+ it('getFloatParentHost is cleared when the float is closed', () => {
119
+ const { anchor } = makeOverlayHost('modal');
120
+ const id = floatManager.open('test:view', { dismissable: true, anchor });
121
+ expect(getFloatParentHost(id)).toBeDefined();
122
+ floatManager.close(id);
123
+ expect(getFloatParentHost(id)).toBeUndefined();
124
+ });
125
+ });
83
126
  // ---------------------------------------------------------------------------
84
127
  // DOM tests — floatManager + FloatLayer.svelte in happy-dom
85
128
  // ---------------------------------------------------------------------------
@@ -311,3 +354,55 @@ describe('floats — F.6 multi-picker interaction', () => {
311
354
  expect(floatManager.list().some((f) => f.id === id)).toBe(false);
312
355
  });
313
356
  });
357
+ // ---------------------------------------------------------------------------
358
+ // F.7 — anchor portals dismissable float into the enclosing overlay host
359
+ // ---------------------------------------------------------------------------
360
+ describe('floats — F.7 anchor portals to enclosing overlay host', () => {
361
+ beforeEach(() => {
362
+ resetFramework();
363
+ bindManagerToStore();
364
+ });
365
+ it('reparents the FloatFrame into the anchor’s enclosing overlay host', async () => {
366
+ const { container } = renderWithShell(FloatLayer, {});
367
+ const fakeModalHost = document.createElement('div');
368
+ fakeModalHost.className = 'fake-modal-host';
369
+ fakeModalHost.dataset.shellOverlayHost = 'modal';
370
+ const anchor = document.createElement('button');
371
+ fakeModalHost.appendChild(anchor);
372
+ document.body.appendChild(fakeModalHost);
373
+ floatManager.open('test:view', {
374
+ dismissable: true,
375
+ anchor,
376
+ title: 'Picker',
377
+ });
378
+ await tick();
379
+ const frame = document.querySelector('[role="dialog"][aria-label="Picker"]');
380
+ expect(frame).toBeTruthy();
381
+ expect(fakeModalHost.contains(frame)).toBe(true);
382
+ expect(container.contains(frame)).toBe(false);
383
+ });
384
+ it('renders inside FloatLayer when no anchor is provided', async () => {
385
+ const { container } = renderWithShell(FloatLayer, {});
386
+ floatManager.open('test:view', { dismissable: true, title: 'NoAnchor' });
387
+ await tick();
388
+ const frame = container.querySelector('[role="dialog"][aria-label="NoAnchor"]');
389
+ expect(frame).toBeTruthy();
390
+ });
391
+ });
392
+ // ---------------------------------------------------------------------------
393
+ // F.8 — overlay host marker on FloatFrame
394
+ // ---------------------------------------------------------------------------
395
+ describe('floats — F.8 overlay host marker', () => {
396
+ beforeEach(() => {
397
+ resetFramework();
398
+ bindManagerToStore();
399
+ });
400
+ it('marks each FloatFrame with data-shell-overlay-host="float"', async () => {
401
+ const { container } = renderWithShell(FloatLayer, {});
402
+ floatManager.open('test:view', { title: 'Marked' });
403
+ await tick();
404
+ const frame = container.querySelector('[role="dialog"][aria-label="Marked"]');
405
+ expect(frame).toBeTruthy();
406
+ expect(frame.dataset.shellOverlayHost).toBe('float');
407
+ });
408
+ });
@@ -109,6 +109,7 @@ function openModal(Content, props, options) {
109
109
  const root = getLayerRoot('modal');
110
110
  const host = document.createElement('div');
111
111
  host.className = 'sh3-modal-host';
112
+ host.dataset.shellOverlayHost = 'modal';
112
113
  host.style.position = 'absolute';
113
114
  host.style.inset = '0';
114
115
  host.style.pointerEvents = 'auto';
@@ -88,3 +88,20 @@ describe('modal — back-cascade integration', () => {
88
88
  expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
89
89
  });
90
90
  });
91
+ describe('modal — overlay host marker', () => {
92
+ let layerRoot;
93
+ beforeEach(() => {
94
+ layerRoot = makeLayerRoot();
95
+ });
96
+ afterEach(() => {
97
+ modalManager.closeAll();
98
+ teardownLayerRoot(layerRoot);
99
+ });
100
+ it('marks the modal host with data-shell-overlay-host="modal"', async () => {
101
+ modalManager.open(DummyFrame, {});
102
+ await tick();
103
+ const host = layerRoot.querySelector('.sh3-modal-host');
104
+ expect(host).not.toBeNull();
105
+ expect(host.dataset.shellOverlayHost).toBe('modal');
106
+ });
107
+ });
@@ -0,0 +1 @@
1
+ export declare function findEnclosingOverlayHost(anchor: HTMLElement): HTMLElement | null;
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Walks up from `anchor` looking for an element marked as an overlay host
3
+ * via `data-shell-overlay-host`. Modal hosts, popup hosts, and float frames
4
+ * tag themselves so anchored overlays (popups, dismissable picker floats)
5
+ * can mount inside their opener's stacking context instead of at a global
6
+ * layer root — which is what the layer-z-index invariant gives us when a
7
+ * popover is logically "inside" a modal.
8
+ *
9
+ * Returns null when the anchor lives in the docked tree; callers fall back
10
+ * to their configured layer root in that case. The marker is read via
11
+ * `Element.closest`, so a marker on the anchor itself counts.
12
+ */
13
+ export function findEnclosingOverlayHost(anchor) {
14
+ return anchor.closest('[data-shell-overlay-host]');
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { findEnclosingOverlayHost } from './parentHost';
3
+ afterEach(() => {
4
+ document.body.innerHTML = '';
5
+ });
6
+ describe('findEnclosingOverlayHost', () => {
7
+ it('returns the nearest ancestor with data-shell-overlay-host', () => {
8
+ const host = document.createElement('div');
9
+ host.dataset.shellOverlayHost = 'modal';
10
+ const inner = document.createElement('div');
11
+ const anchor = document.createElement('button');
12
+ inner.appendChild(anchor);
13
+ host.appendChild(inner);
14
+ document.body.appendChild(host);
15
+ expect(findEnclosingOverlayHost(anchor)).toBe(host);
16
+ });
17
+ it('returns the anchor itself when it carries the marker', () => {
18
+ const anchor = document.createElement('div');
19
+ anchor.dataset.shellOverlayHost = 'float';
20
+ document.body.appendChild(anchor);
21
+ expect(findEnclosingOverlayHost(anchor)).toBe(anchor);
22
+ });
23
+ it('returns null when no ancestor carries the marker', () => {
24
+ const anchor = document.createElement('button');
25
+ document.body.appendChild(anchor);
26
+ expect(findEnclosingOverlayHost(anchor)).toBeNull();
27
+ });
28
+ it('returns the innermost host when overlay hosts are nested', () => {
29
+ const outer = document.createElement('div');
30
+ outer.dataset.shellOverlayHost = 'modal';
31
+ const inner = document.createElement('div');
32
+ inner.dataset.shellOverlayHost = 'float';
33
+ const anchor = document.createElement('button');
34
+ inner.appendChild(anchor);
35
+ outer.appendChild(inner);
36
+ document.body.appendChild(outer);
37
+ expect(findEnclosingOverlayHost(anchor)).toBe(inner);
38
+ });
39
+ });
@@ -83,6 +83,7 @@ function showPopup(Content, options, props) {
83
83
  const root = getLayerRoot('popup');
84
84
  const host = document.createElement('div');
85
85
  host.className = 'sh3-popup-host';
86
+ host.dataset.shellOverlayHost = 'popup';
86
87
  host.style.position = 'absolute';
87
88
  host.style.inset = '0';
88
89
  host.style.pointerEvents = 'none'; // only the frame captures pointer events
@@ -126,3 +126,22 @@ describe('popup — back-cascade integration', () => {
126
126
  expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
127
127
  });
128
128
  });
129
+ describe('popup — overlay host marker', () => {
130
+ let layerRoot;
131
+ beforeEach(() => {
132
+ vi.stubGlobal('innerWidth', 2000);
133
+ vi.stubGlobal('innerHeight', 2000);
134
+ layerRoot = makeLayerRoot();
135
+ });
136
+ afterEach(() => {
137
+ __resetPopupManagerForTest();
138
+ teardownLayerRoot(layerRoot);
139
+ vi.unstubAllGlobals();
140
+ });
141
+ it('marks the popup host with data-shell-overlay-host="popup"', () => {
142
+ popupManager.show(DummyFrame, { anchor: { x: 100, y: 100 } }, {});
143
+ const host = layerRoot.querySelector('.sh3-popup-host');
144
+ expect(host).not.toBeNull();
145
+ expect(host.dataset.shellOverlayHost).toBe('popup');
146
+ });
147
+ });
@@ -11,6 +11,8 @@
11
11
  import { registerBuiltinModes } from './modes/builtin';
12
12
  import { resolveInitialMode, writeLastMode } from './modes/prefs';
13
13
  import type { ShellMode, ShellRole } from './modes/types';
14
+ import type { ContributionsApi } from '../contributions/types';
15
+ import { SHELL_MODE_CONTRIBUTION_POINT, type ShellModeDescriptor } from './contract';
14
16
  import { makeDispatch } from './dispatch';
15
17
  import { computeRelocate } from './auto-relocate';
16
18
  import { activeLayout } from '../layout/store.svelte';
@@ -26,26 +28,64 @@
26
28
  wsUrl: string;
27
29
  userId: string;
28
30
  role: ShellRole;
31
+ contributions: ContributionsApi;
29
32
  }
30
- let { shell, wsUrl, userId, role }: Props = $props();
33
+ let { shell, wsUrl, userId, role, contributions }: Props = $props();
31
34
 
32
35
  const scrollback = new Scrollback();
33
36
  const resolver = new VerbRegistry();
34
37
  const fs = new TenantFsClient();
35
38
 
36
- // Mode registry
39
+ // Mode registry — holds builtins only. Contributed modes flow through
40
+ // the contributions API and are merged reactively below.
37
41
  const modeRegistry = new ShellModeRegistry();
38
42
  registerBuiltinModes(modeRegistry);
39
43
 
44
+ // contributions.list() returns a plain array (not reactive). Mirror it
45
+ // into a $state cell and refresh on every onChange notification so the
46
+ // picker, verb listing, and active-mode fallback all react to shard
47
+ // hot-mount/unmount without polling.
48
+ let contributedModes = $state<ShellModeDescriptor[]>(
49
+ untrack(() => contributions.list<ShellModeDescriptor>(SHELL_MODE_CONTRIBUTION_POINT)),
50
+ );
51
+ $effect(() => {
52
+ const off = contributions.onChange(SHELL_MODE_CONTRIBUTION_POINT, () => {
53
+ contributedModes = contributions.list<ShellModeDescriptor>(SHELL_MODE_CONTRIBUTION_POINT);
54
+ });
55
+ return () => off();
56
+ });
57
+
58
+ /** Convert a descriptor to the internal ShellMode shape so the picker and
59
+ * the dispatch path treat builtin and contributed modes uniformly. */
60
+ function descriptorToMode(d: ShellModeDescriptor): ShellMode {
61
+ return {
62
+ id: d.id,
63
+ label: d.label,
64
+ requiresRole: d.requiresRole,
65
+ transport: 'custom',
66
+ autoRelocate: d.autoRelocate ?? false,
67
+ };
68
+ }
69
+
70
+ let visibleModes = $derived<ShellMode[]>([
71
+ ...modeRegistry.list(role),
72
+ ...contributedModes
73
+ .filter((d) => !d.requiresRole || d.requiresRole === role)
74
+ .map(descriptorToMode),
75
+ ]);
76
+
40
77
  // Reactive current mode
41
78
  let mode = $state<ShellMode>(
42
79
  untrack(() => resolveInitialMode(modeRegistry, userId, role)),
43
80
  );
44
81
 
45
82
  function setMode(id: string): void {
46
- const next = modeRegistry.get(id);
83
+ const next = visibleModes.find((m) => m.id === id);
47
84
  if (!next) return;
48
85
  if (next.requiresRole && next.requiresRole !== role) return;
86
+ // Abort any in-flight custom-mode dispatch from the outgoing mode
87
+ // before flipping. Safe no-op if there's nothing running.
88
+ cancelDispatch();
49
89
  mode = next;
50
90
  writeLastMode(userId, id);
51
91
  if (next.transport !== 'ws') {
@@ -53,18 +93,55 @@
53
93
  }
54
94
  }
55
95
 
96
+ // If the active mode disappears (shard unloaded), fall back to sh3 — or
97
+ // the first available mode if even sh3 is gone.
98
+ $effect(() => {
99
+ if (!visibleModes.find((m) => m.id === mode.id)) {
100
+ const fallback = visibleModes.find((m) => m.id === 'sh3') ?? visibleModes[0];
101
+ if (fallback) {
102
+ const lostId = mode.id;
103
+ mode = fallback;
104
+ writeLastMode(userId, fallback.id);
105
+ scrollback.push({
106
+ kind: 'status',
107
+ text: `mode '${lostId}' is no longer available — switched to '${fallback.id}'`,
108
+ level: 'warn',
109
+ ts: Date.now(),
110
+ });
111
+ }
112
+ }
113
+ });
114
+
115
+ // Extend the shell prop with view-local mode switching so verbs (like
116
+ // `mode`) can drive the picker. Only this view knows the live registry
117
+ // and the setMode closure, so the wrapper happens here. The shell prop
118
+ // is stable for the view's lifetime, so capturing its initial value via
119
+ // untrack is intentional.
120
+ const shellWithModes: ShellApi = untrack(() => ({
121
+ ...shell,
122
+ setMode: (id: string) => {
123
+ const next = visibleModes.find((m) => m.id === id);
124
+ if (!next) return false;
125
+ if (next.requiresRole && next.requiresRole !== role) return false;
126
+ setMode(id);
127
+ return true;
128
+ },
129
+ listModes: () => visibleModes.map((m) => ({ id: m.id, label: m.label })),
130
+ }));
131
+
56
132
  // wsUrl is a prop read at construction only. untrack prevents Svelte 5's
57
133
  // "referenced outside a closure" warning; the URL never changes at runtime.
58
134
  const session = untrack(() => new SessionClient(wsUrl));
59
135
 
60
- const dispatch = untrack(() => makeDispatch({
136
+ const { dispatch, cancel: cancelDispatch } = untrack(() => makeDispatch({
61
137
  mode: () => mode,
62
138
  resolver,
63
139
  scrollback,
64
140
  session,
65
- shell,
141
+ shell: shellWithModes,
66
142
  fs,
67
143
  cwd: () => session.cwd,
144
+ customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
68
145
  }));
69
146
 
70
147
  let locked = $state(false);
@@ -81,8 +158,8 @@
81
158
  // Toolbar slot registry
82
159
  const toolbarRegistry = new ToolbarSlotRegistry();
83
160
  toolbarRegistry.register({ id: 'mode', order: 10, visible: () => true, component: ModeSlot });
84
- toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'user', component: FocusLockSlot });
85
- toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'user', component: TargetShardSlot });
161
+ toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'sh3', component: FocusLockSlot });
162
+ toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'sh3', component: TargetShardSlot });
86
163
 
87
164
  /** Walk the layout tree and return the viewId of the active tab in the first
88
165
  * TabsNode found (breadth-first). Returns null if the layout contains no
@@ -194,7 +271,7 @@
194
271
  registry={toolbarRegistry}
195
272
  ctx={{ mode, role }}
196
273
  slotProps={{
197
- mode: { mode, role, registry: modeRegistry, onSelect: setMode },
274
+ mode: { mode, modes: visibleModes, onSelect: setMode },
198
275
  'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
199
276
  'target-shard': { target: targetShard },
200
277
  }}
@@ -1,10 +1,12 @@
1
1
  import { type ShellApi } from './registry';
2
2
  import type { ShellRole } from './modes/types';
3
+ import type { ContributionsApi } from '../contributions/types';
3
4
  interface Props {
4
5
  shell: ShellApi;
5
6
  wsUrl: string;
6
7
  userId: string;
7
8
  role: ShellRole;
9
+ contributions: ContributionsApi;
8
10
  }
9
11
  declare const Terminal: import("svelte").Component<Props, {}, "">;
10
12
  type Terminal = ReturnType<typeof Terminal>;
@@ -0,0 +1,65 @@
1
+ import type { Component } from 'svelte';
2
+ /** Contribution-point id under which mode descriptors are registered. */
3
+ export declare const SHELL_MODE_CONTRIBUTION_POINT = "sh3.shell.mode";
4
+ /** Where the descriptor's dispatch handler executes. v1 only honors 'client'. */
5
+ export type ShellModeRunsOn = 'client' | 'server';
6
+ /** A single shell-mode contribution. */
7
+ export interface ShellModeDescriptor {
8
+ /** Unique id, namespaced in practice (e.g. 'gemini', 'claude-code'). */
9
+ id: string;
10
+ /** Short label rendered in the segmented picker. */
11
+ label: string;
12
+ /** Optional segment icon. v1 picker ignores this; reserved for forward compat. */
13
+ icon?: string | Component;
14
+ /** Role gate. Same semantics as the builtin bash mode. */
15
+ requiresRole?: 'admin';
16
+ /** Where dispatch runs. v1 only honors 'client'. */
17
+ runsOn: ShellModeRunsOn;
18
+ /** Whether the shell auto-relocates cwd when a shard takes focus. */
19
+ autoRelocate?: boolean;
20
+ /** Brain: receives input and pushes output. */
21
+ dispatch: ShellModeDispatchHandler;
22
+ /** Optional lifecycle hook fired when the mode is selected. */
23
+ activate?: (ctx: unknown) => void | Promise<void>;
24
+ /** Optional lifecycle hook fired when the mode is deselected. */
25
+ deactivate?: (ctx: unknown) => void;
26
+ }
27
+ export interface ShellModeDispatchInput {
28
+ /** The raw line as submitted by the user. */
29
+ line: string;
30
+ /** Current working directory at submit time. */
31
+ cwd: string;
32
+ /**
33
+ * Aborts when the user switches mode, runs `clear`, or otherwise cancels
34
+ * the in-flight dispatch. Mode handlers MUST propagate this signal to
35
+ * any long-running work (e.g. pass to fetch).
36
+ */
37
+ signal: AbortSignal;
38
+ }
39
+ export type ShellModeDispatchHandler = (input: ShellModeDispatchInput, output: ShellModeOutput) => Promise<void>;
40
+ export interface ShellModeOutput {
41
+ /** Push a text chunk to the scrollback. Consecutive same-stream chunks coalesce. */
42
+ text(stream: 'stdout' | 'stderr', chunk: string): void;
43
+ /** Push a status entry (info / warn / error). */
44
+ status(level: 'info' | 'warn' | 'error', msg: string): void;
45
+ /** Push a rich entry whose props can be patched later via the returned handle. */
46
+ rich(component: Component<any>, props: Record<string, unknown>): RichEntryHandle;
47
+ /**
48
+ * Push a streaming rich entry. Returns a handle the mode appends to as
49
+ * tokens arrive. The framework marks the entry mid-stream until `complete()`
50
+ * or `error()` is called so the renderer can show a loading affordance.
51
+ */
52
+ stream(component: Component<any>, initialProps: Record<string, unknown>): StreamHandle;
53
+ }
54
+ export interface RichEntryHandle {
55
+ /** Patch the entry's props. Triggers Svelte reactivity. */
56
+ update(patch: Record<string, unknown>): void;
57
+ }
58
+ export interface StreamHandle {
59
+ /** Patch props as new tokens arrive. */
60
+ append(patch: Record<string, unknown>): void;
61
+ /** Mark the stream finished cleanly. */
62
+ complete(): void;
63
+ /** Mark the stream finished with an error; renders an error status. */
64
+ error(err: unknown): void;
65
+ }
@@ -0,0 +1,11 @@
1
+ /*
2
+ * Public contract for shell-mode contributions. External shards register
3
+ * descriptors via `registerShellMode(ctx, descriptor)` (see registerShellMode.ts);
4
+ * the shell-shard's dispatch path looks them up by id when transport === 'custom'.
5
+ *
6
+ * v1 only implements `runsOn: 'client'`. Selecting a `runsOn: 'server'` mode
7
+ * is rejected at dispatch with a clear status — server-side execution is a
8
+ * future addition that will not change this contract.
9
+ */
10
+ /** Contribution-point id under which mode descriptors are registered. */
11
+ export const SHELL_MODE_CONTRIBUTION_POINT = 'sh3.shell.mode';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { makeDispatch } from './dispatch';
3
+ function makeStubDeps(mode, customMode) {
4
+ const pushed = [];
5
+ const scrollback = { push: (e) => pushed.push(e) };
6
+ const session = {
7
+ history: { push: vi.fn() },
8
+ send: () => { },
9
+ cwd: '/',
10
+ };
11
+ const shell = {};
12
+ const fs = {};
13
+ const resolver = {
14
+ resolve: (line) => ({ kind: 'forward', line }),
15
+ };
16
+ return {
17
+ deps: {
18
+ mode: () => mode,
19
+ resolver,
20
+ scrollback,
21
+ session,
22
+ shell,
23
+ fs,
24
+ cwd: () => '/',
25
+ customMode,
26
+ },
27
+ pushed,
28
+ };
29
+ }
30
+ describe('dispatch — custom transport', () => {
31
+ it('routes the line to the descriptor.dispatch', async () => {
32
+ const handler = vi.fn(async () => { });
33
+ const desc = {
34
+ id: 'gemini',
35
+ label: 'Gemini',
36
+ runsOn: 'client',
37
+ dispatch: handler,
38
+ };
39
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
40
+ const { deps } = makeStubDeps(mode, () => desc);
41
+ const { dispatch } = makeDispatch(deps);
42
+ await dispatch('hello');
43
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({ line: 'hello', cwd: '/' }), expect.objectContaining({ text: expect.any(Function) }));
44
+ });
45
+ it('rejects runsOn: server with a clear status', async () => {
46
+ const desc = { id: 'srv', label: 'Srv', runsOn: 'server', dispatch: async () => { } };
47
+ const mode = { id: 'srv', label: 'Srv', transport: 'custom', autoRelocate: false };
48
+ const { deps, pushed } = makeStubDeps(mode, () => desc);
49
+ const { dispatch } = makeDispatch(deps);
50
+ await dispatch('hi');
51
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
52
+ expect(err).toBeDefined();
53
+ expect(err.text).toMatch(/server-side modes are not yet supported/);
54
+ });
55
+ it('catches handler throws and renders an error status', async () => {
56
+ const desc = {
57
+ id: 'gemini',
58
+ label: 'Gemini',
59
+ runsOn: 'client',
60
+ dispatch: async () => {
61
+ throw new Error('kaboom');
62
+ },
63
+ };
64
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
65
+ const { deps, pushed } = makeStubDeps(mode, () => desc);
66
+ const { dispatch } = makeDispatch(deps);
67
+ await dispatch('hi');
68
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
69
+ expect(err).toBeDefined();
70
+ expect(err.text).toMatch(/kaboom/);
71
+ });
72
+ it('aborts in-flight dispatch when cancel() is called', async () => {
73
+ let aborted = false;
74
+ const desc = {
75
+ id: 'gemini',
76
+ label: 'Gemini',
77
+ runsOn: 'client',
78
+ dispatch: async (input) => {
79
+ await new Promise((_resolve, reject) => {
80
+ input.signal.addEventListener('abort', () => {
81
+ aborted = true;
82
+ reject(new DOMException('aborted', 'AbortError'));
83
+ });
84
+ });
85
+ },
86
+ };
87
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
88
+ const { deps } = makeStubDeps(mode, () => desc);
89
+ const { dispatch, cancel } = makeDispatch(deps);
90
+ const promise = dispatch('hi');
91
+ cancel();
92
+ await promise;
93
+ expect(aborted).toBe(true);
94
+ });
95
+ it('emits an error if the descriptor has been unloaded', async () => {
96
+ const mode = { id: 'ghost', label: 'Ghost', transport: 'custom', autoRelocate: false };
97
+ const { deps, pushed } = makeStubDeps(mode, () => null);
98
+ const { dispatch } = makeDispatch(deps);
99
+ await dispatch('hi');
100
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
101
+ expect(err).toBeDefined();
102
+ expect(err.text).toMatch(/no longer available/);
103
+ });
104
+ });
@@ -3,6 +3,7 @@ import type { Scrollback } from './scrollback.svelte';
3
3
  import type { SessionClient } from './session-client.svelte';
4
4
  import type { TenantFsClient } from './tenant-fs-client';
5
5
  import type { ShellMode } from './modes/types';
6
+ import type { ShellModeDescriptor } from './contract';
6
7
  export interface DispatchDeps {
7
8
  mode: () => ShellMode;
8
9
  resolver: VerbRegistry;
@@ -11,5 +12,17 @@ export interface DispatchDeps {
11
12
  shell: ShellApi;
12
13
  fs: TenantFsClient;
13
14
  cwd: () => string;
15
+ /**
16
+ * Look up a contributed mode descriptor by id. Called only when the active
17
+ * mode has `transport: 'custom'`. Returns null if the descriptor has been
18
+ * unloaded (rare race; the active-mode-fallback effect in Terminal.svelte
19
+ * handles this on the next tick — dispatch surfaces an error in the meantime).
20
+ */
21
+ customMode?: (id: string) => ShellModeDescriptor | null;
14
22
  }
15
- export declare function makeDispatch(deps: DispatchDeps): (line: string) => Promise<void>;
23
+ export interface DispatchHandle {
24
+ dispatch: (line: string) => Promise<void>;
25
+ /** Abort any in-flight custom-mode dispatch. Safe to call repeatedly. */
26
+ cancel: () => void;
27
+ }
28
+ export declare function makeDispatch(deps: DispatchDeps): DispatchHandle;