sh3-core 0.13.4 → 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.
- package/dist/api.d.ts +3 -0
- package/dist/api.js +3 -0
- package/dist/host.js +2 -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/shell-shard/Terminal.svelte +85 -8
- package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
- package/dist/shell-shard/contract.d.ts +65 -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 +104 -0
- package/dist/shell-shard/dispatch.d.ts +14 -1
- package/dist/shell-shard/dispatch.js +58 -5
- 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 +3 -0
- package/dist/shell-shard/output.js +75 -0
- package/dist/shell-shard/output.test.d.ts +1 -0
- package/dist/shell-shard/output.test.js +54 -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/shellShard.svelte.js +8 -1
- package/dist/shell-shard/terminal-dispatch.test.js +9 -9
- 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/index.js +2 -0
- package/dist/shell-shard/verbs/mode.d.ts +2 -0
- package/dist/shell-shard/verbs/mode.js +28 -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 +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -50,6 +50,9 @@ export type { Verb, VerbContext, ShellApi } from './verbs/types';
|
|
|
50
50
|
export type { Scrollback } from './shell-shard/scrollback.svelte';
|
|
51
51
|
export type { SessionClient } from './shell-shard/session-client.svelte';
|
|
52
52
|
export { listVerbs } from './shards/registry';
|
|
53
|
+
export { registerShellMode } from './shell-shard/registerShellMode';
|
|
54
|
+
export type { ShellModeDescriptor, ShellModeOutput, ShellModeDispatchHandler, ShellModeDispatchInput, ShellModeRunsOn, RichEntryHandle, StreamHandle, } from './shell-shard/contract';
|
|
55
|
+
export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
|
|
53
56
|
export { VERSION } from './version';
|
|
54
57
|
export declare const FRAMEWORK_SHARD_IDS: readonly string[];
|
|
55
58
|
export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
|
package/dist/api.js
CHANGED
|
@@ -56,6 +56,9 @@ export const capabilities = {
|
|
|
56
56
|
hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
|
|
57
57
|
};
|
|
58
58
|
export { listVerbs } from './shards/registry';
|
|
59
|
+
// Shell mode contributions (external shards extend the shell with new modes).
|
|
60
|
+
export { registerShellMode } from './shell-shard/registerShellMode';
|
|
61
|
+
export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
|
|
59
62
|
// Package version.
|
|
60
63
|
export { VERSION } from './version';
|
|
61
64
|
// Framework shard IDs — shards that are always present (built-in to sh3-core).
|
package/dist/host.js
CHANGED
|
@@ -31,6 +31,7 @@ import { storeApp } from './app/store/storeApp';
|
|
|
31
31
|
import { adminShard } from './app/admin/adminShard.svelte';
|
|
32
32
|
import { adminApp } from './app/admin/adminApp';
|
|
33
33
|
import { runShellRenameMigration, } from './migrations/shell-rename';
|
|
34
|
+
import { runModeIdRenameMigration } from './migrations/mode-id-rename';
|
|
34
35
|
import { setLifecycleHandlers } from './navigation/back-stack';
|
|
35
36
|
import { installWebEmitter } from './navigation/platform-web';
|
|
36
37
|
import { returnToHome } from './apps/lifecycle';
|
|
@@ -60,6 +61,7 @@ export async function bootstrap(config) {
|
|
|
60
61
|
// already in place when shards activate.
|
|
61
62
|
if (typeof globalThis.localStorage !== 'undefined') {
|
|
62
63
|
runShellRenameMigration(createWorkspaceZoneAdapter(), globalThis.localStorage);
|
|
64
|
+
runModeIdRenameMigration(globalThis.localStorage);
|
|
63
65
|
// Per ADR-002 amendment, app workspace state is keyed by (scopeId, appId).
|
|
64
66
|
// Rewrite legacy unkeyed entries to the personal scope namespace.
|
|
65
67
|
const { migrateLegacyWorkspaceKeys } = await import('./apps/workspace-rekey');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface MinimalStorage {
|
|
2
|
+
getItem(key: string): string | null;
|
|
3
|
+
setItem(key: string, value: string): void;
|
|
4
|
+
removeItem(key: string): void;
|
|
5
|
+
}
|
|
6
|
+
export declare function runModeIdRenameMigration(storage: MinimalStorage & {
|
|
7
|
+
_keys?: () => string[];
|
|
8
|
+
}): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One-shot migration: rewrites persisted shell-mode preferences from the
|
|
3
|
+
* pre-rename ids (`dev`, `user`) to the new ids (`bash`, `sh3`). Idempotent —
|
|
4
|
+
* gated by a localStorage flag, safe to call on every boot.
|
|
5
|
+
*
|
|
6
|
+
* Persistence shape: localStorage keys of the form `sh3.shell.lastMode.<userId>`
|
|
7
|
+
* (see packages/sh3-core/src/shell-shard/modes/prefs.ts).
|
|
8
|
+
*/
|
|
9
|
+
const FLAG_KEY = 'sh3:migrations:mode-id-rename:done';
|
|
10
|
+
const KEY_PREFIX = 'sh3.shell.lastMode.';
|
|
11
|
+
const REWRITES = { dev: 'bash', user: 'sh3' };
|
|
12
|
+
function listKeys(storage) {
|
|
13
|
+
if (typeof storage._keys === 'function')
|
|
14
|
+
return storage._keys();
|
|
15
|
+
const ls = storage;
|
|
16
|
+
if (typeof ls.length === 'number' && typeof ls.key === 'function') {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 0; i < ls.length; i++) {
|
|
19
|
+
const k = ls.key(i);
|
|
20
|
+
if (k !== null)
|
|
21
|
+
out.push(k);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
export function runModeIdRenameMigration(storage) {
|
|
28
|
+
if (storage.getItem(FLAG_KEY))
|
|
29
|
+
return;
|
|
30
|
+
for (const key of listKeys(storage)) {
|
|
31
|
+
if (!key.startsWith(KEY_PREFIX))
|
|
32
|
+
continue;
|
|
33
|
+
const value = storage.getItem(key);
|
|
34
|
+
if (value && Object.prototype.hasOwnProperty.call(REWRITES, value)) {
|
|
35
|
+
storage.setItem(key, REWRITES[value]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
storage.setItem(FLAG_KEY, '1');
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { runModeIdRenameMigration } from './mode-id-rename';
|
|
3
|
+
function makeStorage(initial = {}) {
|
|
4
|
+
const map = new Map(Object.entries(initial));
|
|
5
|
+
return {
|
|
6
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
7
|
+
setItem: (k, v) => { map.set(k, v); },
|
|
8
|
+
removeItem: (k) => { map.delete(k); },
|
|
9
|
+
_dump: () => Object.fromEntries(map),
|
|
10
|
+
_keys: () => [...map.keys()],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
describe('runModeIdRenameMigration', () => {
|
|
14
|
+
it('rewrites dev → bash for every user-keyed pref', () => {
|
|
15
|
+
const s = makeStorage({
|
|
16
|
+
'sh3.shell.lastMode.alice': 'dev',
|
|
17
|
+
'sh3.shell.lastMode.bob': 'dev',
|
|
18
|
+
});
|
|
19
|
+
runModeIdRenameMigration(s);
|
|
20
|
+
expect(s._dump()).toMatchObject({
|
|
21
|
+
'sh3.shell.lastMode.alice': 'bash',
|
|
22
|
+
'sh3.shell.lastMode.bob': 'bash',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
it('rewrites user → sh3 for every user-keyed pref', () => {
|
|
26
|
+
const s = makeStorage({ 'sh3.shell.lastMode.alice': 'user' });
|
|
27
|
+
runModeIdRenameMigration(s);
|
|
28
|
+
expect(s.getItem('sh3.shell.lastMode.alice')).toBe('sh3');
|
|
29
|
+
});
|
|
30
|
+
it('leaves unknown values untouched', () => {
|
|
31
|
+
const s = makeStorage({ 'sh3.shell.lastMode.alice': 'gemini' });
|
|
32
|
+
runModeIdRenameMigration(s);
|
|
33
|
+
expect(s.getItem('sh3.shell.lastMode.alice')).toBe('gemini');
|
|
34
|
+
});
|
|
35
|
+
it('is idempotent (gated by a done flag)', () => {
|
|
36
|
+
const s = makeStorage({ 'sh3.shell.lastMode.alice': 'dev' });
|
|
37
|
+
runModeIdRenameMigration(s);
|
|
38
|
+
s.setItem('sh3.shell.lastMode.alice', 'dev');
|
|
39
|
+
runModeIdRenameMigration(s);
|
|
40
|
+
expect(s.getItem('sh3.shell.lastMode.alice')).toBe('dev');
|
|
41
|
+
});
|
|
42
|
+
it('ignores unrelated keys', () => {
|
|
43
|
+
const s = makeStorage({
|
|
44
|
+
'sh3.shell.lastMode.alice': 'dev',
|
|
45
|
+
'sh3.unrelated': 'dev',
|
|
46
|
+
'random': 'user',
|
|
47
|
+
});
|
|
48
|
+
runModeIdRenameMigration(s);
|
|
49
|
+
expect(s.getItem('sh3.unrelated')).toBe('dev');
|
|
50
|
+
expect(s.getItem('random')).toBe('user');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -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 =
|
|
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 === '
|
|
85
|
-
toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === '
|
|
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,
|
|
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
|
|
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;
|
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
* Pure function (no Svelte reactivity) so it can be unit-tested independently.
|
|
5
5
|
* The mode is passed as a getter so the dispatch closure always sees the
|
|
6
6
|
* current mode without being reconstructed on every mode change.
|
|
7
|
+
*
|
|
8
|
+
* Returns a `{ dispatch, cancel }` handle so the caller can abort an
|
|
9
|
+
* in-flight custom-mode dispatch (e.g. when the user switches mode mid-stream).
|
|
7
10
|
*/
|
|
11
|
+
import { makeShellModeOutput } from './output';
|
|
8
12
|
export function makeDispatch(deps) {
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
let activeController = null;
|
|
14
|
+
async function dispatch(line) {
|
|
15
|
+
var _a, _b, _c, _d;
|
|
16
|
+
// Abort any in-flight custom dispatch when a new line is submitted.
|
|
17
|
+
activeController === null || activeController === void 0 ? void 0 : activeController.abort();
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
activeController = controller;
|
|
11
20
|
const mode = deps.mode();
|
|
12
21
|
deps.session.history.push(line);
|
|
13
22
|
// User-mode $ escape: block server-shell access
|
|
@@ -46,11 +55,55 @@ export function makeDispatch(deps) {
|
|
|
46
55
|
// forward path
|
|
47
56
|
if (mode.transport === 'ws') {
|
|
48
57
|
deps.session.send({ t: 'submit', line: resolution.line });
|
|
58
|
+
return;
|
|
49
59
|
}
|
|
50
|
-
|
|
51
|
-
const firstToken = (_a = resolution.line.split(/\s+/)[0]) !== null && _a !== void 0 ? _a : '';
|
|
60
|
+
if (mode.transport === 'custom') {
|
|
52
61
|
deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
|
|
53
|
-
deps.
|
|
62
|
+
const desc = (_b = (_a = deps.customMode) === null || _a === void 0 ? void 0 : _a.call(deps, mode.id)) !== null && _b !== void 0 ? _b : null;
|
|
63
|
+
if (!desc) {
|
|
64
|
+
deps.scrollback.push({
|
|
65
|
+
kind: 'status',
|
|
66
|
+
text: `mode '${mode.id}' is no longer available`,
|
|
67
|
+
level: 'error',
|
|
68
|
+
ts: Date.now(),
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (desc.runsOn === 'server') {
|
|
73
|
+
deps.scrollback.push({
|
|
74
|
+
kind: 'status',
|
|
75
|
+
text: 'server-side modes are not yet supported (planned for a future release)',
|
|
76
|
+
level: 'error',
|
|
77
|
+
ts: Date.now(),
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const output = makeShellModeOutput(deps.scrollback);
|
|
82
|
+
try {
|
|
83
|
+
await desc.dispatch({ line: resolution.line, cwd: deps.cwd(), signal: controller.signal }, output);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if ((err === null || err === void 0 ? void 0 : err.name) === 'AbortError') {
|
|
87
|
+
deps.scrollback.push({ kind: 'status', text: 'mode dispatch aborted', level: 'info', ts: Date.now() });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
deps.scrollback.push({
|
|
91
|
+
kind: 'status',
|
|
92
|
+
text: `mode '${mode.id}' threw — ${(_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : String(err)}`,
|
|
93
|
+
level: 'error',
|
|
94
|
+
ts: Date.now(),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
54
99
|
}
|
|
100
|
+
// 'none' transport, unknown verb: print error
|
|
101
|
+
const firstToken = (_d = resolution.line.split(/\s+/)[0]) !== null && _d !== void 0 ? _d : '';
|
|
102
|
+
deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
|
|
103
|
+
deps.scrollback.push({ kind: 'status', text: `unknown verb: ${firstToken}`, level: 'error', ts: Date.now() });
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
dispatch,
|
|
107
|
+
cancel: () => activeController === null || activeController === void 0 ? void 0 : activeController.abort(),
|
|
55
108
|
};
|
|
56
109
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ShellModeRegistry } from './registry';
|
|
2
2
|
import type { ShellMode } from './types';
|
|
3
|
-
export declare const
|
|
4
|
-
export declare const
|
|
3
|
+
export declare const BASH_MODE: ShellMode;
|
|
4
|
+
export declare const SH3_MODE: ShellMode;
|
|
5
5
|
export declare function registerBuiltinModes(reg: ShellModeRegistry): void;
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { ShellModeRegistry } from './registry';
|
|
2
|
-
export const
|
|
3
|
-
id: '
|
|
4
|
-
label: '
|
|
2
|
+
export const BASH_MODE = {
|
|
3
|
+
id: 'bash',
|
|
4
|
+
label: 'Bash',
|
|
5
5
|
requiresRole: 'admin',
|
|
6
6
|
transport: 'ws',
|
|
7
7
|
autoRelocate: false,
|
|
8
8
|
};
|
|
9
|
-
export const
|
|
10
|
-
id: '
|
|
11
|
-
label: '
|
|
9
|
+
export const SH3_MODE = {
|
|
10
|
+
id: 'sh3',
|
|
11
|
+
label: 'SH3',
|
|
12
12
|
transport: 'none',
|
|
13
13
|
autoRelocate: true,
|
|
14
14
|
};
|
|
15
15
|
export function registerBuiltinModes(reg) {
|
|
16
|
-
reg.register(
|
|
17
|
-
reg.register(
|
|
16
|
+
reg.register(BASH_MODE);
|
|
17
|
+
reg.register(SH3_MODE);
|
|
18
18
|
}
|
|
@@ -26,6 +26,6 @@ export function resolveInitialMode(reg, userId, role) {
|
|
|
26
26
|
if (m && (!m.requiresRole || m.requiresRole === role))
|
|
27
27
|
return m;
|
|
28
28
|
}
|
|
29
|
-
const fallback = role === 'admin' ? '
|
|
29
|
+
const fallback = role === 'admin' ? 'bash' : 'sh3';
|
|
30
30
|
return reg.get(fallback);
|
|
31
31
|
}
|