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
|
@@ -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);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const modeVerb = {
|
|
2
|
+
name: 'mode',
|
|
3
|
+
summary: 'List or switch shell modes. Usage: mode | mode <id>',
|
|
4
|
+
async run(ctx, args) {
|
|
5
|
+
const ts = Date.now();
|
|
6
|
+
if (args.length === 0) {
|
|
7
|
+
const modes = ctx.shell.listModes();
|
|
8
|
+
const lines = modes.map((m) => ` ${m.id.padEnd(12)} ${m.label}`);
|
|
9
|
+
ctx.scrollback.push({
|
|
10
|
+
kind: 'text',
|
|
11
|
+
stream: 'stdout',
|
|
12
|
+
chunks: [['Available modes:', ...lines].join('\n') + '\n'],
|
|
13
|
+
ts,
|
|
14
|
+
});
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const id = args[0];
|
|
18
|
+
const ok = ctx.shell.setMode(id);
|
|
19
|
+
if (!ok) {
|
|
20
|
+
ctx.scrollback.push({
|
|
21
|
+
kind: 'status',
|
|
22
|
+
text: `mode: unknown or restricted mode '${id}'`,
|
|
23
|
+
level: 'error',
|
|
24
|
+
ts,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { modeVerb } from './mode';
|
|
3
|
+
function makeShell(modes = [{ id: 'bash', label: 'Bash' }, { id: 'sh3', label: 'SH3' }]) {
|
|
4
|
+
const setMode = vi.fn((id) => modes.some((m) => m.id === id));
|
|
5
|
+
const listModes = () => modes;
|
|
6
|
+
return { setMode, listModes };
|
|
7
|
+
}
|
|
8
|
+
function makeCtx(shellExt) {
|
|
9
|
+
const pushed = [];
|
|
10
|
+
const ctx = {
|
|
11
|
+
shell: { setMode: shellExt.setMode, listModes: shellExt.listModes },
|
|
12
|
+
scrollback: { push: (e) => pushed.push(e) },
|
|
13
|
+
session: {},
|
|
14
|
+
cwd: '/',
|
|
15
|
+
dispatch: async () => { },
|
|
16
|
+
fs: {},
|
|
17
|
+
};
|
|
18
|
+
return { ctx, pushed };
|
|
19
|
+
}
|
|
20
|
+
describe('mode verb', () => {
|
|
21
|
+
it('lists modes when called with no args', async () => {
|
|
22
|
+
const shell = makeShell();
|
|
23
|
+
const { ctx, pushed } = makeCtx(shell);
|
|
24
|
+
await modeVerb.run(ctx, []);
|
|
25
|
+
const dump = JSON.stringify(pushed);
|
|
26
|
+
expect(dump).toMatch(/bash/);
|
|
27
|
+
expect(dump).toMatch(/sh3/);
|
|
28
|
+
});
|
|
29
|
+
it('switches mode when called with a known id', async () => {
|
|
30
|
+
const shell = makeShell();
|
|
31
|
+
const { ctx } = makeCtx(shell);
|
|
32
|
+
await modeVerb.run(ctx, ['bash']);
|
|
33
|
+
expect(shell.setMode).toHaveBeenCalledWith('bash');
|
|
34
|
+
});
|
|
35
|
+
it('emits an error status when the id is unknown', async () => {
|
|
36
|
+
const shell = makeShell();
|
|
37
|
+
const { ctx, pushed } = makeCtx(shell);
|
|
38
|
+
await modeVerb.run(ctx, ['nope']);
|
|
39
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
40
|
+
expect(err).toBeDefined();
|
|
41
|
+
expect(err.text).toMatch(/nope/);
|
|
42
|
+
});
|
|
43
|
+
});
|
package/dist/verbs/types.d.ts
CHANGED
|
@@ -66,6 +66,17 @@ export interface ShellApi {
|
|
|
66
66
|
userId: string;
|
|
67
67
|
admin: boolean;
|
|
68
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Switch the active shell mode. The mode must be registered and visible
|
|
71
|
+
* to the current role. Returns true on success, false on unknown or
|
|
72
|
+
* role-restricted id (so verbs can surface a status).
|
|
73
|
+
*/
|
|
74
|
+
setMode(id: string): boolean;
|
|
75
|
+
/** List currently visible modes for the current role. */
|
|
76
|
+
listModes(): readonly {
|
|
77
|
+
id: string;
|
|
78
|
+
label: string;
|
|
79
|
+
}[];
|
|
69
80
|
}
|
|
70
81
|
export interface VerbContext {
|
|
71
82
|
shell: ShellApi;
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.14.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.14.0';
|