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.
- package/dist/api.d.ts +3 -0
- package/dist/api.js +3 -0
- package/dist/host.js +2 -0
- package/dist/layout/LayoutRenderer.svelte +1 -1
- package/dist/layout/tree-walk.js +6 -1
- package/dist/layout/types.d.ts +7 -0
- package/dist/migrations/mode-id-rename.d.ts +9 -0
- package/dist/migrations/mode-id-rename.js +39 -0
- package/dist/migrations/mode-id-rename.test.d.ts +1 -0
- package/dist/migrations/mode-id-rename.test.js +52 -0
- package/dist/overlays/FloatFrame.svelte +8 -2
- package/dist/overlays/float.js +6 -3
- package/dist/overlays/float.test.js +71 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
- package/dist/primitives/widgets/Segmented.svelte +4 -1
- package/dist/sh3core-shard/AppInfoView.svelte +154 -0
- package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
- package/dist/sh3core-shard/appActions.js +23 -5
- package/dist/shell-shard/ScrollbackView.svelte +40 -19
- package/dist/shell-shard/Terminal.svelte +140 -12
- package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
- package/dist/shell-shard/contract.d.ts +99 -0
- package/dist/shell-shard/contract.js +11 -0
- package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-custom.test.js +152 -0
- package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-gating.test.js +63 -0
- package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-invoke.test.js +214 -0
- package/dist/shell-shard/dispatch.d.ts +23 -2
- package/dist/shell-shard/dispatch.js +130 -6
- package/dist/shell-shard/modes/builtin.d.ts +2 -2
- package/dist/shell-shard/modes/builtin.js +8 -8
- package/dist/shell-shard/modes/prefs.js +1 -1
- package/dist/shell-shard/modes/prefs.test.js +13 -13
- package/dist/shell-shard/modes/registry.test.js +13 -13
- package/dist/shell-shard/output.d.ts +10 -0
- package/dist/shell-shard/output.js +91 -0
- package/dist/shell-shard/output.test.d.ts +1 -0
- package/dist/shell-shard/output.test.js +73 -0
- package/dist/shell-shard/registerShellMode.d.ts +13 -0
- package/dist/shell-shard/registerShellMode.js +14 -0
- package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
- package/dist/shell-shard/registerShellMode.test.js +19 -0
- package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
- package/dist/shell-shard/registry-resolve.test.js +26 -0
- package/dist/shell-shard/registry.d.ts +12 -1
- package/dist/shell-shard/registry.js +12 -1
- package/dist/shell-shard/shellShard.svelte.js +8 -1
- package/dist/shell-shard/terminal-dispatch.test.js +19 -12
- package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
- package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +11 -51
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +2 -4
- package/dist/shell-shard/toolbar/slots.test.js +6 -6
- package/dist/shell-shard/verbs/clear.js +1 -0
- package/dist/shell-shard/verbs/index.js +2 -0
- package/dist/shell-shard/verbs/mode.d.ts +2 -0
- package/dist/shell-shard/verbs/mode.js +29 -0
- package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mode.test.js +43 -0
- package/dist/verbs/types.d.ts +19 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
//
|
|
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(() =>
|
|
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 =
|
|
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
|
-
|
|
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 === '
|
|
85
|
-
toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === '
|
|
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
|
-
|
|
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 {};
|