sh3-core 0.14.0 → 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/layout/LayoutRenderer.svelte +1 -1
- package/dist/layout/tree-walk.js +6 -1
- package/dist/layout/types.d.ts +7 -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 +55 -4
- package/dist/shell-shard/contract.d.ts +34 -0
- package/dist/shell-shard/dispatch-custom.test.js +48 -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 +9 -1
- package/dist/shell-shard/dispatch.js +73 -2
- package/dist/shell-shard/output.d.ts +8 -1
- package/dist/shell-shard/output.js +17 -1
- package/dist/shell-shard/output.test.js +24 -5
- 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/terminal-dispatch.test.js +10 -3
- 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/verbs/clear.js +1 -0
- package/dist/shell-shard/verbs/mode.js +1 -0
- package/dist/verbs/types.d.ts +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -16,12 +16,14 @@ function makeStubDeps(mode, customMode) {
|
|
|
16
16
|
return {
|
|
17
17
|
deps: {
|
|
18
18
|
mode: () => mode,
|
|
19
|
+
role: () => 'user',
|
|
19
20
|
resolver,
|
|
20
21
|
scrollback,
|
|
21
22
|
session,
|
|
22
23
|
shell,
|
|
23
24
|
fs,
|
|
24
25
|
cwd: () => '/',
|
|
26
|
+
busy: () => () => { },
|
|
25
27
|
customMode,
|
|
26
28
|
},
|
|
27
29
|
pushed,
|
|
@@ -102,3 +104,49 @@ describe('dispatch — custom transport', () => {
|
|
|
102
104
|
expect(err.text).toMatch(/no longer available/);
|
|
103
105
|
});
|
|
104
106
|
});
|
|
107
|
+
describe('dispatch — auto-spinner', () => {
|
|
108
|
+
it('acquires a busy slot for the duration of a custom-mode dispatch', async () => {
|
|
109
|
+
let active = 0;
|
|
110
|
+
let peak = 0;
|
|
111
|
+
const busy = () => {
|
|
112
|
+
active++;
|
|
113
|
+
peak = Math.max(peak, active);
|
|
114
|
+
return () => { active--; };
|
|
115
|
+
};
|
|
116
|
+
let resolveDispatch;
|
|
117
|
+
const desc = {
|
|
118
|
+
id: 'gemini',
|
|
119
|
+
label: 'Gemini',
|
|
120
|
+
runsOn: 'client',
|
|
121
|
+
dispatch: () => new Promise((resolve) => {
|
|
122
|
+
resolveDispatch = resolve;
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
|
|
126
|
+
const { deps } = makeStubDeps(mode, () => desc);
|
|
127
|
+
const { dispatch } = makeDispatch(Object.assign(Object.assign({}, deps), { busy }));
|
|
128
|
+
const promise = dispatch('hi');
|
|
129
|
+
// Yield once so the dispatch closure runs through to the await on desc.dispatch
|
|
130
|
+
await Promise.resolve();
|
|
131
|
+
expect(peak).toBe(1);
|
|
132
|
+
expect(active).toBe(1);
|
|
133
|
+
resolveDispatch();
|
|
134
|
+
await promise;
|
|
135
|
+
expect(active).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
it('clears busy when the dispatch handler throws', async () => {
|
|
138
|
+
let active = 0;
|
|
139
|
+
const busy = () => { active++; return () => { active--; }; };
|
|
140
|
+
const desc = {
|
|
141
|
+
id: 'gemini',
|
|
142
|
+
label: 'Gemini',
|
|
143
|
+
runsOn: 'client',
|
|
144
|
+
dispatch: async () => { throw new Error('kaboom'); },
|
|
145
|
+
};
|
|
146
|
+
const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
|
|
147
|
+
const { deps } = makeStubDeps(mode, () => desc);
|
|
148
|
+
const { dispatch } = makeDispatch(Object.assign(Object.assign({}, deps), { busy }));
|
|
149
|
+
await dispatch('hi');
|
|
150
|
+
expect(active).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { makeDispatch } from './dispatch';
|
|
3
|
+
const appsVerb = { name: 'apps', summary: '', async run() { } };
|
|
4
|
+
const clearVerb = {
|
|
5
|
+
name: 'clear',
|
|
6
|
+
summary: '',
|
|
7
|
+
globalVerb: true,
|
|
8
|
+
async run(ctx) {
|
|
9
|
+
ctx.scrollback.push({ kind: 'status', text: 'cleared', level: 'info', ts: 0 });
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
function scaffold(mode) {
|
|
13
|
+
const sent = [];
|
|
14
|
+
const pushed = [];
|
|
15
|
+
const scrollback = { push: (e) => pushed.push(e), clear: () => { } };
|
|
16
|
+
const session = { history: { push: vi.fn() }, send: (m) => sent.push(m), cwd: '/', connected: true, connect: vi.fn() };
|
|
17
|
+
const fs = {};
|
|
18
|
+
const shell = {};
|
|
19
|
+
// Real-shape resolver to exercise the new globalOnly path.
|
|
20
|
+
const resolver = {
|
|
21
|
+
resolve: (line, opts = {}) => {
|
|
22
|
+
const head = line.trim().split(/\s+/)[0];
|
|
23
|
+
const verb = head === 'apps' ? appsVerb : head === 'clear' ? clearVerb : null;
|
|
24
|
+
if (!verb)
|
|
25
|
+
return { kind: 'forward', line };
|
|
26
|
+
if (opts.globalOnly && !verb.globalVerb)
|
|
27
|
+
return { kind: 'forward', line };
|
|
28
|
+
return { kind: 'local', verb, args: [], line };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const { dispatch } = makeDispatch({
|
|
32
|
+
mode: () => mode,
|
|
33
|
+
role: () => (mode.requiresRole === 'admin' ? 'admin' : 'user'),
|
|
34
|
+
resolver,
|
|
35
|
+
scrollback,
|
|
36
|
+
session,
|
|
37
|
+
shell,
|
|
38
|
+
fs,
|
|
39
|
+
cwd: () => '/',
|
|
40
|
+
busy: () => () => { },
|
|
41
|
+
});
|
|
42
|
+
return { dispatch, sent, pushed };
|
|
43
|
+
}
|
|
44
|
+
describe('dispatch — mode-gated verb resolution', () => {
|
|
45
|
+
const sh3Mode = { id: 'sh3', label: 'SH3', transport: 'none', autoRelocate: true };
|
|
46
|
+
const bashMode = { id: 'bash', label: 'Bash', transport: 'ws', autoRelocate: false, requiresRole: 'admin' };
|
|
47
|
+
it('sh3 mode resolves sh3-domain verbs locally', async () => {
|
|
48
|
+
const { dispatch, sent } = scaffold(sh3Mode);
|
|
49
|
+
await dispatch('apps');
|
|
50
|
+
expect(sent).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
it('bash mode forwards sh3-domain verbs to ws', async () => {
|
|
53
|
+
const { dispatch, sent } = scaffold(bashMode);
|
|
54
|
+
await dispatch('apps');
|
|
55
|
+
expect(sent.some((m) => m.t === 'submit' && m.line === 'apps')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('bash mode runs globalVerb (clear) locally', async () => {
|
|
58
|
+
const { dispatch, sent, pushed } = scaffold(bashMode);
|
|
59
|
+
await dispatch('clear');
|
|
60
|
+
expect(sent.every((m) => m.t !== 'submit')).toBe(true);
|
|
61
|
+
expect(pushed.some((e) => e.kind === 'status' && e.text === 'cleared')).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,16 +2,24 @@ 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
6
|
import type { ShellModeDescriptor } from './contract';
|
|
7
7
|
export interface DispatchDeps {
|
|
8
8
|
mode: () => ShellMode;
|
|
9
|
+
/** Current shell role — used by invoke() role-gating. */
|
|
10
|
+
role: () => ShellRole;
|
|
9
11
|
resolver: VerbRegistry;
|
|
10
12
|
scrollback: Scrollback;
|
|
11
13
|
session: SessionClient;
|
|
12
14
|
shell: ShellApi;
|
|
13
15
|
fs: TenantFsClient;
|
|
14
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;
|
|
15
23
|
/**
|
|
16
24
|
* Look up a contributed mode descriptor by id. Called only when the active
|
|
17
25
|
* mode has `transport: 'custom'`. Returns null if the descriptor has been
|
|
@@ -11,6 +11,67 @@
|
|
|
11
11
|
import { makeShellModeOutput } from './output';
|
|
12
12
|
export function makeDispatch(deps) {
|
|
13
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
|
+
}
|
|
14
75
|
async function dispatch(line) {
|
|
15
76
|
var _a, _b, _c, _d;
|
|
16
77
|
// Abort any in-flight custom dispatch when a new line is submitted.
|
|
@@ -25,7 +86,9 @@ export function makeDispatch(deps) {
|
|
|
25
86
|
deps.scrollback.push({ kind: 'status', text: 'shell: server shell not available in user mode', level: 'error', ts: Date.now() });
|
|
26
87
|
return;
|
|
27
88
|
}
|
|
28
|
-
const resolution = deps.resolver.resolve(line
|
|
89
|
+
const resolution = deps.resolver.resolve(line, {
|
|
90
|
+
globalOnly: mode.id !== 'sh3',
|
|
91
|
+
});
|
|
29
92
|
if (resolution.kind === 'local') {
|
|
30
93
|
// Log locally-dispatched verbs for shared history (ws only)
|
|
31
94
|
if (mode.transport === 'ws') {
|
|
@@ -78,7 +141,12 @@ export function makeDispatch(deps) {
|
|
|
78
141
|
});
|
|
79
142
|
return;
|
|
80
143
|
}
|
|
81
|
-
const output = makeShellModeOutput(
|
|
144
|
+
const output = makeShellModeOutput({
|
|
145
|
+
scrollback: deps.scrollback,
|
|
146
|
+
busy: deps.busy,
|
|
147
|
+
invoke,
|
|
148
|
+
});
|
|
149
|
+
const clearBusy = deps.busy();
|
|
82
150
|
try {
|
|
83
151
|
await desc.dispatch({ line: resolution.line, cwd: deps.cwd(), signal: controller.signal }, output);
|
|
84
152
|
}
|
|
@@ -95,6 +163,9 @@ export function makeDispatch(deps) {
|
|
|
95
163
|
});
|
|
96
164
|
}
|
|
97
165
|
}
|
|
166
|
+
finally {
|
|
167
|
+
clearBusy();
|
|
168
|
+
}
|
|
98
169
|
return;
|
|
99
170
|
}
|
|
100
171
|
// 'none' transport, unknown verb: print error
|
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
import type { Scrollback } from './scrollback.svelte';
|
|
2
2
|
import type { ShellModeOutput } from './contract';
|
|
3
|
-
export
|
|
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;
|
|
@@ -24,7 +24,8 @@ function lastRichId(sb) {
|
|
|
24
24
|
const last = sb.entries[sb.entries.length - 1];
|
|
25
25
|
return last.id;
|
|
26
26
|
}
|
|
27
|
-
export function makeShellModeOutput(
|
|
27
|
+
export function makeShellModeOutput(deps) {
|
|
28
|
+
const sb = deps.scrollback;
|
|
28
29
|
return {
|
|
29
30
|
text(stream, chunk) {
|
|
30
31
|
sb.push({ kind: 'text', stream, chunks: [chunk], ts: Date.now() });
|
|
@@ -71,5 +72,20 @@ export function makeShellModeOutput(sb) {
|
|
|
71
72
|
},
|
|
72
73
|
};
|
|
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
|
+
},
|
|
74
90
|
};
|
|
75
91
|
}
|
|
@@ -2,10 +2,12 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { Scrollback } from './scrollback.svelte';
|
|
3
3
|
import { makeShellModeOutput } from './output';
|
|
4
4
|
const FakeComponent = (() => { });
|
|
5
|
+
const stubBusy = () => () => { };
|
|
6
|
+
const stubInvoke = async () => { };
|
|
5
7
|
describe('makeShellModeOutput', () => {
|
|
6
8
|
it('text() pushes coalescing text entries', () => {
|
|
7
9
|
const sb = new Scrollback();
|
|
8
|
-
const out = makeShellModeOutput(sb);
|
|
10
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
9
11
|
out.text('stdout', 'hello ');
|
|
10
12
|
out.text('stdout', 'world');
|
|
11
13
|
const text = sb.entries.filter((e) => e.kind === 'text');
|
|
@@ -14,7 +16,7 @@ describe('makeShellModeOutput', () => {
|
|
|
14
16
|
});
|
|
15
17
|
it('status() pushes a status entry with the right level', () => {
|
|
16
18
|
const sb = new Scrollback();
|
|
17
|
-
const out = makeShellModeOutput(sb);
|
|
19
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
18
20
|
out.status('warn', 'careful');
|
|
19
21
|
const s = sb.entries.find((e) => e.kind === 'status');
|
|
20
22
|
expect(s).toBeDefined();
|
|
@@ -23,7 +25,7 @@ describe('makeShellModeOutput', () => {
|
|
|
23
25
|
});
|
|
24
26
|
it('rich().update() mutates the live entry props', () => {
|
|
25
27
|
const sb = new Scrollback();
|
|
26
|
-
const out = makeShellModeOutput(sb);
|
|
28
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
27
29
|
const handle = out.rich(FakeComponent, { tokens: 'a' });
|
|
28
30
|
handle.update({ tokens: 'ab' });
|
|
29
31
|
const entry = sb.entries.find((e) => e.kind === 'rich');
|
|
@@ -31,7 +33,7 @@ describe('makeShellModeOutput', () => {
|
|
|
31
33
|
});
|
|
32
34
|
it('stream() marks the entry mid-stream until complete()', () => {
|
|
33
35
|
const sb = new Scrollback();
|
|
34
|
-
const out = makeShellModeOutput(sb);
|
|
36
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
35
37
|
const h = out.stream(FakeComponent, { tokens: '' });
|
|
36
38
|
let entry = sb.entries.find((e) => e.kind === 'rich');
|
|
37
39
|
expect(entry.props.__streamState).toBe('streaming');
|
|
@@ -42,7 +44,7 @@ describe('makeShellModeOutput', () => {
|
|
|
42
44
|
});
|
|
43
45
|
it('stream().error() marks the entry errored and pushes a status', () => {
|
|
44
46
|
const sb = new Scrollback();
|
|
45
|
-
const out = makeShellModeOutput(sb);
|
|
47
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
46
48
|
const h = out.stream(FakeComponent, { tokens: '' });
|
|
47
49
|
h.error(new Error('boom'));
|
|
48
50
|
const entry = sb.entries.find((e) => e.kind === 'rich');
|
|
@@ -52,3 +54,20 @@ describe('makeShellModeOutput', () => {
|
|
|
52
54
|
expect(s.text).toMatch(/boom/);
|
|
53
55
|
});
|
|
54
56
|
});
|
|
57
|
+
describe('makeShellModeOutput — busy', () => {
|
|
58
|
+
it('forwards busy() to the controller and clear() is idempotent', () => {
|
|
59
|
+
const calls = [];
|
|
60
|
+
let cleared = 0;
|
|
61
|
+
const sb = new Scrollback();
|
|
62
|
+
const busy = (label) => {
|
|
63
|
+
calls.push(`acquire:${label !== null && label !== void 0 ? label : ''}`);
|
|
64
|
+
return () => { cleared++; };
|
|
65
|
+
};
|
|
66
|
+
const out = makeShellModeOutput({ scrollback: sb, busy, invoke: stubInvoke });
|
|
67
|
+
const h = out.busy('thinking');
|
|
68
|
+
expect(calls).toEqual(['acquire:thinking']);
|
|
69
|
+
h.clear();
|
|
70
|
+
h.clear();
|
|
71
|
+
expect(cleared).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { VerbRegistry } from './registry';
|
|
3
|
+
import { registerVerb, __resetViewRegistryForTest } from '../shards/registry';
|
|
4
|
+
const sh3Verb = { name: 'apps', summary: '', async run() { } };
|
|
5
|
+
const globalVerb = { name: 'clear', summary: '', globalVerb: true, async run() { } };
|
|
6
|
+
describe('VerbRegistry.resolve — globalOnly option', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
__resetViewRegistryForTest();
|
|
9
|
+
registerVerb('apps', sh3Verb);
|
|
10
|
+
registerVerb('clear', globalVerb);
|
|
11
|
+
});
|
|
12
|
+
it('without globalOnly resolves any registered verb', () => {
|
|
13
|
+
const r = new VerbRegistry();
|
|
14
|
+
expect(r.resolve('apps').kind).toBe('local');
|
|
15
|
+
expect(r.resolve('clear').kind).toBe('local');
|
|
16
|
+
});
|
|
17
|
+
it('with globalOnly only resolves globalVerb=true entries', () => {
|
|
18
|
+
const r = new VerbRegistry();
|
|
19
|
+
expect(r.resolve('apps', { globalOnly: true }).kind).toBe('forward');
|
|
20
|
+
expect(r.resolve('clear', { globalOnly: true }).kind).toBe('local');
|
|
21
|
+
});
|
|
22
|
+
it('the $ escape always forwards regardless of globalOnly', () => {
|
|
23
|
+
const r = new VerbRegistry();
|
|
24
|
+
expect(r.resolve('$ ls', { globalOnly: true }).kind).toBe('forward');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -3,5 +3,16 @@ export type { Verb, VerbContext, Resolution, ShellApi };
|
|
|
3
3
|
export declare class VerbRegistry {
|
|
4
4
|
list(): Verb[];
|
|
5
5
|
get(name: string): Verb | undefined;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a submitted line to a local verb or a forward instruction.
|
|
8
|
+
*
|
|
9
|
+
* @param line The user-submitted line.
|
|
10
|
+
* @param opts.globalOnly When true, only verbs declared with `globalVerb:
|
|
11
|
+
* true` resolve locally; everything else forwards. Used by the dispatch
|
|
12
|
+
* path to gate sh3-domain verbs to sh3 mode while keeping framework
|
|
13
|
+
* controls (clear, mode) reachable from every mode.
|
|
14
|
+
*/
|
|
15
|
+
resolve(line: string, opts?: {
|
|
16
|
+
globalOnly?: boolean;
|
|
17
|
+
}): Resolution;
|
|
7
18
|
}
|
|
@@ -14,7 +14,16 @@ export class VerbRegistry {
|
|
|
14
14
|
get(name) {
|
|
15
15
|
return getVerb(name);
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a submitted line to a local verb or a forward instruction.
|
|
19
|
+
*
|
|
20
|
+
* @param line The user-submitted line.
|
|
21
|
+
* @param opts.globalOnly When true, only verbs declared with `globalVerb:
|
|
22
|
+
* true` resolve locally; everything else forwards. Used by the dispatch
|
|
23
|
+
* path to gate sh3-domain verbs to sh3 mode while keeping framework
|
|
24
|
+
* controls (clear, mode) reachable from every mode.
|
|
25
|
+
*/
|
|
26
|
+
resolve(line, opts = {}) {
|
|
18
27
|
const trimmed = line.trim();
|
|
19
28
|
if (!trimmed)
|
|
20
29
|
return { kind: 'forward', line };
|
|
@@ -32,6 +41,8 @@ export class VerbRegistry {
|
|
|
32
41
|
const verb = getVerb(head);
|
|
33
42
|
if (!verb)
|
|
34
43
|
return { kind: 'forward', line };
|
|
44
|
+
if (opts.globalOnly && !verb.globalVerb)
|
|
45
|
+
return { kind: 'forward', line };
|
|
35
46
|
// Simple space-split for args — verbs can re-tokenize if they need quoting
|
|
36
47
|
const args = rest.length ? rest.split(/\s+/) : [];
|
|
37
48
|
return { kind: 'local', verb, args, line };
|