sh3-core 0.14.0 → 0.15.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.
Files changed (63) hide show
  1. package/dist/api.d.ts +3 -1
  2. package/dist/api.js +4 -0
  3. package/dist/contributions/index.d.ts +1 -1
  4. package/dist/contributions/index.js +1 -1
  5. package/dist/contributions/registry.d.ts +7 -0
  6. package/dist/contributions/registry.js +24 -4
  7. package/dist/contributions/registry.test.js +56 -1
  8. package/dist/contributions/types.d.ts +9 -0
  9. package/dist/layout/LayoutRenderer.svelte +1 -1
  10. package/dist/layout/tree-walk.js +6 -1
  11. package/dist/layout/types.d.ts +7 -0
  12. package/dist/overlays/FloatFrame.svelte +8 -2
  13. package/dist/overlays/float.js +6 -3
  14. package/dist/overlays/float.test.js +71 -0
  15. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
  16. package/dist/primitives/widgets/Segmented.svelte +4 -1
  17. package/dist/runtime/index.d.ts +2 -0
  18. package/dist/runtime/index.js +1 -0
  19. package/dist/runtime/runVerb.d.ts +10 -0
  20. package/dist/runtime/runVerb.js +97 -0
  21. package/dist/runtime/runVerb.test.d.ts +1 -0
  22. package/dist/runtime/runVerb.test.js +132 -0
  23. package/dist/sh3core-shard/AppInfoView.svelte +154 -0
  24. package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
  25. package/dist/sh3core-shard/appActions.js +23 -5
  26. package/dist/shards/activate-contributions.test.js +31 -0
  27. package/dist/shards/activate-runtime.test.d.ts +1 -0
  28. package/dist/shards/activate-runtime.test.js +179 -0
  29. package/dist/shards/activate.svelte.js +20 -3
  30. package/dist/shards/registry.d.ts +11 -1
  31. package/dist/shards/registry.js +16 -4
  32. package/dist/shards/registry.test.js +24 -16
  33. package/dist/shards/types.d.ts +38 -1
  34. package/dist/shell-shard/ScrollbackView.svelte +40 -19
  35. package/dist/shell-shard/Terminal.svelte +55 -4
  36. package/dist/shell-shard/contract.d.ts +34 -0
  37. package/dist/shell-shard/dispatch-custom.test.js +48 -0
  38. package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
  39. package/dist/shell-shard/dispatch-gating.test.js +63 -0
  40. package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
  41. package/dist/shell-shard/dispatch-invoke.test.js +214 -0
  42. package/dist/shell-shard/dispatch.d.ts +9 -1
  43. package/dist/shell-shard/dispatch.js +73 -2
  44. package/dist/shell-shard/output.d.ts +8 -1
  45. package/dist/shell-shard/output.js +17 -1
  46. package/dist/shell-shard/output.test.js +24 -5
  47. package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
  48. package/dist/shell-shard/registry-resolve.test.js +26 -0
  49. package/dist/shell-shard/registry.d.ts +12 -1
  50. package/dist/shell-shard/registry.js +12 -1
  51. package/dist/shell-shard/shellApi.d.ts +3 -0
  52. package/dist/shell-shard/shellApi.js +142 -0
  53. package/dist/shell-shard/shellShard.svelte.d.ts +1 -7
  54. package/dist/shell-shard/shellShard.svelte.js +8 -163
  55. package/dist/shell-shard/terminal-dispatch.test.js +10 -3
  56. package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
  57. package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
  58. package/dist/shell-shard/verbs/clear.js +1 -0
  59. package/dist/shell-shard/verbs/mode.js +1 -0
  60. package/dist/verbs/types.d.ts +68 -0
  61. package/dist/version.d.ts +1 -1
  62. package/dist/version.js +1 -1
  63. package/package.json +1 -1
@@ -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(deps.scrollback);
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 declare function makeShellModeOutput(sb: Scrollback): ShellModeOutput;
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(sb) {
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, 'shell');
10
+ registerVerb('clear', globalVerb, 'shell');
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 };
@@ -0,0 +1,3 @@
1
+ import type { ShellApi } from './registry';
2
+ export declare function makeShellApiHeadless(): ShellApi;
3
+ export declare function makeShellApiForTest(): ShellApi;
@@ -0,0 +1,142 @@
1
+ /*
2
+ * Headless ShellApi factory.
3
+ *
4
+ * Built only from framework primitives (apps registry, layout inspection,
5
+ * auth, float manager). No Svelte component imports — safe to load from
6
+ * any test project, including the node-only project, and from
7
+ * runtime/runVerb.ts which builds a synthesized VerbContext.
8
+ *
9
+ * Mode-related methods (`setMode`, `listModes`) stub to false / [];
10
+ * Terminal.svelte wraps this with mode-aware closures.
11
+ */
12
+ import { listRegisteredApps, getActiveApp } from '../apps/registry.svelte';
13
+ import { launchApp } from '../apps/lifecycle';
14
+ import { registeredShards, listStandaloneViews } from '../shards/activate.svelte';
15
+ import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout, locateSlot as locateSlotInActiveLayout, } from '../layout/inspection';
16
+ import { floatManager } from '../overlays/float';
17
+ import { getUser, isAdmin } from '../auth/index';
18
+ function collectTabEntries(node) {
19
+ if (node.type === 'tabs') {
20
+ return node.tabs.filter((t) => t.viewId !== null);
21
+ }
22
+ if (node.type === 'split') {
23
+ return node.children.flatMap(collectTabEntries);
24
+ }
25
+ if (node.viewId !== null) {
26
+ return [{ slotId: node.slotId, viewId: node.viewId, label: node.viewId }];
27
+ }
28
+ return [];
29
+ }
30
+ export function makeShellApiHeadless() {
31
+ return {
32
+ listApps() {
33
+ return listRegisteredApps().map((m) => ({ id: m.id, label: m.label }));
34
+ },
35
+ getActiveApp() {
36
+ const m = getActiveApp();
37
+ return m ? { id: m.id, label: m.label } : null;
38
+ },
39
+ launchApp(id) {
40
+ void launchApp(id);
41
+ },
42
+ listShards() {
43
+ return Array.from(registeredShards.values()).map((s) => ({
44
+ id: s.manifest.id,
45
+ label: s.manifest.label,
46
+ version: s.manifest.version,
47
+ }));
48
+ },
49
+ listViewsInCurrentLayout() {
50
+ try {
51
+ const { root } = inspectActiveLayout();
52
+ return collectTabEntries(root.docked).map((t) => {
53
+ var _a;
54
+ return ({
55
+ slotId: t.slotId,
56
+ viewId: (_a = t.viewId) !== null && _a !== void 0 ? _a : '',
57
+ label: t.label,
58
+ });
59
+ });
60
+ }
61
+ catch (_a) {
62
+ return [];
63
+ }
64
+ },
65
+ openViewInCurrentLayout(viewId) {
66
+ try {
67
+ if (focusView(viewId))
68
+ return { ok: true };
69
+ const standalone = listStandaloneViews().find((v) => v.viewId === viewId);
70
+ if (standalone) {
71
+ const slotId = `standalone:${viewId}:${Date.now()}`;
72
+ const ok = dockIntoActiveLayout({ slotId, viewId, label: standalone.label });
73
+ return ok ? { ok: true } : { ok: false, error: `could not dock "${viewId}" — no available slot` };
74
+ }
75
+ return { ok: false, error: `view "${viewId}" not found in current layout` };
76
+ }
77
+ catch (err) {
78
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
79
+ }
80
+ },
81
+ listStandaloneViews() {
82
+ return listStandaloneViews();
83
+ },
84
+ popoutSlot(slotId) {
85
+ try {
86
+ const floatId = popoutView(slotId);
87
+ return floatId ? { ok: true, floatId } : { ok: false, error: `slot "${slotId}" not found in docked tree` };
88
+ }
89
+ catch (err) {
90
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
91
+ }
92
+ },
93
+ dockFloat(floatId) {
94
+ try {
95
+ const ok = dockFloat(floatId);
96
+ return ok ? { ok: true } : { ok: false, error: `float "${floatId}" not found or has no dockable content` };
97
+ }
98
+ catch (err) {
99
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
100
+ }
101
+ },
102
+ locateSlot(slotId) {
103
+ try {
104
+ return locateSlotInActiveLayout(slotId);
105
+ }
106
+ catch (_a) {
107
+ return null;
108
+ }
109
+ },
110
+ listFloats() {
111
+ return floatManager.list().map((f) => {
112
+ var _a, _b, _c, _d, _e;
113
+ const tabs = f.content.type === 'tabs' ? f.content : null;
114
+ const active = tabs ? (_b = tabs.tabs[(_a = tabs.activeTab) !== null && _a !== void 0 ? _a : 0]) !== null && _b !== void 0 ? _b : tabs.tabs[0] : null;
115
+ return {
116
+ floatId: f.id,
117
+ viewId: (_c = active === null || active === void 0 ? void 0 : active.viewId) !== null && _c !== void 0 ? _c : null,
118
+ label: (_e = (_d = f.title) !== null && _d !== void 0 ? _d : active === null || active === void 0 ? void 0 : active.label) !== null && _e !== void 0 ? _e : f.id,
119
+ };
120
+ });
121
+ },
122
+ closeSlot(slotId) {
123
+ void closeTab(slotId);
124
+ return { ok: true };
125
+ },
126
+ listZones(_shardId) { return []; },
127
+ readZone(_shardId, _zoneName) { return null; },
128
+ whoAmI() {
129
+ var _a;
130
+ const user = getUser();
131
+ return {
132
+ userId: (_a = user === null || user === void 0 ? void 0 : user.id) !== null && _a !== void 0 ? _a : 'guest',
133
+ admin: isAdmin(),
134
+ };
135
+ },
136
+ setMode(_id) { return false; },
137
+ listModes() { return []; },
138
+ };
139
+ }
140
+ export function makeShellApiForTest() {
141
+ return makeShellApiHeadless();
142
+ }
@@ -1,9 +1,3 @@
1
1
  import type { Shard } from '../api';
2
- import type { ShellApi } from './registry';
3
- /**
4
- * Test-only ShellApi constructor. Bypasses the admin gate and uses a
5
- * stub ShardContext. Only methods that do not consult `ctx` are
6
- * guaranteed to work — `locateSlot`, `listFloats`, `listApps`, etc.
7
- */
8
- export declare function makeShellApiForTest(): ShellApi;
2
+ export { makeShellApiHeadless, makeShellApiForTest } from './shellApi';
9
3
  export declare const shellShard: Shard;
@@ -11,180 +11,25 @@
11
11
  *
12
12
  * autostart() is defined so the shard activates at boot without requiring
13
13
  * a dedicated app to launch it first, matching the __sh3core__ pattern.
14
+ *
15
+ * The headless ShellApi factory lives in ./shellApi.ts so non-DOM callers
16
+ * (node-only test project, runtime/runVerb) can build a ShellApi without
17
+ * pulling in Terminal.svelte.
14
18
  */
15
19
  import { mount, unmount } from 'svelte';
16
20
  import { manifest } from './manifest';
17
21
  import Terminal from './Terminal.svelte';
18
22
  import { registerV1Verbs } from './verbs';
19
- import { listRegisteredApps, getActiveApp } from '../apps/registry.svelte';
20
- import { launchApp } from '../apps/lifecycle';
21
- import { registeredShards } from '../shards/activate.svelte';
22
- import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout, locateSlot as locateSlotInActiveLayout } from '../layout/inspection';
23
+ import { makeShellApiHeadless } from './shellApi';
24
+ import { focusView } from '../layout/inspection';
23
25
  import { floatManager } from '../overlays/float';
24
- import { listStandaloneViews } from '../shards/activate.svelte';
25
26
  import { getUser, isAdmin } from '../auth/index';
26
- /** Walk a layout tree and collect all tab entries (slotId + viewId + label). */
27
- function collectTabEntries(node) {
28
- if (node.type === 'tabs') {
29
- return node.tabs.filter((t) => t.viewId !== null);
30
- }
31
- if (node.type === 'split') {
32
- return node.children.flatMap(collectTabEntries);
33
- }
34
- // slot node: wrap as a synthetic tab entry if it has a viewId
35
- if (node.viewId !== null) {
36
- return [{ slotId: node.slotId, viewId: node.viewId, label: node.viewId }];
37
- }
38
- return [];
39
- }
40
- function makeShellApi(_ctx) {
41
- return {
42
- // → apps/registry.svelte: listRegisteredApps() returns AppManifest[]
43
- listApps() {
44
- return listRegisteredApps().map((m) => ({ id: m.id, label: m.label }));
45
- },
46
- // → apps/registry.svelte: getActiveApp() returns AppManifest | null
47
- getActiveApp() {
48
- const m = getActiveApp();
49
- return m ? { id: m.id, label: m.label } : null;
50
- },
51
- // → apps/lifecycle: launchApp() is async; fire-and-forget to keep ShellApi sync.
52
- // Verb handlers display feedback independently via scrollback.
53
- launchApp(id) {
54
- void launchApp(id);
55
- },
56
- // → shards/activate.svelte: registeredShards reactive map
57
- listShards() {
58
- return Array.from(registeredShards.values()).map((s) => ({
59
- id: s.manifest.id,
60
- label: s.manifest.label,
61
- version: s.manifest.version,
62
- }));
63
- },
64
- // → layout/inspection: inspectActiveLayout() + tree walk
65
- listViewsInCurrentLayout() {
66
- try {
67
- const { root } = inspectActiveLayout();
68
- return collectTabEntries(root.docked).map((t) => {
69
- var _a;
70
- return ({
71
- slotId: t.slotId,
72
- viewId: (_a = t.viewId) !== null && _a !== void 0 ? _a : '',
73
- label: t.label,
74
- });
75
- });
76
- }
77
- catch (_a) {
78
- return [];
79
- }
80
- },
81
- // → layout/inspection: focusView(viewId). Falls back to dockIntoActiveLayout
82
- // for standalone views that aren't mounted yet — this is the single
83
- // "summon" entry point wired behind the `open` verb.
84
- openViewInCurrentLayout(viewId) {
85
- try {
86
- if (focusView(viewId))
87
- return { ok: true };
88
- const standalone = listStandaloneViews().find((v) => v.viewId === viewId);
89
- if (standalone) {
90
- const slotId = `standalone:${viewId}:${Date.now()}`;
91
- const ok = dockIntoActiveLayout({ slotId, viewId, label: standalone.label });
92
- return ok ? { ok: true } : { ok: false, error: `could not dock "${viewId}" — no available slot` };
93
- }
94
- return { ok: false, error: `view "${viewId}" not found in current layout` };
95
- }
96
- catch (err) {
97
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
98
- }
99
- },
100
- // → shards/activate.svelte: listStandaloneViews() walks activeShards
101
- listStandaloneViews() {
102
- return listStandaloneViews();
103
- },
104
- // → layout/inspection: popoutView(slotId) returns floatId | null
105
- popoutSlot(slotId) {
106
- try {
107
- const floatId = popoutView(slotId);
108
- return floatId ? { ok: true, floatId } : { ok: false, error: `slot "${slotId}" not found in docked tree` };
109
- }
110
- catch (err) {
111
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
112
- }
113
- },
114
- // → layout/inspection: dockFloat(floatId) returns boolean
115
- dockFloat(floatId) {
116
- try {
117
- const ok = dockFloat(floatId);
118
- return ok ? { ok: true } : { ok: false, error: `float "${floatId}" not found or has no dockable content` };
119
- }
120
- catch (err) {
121
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
122
- }
123
- },
124
- // → layout/inspection: locateSlot(slotId) returns TreeRootRef | null
125
- locateSlot(slotId) {
126
- try {
127
- return locateSlotInActiveLayout(slotId);
128
- }
129
- catch (_a) {
130
- return null;
131
- }
132
- },
133
- // → overlays/float: floatManager.list() returns FloatEntry[]
134
- listFloats() {
135
- return floatManager.list().map((f) => {
136
- var _a, _b, _c, _d, _e;
137
- const tabs = f.content.type === 'tabs' ? f.content : null;
138
- const active = tabs ? (_b = tabs.tabs[(_a = tabs.activeTab) !== null && _a !== void 0 ? _a : 0]) !== null && _b !== void 0 ? _b : tabs.tabs[0] : null;
139
- return {
140
- floatId: f.id,
141
- viewId: (_c = active === null || active === void 0 ? void 0 : active.viewId) !== null && _c !== void 0 ? _c : null,
142
- label: (_e = (_d = f.title) !== null && _d !== void 0 ? _d : active === null || active === void 0 ? void 0 : active.label) !== null && _e !== void 0 ? _e : f.id,
143
- };
144
- });
145
- },
146
- // → layout/inspection: closeTab(slotId) is async (guarded close).
147
- // Fire-and-forget; the tab disappears asynchronously. ShellApi stays sync.
148
- closeSlot(slotId) {
149
- void closeTab(slotId);
150
- return { ok: true };
151
- },
152
- // TODO Phase 10: wire to zone manager when state:manage permission is available.
153
- // The shell manifest declares permissions: [] so ctx.zones is undefined.
154
- // A future permission grant + ctx.zones.list() would power these.
155
- listZones(_shardId) { return []; },
156
- readZone(_shardId, _zoneName) { return null; },
157
- // → auth/index: getUser() + isAdmin()
158
- whoAmI() {
159
- var _a;
160
- const user = getUser();
161
- return {
162
- userId: (_a = user === null || user === void 0 ? void 0 : user.id) !== null && _a !== void 0 ? _a : 'guest',
163
- admin: isAdmin(),
164
- };
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 []; },
173
- };
174
- }
175
- /**
176
- * Test-only ShellApi constructor. Bypasses the admin gate and uses a
177
- * stub ShardContext. Only methods that do not consult `ctx` are
178
- * guaranteed to work — `locateSlot`, `listFloats`, `listApps`, etc.
179
- */
180
- export function makeShellApiForTest() {
181
- return makeShellApi({});
182
- }
27
+ export { makeShellApiHeadless, makeShellApiForTest } from './shellApi';
183
28
  export const shellShard = {
184
29
  manifest,
185
30
  activate(ctx) {
186
31
  registerV1Verbs(ctx);
187
- const shell = makeShellApi(ctx);
32
+ const shell = makeShellApiHeadless();
188
33
  // The AZERTY `²` key (top-left on FR keyboards, below Escape) opens the
189
34
  // terminal view — focusing it if already mounted, floating it otherwise.
190
35
  // Migrated from Shell.svelte's inline keydown handler as proof-of-concept
@@ -15,18 +15,25 @@ 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
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
  }