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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { makeDispatch } from './dispatch';
|
|
3
|
+
const openVerb = {
|
|
4
|
+
name: 'open',
|
|
5
|
+
summary: '',
|
|
6
|
+
async run(ctx, args) {
|
|
7
|
+
var _a;
|
|
8
|
+
ctx.scrollback.push({ kind: 'status', text: `opened:${(_a = args[0]) !== null && _a !== void 0 ? _a : ''}`, level: 'info', ts: 0 });
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
function scaffold(opts) {
|
|
12
|
+
const sent = [];
|
|
13
|
+
const pushed = [];
|
|
14
|
+
const connectSpy = vi.fn();
|
|
15
|
+
const scrollback = { push: (e) => pushed.push(e), clear: () => { } };
|
|
16
|
+
const session = {
|
|
17
|
+
history: { push: vi.fn() },
|
|
18
|
+
send: (m) => sent.push(m),
|
|
19
|
+
cwd: '/',
|
|
20
|
+
connect: connectSpy,
|
|
21
|
+
};
|
|
22
|
+
const fs = {};
|
|
23
|
+
const shell = {};
|
|
24
|
+
const resolver = {
|
|
25
|
+
resolve: (line, _opts = {}) => {
|
|
26
|
+
const head = line.trim().split(/\s+/)[0];
|
|
27
|
+
const rest = line.trim().split(/\s+/).slice(1);
|
|
28
|
+
if (head === 'open')
|
|
29
|
+
return { kind: 'local', verb: openVerb, args: rest, line };
|
|
30
|
+
return { kind: 'forward', line };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const { dispatch } = makeDispatch({
|
|
34
|
+
mode: () => opts.current,
|
|
35
|
+
role: () => opts.role,
|
|
36
|
+
resolver,
|
|
37
|
+
scrollback,
|
|
38
|
+
session,
|
|
39
|
+
shell,
|
|
40
|
+
fs,
|
|
41
|
+
cwd: () => '/',
|
|
42
|
+
busy: () => () => { },
|
|
43
|
+
customMode: (id) => { var _a, _b; return (_b = (_a = opts.customs) === null || _a === void 0 ? void 0 : _a.find((d) => d.id === id)) !== null && _b !== void 0 ? _b : null; },
|
|
44
|
+
});
|
|
45
|
+
return { dispatch, sent, pushed, connectSpy };
|
|
46
|
+
}
|
|
47
|
+
const customMode = (id) => ({ id, label: id, transport: 'custom', autoRelocate: false });
|
|
48
|
+
describe('output.invoke — sh3 target', () => {
|
|
49
|
+
it('runs an sh3 verb when invoked from a custom mode', async () => {
|
|
50
|
+
const captured = [];
|
|
51
|
+
const customs = [{
|
|
52
|
+
id: 'gemini',
|
|
53
|
+
label: 'Gemini',
|
|
54
|
+
runsOn: 'client',
|
|
55
|
+
dispatch: async (_input, output) => {
|
|
56
|
+
await output.invoke('sh3', 'open foo.md');
|
|
57
|
+
captured.push('after-invoke');
|
|
58
|
+
},
|
|
59
|
+
}];
|
|
60
|
+
const { dispatch, pushed } = scaffold({
|
|
61
|
+
current: customMode('gemini'),
|
|
62
|
+
role: 'user',
|
|
63
|
+
customs,
|
|
64
|
+
});
|
|
65
|
+
await dispatch('hello');
|
|
66
|
+
expect(pushed.some((e) => e.kind === 'status' && e.text === 'opened:foo.md')).toBe(true);
|
|
67
|
+
expect(captured).toEqual(['after-invoke']);
|
|
68
|
+
});
|
|
69
|
+
it('throws for unknown sh3 verb', async () => {
|
|
70
|
+
let caught;
|
|
71
|
+
const customs = [{
|
|
72
|
+
id: 'gemini',
|
|
73
|
+
label: 'Gemini',
|
|
74
|
+
runsOn: 'client',
|
|
75
|
+
dispatch: async (_input, output) => {
|
|
76
|
+
try {
|
|
77
|
+
await output.invoke('sh3', 'nonexistent');
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
caught = e;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}];
|
|
84
|
+
const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
|
|
85
|
+
await dispatch('hello');
|
|
86
|
+
expect(caught).toBeInstanceOf(Error);
|
|
87
|
+
expect(caught.message).toMatch(/unknown sh3 verb/);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('output.invoke — bash target', () => {
|
|
91
|
+
it('admin invocation lazy-connects and forwards', async () => {
|
|
92
|
+
const customs = [{
|
|
93
|
+
id: 'gemini',
|
|
94
|
+
label: 'Gemini',
|
|
95
|
+
runsOn: 'client',
|
|
96
|
+
dispatch: async (_input, output) => {
|
|
97
|
+
await output.invoke('bash', 'ls');
|
|
98
|
+
},
|
|
99
|
+
}];
|
|
100
|
+
const { dispatch, sent, connectSpy } = scaffold({
|
|
101
|
+
current: customMode('gemini'),
|
|
102
|
+
role: 'admin',
|
|
103
|
+
customs,
|
|
104
|
+
});
|
|
105
|
+
await dispatch('hello');
|
|
106
|
+
expect(connectSpy).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(sent.some((m) => m.t === 'submit' && m.line === 'ls')).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('lazy-connect only fires once across multiple invokes', async () => {
|
|
110
|
+
const customs = [{
|
|
111
|
+
id: 'gemini',
|
|
112
|
+
label: 'Gemini',
|
|
113
|
+
runsOn: 'client',
|
|
114
|
+
dispatch: async (_input, output) => {
|
|
115
|
+
await output.invoke('bash', 'ls');
|
|
116
|
+
await output.invoke('bash', 'pwd');
|
|
117
|
+
},
|
|
118
|
+
}];
|
|
119
|
+
const { dispatch, connectSpy } = scaffold({
|
|
120
|
+
current: customMode('gemini'),
|
|
121
|
+
role: 'admin',
|
|
122
|
+
customs,
|
|
123
|
+
});
|
|
124
|
+
await dispatch('hello');
|
|
125
|
+
expect(connectSpy).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
it('non-admin invoking bash throws', async () => {
|
|
128
|
+
let caught;
|
|
129
|
+
const customs = [{
|
|
130
|
+
id: 'gemini',
|
|
131
|
+
label: 'Gemini',
|
|
132
|
+
runsOn: 'client',
|
|
133
|
+
dispatch: async (_input, output) => {
|
|
134
|
+
try {
|
|
135
|
+
await output.invoke('bash', 'ls');
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
caught = e;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}];
|
|
142
|
+
const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
|
|
143
|
+
await dispatch('hello');
|
|
144
|
+
expect(caught).toBeInstanceOf(Error);
|
|
145
|
+
expect(caught.message).toMatch(/admin role/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('output.invoke — guards', () => {
|
|
149
|
+
it('self-invoke throws', async () => {
|
|
150
|
+
let caught;
|
|
151
|
+
const customs = [{
|
|
152
|
+
id: 'gemini',
|
|
153
|
+
label: 'Gemini',
|
|
154
|
+
runsOn: 'client',
|
|
155
|
+
dispatch: async (_input, output) => {
|
|
156
|
+
try {
|
|
157
|
+
await output.invoke('gemini', 'foo');
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
caught = e;
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
}];
|
|
164
|
+
const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
|
|
165
|
+
await dispatch('hello');
|
|
166
|
+
expect(caught).toBeInstanceOf(Error);
|
|
167
|
+
expect(caught.message).toMatch(/cannot invoke own mode/);
|
|
168
|
+
});
|
|
169
|
+
it('unknown custom mode throws', async () => {
|
|
170
|
+
let caught;
|
|
171
|
+
const customs = [{
|
|
172
|
+
id: 'gemini',
|
|
173
|
+
label: 'Gemini',
|
|
174
|
+
runsOn: 'client',
|
|
175
|
+
dispatch: async (_input, output) => {
|
|
176
|
+
try {
|
|
177
|
+
await output.invoke('claude', 'foo');
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
caught = e;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}];
|
|
184
|
+
const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
|
|
185
|
+
await dispatch('hello');
|
|
186
|
+
expect(caught).toBeInstanceOf(Error);
|
|
187
|
+
expect(caught.message).toMatch(/unknown mode/);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
describe('output.invoke — custom target', () => {
|
|
191
|
+
it('routes through the target descriptor dispatch with the same scrollback', async () => {
|
|
192
|
+
const customs = [
|
|
193
|
+
{
|
|
194
|
+
id: 'gemini',
|
|
195
|
+
label: 'Gemini',
|
|
196
|
+
runsOn: 'client',
|
|
197
|
+
dispatch: async (_input, output) => {
|
|
198
|
+
await output.invoke('claude', 'hi');
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'claude',
|
|
203
|
+
label: 'Claude',
|
|
204
|
+
runsOn: 'client',
|
|
205
|
+
dispatch: async (input, output) => {
|
|
206
|
+
output.text('stdout', `claude:${input.line}\n`);
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
const { dispatch, pushed } = scaffold({ current: customMode('gemini'), role: 'user', customs });
|
|
211
|
+
await dispatch('hello');
|
|
212
|
+
expect(pushed.some((e) => { var _a, _b; return e.kind === 'text' && ((_b = (_a = e.chunks) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.includes('claude:hi')); })).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -2,14 +2,35 @@ import type { VerbRegistry, ShellApi } from './registry';
|
|
|
2
2
|
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
|
-
import type { ShellMode } from './modes/types';
|
|
5
|
+
import type { ShellMode, ShellRole } from './modes/types';
|
|
6
|
+
import type { ShellModeDescriptor } from './contract';
|
|
6
7
|
export interface DispatchDeps {
|
|
7
8
|
mode: () => ShellMode;
|
|
9
|
+
/** Current shell role — used by invoke() role-gating. */
|
|
10
|
+
role: () => ShellRole;
|
|
8
11
|
resolver: VerbRegistry;
|
|
9
12
|
scrollback: Scrollback;
|
|
10
13
|
session: SessionClient;
|
|
11
14
|
shell: ShellApi;
|
|
12
15
|
fs: TenantFsClient;
|
|
13
16
|
cwd: () => string;
|
|
17
|
+
/**
|
|
18
|
+
* Acquire a busy indicator. Returns a clear handle. Calling clear()
|
|
19
|
+
* multiple times is safe (idempotent). Used internally to auto-spawn
|
|
20
|
+
* a spinner around custom-mode dispatch and exposed via output.busy().
|
|
21
|
+
*/
|
|
22
|
+
busy: (label?: string) => () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Look up a contributed mode descriptor by id. Called only when the active
|
|
25
|
+
* mode has `transport: 'custom'`. Returns null if the descriptor has been
|
|
26
|
+
* unloaded (rare race; the active-mode-fallback effect in Terminal.svelte
|
|
27
|
+
* handles this on the next tick — dispatch surfaces an error in the meantime).
|
|
28
|
+
*/
|
|
29
|
+
customMode?: (id: string) => ShellModeDescriptor | null;
|
|
14
30
|
}
|
|
15
|
-
export
|
|
31
|
+
export interface DispatchHandle {
|
|
32
|
+
dispatch: (line: string) => Promise<void>;
|
|
33
|
+
/** Abort any in-flight custom-mode dispatch. Safe to call repeatedly. */
|
|
34
|
+
cancel: () => void;
|
|
35
|
+
}
|
|
36
|
+
export declare function makeDispatch(deps: DispatchDeps): DispatchHandle;
|
|
@@ -4,10 +4,80 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* Programmatic cross-mode dispatch. Validates target id, role gate, and
|
|
16
|
+
* self-invoke; routes to sh3 (full local resolution), bash (WS forward
|
|
17
|
+
* with lazy connect), or a custom descriptor (recursive desc.dispatch).
|
|
18
|
+
*
|
|
19
|
+
* sh3 path bypasses mode gating: a verb's globalVerb flag is irrelevant
|
|
20
|
+
* because the caller is a trusted mode shard, not user input from a
|
|
21
|
+
* different mode.
|
|
22
|
+
*/
|
|
23
|
+
let bashConnected = false;
|
|
24
|
+
async function invoke(modeId, line) {
|
|
25
|
+
var _a, _b, _c, _d;
|
|
26
|
+
const current = deps.mode();
|
|
27
|
+
if (modeId === current.id) {
|
|
28
|
+
throw new Error(`invoke: cannot invoke own mode '${modeId}'`);
|
|
29
|
+
}
|
|
30
|
+
if (modeId === 'sh3') {
|
|
31
|
+
const resolution = deps.resolver.resolve(line, { globalOnly: false });
|
|
32
|
+
if (resolution.kind === 'forward') {
|
|
33
|
+
const head = (_a = line.trim().split(/\s+/)[0]) !== null && _a !== void 0 ? _a : '';
|
|
34
|
+
throw new Error(`invoke: unknown sh3 verb '${head}'`);
|
|
35
|
+
}
|
|
36
|
+
await resolution.verb.run({
|
|
37
|
+
shell: deps.shell,
|
|
38
|
+
scrollback: deps.scrollback,
|
|
39
|
+
session: deps.session,
|
|
40
|
+
cwd: deps.cwd(),
|
|
41
|
+
dispatch,
|
|
42
|
+
fs: deps.fs,
|
|
43
|
+
}, resolution.args);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (modeId === 'bash') {
|
|
47
|
+
if (deps.role() !== 'admin') {
|
|
48
|
+
throw new Error("invoke: 'bash' requires admin role");
|
|
49
|
+
}
|
|
50
|
+
if (!bashConnected) {
|
|
51
|
+
bashConnected = true;
|
|
52
|
+
deps.session.connect();
|
|
53
|
+
}
|
|
54
|
+
deps.session.send({ t: 'submit', line });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Custom mode
|
|
58
|
+
const desc = (_c = (_b = deps.customMode) === null || _b === void 0 ? void 0 : _b.call(deps, modeId)) !== null && _c !== void 0 ? _c : null;
|
|
59
|
+
if (!desc) {
|
|
60
|
+
throw new Error(`invoke: unknown mode '${modeId}'`);
|
|
61
|
+
}
|
|
62
|
+
if (desc.requiresRole && desc.requiresRole !== deps.role()) {
|
|
63
|
+
throw new Error(`invoke: mode '${modeId}' requires ${desc.requiresRole} role`);
|
|
64
|
+
}
|
|
65
|
+
if (desc.runsOn === 'server') {
|
|
66
|
+
throw new Error('invoke: server-side modes are not yet supported');
|
|
67
|
+
}
|
|
68
|
+
const subOutput = makeShellModeOutput({
|
|
69
|
+
scrollback: deps.scrollback,
|
|
70
|
+
busy: deps.busy,
|
|
71
|
+
invoke,
|
|
72
|
+
});
|
|
73
|
+
await desc.dispatch({ line, cwd: deps.cwd(), signal: (_d = activeController === null || activeController === void 0 ? void 0 : activeController.signal) !== null && _d !== void 0 ? _d : new AbortController().signal }, subOutput);
|
|
74
|
+
}
|
|
75
|
+
async function dispatch(line) {
|
|
76
|
+
var _a, _b, _c, _d;
|
|
77
|
+
// Abort any in-flight custom dispatch when a new line is submitted.
|
|
78
|
+
activeController === null || activeController === void 0 ? void 0 : activeController.abort();
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
activeController = controller;
|
|
11
81
|
const mode = deps.mode();
|
|
12
82
|
deps.session.history.push(line);
|
|
13
83
|
// User-mode $ escape: block server-shell access
|
|
@@ -16,7 +86,9 @@ export function makeDispatch(deps) {
|
|
|
16
86
|
deps.scrollback.push({ kind: 'status', text: 'shell: server shell not available in user mode', level: 'error', ts: Date.now() });
|
|
17
87
|
return;
|
|
18
88
|
}
|
|
19
|
-
const resolution = deps.resolver.resolve(line
|
|
89
|
+
const resolution = deps.resolver.resolve(line, {
|
|
90
|
+
globalOnly: mode.id !== 'sh3',
|
|
91
|
+
});
|
|
20
92
|
if (resolution.kind === 'local') {
|
|
21
93
|
// Log locally-dispatched verbs for shared history (ws only)
|
|
22
94
|
if (mode.transport === 'ws') {
|
|
@@ -46,11 +118,63 @@ export function makeDispatch(deps) {
|
|
|
46
118
|
// forward path
|
|
47
119
|
if (mode.transport === 'ws') {
|
|
48
120
|
deps.session.send({ t: 'submit', line: resolution.line });
|
|
121
|
+
return;
|
|
49
122
|
}
|
|
50
|
-
|
|
51
|
-
const firstToken = (_a = resolution.line.split(/\s+/)[0]) !== null && _a !== void 0 ? _a : '';
|
|
123
|
+
if (mode.transport === 'custom') {
|
|
52
124
|
deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
|
|
53
|
-
deps.
|
|
125
|
+
const desc = (_b = (_a = deps.customMode) === null || _a === void 0 ? void 0 : _a.call(deps, mode.id)) !== null && _b !== void 0 ? _b : null;
|
|
126
|
+
if (!desc) {
|
|
127
|
+
deps.scrollback.push({
|
|
128
|
+
kind: 'status',
|
|
129
|
+
text: `mode '${mode.id}' is no longer available`,
|
|
130
|
+
level: 'error',
|
|
131
|
+
ts: Date.now(),
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (desc.runsOn === 'server') {
|
|
136
|
+
deps.scrollback.push({
|
|
137
|
+
kind: 'status',
|
|
138
|
+
text: 'server-side modes are not yet supported (planned for a future release)',
|
|
139
|
+
level: 'error',
|
|
140
|
+
ts: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const output = makeShellModeOutput({
|
|
145
|
+
scrollback: deps.scrollback,
|
|
146
|
+
busy: deps.busy,
|
|
147
|
+
invoke,
|
|
148
|
+
});
|
|
149
|
+
const clearBusy = deps.busy();
|
|
150
|
+
try {
|
|
151
|
+
await desc.dispatch({ line: resolution.line, cwd: deps.cwd(), signal: controller.signal }, output);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if ((err === null || err === void 0 ? void 0 : err.name) === 'AbortError') {
|
|
155
|
+
deps.scrollback.push({ kind: 'status', text: 'mode dispatch aborted', level: 'info', ts: Date.now() });
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
deps.scrollback.push({
|
|
159
|
+
kind: 'status',
|
|
160
|
+
text: `mode '${mode.id}' threw — ${(_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : String(err)}`,
|
|
161
|
+
level: 'error',
|
|
162
|
+
ts: Date.now(),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
clearBusy();
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
54
170
|
}
|
|
171
|
+
// 'none' transport, unknown verb: print error
|
|
172
|
+
const firstToken = (_d = resolution.line.split(/\s+/)[0]) !== null && _d !== void 0 ? _d : '';
|
|
173
|
+
deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
|
|
174
|
+
deps.scrollback.push({ kind: 'status', text: `unknown verb: ${firstToken}`, level: 'error', ts: Date.now() });
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
dispatch,
|
|
178
|
+
cancel: () => activeController === null || activeController === void 0 ? void 0 : activeController.abort(),
|
|
55
179
|
};
|
|
56
180
|
}
|
|
@@ -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,10 @@
|
|
|
1
|
+
import type { Scrollback } from './scrollback.svelte';
|
|
2
|
+
import type { ShellModeOutput } from './contract';
|
|
3
|
+
export interface ShellModeOutputDeps {
|
|
4
|
+
scrollback: Scrollback;
|
|
5
|
+
/** Acquire a busy indicator; returns a clear handle. */
|
|
6
|
+
busy: (label?: string) => () => void;
|
|
7
|
+
/** Programmatically dispatch a line through another mode's resolution path. */
|
|
8
|
+
invoke: (modeId: string, line: string) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function makeShellModeOutput(deps: ShellModeOutputDeps): ShellModeOutput;
|
|
@@ -0,0 +1,91 @@
|
|
|
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(deps) {
|
|
28
|
+
const sb = deps.scrollback;
|
|
29
|
+
return {
|
|
30
|
+
text(stream, chunk) {
|
|
31
|
+
sb.push({ kind: 'text', stream, chunks: [chunk], ts: Date.now() });
|
|
32
|
+
},
|
|
33
|
+
status(level, msg) {
|
|
34
|
+
sb.push({ kind: 'status', text: msg, level, ts: Date.now() });
|
|
35
|
+
},
|
|
36
|
+
rich(component, props) {
|
|
37
|
+
sb.push({ kind: 'rich', component, props: Object.assign({}, props), ts: Date.now() });
|
|
38
|
+
return makeRichHandle(sb, lastRichId(sb));
|
|
39
|
+
},
|
|
40
|
+
stream(component, initialProps) {
|
|
41
|
+
sb.push({
|
|
42
|
+
kind: 'rich',
|
|
43
|
+
component,
|
|
44
|
+
props: Object.assign(Object.assign({}, initialProps), { __streamState: 'streaming' }),
|
|
45
|
+
ts: Date.now(),
|
|
46
|
+
});
|
|
47
|
+
const id = lastRichId(sb);
|
|
48
|
+
return {
|
|
49
|
+
append(patch) {
|
|
50
|
+
const entry = findRich(sb, id);
|
|
51
|
+
if (!entry)
|
|
52
|
+
return;
|
|
53
|
+
Object.assign(entry.props, patch);
|
|
54
|
+
},
|
|
55
|
+
complete() {
|
|
56
|
+
const entry = findRich(sb, id);
|
|
57
|
+
if (!entry)
|
|
58
|
+
return;
|
|
59
|
+
entry.props.__streamState = 'complete';
|
|
60
|
+
},
|
|
61
|
+
error(err) {
|
|
62
|
+
var _a;
|
|
63
|
+
const entry = findRich(sb, id);
|
|
64
|
+
if (entry)
|
|
65
|
+
entry.props.__streamState = 'error';
|
|
66
|
+
sb.push({
|
|
67
|
+
kind: 'status',
|
|
68
|
+
text: `mode: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err)}`,
|
|
69
|
+
level: 'error',
|
|
70
|
+
ts: Date.now(),
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
busy(label) {
|
|
76
|
+
const clearController = deps.busy(label);
|
|
77
|
+
let cleared = false;
|
|
78
|
+
return {
|
|
79
|
+
clear() {
|
|
80
|
+
if (cleared)
|
|
81
|
+
return;
|
|
82
|
+
cleared = true;
|
|
83
|
+
clearController();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
invoke(modeId, line) {
|
|
88
|
+
return deps.invoke(modeId, line);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|