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,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Scrollback } from './scrollback.svelte';
|
|
3
|
+
import { makeShellModeOutput } from './output';
|
|
4
|
+
const FakeComponent = (() => { });
|
|
5
|
+
const stubBusy = () => () => { };
|
|
6
|
+
const stubInvoke = async () => { };
|
|
7
|
+
describe('makeShellModeOutput', () => {
|
|
8
|
+
it('text() pushes coalescing text entries', () => {
|
|
9
|
+
const sb = new Scrollback();
|
|
10
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
11
|
+
out.text('stdout', 'hello ');
|
|
12
|
+
out.text('stdout', 'world');
|
|
13
|
+
const text = sb.entries.filter((e) => e.kind === 'text');
|
|
14
|
+
expect(text).toHaveLength(1);
|
|
15
|
+
expect(text[0].chunks.join('')).toBe('hello world');
|
|
16
|
+
});
|
|
17
|
+
it('status() pushes a status entry with the right level', () => {
|
|
18
|
+
const sb = new Scrollback();
|
|
19
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
20
|
+
out.status('warn', 'careful');
|
|
21
|
+
const s = sb.entries.find((e) => e.kind === 'status');
|
|
22
|
+
expect(s).toBeDefined();
|
|
23
|
+
expect(s.level).toBe('warn');
|
|
24
|
+
expect(s.text).toBe('careful');
|
|
25
|
+
});
|
|
26
|
+
it('rich().update() mutates the live entry props', () => {
|
|
27
|
+
const sb = new Scrollback();
|
|
28
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
29
|
+
const handle = out.rich(FakeComponent, { tokens: 'a' });
|
|
30
|
+
handle.update({ tokens: 'ab' });
|
|
31
|
+
const entry = sb.entries.find((e) => e.kind === 'rich');
|
|
32
|
+
expect(entry.props.tokens).toBe('ab');
|
|
33
|
+
});
|
|
34
|
+
it('stream() marks the entry mid-stream until complete()', () => {
|
|
35
|
+
const sb = new Scrollback();
|
|
36
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
37
|
+
const h = out.stream(FakeComponent, { tokens: '' });
|
|
38
|
+
let entry = sb.entries.find((e) => e.kind === 'rich');
|
|
39
|
+
expect(entry.props.__streamState).toBe('streaming');
|
|
40
|
+
h.append({ tokens: 'a' });
|
|
41
|
+
expect(entry.props.tokens).toBe('a');
|
|
42
|
+
h.complete();
|
|
43
|
+
expect(entry.props.__streamState).toBe('complete');
|
|
44
|
+
});
|
|
45
|
+
it('stream().error() marks the entry errored and pushes a status', () => {
|
|
46
|
+
const sb = new Scrollback();
|
|
47
|
+
const out = makeShellModeOutput({ scrollback: sb, busy: stubBusy, invoke: stubInvoke });
|
|
48
|
+
const h = out.stream(FakeComponent, { tokens: '' });
|
|
49
|
+
h.error(new Error('boom'));
|
|
50
|
+
const entry = sb.entries.find((e) => e.kind === 'rich');
|
|
51
|
+
expect(entry.props.__streamState).toBe('error');
|
|
52
|
+
const s = sb.entries.find((e) => e.kind === 'status' && e.level === 'error');
|
|
53
|
+
expect(s).toBeDefined();
|
|
54
|
+
expect(s.text).toMatch(/boom/);
|
|
55
|
+
});
|
|
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,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
|
+
});
|
|
@@ -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 };
|
|
@@ -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() },
|
|
@@ -15,38 +15,45 @@ function scaffold(modeId) {
|
|
|
15
15
|
const fs = {};
|
|
16
16
|
const shell = {};
|
|
17
17
|
const resolver = {
|
|
18
|
-
resolve: (line) =>
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
resolve: (line, opts = {}) => {
|
|
19
|
+
// The test only exercises 'pwd' (sh3-domain) and unknown lines.
|
|
20
|
+
// Under globalOnly, 'pwd' should forward.
|
|
21
|
+
if (line.startsWith('pwd') && !opts.globalOnly) {
|
|
22
|
+
return { kind: 'local', verb: { name: 'pwd', run: async () => { } }, args: [], line };
|
|
23
|
+
}
|
|
24
|
+
return { kind: 'forward', line };
|
|
25
|
+
},
|
|
21
26
|
};
|
|
22
|
-
const dispatch = makeDispatch({
|
|
27
|
+
const { dispatch } = makeDispatch({
|
|
23
28
|
mode: () => mode,
|
|
29
|
+
role: () => (modeId === 'bash' ? 'admin' : 'user'),
|
|
24
30
|
resolver,
|
|
25
31
|
scrollback,
|
|
26
32
|
session,
|
|
27
33
|
shell,
|
|
28
34
|
fs,
|
|
29
35
|
cwd: () => '/',
|
|
36
|
+
busy: () => () => { },
|
|
30
37
|
});
|
|
31
38
|
return { dispatch, sent, pushed };
|
|
32
39
|
}
|
|
33
|
-
describe('dispatch —
|
|
40
|
+
describe('dispatch — sh3 mode', () => {
|
|
34
41
|
it('unknown verbs print error, do not send', async () => {
|
|
35
|
-
const { dispatch, sent, pushed } = scaffold('
|
|
42
|
+
const { dispatch, sent, pushed } = scaffold('sh3');
|
|
36
43
|
await dispatch('foo');
|
|
37
44
|
expect(sent).toEqual([]);
|
|
38
45
|
expect(pushed.some((e) => e.kind === 'status' && /unknown verb/.test(e.text))).toBe(true);
|
|
39
46
|
});
|
|
40
47
|
it('$ escape prints error', async () => {
|
|
41
|
-
const { dispatch, sent, pushed } = scaffold('
|
|
48
|
+
const { dispatch, sent, pushed } = scaffold('sh3');
|
|
42
49
|
await dispatch('$ ls');
|
|
43
50
|
expect(sent).toEqual([]);
|
|
44
51
|
expect(pushed.some((e) => e.kind === 'status' && /server shell not available/.test(e.text))).toBe(true);
|
|
45
52
|
});
|
|
46
53
|
});
|
|
47
|
-
describe('dispatch —
|
|
54
|
+
describe('dispatch — bash mode', () => {
|
|
48
55
|
it('forwards unknown verbs to server', async () => {
|
|
49
|
-
const { dispatch, sent } = scaffold('
|
|
56
|
+
const { dispatch, sent } = scaffold('bash');
|
|
50
57
|
await dispatch('foo');
|
|
51
58
|
expect(sent.some((m) => m.t === 'submit' && m.line === 'foo')).toBe(true);
|
|
52
59
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
active: boolean;
|
|
4
|
+
label: string | null;
|
|
5
|
+
}
|
|
6
|
+
let { active, label }: Props = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
{#if active}
|
|
10
|
+
<div class="busy" role="status" aria-live="polite">
|
|
11
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
12
|
+
{#if label}<span class="label">{label}</span>{/if}
|
|
13
|
+
</div>
|
|
14
|
+
{/if}
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
.busy {
|
|
18
|
+
display: inline-flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: 6px;
|
|
21
|
+
color: var(--shell-fg-muted, #aaa);
|
|
22
|
+
font-size: 0.85em;
|
|
23
|
+
}
|
|
24
|
+
.spinner {
|
|
25
|
+
width: 12px;
|
|
26
|
+
height: 12px;
|
|
27
|
+
border: 2px solid var(--shell-fg-muted, #aaa);
|
|
28
|
+
border-top-color: transparent;
|
|
29
|
+
border-radius: 50%;
|
|
30
|
+
animation: busy-spin 0.8s linear infinite;
|
|
31
|
+
}
|
|
32
|
+
@keyframes busy-spin {
|
|
33
|
+
to { transform: rotate(360deg); }
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
@@ -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,29 @@
|
|
|
1
|
+
export const modeVerb = {
|
|
2
|
+
name: 'mode',
|
|
3
|
+
summary: 'List or switch shell modes. Usage: mode | mode <id>',
|
|
4
|
+
globalVerb: true,
|
|
5
|
+
async run(ctx, args) {
|
|
6
|
+
const ts = Date.now();
|
|
7
|
+
if (args.length === 0) {
|
|
8
|
+
const modes = ctx.shell.listModes();
|
|
9
|
+
const lines = modes.map((m) => ` ${m.id.padEnd(12)} ${m.label}`);
|
|
10
|
+
ctx.scrollback.push({
|
|
11
|
+
kind: 'text',
|
|
12
|
+
stream: 'stdout',
|
|
13
|
+
chunks: [['Available modes:', ...lines].join('\n') + '\n'],
|
|
14
|
+
ts,
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const id = args[0];
|
|
19
|
+
const ok = ctx.shell.setMode(id);
|
|
20
|
+
if (!ok) {
|
|
21
|
+
ctx.scrollback.push({
|
|
22
|
+
kind: 'status',
|
|
23
|
+
text: `mode: unknown or restricted mode '${id}'`,
|
|
24
|
+
level: 'error',
|
|
25
|
+
ts,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -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;
|
|
@@ -79,6 +90,14 @@ export interface VerbContext {
|
|
|
79
90
|
export interface Verb {
|
|
80
91
|
name: string;
|
|
81
92
|
summary: string;
|
|
93
|
+
/**
|
|
94
|
+
* When true, this verb resolves in every shell mode — including bash and
|
|
95
|
+
* external shards' custom modes. Defaults to false: sh3-domain verbs only
|
|
96
|
+
* resolve when `mode.id === 'sh3'`. Reserve this flag for verbs whose
|
|
97
|
+
* action is mode-agnostic (e.g. `clear` clears the local scrollback,
|
|
98
|
+
* `mode` switches modes — both make sense everywhere).
|
|
99
|
+
*/
|
|
100
|
+
globalVerb?: boolean;
|
|
82
101
|
run(ctx: VerbContext, args: string[]): Promise<void>;
|
|
83
102
|
}
|
|
84
103
|
export type Resolution = {
|
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.3";
|
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.3';
|