sh3-core 0.13.4 → 0.14.3

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 (65) hide show
  1. package/dist/api.d.ts +3 -0
  2. package/dist/api.js +3 -0
  3. package/dist/host.js +2 -0
  4. package/dist/layout/LayoutRenderer.svelte +1 -1
  5. package/dist/layout/tree-walk.js +6 -1
  6. package/dist/layout/types.d.ts +7 -0
  7. package/dist/migrations/mode-id-rename.d.ts +9 -0
  8. package/dist/migrations/mode-id-rename.js +39 -0
  9. package/dist/migrations/mode-id-rename.test.d.ts +1 -0
  10. package/dist/migrations/mode-id-rename.test.js +52 -0
  11. package/dist/overlays/FloatFrame.svelte +8 -2
  12. package/dist/overlays/float.js +6 -3
  13. package/dist/overlays/float.test.js +71 -0
  14. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
  15. package/dist/primitives/widgets/Segmented.svelte +4 -1
  16. package/dist/sh3core-shard/AppInfoView.svelte +154 -0
  17. package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
  18. package/dist/sh3core-shard/appActions.js +23 -5
  19. package/dist/shell-shard/ScrollbackView.svelte +40 -19
  20. package/dist/shell-shard/Terminal.svelte +140 -12
  21. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  22. package/dist/shell-shard/contract.d.ts +99 -0
  23. package/dist/shell-shard/contract.js +11 -0
  24. package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
  25. package/dist/shell-shard/dispatch-custom.test.js +152 -0
  26. package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
  27. package/dist/shell-shard/dispatch-gating.test.js +63 -0
  28. package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
  29. package/dist/shell-shard/dispatch-invoke.test.js +214 -0
  30. package/dist/shell-shard/dispatch.d.ts +23 -2
  31. package/dist/shell-shard/dispatch.js +130 -6
  32. package/dist/shell-shard/modes/builtin.d.ts +2 -2
  33. package/dist/shell-shard/modes/builtin.js +8 -8
  34. package/dist/shell-shard/modes/prefs.js +1 -1
  35. package/dist/shell-shard/modes/prefs.test.js +13 -13
  36. package/dist/shell-shard/modes/registry.test.js +13 -13
  37. package/dist/shell-shard/output.d.ts +10 -0
  38. package/dist/shell-shard/output.js +91 -0
  39. package/dist/shell-shard/output.test.d.ts +1 -0
  40. package/dist/shell-shard/output.test.js +73 -0
  41. package/dist/shell-shard/registerShellMode.d.ts +13 -0
  42. package/dist/shell-shard/registerShellMode.js +14 -0
  43. package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
  44. package/dist/shell-shard/registerShellMode.test.js +19 -0
  45. package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
  46. package/dist/shell-shard/registry-resolve.test.js +26 -0
  47. package/dist/shell-shard/registry.d.ts +12 -1
  48. package/dist/shell-shard/registry.js +12 -1
  49. package/dist/shell-shard/shellShard.svelte.js +8 -1
  50. package/dist/shell-shard/terminal-dispatch.test.js +19 -12
  51. package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
  52. package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
  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/clear.js +1 -0
  57. package/dist/shell-shard/verbs/index.js +2 -0
  58. package/dist/shell-shard/verbs/mode.d.ts +2 -0
  59. package/dist/shell-shard/verbs/mode.js +29 -0
  60. package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
  61. package/dist/shell-shard/verbs/mode.test.js +43 -0
  62. package/dist/verbs/types.d.ts +19 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +1 -1
@@ -9,8 +9,10 @@
9
9
  import { TenantFsClient } from './tenant-fs-client';
10
10
  import { ShellModeRegistry } from './modes/registry';
11
11
  import { registerBuiltinModes } from './modes/builtin';
12
- import { resolveInitialMode, writeLastMode } from './modes/prefs';
12
+ import { readLastMode, 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';
@@ -20,32 +22,86 @@
20
22
  import ModeSlot from './toolbar/slots/ModeSlot.svelte';
21
23
  import FocusLockSlot from './toolbar/slots/FocusLockSlot.svelte';
22
24
  import TargetShardSlot from './toolbar/slots/TargetShardSlot.svelte';
25
+ import BusySlot from './toolbar/slots/BusySlot.svelte';
23
26
 
24
27
  interface Props {
25
28
  shell: ShellApi;
26
29
  wsUrl: string;
27
30
  userId: string;
28
31
  role: ShellRole;
32
+ contributions: ContributionsApi;
29
33
  }
30
- let { shell, wsUrl, userId, role }: Props = $props();
34
+ let { shell, wsUrl, userId, role, contributions }: Props = $props();
31
35
 
32
36
  const scrollback = new Scrollback();
33
37
  const resolver = new VerbRegistry();
34
38
  const fs = new TenantFsClient();
35
39
 
36
- // Mode registry
40
+ // Mode registry — holds builtins only. Contributed modes flow through
41
+ // the contributions API and are merged reactively below.
37
42
  const modeRegistry = new ShellModeRegistry();
38
43
  registerBuiltinModes(modeRegistry);
39
44
 
40
- // Reactive current mode
45
+ // contributions.list() returns a plain array (not reactive). Mirror it
46
+ // into a $state cell and refresh on every onChange notification so the
47
+ // picker, verb listing, and active-mode fallback all react to shard
48
+ // hot-mount/unmount without polling.
49
+ let contributedModes = $state<ShellModeDescriptor[]>(
50
+ untrack(() => contributions.list<ShellModeDescriptor>(SHELL_MODE_CONTRIBUTION_POINT)),
51
+ );
52
+ $effect(() => {
53
+ const off = contributions.onChange(SHELL_MODE_CONTRIBUTION_POINT, () => {
54
+ contributedModes = contributions.list<ShellModeDescriptor>(SHELL_MODE_CONTRIBUTION_POINT);
55
+ });
56
+ return () => off();
57
+ });
58
+
59
+ /** Convert a descriptor to the internal ShellMode shape so the picker and
60
+ * the dispatch path treat builtin and contributed modes uniformly. */
61
+ function descriptorToMode(d: ShellModeDescriptor): ShellMode {
62
+ return {
63
+ id: d.id,
64
+ label: d.label,
65
+ requiresRole: d.requiresRole,
66
+ transport: 'custom',
67
+ autoRelocate: d.autoRelocate ?? false,
68
+ };
69
+ }
70
+
71
+ let visibleModes = $derived<ShellMode[]>([
72
+ ...modeRegistry.list(role),
73
+ ...contributedModes
74
+ .filter((d) => !d.requiresRole || d.requiresRole === role)
75
+ .map(descriptorToMode),
76
+ ]);
77
+
78
+ // Resolve against the merged visible set so a persisted contributed-mode
79
+ // id is honored at boot when its shard activated before this view mounted.
80
+ // Async activations still land on the role default.
41
81
  let mode = $state<ShellMode>(
42
- untrack(() => resolveInitialMode(modeRegistry, userId, role)),
82
+ untrack(() => {
83
+ const persisted = readLastMode(userId);
84
+ if (persisted) {
85
+ const initialVisible: ShellMode[] = [
86
+ ...modeRegistry.list(role),
87
+ ...contributedModes
88
+ .filter((d) => !d.requiresRole || d.requiresRole === role)
89
+ .map(descriptorToMode),
90
+ ];
91
+ const found = initialVisible.find((m) => m.id === persisted);
92
+ if (found) return found;
93
+ }
94
+ return resolveInitialMode(modeRegistry, userId, role);
95
+ }),
43
96
  );
44
97
 
45
98
  function setMode(id: string): void {
46
- const next = modeRegistry.get(id);
99
+ const next = visibleModes.find((m) => m.id === id);
47
100
  if (!next) return;
48
101
  if (next.requiresRole && next.requiresRole !== role) return;
102
+ // Abort any in-flight custom-mode dispatch from the outgoing mode
103
+ // before flipping. Safe no-op if there's nothing running.
104
+ cancelDispatch();
49
105
  mode = next;
50
106
  writeLastMode(userId, id);
51
107
  if (next.transport !== 'ws') {
@@ -53,18 +109,85 @@
53
109
  }
54
110
  }
55
111
 
112
+ // If the active mode disappears (shard unloaded), fall back to sh3 — or
113
+ // the first available mode if even sh3 is gone.
114
+ $effect(() => {
115
+ if (!visibleModes.find((m) => m.id === mode.id)) {
116
+ const fallback = visibleModes.find((m) => m.id === 'sh3') ?? visibleModes[0];
117
+ if (fallback) {
118
+ const lostId = mode.id;
119
+ mode = fallback;
120
+ writeLastMode(userId, fallback.id);
121
+ scrollback.push({
122
+ kind: 'status',
123
+ text: `mode '${lostId}' is no longer available — switched to '${fallback.id}'`,
124
+ level: 'warn',
125
+ ts: Date.now(),
126
+ });
127
+ }
128
+ }
129
+ });
130
+
131
+ // Extend the shell prop with view-local mode switching so verbs (like
132
+ // `mode`) can drive the picker. Only this view knows the live registry
133
+ // and the setMode closure, so the wrapper happens here. The shell prop
134
+ // is stable for the view's lifetime, so capturing its initial value via
135
+ // untrack is intentional.
136
+ const shellWithModes: ShellApi = untrack(() => ({
137
+ ...shell,
138
+ setMode: (id: string) => {
139
+ const next = visibleModes.find((m) => m.id === id);
140
+ if (!next) return false;
141
+ if (next.requiresRole && next.requiresRole !== role) return false;
142
+ setMode(id);
143
+ return true;
144
+ },
145
+ listModes: () => visibleModes.map((m) => ({ id: m.id, label: m.label })),
146
+ }));
147
+
56
148
  // wsUrl is a prop read at construction only. untrack prevents Svelte 5's
57
149
  // "referenced outside a closure" warning; the URL never changes at runtime.
58
150
  const session = untrack(() => new SessionClient(wsUrl));
59
151
 
60
- const dispatch = untrack(() => makeDispatch({
152
+ // Busy controller feeds the toolbar spinner. Each call to acquireBusy(label)
153
+ // adds an entry to the map; the returned disposer removes it. The slot
154
+ // renders when the map is non-empty.
155
+ let busyCounter = 0;
156
+ let busyEntries = $state(new Map<number, string | undefined>());
157
+
158
+ function acquireBusy(label?: string): () => void {
159
+ const id = ++busyCounter;
160
+ busyEntries.set(id, label);
161
+ busyEntries = new Map(busyEntries);
162
+ let cleared = false;
163
+ return () => {
164
+ if (cleared) return;
165
+ cleared = true;
166
+ busyEntries.delete(id);
167
+ busyEntries = new Map(busyEntries);
168
+ };
169
+ }
170
+
171
+ let busyActive = $derived(busyEntries.size > 0);
172
+ let busyLabel = $derived.by<string | null>(() => {
173
+ if (busyEntries.size === 0) return null;
174
+ // Most-recent label wins (insertion-order Map).
175
+ let last: string | undefined;
176
+ for (const v of busyEntries.values()) last = v;
177
+ return last ?? null;
178
+ });
179
+
180
+ const { dispatch, cancel: cancelDispatch } = untrack(() => makeDispatch({
61
181
  mode: () => mode,
182
+ role: () => role,
62
183
  resolver,
63
184
  scrollback,
64
185
  session,
65
- shell,
186
+ shell: shellWithModes,
66
187
  fs,
67
188
  cwd: () => session.cwd,
189
+ busy: acquireBusy,
190
+ customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
68
191
  }));
69
192
 
70
193
  let locked = $state(false);
@@ -78,11 +201,15 @@
78
201
  let focusLocked = $state(false);
79
202
  let targetShard = $state<string | null>(null);
80
203
 
81
- // Toolbar slot registry
204
+ // Toolbar slot registry. The 'busy' slot is always-visible at the registry
205
+ // level; the BusySlot component itself gates rendering on `active` so we
206
+ // don't have to invalidate the toolbar's `slots` derivation when the
207
+ // spinner toggles (the toolbar re-derives from ctx, not from busyActive).
82
208
  const toolbarRegistry = new ToolbarSlotRegistry();
209
+ toolbarRegistry.register({ id: 'busy', order: 5, visible: () => true, component: BusySlot });
83
210
  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 });
211
+ toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'sh3', component: FocusLockSlot });
212
+ toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'sh3', component: TargetShardSlot });
86
213
 
87
214
  /** Walk the layout tree and return the viewId of the active tab in the first
88
215
  * TabsNode found (breadth-first). Returns null if the layout contains no
@@ -194,7 +321,8 @@
194
321
  registry={toolbarRegistry}
195
322
  ctx={{ mode, role }}
196
323
  slotProps={{
197
- mode: { mode, role, registry: modeRegistry, onSelect: setMode },
324
+ busy: { active: busyActive, label: busyLabel },
325
+ mode: { mode, modes: visibleModes, onSelect: setMode },
198
326
  'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
199
327
  'target-shard': { target: targetShard },
200
328
  }}
@@ -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,99 @@
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
+ * Show a spinner in the shell header for the duration of a long-running
55
+ * operation. The optional label is rendered next to the spinner; pass
56
+ * undefined for an unlabelled indicator. Returns a clear handle — call
57
+ * clear() exactly once when the operation completes (idempotent).
58
+ *
59
+ * The framework already auto-spawns a spinner while a dispatch is in
60
+ * flight. Use this method for work that runs *outside* a dispatch (e.g.
61
+ * during the descriptor's `activate()` lifecycle hook, or background
62
+ * pre-fetching).
63
+ */
64
+ busy(label?: string): BusyHandle;
65
+ /**
66
+ * Programmatically dispatch a line through another mode's resolution path.
67
+ *
68
+ * - `'sh3'` — full sh3 verb resolution (bypasses the mode-gating that
69
+ * normally restricts sh3-domain verbs to sh3 mode).
70
+ * - `'bash'` — forward to the WS session. Lazy-connects on first use.
71
+ * Resolves immediately on send (output streams asynchronously to the
72
+ * scrollback via the WS protocol).
73
+ * - any other registered mode id — routes through that mode's `dispatch()`
74
+ * handler. Output flows to the same scrollback.
75
+ *
76
+ * Throws synchronously on:
77
+ * - unknown mode id
78
+ * - role mismatch (e.g. a non-admin context targeting `'bash'`)
79
+ * - self-invocation (a mode invoking its own id — direct calls into the
80
+ * mode's helpers should be used instead)
81
+ */
82
+ invoke(modeId: string, line: string): Promise<void>;
83
+ }
84
+ export interface RichEntryHandle {
85
+ /** Patch the entry's props. Triggers Svelte reactivity. */
86
+ update(patch: Record<string, unknown>): void;
87
+ }
88
+ export interface StreamHandle {
89
+ /** Patch props as new tokens arrive. */
90
+ append(patch: Record<string, unknown>): void;
91
+ /** Mark the stream finished cleanly. */
92
+ complete(): void;
93
+ /** Mark the stream finished with an error; renders an error status. */
94
+ error(err: unknown): void;
95
+ }
96
+ export interface BusyHandle {
97
+ /** Remove this busy indicator. Idempotent. */
98
+ clear(): void;
99
+ }
@@ -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,152 @@
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
+ role: () => 'user',
20
+ resolver,
21
+ scrollback,
22
+ session,
23
+ shell,
24
+ fs,
25
+ cwd: () => '/',
26
+ busy: () => () => { },
27
+ customMode,
28
+ },
29
+ pushed,
30
+ };
31
+ }
32
+ describe('dispatch — custom transport', () => {
33
+ it('routes the line to the descriptor.dispatch', async () => {
34
+ const handler = vi.fn(async () => { });
35
+ const desc = {
36
+ id: 'gemini',
37
+ label: 'Gemini',
38
+ runsOn: 'client',
39
+ dispatch: handler,
40
+ };
41
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
42
+ const { deps } = makeStubDeps(mode, () => desc);
43
+ const { dispatch } = makeDispatch(deps);
44
+ await dispatch('hello');
45
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({ line: 'hello', cwd: '/' }), expect.objectContaining({ text: expect.any(Function) }));
46
+ });
47
+ it('rejects runsOn: server with a clear status', async () => {
48
+ const desc = { id: 'srv', label: 'Srv', runsOn: 'server', dispatch: async () => { } };
49
+ const mode = { id: 'srv', label: 'Srv', transport: 'custom', autoRelocate: false };
50
+ const { deps, pushed } = makeStubDeps(mode, () => desc);
51
+ const { dispatch } = makeDispatch(deps);
52
+ await dispatch('hi');
53
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
54
+ expect(err).toBeDefined();
55
+ expect(err.text).toMatch(/server-side modes are not yet supported/);
56
+ });
57
+ it('catches handler throws and renders an error status', async () => {
58
+ const desc = {
59
+ id: 'gemini',
60
+ label: 'Gemini',
61
+ runsOn: 'client',
62
+ dispatch: async () => {
63
+ throw new Error('kaboom');
64
+ },
65
+ };
66
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
67
+ const { deps, pushed } = makeStubDeps(mode, () => desc);
68
+ const { dispatch } = makeDispatch(deps);
69
+ await dispatch('hi');
70
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
71
+ expect(err).toBeDefined();
72
+ expect(err.text).toMatch(/kaboom/);
73
+ });
74
+ it('aborts in-flight dispatch when cancel() is called', async () => {
75
+ let aborted = false;
76
+ const desc = {
77
+ id: 'gemini',
78
+ label: 'Gemini',
79
+ runsOn: 'client',
80
+ dispatch: async (input) => {
81
+ await new Promise((_resolve, reject) => {
82
+ input.signal.addEventListener('abort', () => {
83
+ aborted = true;
84
+ reject(new DOMException('aborted', 'AbortError'));
85
+ });
86
+ });
87
+ },
88
+ };
89
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
90
+ const { deps } = makeStubDeps(mode, () => desc);
91
+ const { dispatch, cancel } = makeDispatch(deps);
92
+ const promise = dispatch('hi');
93
+ cancel();
94
+ await promise;
95
+ expect(aborted).toBe(true);
96
+ });
97
+ it('emits an error if the descriptor has been unloaded', async () => {
98
+ const mode = { id: 'ghost', label: 'Ghost', transport: 'custom', autoRelocate: false };
99
+ const { deps, pushed } = makeStubDeps(mode, () => null);
100
+ const { dispatch } = makeDispatch(deps);
101
+ await dispatch('hi');
102
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
103
+ expect(err).toBeDefined();
104
+ expect(err.text).toMatch(/no longer available/);
105
+ });
106
+ });
107
+ describe('dispatch — auto-spinner', () => {
108
+ it('acquires a busy slot for the duration of a custom-mode dispatch', async () => {
109
+ let active = 0;
110
+ let peak = 0;
111
+ const busy = () => {
112
+ active++;
113
+ peak = Math.max(peak, active);
114
+ return () => { active--; };
115
+ };
116
+ let resolveDispatch;
117
+ const desc = {
118
+ id: 'gemini',
119
+ label: 'Gemini',
120
+ runsOn: 'client',
121
+ dispatch: () => new Promise((resolve) => {
122
+ resolveDispatch = resolve;
123
+ }),
124
+ };
125
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
126
+ const { deps } = makeStubDeps(mode, () => desc);
127
+ const { dispatch } = makeDispatch(Object.assign(Object.assign({}, deps), { busy }));
128
+ const promise = dispatch('hi');
129
+ // Yield once so the dispatch closure runs through to the await on desc.dispatch
130
+ await Promise.resolve();
131
+ expect(peak).toBe(1);
132
+ expect(active).toBe(1);
133
+ resolveDispatch();
134
+ await promise;
135
+ expect(active).toBe(0);
136
+ });
137
+ it('clears busy when the dispatch handler throws', async () => {
138
+ let active = 0;
139
+ const busy = () => { active++; return () => { active--; }; };
140
+ const desc = {
141
+ id: 'gemini',
142
+ label: 'Gemini',
143
+ runsOn: 'client',
144
+ dispatch: async () => { throw new Error('kaboom'); },
145
+ };
146
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
147
+ const { deps } = makeStubDeps(mode, () => desc);
148
+ const { dispatch } = makeDispatch(Object.assign(Object.assign({}, deps), { busy }));
149
+ await dispatch('hi');
150
+ expect(active).toBe(0);
151
+ });
152
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { makeDispatch } from './dispatch';
3
+ const appsVerb = { name: 'apps', summary: '', async run() { } };
4
+ const clearVerb = {
5
+ name: 'clear',
6
+ summary: '',
7
+ globalVerb: true,
8
+ async run(ctx) {
9
+ ctx.scrollback.push({ kind: 'status', text: 'cleared', level: 'info', ts: 0 });
10
+ },
11
+ };
12
+ function scaffold(mode) {
13
+ const sent = [];
14
+ const pushed = [];
15
+ const scrollback = { push: (e) => pushed.push(e), clear: () => { } };
16
+ const session = { history: { push: vi.fn() }, send: (m) => sent.push(m), cwd: '/', connected: true, connect: vi.fn() };
17
+ const fs = {};
18
+ const shell = {};
19
+ // Real-shape resolver to exercise the new globalOnly path.
20
+ const resolver = {
21
+ resolve: (line, opts = {}) => {
22
+ const head = line.trim().split(/\s+/)[0];
23
+ const verb = head === 'apps' ? appsVerb : head === 'clear' ? clearVerb : null;
24
+ if (!verb)
25
+ return { kind: 'forward', line };
26
+ if (opts.globalOnly && !verb.globalVerb)
27
+ return { kind: 'forward', line };
28
+ return { kind: 'local', verb, args: [], line };
29
+ },
30
+ };
31
+ const { dispatch } = makeDispatch({
32
+ mode: () => mode,
33
+ role: () => (mode.requiresRole === 'admin' ? 'admin' : 'user'),
34
+ resolver,
35
+ scrollback,
36
+ session,
37
+ shell,
38
+ fs,
39
+ cwd: () => '/',
40
+ busy: () => () => { },
41
+ });
42
+ return { dispatch, sent, pushed };
43
+ }
44
+ describe('dispatch — mode-gated verb resolution', () => {
45
+ const sh3Mode = { id: 'sh3', label: 'SH3', transport: 'none', autoRelocate: true };
46
+ const bashMode = { id: 'bash', label: 'Bash', transport: 'ws', autoRelocate: false, requiresRole: 'admin' };
47
+ it('sh3 mode resolves sh3-domain verbs locally', async () => {
48
+ const { dispatch, sent } = scaffold(sh3Mode);
49
+ await dispatch('apps');
50
+ expect(sent).toEqual([]);
51
+ });
52
+ it('bash mode forwards sh3-domain verbs to ws', async () => {
53
+ const { dispatch, sent } = scaffold(bashMode);
54
+ await dispatch('apps');
55
+ expect(sent.some((m) => m.t === 'submit' && m.line === 'apps')).toBe(true);
56
+ });
57
+ it('bash mode runs globalVerb (clear) locally', async () => {
58
+ const { dispatch, sent, pushed } = scaffold(bashMode);
59
+ await dispatch('clear');
60
+ expect(sent.every((m) => m.t !== 'submit')).toBe(true);
61
+ expect(pushed.some((e) => e.kind === 'status' && e.text === 'cleared')).toBe(true);
62
+ });
63
+ });
@@ -0,0 +1 @@
1
+ export {};