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.
Files changed (65) hide show
  1. package/dist/api.d.ts +3 -0
  2. package/dist/api.js +3 -0
  3. package/dist/host.js +2 -0
  4. package/dist/layout/LayoutRenderer.svelte +1 -1
  5. package/dist/layout/tree-walk.js +6 -1
  6. package/dist/layout/types.d.ts +7 -0
  7. package/dist/migrations/mode-id-rename.d.ts +9 -0
  8. package/dist/migrations/mode-id-rename.js +39 -0
  9. package/dist/migrations/mode-id-rename.test.d.ts +1 -0
  10. package/dist/migrations/mode-id-rename.test.js +52 -0
  11. package/dist/overlays/FloatFrame.svelte +8 -2
  12. package/dist/overlays/float.js +6 -3
  13. package/dist/overlays/float.test.js +71 -0
  14. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
  15. package/dist/primitives/widgets/Segmented.svelte +4 -1
  16. package/dist/sh3core-shard/AppInfoView.svelte +154 -0
  17. package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
  18. package/dist/sh3core-shard/appActions.js +23 -5
  19. package/dist/shell-shard/ScrollbackView.svelte +40 -19
  20. package/dist/shell-shard/Terminal.svelte +140 -12
  21. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  22. package/dist/shell-shard/contract.d.ts +99 -0
  23. package/dist/shell-shard/contract.js +11 -0
  24. package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
  25. package/dist/shell-shard/dispatch-custom.test.js +152 -0
  26. package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
  27. package/dist/shell-shard/dispatch-gating.test.js +63 -0
  28. package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
  29. package/dist/shell-shard/dispatch-invoke.test.js +214 -0
  30. package/dist/shell-shard/dispatch.d.ts +23 -2
  31. package/dist/shell-shard/dispatch.js +130 -6
  32. package/dist/shell-shard/modes/builtin.d.ts +2 -2
  33. package/dist/shell-shard/modes/builtin.js +8 -8
  34. package/dist/shell-shard/modes/prefs.js +1 -1
  35. package/dist/shell-shard/modes/prefs.test.js +13 -13
  36. package/dist/shell-shard/modes/registry.test.js +13 -13
  37. package/dist/shell-shard/output.d.ts +10 -0
  38. package/dist/shell-shard/output.js +91 -0
  39. package/dist/shell-shard/output.test.d.ts +1 -0
  40. package/dist/shell-shard/output.test.js +73 -0
  41. package/dist/shell-shard/registerShellMode.d.ts +13 -0
  42. package/dist/shell-shard/registerShellMode.js +14 -0
  43. package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
  44. package/dist/shell-shard/registerShellMode.test.js +19 -0
  45. package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
  46. package/dist/shell-shard/registry-resolve.test.js +26 -0
  47. package/dist/shell-shard/registry.d.ts +12 -1
  48. package/dist/shell-shard/registry.js +12 -1
  49. package/dist/shell-shard/shellShard.svelte.js +8 -1
  50. package/dist/shell-shard/terminal-dispatch.test.js +19 -12
  51. package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
  52. package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
  53. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +11 -51
  54. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +2 -4
  55. package/dist/shell-shard/toolbar/slots.test.js +6 -6
  56. package/dist/shell-shard/verbs/clear.js +1 -0
  57. package/dist/shell-shard/verbs/index.js +2 -0
  58. package/dist/shell-shard/verbs/mode.d.ts +2 -0
  59. package/dist/shell-shard/verbs/mode.js +29 -0
  60. package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
  61. package/dist/shell-shard/verbs/mode.test.js +43 -0
  62. package/dist/verbs/types.d.ts +19 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. 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
- resolve(line: string): Resolution;
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
- resolve(line) {
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 === 'dev'
7
- ? { id: 'dev', label: 'Dev', transport: 'ws', autoRelocate: false, requiresRole: 'admin' }
8
- : { id: 'user', label: 'User', transport: 'none', autoRelocate: true };
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) => line.startsWith('pwd')
19
- ? { kind: 'local', verb: { name: 'pwd', run: async () => { } }, args: [], line }
20
- : { kind: 'forward', line },
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 — user mode', () => {
40
+ describe('dispatch — sh3 mode', () => {
34
41
  it('unknown verbs print error, do not send', async () => {
35
- const { dispatch, sent, pushed } = scaffold('user');
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('user');
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 — dev mode', () => {
54
+ describe('dispatch — bash mode', () => {
48
55
  it('forwards unknown verbs to server', async () => {
49
- const { dispatch, sent } = scaffold('dev');
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>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ active: boolean;
3
+ label: string | null;
4
+ }
5
+ declare const BusySlot: import("svelte").Component<Props, {}, "">;
6
+ type BusySlot = ReturnType<typeof BusySlot>;
7
+ export default BusySlot;
@@ -1,67 +1,27 @@
1
1
  <script lang="ts">
2
- import type { ShellMode, ShellRole } from '../../modes/types';
3
- import type { ShellModeRegistry } from '../../modes/registry';
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
- role: ShellRole;
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 { mode, role, registry, onSelect }: Props = $props();
13
-
14
- let modes = $derived(registry.list(role));
13
+ let options: SegmentedOption[] = $derived(
14
+ modes.map((m) => ({ value: m.id, label: m.label })),
15
+ );
15
16
  </script>
16
17
 
17
- {#if role === 'admin'}
18
- <div class="mode-bar" role="toolbar" aria-label="Shell mode">
19
- {#each modes as m (m.id)}
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, ShellRole } from '../../modes/types';
2
- import type { ShellModeRegistry } from '../../modes/registry';
1
+ import type { ShellMode } from '../../modes/types';
3
2
  interface Props {
4
3
  mode: ShellMode;
5
- role: ShellRole;
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 === 'user', component: {} };
6
- const userCtx = { mode: { id: 'user', label: 'User', transport: 'none', autoRelocate: true }, role: 'user' };
7
- const devCtx = { mode: { id: 'dev', label: 'Dev', transport: 'ws', autoRelocate: false, requiresRole: 'admin' }, role: 'admin' };
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(userCtx).map((s) => s.id)).toEqual(['b', 'a', 'c']);
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(devCtx).map((s) => s.id)).toEqual(['a']);
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(userCtx)[0].order).toBe(99);
26
+ expect(r.list(sh3Ctx)[0].order).toBe(99);
27
27
  });
28
28
  });
@@ -1,6 +1,7 @@
1
1
  export const clearVerb = {
2
2
  name: 'clear',
3
3
  summary: 'Clear the scrollback (local only — other views are unaffected).',
4
+ globalVerb: true,
4
5
  async run(ctx) {
5
6
  ctx.scrollback.clear();
6
7
  },
@@ -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,2 @@
1
+ import type { Verb } from '../../verbs/types';
2
+ export declare const modeVerb: Verb;
@@ -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
+ });
@@ -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.13.4";
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.13.4';
2
+ export const VERSION = '0.14.3';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.13.4",
3
+ "version": "0.14.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"