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.
- package/dist/api.d.ts +3 -0
- package/dist/api.js +3 -0
- package/dist/app/store/StoreView.svelte +15 -4
- package/dist/app/store/permissionConfirm.js +1 -2
- package/dist/app/store/storeApp.js +0 -1
- package/dist/app/store/storeShard.svelte.js +9 -18
- package/dist/app/store/storeTypes.d.ts +21 -0
- package/dist/app/store/storeTypes.js +33 -0
- package/dist/app/store/storeTypes.test.d.ts +1 -0
- package/dist/app/store/storeTypes.test.js +41 -0
- package/dist/app/store/updatePackage.test.js +1 -1
- package/dist/app/store/verbs.test.js +20 -17
- 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/overlays/FloatFrame.svelte +18 -1
- package/dist/overlays/float.d.ts +12 -0
- package/dist/overlays/float.js +16 -0
- package/dist/overlays/float.test.js +97 -2
- package/dist/overlays/modal.js +1 -0
- package/dist/overlays/modal.test.js +17 -0
- package/dist/overlays/parentHost.d.ts +1 -0
- package/dist/overlays/parentHost.js +15 -0
- package/dist/overlays/parentHost.test.d.ts +1 -0
- package/dist/overlays/parentHost.test.js +39 -0
- package/dist/overlays/popup.js +1 -0
- package/dist/overlays/popup.test.js +19 -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/app/store/InstalledView.svelte +0 -255
- package/dist/app/store/InstalledView.svelte.d.ts +0 -3
|
@@ -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
|
}
|
|
@@ -15,8 +15,8 @@ beforeEach(() => {
|
|
|
15
15
|
});
|
|
16
16
|
describe('readLastMode / writeLastMode', () => {
|
|
17
17
|
it('round-trips a mode id for a user', () => {
|
|
18
|
-
writeLastMode('alice', '
|
|
19
|
-
expect(readLastMode('alice')).toBe('
|
|
18
|
+
writeLastMode('alice', 'sh3');
|
|
19
|
+
expect(readLastMode('alice')).toBe('sh3');
|
|
20
20
|
});
|
|
21
21
|
it('returns null when nothing persisted', () => {
|
|
22
22
|
expect(readLastMode('bob')).toBeNull();
|
|
@@ -25,22 +25,22 @@ describe('readLastMode / writeLastMode', () => {
|
|
|
25
25
|
describe('resolveInitialMode', () => {
|
|
26
26
|
const reg = new ShellModeRegistry();
|
|
27
27
|
registerBuiltinModes(reg);
|
|
28
|
-
it('admin with no pref →
|
|
29
|
-
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('
|
|
28
|
+
it('admin with no pref → bash', () => {
|
|
29
|
+
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('bash');
|
|
30
30
|
});
|
|
31
|
-
it('user with no pref →
|
|
32
|
-
expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('
|
|
31
|
+
it('user with no pref → sh3', () => {
|
|
32
|
+
expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('sh3');
|
|
33
33
|
});
|
|
34
|
-
it('admin with persisted
|
|
35
|
-
writeLastMode('alice', '
|
|
36
|
-
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('
|
|
34
|
+
it('admin with persisted sh3 → sh3', () => {
|
|
35
|
+
writeLastMode('alice', 'sh3');
|
|
36
|
+
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('sh3');
|
|
37
37
|
});
|
|
38
|
-
it('user with persisted
|
|
39
|
-
writeLastMode('alice', '
|
|
40
|
-
expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('
|
|
38
|
+
it('user with persisted bash (not allowed) → falls back to sh3', () => {
|
|
39
|
+
writeLastMode('alice', 'bash');
|
|
40
|
+
expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('sh3');
|
|
41
41
|
});
|
|
42
42
|
it('persisted unknown id → role default', () => {
|
|
43
43
|
writeLastMode('alice', 'nonsense');
|
|
44
|
-
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('
|
|
44
|
+
expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('bash');
|
|
45
45
|
});
|
|
46
46
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { ShellModeRegistry } from './registry';
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const bash = { id: 'bash', label: 'Bash', requiresRole: 'admin', transport: 'ws', autoRelocate: false };
|
|
4
|
+
const sh3 = { id: 'sh3', label: 'SH3', transport: 'none', autoRelocate: true };
|
|
5
5
|
const ssh = { id: 'ssh', label: 'SSH', requiresRole: 'admin', transport: 'custom', autoRelocate: false };
|
|
6
6
|
describe('ShellModeRegistry', () => {
|
|
7
7
|
let reg;
|
|
@@ -9,27 +9,27 @@ describe('ShellModeRegistry', () => {
|
|
|
9
9
|
reg = new ShellModeRegistry();
|
|
10
10
|
});
|
|
11
11
|
it('registers and retrieves modes', () => {
|
|
12
|
-
reg.register(
|
|
13
|
-
expect(reg.get('
|
|
12
|
+
reg.register(bash);
|
|
13
|
+
expect(reg.get('bash')).toEqual(bash);
|
|
14
14
|
});
|
|
15
15
|
it('list(user) excludes admin-only modes', () => {
|
|
16
|
-
reg.register(
|
|
17
|
-
reg.register(
|
|
16
|
+
reg.register(bash);
|
|
17
|
+
reg.register(sh3);
|
|
18
18
|
reg.register(ssh);
|
|
19
19
|
const ids = reg.list('user').map((m) => m.id);
|
|
20
|
-
expect(ids).toEqual(['
|
|
20
|
+
expect(ids).toEqual(['sh3']);
|
|
21
21
|
});
|
|
22
22
|
it('list(admin) includes all modes', () => {
|
|
23
|
-
reg.register(
|
|
24
|
-
reg.register(
|
|
23
|
+
reg.register(bash);
|
|
24
|
+
reg.register(sh3);
|
|
25
25
|
reg.register(ssh);
|
|
26
26
|
const ids = reg.list('admin').map((m) => m.id).sort();
|
|
27
|
-
expect(ids).toEqual(['
|
|
27
|
+
expect(ids).toEqual(['bash', 'sh3', 'ssh']);
|
|
28
28
|
});
|
|
29
29
|
it('re-registering same id replaces the mode', () => {
|
|
30
30
|
var _a;
|
|
31
|
-
reg.register(
|
|
32
|
-
reg.register(Object.assign(Object.assign({},
|
|
33
|
-
expect((_a = reg.get('
|
|
31
|
+
reg.register(bash);
|
|
32
|
+
reg.register(Object.assign(Object.assign({}, bash), { label: 'Bash+' }));
|
|
33
|
+
expect((_a = reg.get('bash')) === null || _a === void 0 ? void 0 : _a.label).toBe('Bash+');
|
|
34
34
|
});
|
|
35
35
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* makeShellModeOutput — wraps a Scrollback in the typed ShellModeOutput
|
|
3
|
+
* surface external mode shards consume. The framework is the only writer to
|
|
4
|
+
* scrollback shapes; mode authors talk to this handle.
|
|
5
|
+
*
|
|
6
|
+
* Streaming entries carry a `__streamState` prop set to 'streaming' on push,
|
|
7
|
+
* 'complete' on complete(), 'error' on error(). The entry's component is
|
|
8
|
+
* expected to read this prop (or ignore it — it's optional).
|
|
9
|
+
*/
|
|
10
|
+
function findRich(sb, entryId) {
|
|
11
|
+
return sb.entries.find((e) => e.kind === 'rich' && e.id === entryId);
|
|
12
|
+
}
|
|
13
|
+
function makeRichHandle(sb, entryId) {
|
|
14
|
+
return {
|
|
15
|
+
update(patch) {
|
|
16
|
+
const entry = findRich(sb, entryId);
|
|
17
|
+
if (!entry)
|
|
18
|
+
return;
|
|
19
|
+
Object.assign(entry.props, patch);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function lastRichId(sb) {
|
|
24
|
+
const last = sb.entries[sb.entries.length - 1];
|
|
25
|
+
return last.id;
|
|
26
|
+
}
|
|
27
|
+
export function makeShellModeOutput(sb) {
|
|
28
|
+
return {
|
|
29
|
+
text(stream, chunk) {
|
|
30
|
+
sb.push({ kind: 'text', stream, chunks: [chunk], ts: Date.now() });
|
|
31
|
+
},
|
|
32
|
+
status(level, msg) {
|
|
33
|
+
sb.push({ kind: 'status', text: msg, level, ts: Date.now() });
|
|
34
|
+
},
|
|
35
|
+
rich(component, props) {
|
|
36
|
+
sb.push({ kind: 'rich', component, props: Object.assign({}, props), ts: Date.now() });
|
|
37
|
+
return makeRichHandle(sb, lastRichId(sb));
|
|
38
|
+
},
|
|
39
|
+
stream(component, initialProps) {
|
|
40
|
+
sb.push({
|
|
41
|
+
kind: 'rich',
|
|
42
|
+
component,
|
|
43
|
+
props: Object.assign(Object.assign({}, initialProps), { __streamState: 'streaming' }),
|
|
44
|
+
ts: Date.now(),
|
|
45
|
+
});
|
|
46
|
+
const id = lastRichId(sb);
|
|
47
|
+
return {
|
|
48
|
+
append(patch) {
|
|
49
|
+
const entry = findRich(sb, id);
|
|
50
|
+
if (!entry)
|
|
51
|
+
return;
|
|
52
|
+
Object.assign(entry.props, patch);
|
|
53
|
+
},
|
|
54
|
+
complete() {
|
|
55
|
+
const entry = findRich(sb, id);
|
|
56
|
+
if (!entry)
|
|
57
|
+
return;
|
|
58
|
+
entry.props.__streamState = 'complete';
|
|
59
|
+
},
|
|
60
|
+
error(err) {
|
|
61
|
+
var _a;
|
|
62
|
+
const entry = findRich(sb, id);
|
|
63
|
+
if (entry)
|
|
64
|
+
entry.props.__streamState = 'error';
|
|
65
|
+
sb.push({
|
|
66
|
+
kind: 'status',
|
|
67
|
+
text: `mode: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err)}`,
|
|
68
|
+
level: 'error',
|
|
69
|
+
ts: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Scrollback } from './scrollback.svelte';
|
|
3
|
+
import { makeShellModeOutput } from './output';
|
|
4
|
+
const FakeComponent = (() => { });
|
|
5
|
+
describe('makeShellModeOutput', () => {
|
|
6
|
+
it('text() pushes coalescing text entries', () => {
|
|
7
|
+
const sb = new Scrollback();
|
|
8
|
+
const out = makeShellModeOutput(sb);
|
|
9
|
+
out.text('stdout', 'hello ');
|
|
10
|
+
out.text('stdout', 'world');
|
|
11
|
+
const text = sb.entries.filter((e) => e.kind === 'text');
|
|
12
|
+
expect(text).toHaveLength(1);
|
|
13
|
+
expect(text[0].chunks.join('')).toBe('hello world');
|
|
14
|
+
});
|
|
15
|
+
it('status() pushes a status entry with the right level', () => {
|
|
16
|
+
const sb = new Scrollback();
|
|
17
|
+
const out = makeShellModeOutput(sb);
|
|
18
|
+
out.status('warn', 'careful');
|
|
19
|
+
const s = sb.entries.find((e) => e.kind === 'status');
|
|
20
|
+
expect(s).toBeDefined();
|
|
21
|
+
expect(s.level).toBe('warn');
|
|
22
|
+
expect(s.text).toBe('careful');
|
|
23
|
+
});
|
|
24
|
+
it('rich().update() mutates the live entry props', () => {
|
|
25
|
+
const sb = new Scrollback();
|
|
26
|
+
const out = makeShellModeOutput(sb);
|
|
27
|
+
const handle = out.rich(FakeComponent, { tokens: 'a' });
|
|
28
|
+
handle.update({ tokens: 'ab' });
|
|
29
|
+
const entry = sb.entries.find((e) => e.kind === 'rich');
|
|
30
|
+
expect(entry.props.tokens).toBe('ab');
|
|
31
|
+
});
|
|
32
|
+
it('stream() marks the entry mid-stream until complete()', () => {
|
|
33
|
+
const sb = new Scrollback();
|
|
34
|
+
const out = makeShellModeOutput(sb);
|
|
35
|
+
const h = out.stream(FakeComponent, { tokens: '' });
|
|
36
|
+
let entry = sb.entries.find((e) => e.kind === 'rich');
|
|
37
|
+
expect(entry.props.__streamState).toBe('streaming');
|
|
38
|
+
h.append({ tokens: 'a' });
|
|
39
|
+
expect(entry.props.tokens).toBe('a');
|
|
40
|
+
h.complete();
|
|
41
|
+
expect(entry.props.__streamState).toBe('complete');
|
|
42
|
+
});
|
|
43
|
+
it('stream().error() marks the entry errored and pushes a status', () => {
|
|
44
|
+
const sb = new Scrollback();
|
|
45
|
+
const out = makeShellModeOutput(sb);
|
|
46
|
+
const h = out.stream(FakeComponent, { tokens: '' });
|
|
47
|
+
h.error(new Error('boom'));
|
|
48
|
+
const entry = sb.entries.find((e) => e.kind === 'rich');
|
|
49
|
+
expect(entry.props.__streamState).toBe('error');
|
|
50
|
+
const s = sb.entries.find((e) => e.kind === 'status' && e.level === 'error');
|
|
51
|
+
expect(s).toBeDefined();
|
|
52
|
+
expect(s.text).toMatch(/boom/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ShardContext } from '../shards/types';
|
|
2
|
+
import { type ShellModeDescriptor } from './contract';
|
|
3
|
+
/**
|
|
4
|
+
* Register a shell-mode descriptor from a shard's `activate(ctx)`. The mode
|
|
5
|
+
* appears in the picker and in `mode` verb output; submitted lines that don't
|
|
6
|
+
* match a local verb route to `descriptor.dispatch(input, output)`.
|
|
7
|
+
*
|
|
8
|
+
* Thin wrapper around `ctx.contributions.register` so authors get
|
|
9
|
+
* type-checking without `ShardContext` growing another method. Returns the
|
|
10
|
+
* upstream disposer; calling it is optional — the framework auto-unregisters
|
|
11
|
+
* when the owning shard deactivates.
|
|
12
|
+
*/
|
|
13
|
+
export declare function registerShellMode(ctx: ShardContext, descriptor: ShellModeDescriptor): () => void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SHELL_MODE_CONTRIBUTION_POINT } from './contract';
|
|
2
|
+
/**
|
|
3
|
+
* Register a shell-mode descriptor from a shard's `activate(ctx)`. The mode
|
|
4
|
+
* appears in the picker and in `mode` verb output; submitted lines that don't
|
|
5
|
+
* match a local verb route to `descriptor.dispatch(input, output)`.
|
|
6
|
+
*
|
|
7
|
+
* Thin wrapper around `ctx.contributions.register` so authors get
|
|
8
|
+
* type-checking without `ShardContext` growing another method. Returns the
|
|
9
|
+
* upstream disposer; calling it is optional — the framework auto-unregisters
|
|
10
|
+
* when the owning shard deactivates.
|
|
11
|
+
*/
|
|
12
|
+
export function registerShellMode(ctx, descriptor) {
|
|
13
|
+
return ctx.contributions.register(SHELL_MODE_CONTRIBUTION_POINT, descriptor);
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { registerShellMode } from './registerShellMode';
|
|
3
|
+
import { SHELL_MODE_CONTRIBUTION_POINT } from './contract';
|
|
4
|
+
describe('registerShellMode', () => {
|
|
5
|
+
it('registers under the canonical contribution point id', () => {
|
|
6
|
+
const dispose = vi.fn();
|
|
7
|
+
const register = vi.fn(() => dispose);
|
|
8
|
+
const ctx = { contributions: { register } };
|
|
9
|
+
const desc = {
|
|
10
|
+
id: 'gemini',
|
|
11
|
+
label: 'Gemini',
|
|
12
|
+
runsOn: 'client',
|
|
13
|
+
dispatch: async () => { },
|
|
14
|
+
};
|
|
15
|
+
const ret = registerShellMode(ctx, desc);
|
|
16
|
+
expect(register).toHaveBeenCalledWith(SHELL_MODE_CONTRIBUTION_POINT, desc);
|
|
17
|
+
expect(ret).toBe(dispose);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -163,6 +163,13 @@ function makeShellApi(_ctx) {
|
|
|
163
163
|
admin: isAdmin(),
|
|
164
164
|
};
|
|
165
165
|
},
|
|
166
|
+
// Mode switching is per-view state owned by Terminal.svelte; the base
|
|
167
|
+
// ShellApi cannot reach it from the shard scope. Terminal.svelte wraps
|
|
168
|
+
// this object and overrides setMode/listModes with the live registry +
|
|
169
|
+
// setMode closure. Verbs called outside a terminal context fall through
|
|
170
|
+
// these stubs (no-op switch, empty list).
|
|
171
|
+
setMode(_id) { return false; },
|
|
172
|
+
listModes() { return []; },
|
|
166
173
|
};
|
|
167
174
|
}
|
|
168
175
|
/**
|
|
@@ -205,7 +212,7 @@ export const shellShard = {
|
|
|
205
212
|
const role = isAdmin() ? 'admin' : 'user';
|
|
206
213
|
const instance = mount(Terminal, {
|
|
207
214
|
target: container,
|
|
208
|
-
props: { shell, wsUrl, userId, role },
|
|
215
|
+
props: { shell, wsUrl, userId, role, contributions: ctx.contributions },
|
|
209
216
|
});
|
|
210
217
|
return {
|
|
211
218
|
unmount() {
|
|
@@ -3,9 +3,9 @@ import { makeDispatch } from './dispatch';
|
|
|
3
3
|
function scaffold(modeId) {
|
|
4
4
|
const sent = [];
|
|
5
5
|
const pushed = [];
|
|
6
|
-
const mode = modeId === '
|
|
7
|
-
? { id: '
|
|
8
|
-
: { id: '
|
|
6
|
+
const mode = modeId === 'bash'
|
|
7
|
+
? { id: 'bash', label: 'Bash', transport: 'ws', autoRelocate: false, requiresRole: 'admin' }
|
|
8
|
+
: { id: 'sh3', label: 'SH3', transport: 'none', autoRelocate: true };
|
|
9
9
|
const scrollback = { push: (e) => pushed.push(e) };
|
|
10
10
|
const session = {
|
|
11
11
|
history: { push: vi.fn() },
|
|
@@ -19,7 +19,7 @@ function scaffold(modeId) {
|
|
|
19
19
|
? { kind: 'local', verb: { name: 'pwd', run: async () => { } }, args: [], line }
|
|
20
20
|
: { kind: 'forward', line },
|
|
21
21
|
};
|
|
22
|
-
const dispatch = makeDispatch({
|
|
22
|
+
const { dispatch } = makeDispatch({
|
|
23
23
|
mode: () => mode,
|
|
24
24
|
resolver,
|
|
25
25
|
scrollback,
|
|
@@ -30,23 +30,23 @@ function scaffold(modeId) {
|
|
|
30
30
|
});
|
|
31
31
|
return { dispatch, sent, pushed };
|
|
32
32
|
}
|
|
33
|
-
describe('dispatch —
|
|
33
|
+
describe('dispatch — sh3 mode', () => {
|
|
34
34
|
it('unknown verbs print error, do not send', async () => {
|
|
35
|
-
const { dispatch, sent, pushed } = scaffold('
|
|
35
|
+
const { dispatch, sent, pushed } = scaffold('sh3');
|
|
36
36
|
await dispatch('foo');
|
|
37
37
|
expect(sent).toEqual([]);
|
|
38
38
|
expect(pushed.some((e) => e.kind === 'status' && /unknown verb/.test(e.text))).toBe(true);
|
|
39
39
|
});
|
|
40
40
|
it('$ escape prints error', async () => {
|
|
41
|
-
const { dispatch, sent, pushed } = scaffold('
|
|
41
|
+
const { dispatch, sent, pushed } = scaffold('sh3');
|
|
42
42
|
await dispatch('$ ls');
|
|
43
43
|
expect(sent).toEqual([]);
|
|
44
44
|
expect(pushed.some((e) => e.kind === 'status' && /server shell not available/.test(e.text))).toBe(true);
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
|
-
describe('dispatch —
|
|
47
|
+
describe('dispatch — bash mode', () => {
|
|
48
48
|
it('forwards unknown verbs to server', async () => {
|
|
49
|
-
const { dispatch, sent } = scaffold('
|
|
49
|
+
const { dispatch, sent } = scaffold('bash');
|
|
50
50
|
await dispatch('foo');
|
|
51
51
|
expect(sent.some((m) => m.t === 'submit' && m.line === 'foo')).toBe(true);
|
|
52
52
|
});
|
|
@@ -1,67 +1,27 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type { ShellMode
|
|
3
|
-
import
|
|
2
|
+
import type { ShellMode } from '../../modes/types';
|
|
3
|
+
import Segmented from '../../../primitives/widgets/Segmented.svelte';
|
|
4
|
+
import type { SegmentedOption } from '../../../primitives/widgets/Segmented';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
mode: ShellMode;
|
|
7
|
-
|
|
8
|
-
registry: ShellModeRegistry;
|
|
8
|
+
modes: ShellMode[];
|
|
9
9
|
onSelect: (id: string) => void;
|
|
10
10
|
}
|
|
11
|
+
let { mode, modes, onSelect }: Props = $props();
|
|
11
12
|
|
|
12
|
-
let
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
let options: SegmentedOption[] = $derived(
|
|
14
|
+
modes.map((m) => ({ value: m.id, label: m.label })),
|
|
15
|
+
);
|
|
15
16
|
</script>
|
|
16
17
|
|
|
17
|
-
{#if
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
<button
|
|
21
|
-
type="button"
|
|
22
|
-
class="mode-btn"
|
|
23
|
-
class:active={m.id === mode.id}
|
|
24
|
-
aria-pressed={m.id === mode.id}
|
|
25
|
-
onclick={() => onSelect(m.id)}
|
|
26
|
-
>
|
|
27
|
-
{m.label}
|
|
28
|
-
</button>
|
|
29
|
-
{/each}
|
|
30
|
-
</div>
|
|
31
|
-
{:else}
|
|
18
|
+
{#if modes.length >= 2}
|
|
19
|
+
<Segmented {options} value={mode.id} size="sm" onchange={(next) => onSelect(next)} />
|
|
20
|
+
{:else if modes.length === 1}
|
|
32
21
|
<span class="mode-label">{mode.label}</span>
|
|
33
22
|
{/if}
|
|
34
23
|
|
|
35
24
|
<style>
|
|
36
|
-
.mode-bar {
|
|
37
|
-
display: inline-flex;
|
|
38
|
-
gap: 2px;
|
|
39
|
-
padding: 1px;
|
|
40
|
-
border: 1px solid var(--shell-border, #444);
|
|
41
|
-
border-radius: 3px;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
.mode-btn {
|
|
45
|
-
background: none;
|
|
46
|
-
border: none;
|
|
47
|
-
color: var(--shell-fg-dim, var(--shell-fg-muted, #888));
|
|
48
|
-
padding: 2px 8px;
|
|
49
|
-
border-radius: 2px;
|
|
50
|
-
cursor: pointer;
|
|
51
|
-
font-size: 0.85em;
|
|
52
|
-
line-height: 1.4;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.mode-btn:hover {
|
|
56
|
-
background: var(--shell-hover, color-mix(in srgb, var(--shell-fg, #ddd) 10%, transparent));
|
|
57
|
-
color: var(--shell-fg, #ddd);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
.mode-btn.active {
|
|
61
|
-
background: var(--shell-accent, #7c7cf0);
|
|
62
|
-
color: var(--shell-bg, #1a1a2e);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
25
|
.mode-label {
|
|
66
26
|
font-size: 0.85em;
|
|
67
27
|
color: var(--shell-fg-dim, var(--shell-fg-muted, #888));
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import type { ShellMode
|
|
2
|
-
import type { ShellModeRegistry } from '../../modes/registry';
|
|
1
|
+
import type { ShellMode } from '../../modes/types';
|
|
3
2
|
interface Props {
|
|
4
3
|
mode: ShellMode;
|
|
5
|
-
|
|
6
|
-
registry: ShellModeRegistry;
|
|
4
|
+
modes: ShellMode[];
|
|
7
5
|
onSelect: (id: string) => void;
|
|
8
6
|
}
|
|
9
7
|
declare const ModeSlot: import("svelte").Component<Props, {}, "">;
|
|
@@ -2,27 +2,27 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { ToolbarSlotRegistry } from './slots';
|
|
3
3
|
const A = { id: 'a', order: 20, visible: () => true, component: {} };
|
|
4
4
|
const B = { id: 'b', order: 10, visible: () => true, component: {} };
|
|
5
|
-
const C = { id: 'c', order: 30, visible: (ctx) => ctx.mode.id === '
|
|
6
|
-
const
|
|
7
|
-
const
|
|
5
|
+
const C = { id: 'c', order: 30, visible: (ctx) => ctx.mode.id === 'sh3', component: {} };
|
|
6
|
+
const sh3Ctx = { mode: { id: 'sh3', label: 'SH3', transport: 'none', autoRelocate: true }, role: 'user' };
|
|
7
|
+
const bashCtx = { mode: { id: 'bash', label: 'Bash', transport: 'ws', autoRelocate: false, requiresRole: 'admin' }, role: 'admin' };
|
|
8
8
|
describe('ToolbarSlotRegistry', () => {
|
|
9
9
|
it('lists visible slots in order', () => {
|
|
10
10
|
const r = new ToolbarSlotRegistry();
|
|
11
11
|
r.register(A);
|
|
12
12
|
r.register(B);
|
|
13
13
|
r.register(C);
|
|
14
|
-
expect(r.list(
|
|
14
|
+
expect(r.list(sh3Ctx).map((s) => s.id)).toEqual(['b', 'a', 'c']);
|
|
15
15
|
});
|
|
16
16
|
it('filters by visible', () => {
|
|
17
17
|
const r = new ToolbarSlotRegistry();
|
|
18
18
|
r.register(A);
|
|
19
19
|
r.register(C);
|
|
20
|
-
expect(r.list(
|
|
20
|
+
expect(r.list(bashCtx).map((s) => s.id)).toEqual(['a']);
|
|
21
21
|
});
|
|
22
22
|
it('re-registering by id replaces', () => {
|
|
23
23
|
const r = new ToolbarSlotRegistry();
|
|
24
24
|
r.register(A);
|
|
25
25
|
r.register(Object.assign(Object.assign({}, A), { order: 99 }));
|
|
26
|
-
expect(r.list(
|
|
26
|
+
expect(r.list(sh3Ctx)[0].order).toBe(99);
|
|
27
27
|
});
|
|
28
28
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { makeHelpVerb } from './help';
|
|
6
6
|
import { clearVerb } from './clear';
|
|
7
|
+
import { modeVerb } from './mode';
|
|
7
8
|
import { historyVerb } from './history';
|
|
8
9
|
import { appsVerb, appVerb } from './apps';
|
|
9
10
|
import { shardsVerb } from './shards';
|
|
@@ -17,6 +18,7 @@ import { resetVerb } from './reset';
|
|
|
17
18
|
export function registerV1Verbs(ctx) {
|
|
18
19
|
ctx.registerVerb(makeHelpVerb());
|
|
19
20
|
ctx.registerVerb(clearVerb);
|
|
21
|
+
ctx.registerVerb(modeVerb);
|
|
20
22
|
ctx.registerVerb(historyVerb);
|
|
21
23
|
ctx.registerVerb(appsVerb);
|
|
22
24
|
ctx.registerVerb(appVerb);
|