sh3-core 0.15.2 → 0.15.4

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 (64) hide show
  1. package/dist/api.d.ts +4 -1
  2. package/dist/api.js +2 -0
  3. package/dist/apps/lifecycle.d.ts +21 -2
  4. package/dist/apps/lifecycle.js +13 -7
  5. package/dist/apps/lifecycle.test.js +18 -0
  6. package/dist/boot/satelliteMode.d.ts +7 -0
  7. package/dist/boot/satelliteMode.js +22 -0
  8. package/dist/boot/satelliteMode.test.d.ts +1 -0
  9. package/dist/boot/satelliteMode.test.js +55 -0
  10. package/dist/boot/satellitePayload.d.ts +17 -0
  11. package/dist/boot/satellitePayload.js +60 -0
  12. package/dist/boot/satellitePayload.test.d.ts +1 -0
  13. package/dist/boot/satellitePayload.test.js +53 -0
  14. package/dist/createShell.js +72 -25
  15. package/dist/host.d.ts +13 -0
  16. package/dist/host.js +36 -0
  17. package/dist/layout/store.svelte.d.ts +11 -0
  18. package/dist/layout/store.svelte.js +15 -0
  19. package/dist/overlays/FloatFrame.svelte +36 -0
  20. package/dist/runtime/runVerb-shell.test.js +0 -39
  21. package/dist/runtime/runVerb.test.js +17 -0
  22. package/dist/satellite/SatelliteShell.svelte +60 -0
  23. package/dist/satellite/SatelliteShell.svelte.d.ts +9 -0
  24. package/dist/satellite/seed.d.ts +3 -0
  25. package/dist/satellite/seed.js +20 -0
  26. package/dist/satellite/seed.test.d.ts +1 -0
  27. package/dist/satellite/seed.test.js +38 -0
  28. package/dist/satellite/walkShards.d.ts +2 -0
  29. package/dist/satellite/walkShards.js +44 -0
  30. package/dist/satellite/walkShards.test.d.ts +1 -0
  31. package/dist/satellite/walkShards.test.js +65 -0
  32. package/dist/sh3core-shard/appActions.js +51 -0
  33. package/dist/shards/activate.svelte.d.ts +2 -2
  34. package/dist/shards/activate.svelte.js +1 -1
  35. package/dist/shards/registry.d.ts +2 -1
  36. package/dist/shards/registry.js +13 -4
  37. package/dist/shards/registry.test.js +22 -1
  38. package/dist/shards/types.d.ts +1 -0
  39. package/dist/shell-shard/CommandLine.svelte +3 -0
  40. package/dist/shell-shard/CommandLine.svelte.d.ts +1 -0
  41. package/dist/shell-shard/InputLine.svelte +4 -1
  42. package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
  43. package/dist/shell-shard/Terminal.svelte +24 -0
  44. package/dist/shell-shard/dispatch-to-terminal.d.ts +13 -0
  45. package/dist/shell-shard/dispatch-to-terminal.js +37 -0
  46. package/dist/shell-shard/dispatch-to-terminal.test.d.ts +1 -0
  47. package/dist/shell-shard/dispatch-to-terminal.test.js +79 -0
  48. package/dist/shell-shard/shellApi.js +2 -0
  49. package/dist/shell-shard/terminal-registry.d.ts +25 -0
  50. package/dist/shell-shard/terminal-registry.js +62 -0
  51. package/dist/shell-shard/terminal-registry.test.d.ts +1 -0
  52. package/dist/shell-shard/terminal-registry.test.js +88 -0
  53. package/dist/shellApi/window.d.ts +15 -0
  54. package/dist/shellApi/window.js +43 -0
  55. package/dist/shellApi/window.test.d.ts +1 -0
  56. package/dist/shellApi/window.test.js +19 -0
  57. package/dist/shellRuntime.svelte.d.ts +12 -0
  58. package/dist/shellRuntime.svelte.js +2 -0
  59. package/dist/shellRuntime.svelte.test.d.ts +1 -0
  60. package/dist/shellRuntime.svelte.test.js +46 -0
  61. package/dist/verbs/types.d.ts +15 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/package.json +1 -1
@@ -10,8 +10,10 @@
10
10
  history: string[]; // persisted history, newest last
11
11
  session: SessionClient;
12
12
  onSubmit: (line: string) => void; // called with the raw entered line
13
+ /** Fired when the inner <input> receives focus. */
14
+ onFocus?: () => void;
13
15
  }
14
- let { cwd, showCwd = true, locked, history, session, onSubmit }: Props = $props();
16
+ let { cwd, showCwd = true, locked, history, session, onSubmit, onFocus }: Props = $props();
15
17
 
16
18
  let draft = $state('');
17
19
  let historyIndex = $state<number | null>(null); // null = live draft
@@ -94,6 +96,7 @@
94
96
  disabled={locked}
95
97
  name="shell-cmdline"
96
98
  onkeydown={onKeyDown}
99
+ onfocus={() => onFocus?.()}
97
100
  >
98
101
  {#snippet prefix()}
99
102
  {#if showCwd}<span class="shell-input-cwd">{cwd}</span>{/if}
@@ -7,6 +7,8 @@ interface Props {
7
7
  history: string[];
8
8
  session: SessionClient;
9
9
  onSubmit: (line: string) => void;
10
+ /** Fired when the inner <input> receives focus. */
11
+ onFocus?: () => void;
10
12
  }
11
13
  declare const InputLine: import("svelte").Component<Props, {}, "">;
12
14
  type InputLine = ReturnType<typeof InputLine>;
@@ -26,6 +26,8 @@
26
26
  import FocusLockSlot from './toolbar/slots/FocusLockSlot.svelte';
27
27
  import TargetShardSlot from './toolbar/slots/TargetShardSlot.svelte';
28
28
  import BusySlot from './toolbar/slots/BusySlot.svelte';
29
+ import { registerTerminalView, mintTerminalId, type TerminalHandle } from './terminal-registry';
30
+ import { makeDispatchToTerminal } from './dispatch-to-terminal';
29
31
 
30
32
  interface Props {
31
33
  shell: ShellApi;
@@ -116,6 +118,16 @@
116
118
  }),
117
119
  );
118
120
 
121
+ // Focus stamp — bumped when the input receives focus so dispatchToTerminal
122
+ // can pick this terminal over other open terminals. Programmatic dispatches
123
+ // do NOT bump the stamp; otherwise they'd self-elect their target as
124
+ // 'focused' and mask multi-terminal ambiguity.
125
+ let focusStamp = $state(0);
126
+ function bumpFocusStamp(): void {
127
+ focusStamp = performance.now();
128
+ }
129
+ const terminalId = untrack(() => mintTerminalId());
130
+
119
131
  // Active-mode buffer derivation. Reads `mode.id` reactively so that
120
132
  // mode-switch flips ScrollbackView and InputLine bindings in lockstep.
121
133
  const currentBuffer = $derived(getBuffer(mode.id));
@@ -195,6 +207,7 @@
195
207
  },
196
208
  listModes: () => visibleModes.map((m) => ({ id: m.id, label: m.label })),
197
209
  getMode: () => ({ id: mode.id, label: mode.label }),
210
+ dispatchToTerminal: makeDispatchToTerminal({ headless: false }),
198
211
  }));
199
212
 
200
213
  // wsUrl is a prop read at construction only. untrack prevents Svelte 5's
@@ -364,6 +377,7 @@
364
377
  }
365
378
 
366
379
  let unsub: (() => void) | null = null;
380
+ let disposeRegistration: (() => void) | null = null;
367
381
 
368
382
  onMount(() => {
369
383
  unsub = session.onMessage(handleServerMessage);
@@ -379,11 +393,20 @@
379
393
  // negligible, and non-bash modes still need to log mode-tagged
380
394
  // history server-side via history-log messages.
381
395
  session.connect();
396
+
397
+ const handle: TerminalHandle = {
398
+ id: terminalId,
399
+ dispatch,
400
+ getFocusStamp: () => focusStamp,
401
+ getMode: () => mode,
402
+ };
403
+ disposeRegistration = registerTerminalView(handle);
382
404
  });
383
405
 
384
406
  onDestroy(() => {
385
407
  unsub?.();
386
408
  session.close();
409
+ disposeRegistration?.();
387
410
  });
388
411
  </script>
389
412
 
@@ -406,6 +429,7 @@
406
429
  history={currentBuffer.history}
407
430
  {session}
408
431
  onSubmit={dispatch}
432
+ onFocus={bumpFocusStamp}
409
433
  />
410
434
  </div>
411
435
 
@@ -0,0 +1,13 @@
1
+ export type DispatchToTerminalResult = {
2
+ ok: true;
3
+ terminalId: string;
4
+ } | {
5
+ ok: false;
6
+ error: 'no-terminal' | 'ambiguous' | 'no-active-context';
7
+ message: string;
8
+ };
9
+ export interface MakeDispatchToTerminalOpts {
10
+ /** When true, always return 'no-active-context'. */
11
+ headless: boolean;
12
+ }
13
+ export declare function makeDispatchToTerminal(opts: MakeDispatchToTerminalOpts): (line: string) => DispatchToTerminalResult;
@@ -0,0 +1,37 @@
1
+ /*
2
+ * makeDispatchToTerminal — factory returning the `shell.dispatchToTerminal`
3
+ * function. Two flavors:
4
+ * - headless: always returns 'no-active-context'. Used by makeShellApiHeadless
5
+ * so verbs run via runVerbProgrammatic (and node-only tests) get a clean
6
+ * failure instead of hitting the live registry.
7
+ * - live: consults the module-scoped terminal-registry and routes through the
8
+ * resolved handle's dispatch. Used by Terminal.svelte's shellWithModes
9
+ * wrapper.
10
+ *
11
+ * The dispatch itself is fire-and-forget — the function resolves synchronously
12
+ * with the routing outcome (ok or error). Matches InputLine submit semantics:
13
+ * the caller doesn't await the targeted terminal's dispatch.
14
+ */
15
+ import { resolveTargetTerminal } from './terminal-registry';
16
+ const ERROR_MESSAGES = {
17
+ 'no-terminal': 'no terminal open',
18
+ 'ambiguous': 'multiple terminals open; focus one first',
19
+ 'no-active-context': 'dispatchToTerminal is not available in this context',
20
+ };
21
+ export function makeDispatchToTerminal(opts) {
22
+ if (opts.headless) {
23
+ return () => ({
24
+ ok: false,
25
+ error: 'no-active-context',
26
+ message: ERROR_MESSAGES['no-active-context'],
27
+ });
28
+ }
29
+ return (line) => {
30
+ const r = resolveTargetTerminal();
31
+ if (!r.ok) {
32
+ return { ok: false, error: r.error, message: ERROR_MESSAGES[r.error] };
33
+ }
34
+ void r.handle.dispatch(line);
35
+ return { ok: true, terminalId: r.handle.id };
36
+ };
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { registerTerminalView, __resetTerminalRegistryForTest, } from './terminal-registry';
3
+ import { makeDispatchToTerminal } from './dispatch-to-terminal';
4
+ function makeHandle(id, focusStamp = 0, dispatch = vi.fn(async () => undefined)) {
5
+ return {
6
+ id,
7
+ dispatch,
8
+ getFocusStamp: () => focusStamp,
9
+ getMode: () => ({ id: 'sh3', label: 'sh3', transport: 'none', autoRelocate: false, showCwd: true }),
10
+ };
11
+ }
12
+ describe('makeDispatchToTerminal', () => {
13
+ beforeEach(() => {
14
+ __resetTerminalRegistryForTest();
15
+ });
16
+ describe('headless: true', () => {
17
+ const dispatchToTerminal = makeDispatchToTerminal({ headless: true });
18
+ it('returns no-active-context even when handles are registered', () => {
19
+ registerTerminalView(makeHandle('term-1'));
20
+ const r = dispatchToTerminal('foo');
21
+ expect(r).toMatchObject({
22
+ ok: false,
23
+ error: 'no-active-context',
24
+ message: expect.stringContaining('not available'),
25
+ });
26
+ });
27
+ it('returns no-active-context when registry is empty', () => {
28
+ const r = dispatchToTerminal('foo');
29
+ expect(r).toMatchObject({ ok: false, error: 'no-active-context' });
30
+ });
31
+ });
32
+ describe('headless: false (live)', () => {
33
+ const dispatchToTerminal = makeDispatchToTerminal({ headless: false });
34
+ it('returns no-terminal when registry is empty', () => {
35
+ const r = dispatchToTerminal('foo');
36
+ expect(r).toMatchObject({
37
+ ok: false,
38
+ error: 'no-terminal',
39
+ message: 'no terminal open',
40
+ });
41
+ });
42
+ it('returns ambiguous when multiple terminals all have stamp 0', () => {
43
+ registerTerminalView(makeHandle('term-a', 0));
44
+ registerTerminalView(makeHandle('term-b', 0));
45
+ const r = dispatchToTerminal('foo');
46
+ expect(r).toMatchObject({
47
+ ok: false,
48
+ error: 'ambiguous',
49
+ message: 'multiple terminals open; focus one first',
50
+ });
51
+ });
52
+ it('hands the line to the resolved handle and returns ok with terminalId', () => {
53
+ const dispatch = vi.fn(async () => undefined);
54
+ registerTerminalView(makeHandle('term-1', 0, dispatch));
55
+ const r = dispatchToTerminal('foo bar baz');
56
+ expect(r).toEqual({ ok: true, terminalId: 'term-1' });
57
+ expect(dispatch).toHaveBeenCalledWith('foo bar baz');
58
+ });
59
+ it('targets the highest-stamped handle when multiple are registered', () => {
60
+ const dispatchA = vi.fn(async () => undefined);
61
+ const dispatchB = vi.fn(async () => undefined);
62
+ registerTerminalView(makeHandle('term-a', 100, dispatchA));
63
+ registerTerminalView(makeHandle('term-b', 200, dispatchB));
64
+ const r = dispatchToTerminal('hi');
65
+ expect(r).toEqual({ ok: true, terminalId: 'term-b' });
66
+ expect(dispatchA).not.toHaveBeenCalled();
67
+ expect(dispatchB).toHaveBeenCalledWith('hi');
68
+ });
69
+ it('does not await the dispatch (returns synchronously even when dispatch is slow)', async () => {
70
+ let resolveDispatch;
71
+ const slow = vi.fn(() => new Promise((res) => { resolveDispatch = res; }));
72
+ registerTerminalView(makeHandle('term-1', 0, slow));
73
+ const r = dispatchToTerminal('hi');
74
+ expect(r).toEqual({ ok: true, terminalId: 'term-1' });
75
+ expect(slow).toHaveBeenCalledWith('hi');
76
+ resolveDispatch();
77
+ });
78
+ });
79
+ });
@@ -15,6 +15,7 @@ import { registeredShards, listStandaloneViews } from '../shards/activate.svelte
15
15
  import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout, locateSlot as locateSlotInActiveLayout, } from '../layout/inspection';
16
16
  import { floatManager } from '../overlays/float';
17
17
  import { getUser, isAdmin } from '../auth/index';
18
+ import { makeDispatchToTerminal } from './dispatch-to-terminal';
18
19
  const KNOWN_ZONES = ['ephemeral', 'session', 'workspace', 'user'];
19
20
  function collectTabEntries(node) {
20
21
  if (node.type === 'tabs') {
@@ -164,6 +165,7 @@ export function makeShellApiHeadless(zones) {
164
165
  setMode(_id) { return false; },
165
166
  listModes() { return []; },
166
167
  getMode() { return { id: 'sh3', label: 'sh3' }; },
168
+ dispatchToTerminal: makeDispatchToTerminal({ headless: true }),
167
169
  };
168
170
  }
169
171
  export function makeShellApiForTest() {
@@ -0,0 +1,25 @@
1
+ import type { ShellMode } from './modes/types';
2
+ export interface TerminalHandle {
3
+ /** Stable per-mount id, e.g. `term-${counter}`. */
4
+ id: string;
5
+ /** Same dispatch fn `InputLine` calls on submit. */
6
+ dispatch: (line: string) => Promise<void>;
7
+ /** Monotonic stamp; 0 if the terminal has never been focused. */
8
+ getFocusStamp: () => number;
9
+ /** Currently active ShellMode for this terminal (informational). */
10
+ getMode: () => ShellMode;
11
+ }
12
+ export type ResolveTargetResult = {
13
+ ok: true;
14
+ handle: TerminalHandle;
15
+ } | {
16
+ ok: false;
17
+ error: 'no-terminal' | 'ambiguous';
18
+ };
19
+ /** Mint a stable id for a Terminal.svelte instance. Called once per mount. */
20
+ export declare function mintTerminalId(): string;
21
+ export declare function registerTerminalView(handle: TerminalHandle): () => void;
22
+ export declare function listTerminalViews(): TerminalHandle[];
23
+ export declare function resolveTargetTerminal(): ResolveTargetResult;
24
+ /** Test-only — wipes the registry and id counter between cases. */
25
+ export declare function __resetTerminalRegistryForTest(): void;
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Module-scoped registry of live Terminal.svelte instances.
3
+ *
4
+ * Used by `shell.dispatchToTerminal(line)` to find a target terminal when a
5
+ * non-terminal view (floating picker, dialog) wants to drive a terminal as
6
+ * if the user had typed the line. Each Terminal.svelte instance registers
7
+ * itself on mount and disposes on destroy.
8
+ *
9
+ * Resolution rule (resolveTargetTerminal):
10
+ * - empty registry → { ok: false, error: 'no-terminal' }
11
+ * - exactly one handle → { ok: true, handle }
12
+ * - >1 handles, all stamps 0 → { ok: false, error: 'ambiguous' }
13
+ * - >1 handles, ≥1 non-zero → highest-stamped wins; equal non-zero stamps
14
+ * break to last-registered.
15
+ *
16
+ * No Svelte runes here — the registry is plain module state so node-project
17
+ * tests can drive it directly.
18
+ */
19
+ const handles = [];
20
+ let nextTerminalId = 0;
21
+ /** Mint a stable id for a Terminal.svelte instance. Called once per mount. */
22
+ export function mintTerminalId() {
23
+ return `term-${++nextTerminalId}`;
24
+ }
25
+ export function registerTerminalView(handle) {
26
+ handles.push(handle);
27
+ let disposed = false;
28
+ return () => {
29
+ if (disposed)
30
+ return;
31
+ disposed = true;
32
+ const idx = handles.indexOf(handle);
33
+ if (idx !== -1)
34
+ handles.splice(idx, 1);
35
+ };
36
+ }
37
+ export function listTerminalViews() {
38
+ return handles.slice();
39
+ }
40
+ export function resolveTargetTerminal() {
41
+ if (handles.length === 0)
42
+ return { ok: false, error: 'no-terminal' };
43
+ if (handles.length === 1)
44
+ return { ok: true, handle: handles[0] };
45
+ let best = null;
46
+ let bestStamp = 0;
47
+ for (const h of handles) {
48
+ const stamp = h.getFocusStamp();
49
+ if (stamp > 0 && stamp >= bestStamp) {
50
+ best = h;
51
+ bestStamp = stamp;
52
+ }
53
+ }
54
+ if (best === null)
55
+ return { ok: false, error: 'ambiguous' };
56
+ return { ok: true, handle: best };
57
+ }
58
+ /** Test-only — wipes the registry and id counter between cases. */
59
+ export function __resetTerminalRegistryForTest() {
60
+ handles.length = 0;
61
+ nextTerminalId = 0;
62
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { registerTerminalView, listTerminalViews, resolveTargetTerminal, mintTerminalId, __resetTerminalRegistryForTest, } from './terminal-registry';
3
+ function makeHandle(id, focusStamp = 0) {
4
+ return {
5
+ id,
6
+ dispatch: async () => undefined,
7
+ getFocusStamp: () => focusStamp,
8
+ getMode: () => ({ id: 'sh3', label: 'sh3', transport: 'none', autoRelocate: false, showCwd: true }),
9
+ };
10
+ }
11
+ describe('terminal-registry', () => {
12
+ beforeEach(() => {
13
+ __resetTerminalRegistryForTest();
14
+ });
15
+ describe('registerTerminalView', () => {
16
+ it('returns a disposer that removes the handle', () => {
17
+ const h = makeHandle('term-1');
18
+ const dispose = registerTerminalView(h);
19
+ expect(listTerminalViews()).toEqual([h]);
20
+ dispose();
21
+ expect(listTerminalViews()).toEqual([]);
22
+ });
23
+ it('disposer is idempotent', () => {
24
+ const dispose = registerTerminalView(makeHandle('term-1'));
25
+ dispose();
26
+ expect(() => dispose()).not.toThrow();
27
+ expect(listTerminalViews()).toEqual([]);
28
+ });
29
+ it('registers multiple handles in order', () => {
30
+ const a = makeHandle('term-a');
31
+ const b = makeHandle('term-b');
32
+ registerTerminalView(a);
33
+ registerTerminalView(b);
34
+ expect(listTerminalViews()).toEqual([a, b]);
35
+ });
36
+ });
37
+ describe('resolveTargetTerminal', () => {
38
+ it('returns no-terminal when registry is empty', () => {
39
+ expect(resolveTargetTerminal()).toEqual({ ok: false, error: 'no-terminal' });
40
+ });
41
+ it('returns the sole handle when exactly one is registered', () => {
42
+ const h = makeHandle('term-1');
43
+ registerTerminalView(h);
44
+ expect(resolveTargetTerminal()).toEqual({ ok: true, handle: h });
45
+ });
46
+ it('returns ambiguous when multiple handles all have stamp 0', () => {
47
+ registerTerminalView(makeHandle('term-a', 0));
48
+ registerTerminalView(makeHandle('term-b', 0));
49
+ expect(resolveTargetTerminal()).toEqual({ ok: false, error: 'ambiguous' });
50
+ });
51
+ it('returns the handle with the highest non-zero stamp', () => {
52
+ const a = makeHandle('term-a', 100);
53
+ const b = makeHandle('term-b', 200);
54
+ registerTerminalView(a);
55
+ registerTerminalView(b);
56
+ const r = resolveTargetTerminal();
57
+ expect(r).toEqual({ ok: true, handle: b });
58
+ });
59
+ it('returns the highest-stamped handle when one has stamp 0 and the other does not', () => {
60
+ const a = makeHandle('term-a', 0);
61
+ const b = makeHandle('term-b', 50);
62
+ registerTerminalView(a);
63
+ registerTerminalView(b);
64
+ expect(resolveTargetTerminal()).toEqual({ ok: true, handle: b });
65
+ });
66
+ it('breaks equal non-zero stamps by last-registered', () => {
67
+ const a = makeHandle('term-a', 100);
68
+ const b = makeHandle('term-b', 100);
69
+ registerTerminalView(a);
70
+ registerTerminalView(b);
71
+ expect(resolveTargetTerminal()).toEqual({ ok: true, handle: b });
72
+ });
73
+ });
74
+ describe('mintTerminalId', () => {
75
+ it('produces unique ids on each call', () => {
76
+ const a = mintTerminalId();
77
+ const b = mintTerminalId();
78
+ expect(a).not.toEqual(b);
79
+ expect(a).toMatch(/^term-\d+$/);
80
+ });
81
+ it('is reset by __resetTerminalRegistryForTest', () => {
82
+ mintTerminalId();
83
+ mintTerminalId();
84
+ __resetTerminalRegistryForTest();
85
+ expect(mintTerminalId()).toBe('term-1');
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,15 @@
1
+ import { type SatellitePayload } from '../boot/satellitePayload';
2
+ export interface SpawnSatelliteOptions {
3
+ /** Suggested window title. Defaults to a payload-derived title. */
4
+ title?: string;
5
+ /** Initial window inner width in CSS px. */
6
+ width?: number;
7
+ /** Initial window inner height in CSS px. */
8
+ height?: number;
9
+ }
10
+ /**
11
+ * Open a new native window rendering a satellite slice of SH3.
12
+ * Returns the unique label assigned to the new window, or an empty
13
+ * string when called outside Tauri.
14
+ */
15
+ export declare function spawnSatellite(payload: SatellitePayload, opts?: SpawnSatelliteOptions): Promise<string>;
@@ -0,0 +1,43 @@
1
+ /*
2
+ * shell.window — host-side facade for spawning satellite windows.
3
+ *
4
+ * Under Tauri, `spawnSatellite` invokes the `sh3_spawn_satellite` Rust
5
+ * command which opens a native WebviewWindow pointed at the embedded
6
+ * sh3-server with the encoded payload in the query string. Outside
7
+ * Tauri (web builds) the call logs a warning and resolves with an
8
+ * empty string so callers can no-op gracefully.
9
+ */
10
+ import { encodePayload } from '../boot/satellitePayload';
11
+ function isTauri() {
12
+ return typeof globalThis.__TAURI_INTERNALS__ !== 'undefined';
13
+ }
14
+ let labelCounter = 0;
15
+ function nextLabel() {
16
+ labelCounter += 1;
17
+ return `sh3-satellite-${Date.now()}-${labelCounter}`;
18
+ }
19
+ /**
20
+ * Open a new native window rendering a satellite slice of SH3.
21
+ * Returns the unique label assigned to the new window, or an empty
22
+ * string when called outside Tauri.
23
+ */
24
+ export async function spawnSatellite(payload, opts = {}) {
25
+ var _a, _b, _c, _d;
26
+ if (!isTauri()) {
27
+ console.warn('[sh3] shell.window.spawn called outside Tauri — no-op');
28
+ return '';
29
+ }
30
+ const { invoke } = await import('@tauri-apps/api/core');
31
+ const encoded = encodePayload(payload);
32
+ const label = nextLabel();
33
+ const title = (_a = opts.title) !== null && _a !== void 0 ? _a : (payload.kind === 'float' ? ((_b = payload.title) !== null && _b !== void 0 ? _b : 'SH3') : `SH3 — ${payload.appId}`);
34
+ const width = (_c = opts.width) !== null && _c !== void 0 ? _c : (payload.kind === 'float' ? payload.size.w : 1024);
35
+ const height = (_d = opts.height) !== null && _d !== void 0 ? _d : (payload.kind === 'float' ? payload.size.h : 768);
36
+ return invoke('sh3_spawn_satellite', {
37
+ payload: encoded,
38
+ label,
39
+ title,
40
+ width,
41
+ height,
42
+ });
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { spawnSatellite } from './window';
3
+ describe('shell.window.spawn (non-Tauri)', () => {
4
+ beforeEach(() => {
5
+ delete globalThis.__TAURI_INTERNALS__;
6
+ });
7
+ it('returns empty string and warns when called without Tauri', async () => {
8
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
9
+ const payload = {
10
+ kind: 'app',
11
+ appId: 'x',
12
+ activateShards: [],
13
+ };
14
+ const result = await spawnSatellite(payload);
15
+ expect(result).toBe('');
16
+ expect(warn).toHaveBeenCalled();
17
+ warn.mockRestore();
18
+ });
19
+ });
@@ -9,6 +9,7 @@ import type { ConflictsApi } from './conflicts/api';
9
9
  import type { ColorApi } from './color/api';
10
10
  import { type OpenContextMenuOpts, type OpenPaletteOpts } from './actions/listeners';
11
11
  import type { ActiveActionDescriptor } from './actions/types';
12
+ import { type DispatchToTerminalResult } from './shell-shard/dispatch-to-terminal';
12
13
  /**
13
14
  * The process-wide shell singleton exposed to shards and the shell's own
14
15
  * internal code. Provides state zone creation and overlay managers.
@@ -38,6 +39,17 @@ export interface Shell {
38
39
  color: ColorApi;
39
40
  /** Actions facade — rebind keys, query bindings, open menus/palette. */
40
41
  actions: ShellActionsApi;
42
+ /**
43
+ * Dispatch `line` through a Terminal view's normal submit path. Used by
44
+ * views outside a verb context (floating pickers, dialogs) to drive a
45
+ * terminal as if the user had typed the line. Resolves which terminal:
46
+ * focused → sole → fail. Returns synchronously with the routing outcome;
47
+ * the dispatch itself runs fire-and-forget.
48
+ *
49
+ * Mirrors `ShellApi.dispatchToTerminal` (the verb-side surface). Both
50
+ * read the same module-scoped registry of live Terminal.svelte instances.
51
+ */
52
+ dispatchToTerminal(line: string): DispatchToTerminalResult;
41
53
  }
42
54
  /**
43
55
  * API for managing action bindings and triggering menus/palette
@@ -27,6 +27,7 @@ import { openContextMenu as listenersOpenContextMenu, openPalette as listenersOp
27
27
  import { setUserBindings, getLiveDispatcherState, onActiveChange as onActiveChangeState, __notifyActiveChange, } from './actions/state.svelte';
28
28
  import { listActions, onActionsChange } from './actions/registry';
29
29
  import { listActiveFromEntries } from './actions/listActive';
30
+ import { makeDispatchToTerminal } from './shell-shard/dispatch-to-terminal';
30
31
  const shellActions = {
31
32
  async rebind(appId, actionId, shortcut) {
32
33
  await saveUserBinding(appId, actionId, shortcut);
@@ -75,4 +76,5 @@ export const shell = {
75
76
  conflicts: conflictsApi,
76
77
  color: colorApi,
77
78
  actions: shellActions,
79
+ dispatchToTerminal: makeDispatchToTerminal({ headless: false }),
78
80
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ /*
2
+ * Targeted coverage for the `shell` runtime singleton's dispatchToTerminal.
3
+ *
4
+ * The wiring is what matters: the singleton must consult the same module-
5
+ * scoped terminal-registry as ShellApi.dispatchToTerminal so a Svelte view
6
+ * (which imports `shell`) and a verb (which receives `vctx.shell`) hit the
7
+ * same set of live Terminal handles. A view import is the only path a
8
+ * picker has — without this, the dispatchToTerminal API is unreachable
9
+ * from the place it was designed to be called.
10
+ */
11
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
12
+ import { shell } from './shellRuntime.svelte';
13
+ import { registerTerminalView, __resetTerminalRegistryForTest, } from './shell-shard/terminal-registry';
14
+ function makeHandle(id, focusStamp = 0, dispatch = vi.fn(async () => undefined)) {
15
+ return {
16
+ id,
17
+ dispatch,
18
+ getFocusStamp: () => focusStamp,
19
+ getMode: () => ({ id: 'sh3', label: 'sh3', transport: 'none', autoRelocate: false, showCwd: true }),
20
+ };
21
+ }
22
+ describe('shell runtime singleton — dispatchToTerminal', () => {
23
+ beforeEach(() => {
24
+ __resetTerminalRegistryForTest();
25
+ });
26
+ it('returns no-terminal when registry is empty', () => {
27
+ const r = shell.dispatchToTerminal('foo');
28
+ expect(r).toMatchObject({ ok: false, error: 'no-terminal', message: 'no terminal open' });
29
+ });
30
+ it('routes through the resolved handle when one is registered', () => {
31
+ const dispatch = vi.fn(async () => undefined);
32
+ registerTerminalView(makeHandle('term-1', 0, dispatch));
33
+ const r = shell.dispatchToTerminal('apps');
34
+ expect(r).toEqual({ ok: true, terminalId: 'term-1' });
35
+ expect(dispatch).toHaveBeenCalledWith('apps');
36
+ });
37
+ it('shares the registry with ShellApi.dispatchToTerminal (same handles, same routing)', () => {
38
+ const dispatch = vi.fn(async () => undefined);
39
+ registerTerminalView(makeHandle('term-shared', 0, dispatch));
40
+ // The singleton is the surface a Svelte view sees; it must hit the
41
+ // same registry as the verb-side ShellApi.
42
+ const r = shell.dispatchToTerminal('hi');
43
+ expect(r).toEqual({ ok: true, terminalId: 'term-shared' });
44
+ expect(dispatch).toHaveBeenCalledWith('hi');
45
+ });
46
+ });
@@ -2,6 +2,7 @@ import type { Scrollback } from '../shell-shard/scrollback.svelte';
2
2
  import type { SessionClient } from '../shell-shard/session-client.svelte';
3
3
  import type { TenantFsClient } from '../shell-shard/tenant-fs-client';
4
4
  import type { TreeRootRef } from '../layout/types';
5
+ import type { DispatchToTerminalResult } from '../shell-shard/dispatch-to-terminal';
5
6
  export interface ShellApi {
6
7
  listApps(): Array<{
7
8
  id: string;
@@ -86,7 +87,21 @@ export interface ShellApi {
86
87
  id: string;
87
88
  label: string;
88
89
  };
90
+ /**
91
+ * Dispatch `line` through a Terminal view's normal submit path. Resolves
92
+ * which terminal to target: the focused one if any has been focused; the
93
+ * sole one if exactly one terminal is open; fail otherwise. The dispatch
94
+ * runs fire-and-forget on the targeted terminal — this method returns
95
+ * synchronously with the routing outcome.
96
+ *
97
+ * Used by views outside a verb context (floating pickers, dialogs) to
98
+ * drive a terminal as if the user had typed the line. The targeted
99
+ * terminal's current mode + cwd apply — `line` is resolved against
100
+ * its verb registry, gating, and history exactly like a typed submit.
101
+ */
102
+ dispatchToTerminal(line: string): DispatchToTerminalResult;
89
103
  }
104
+ export type { DispatchToTerminalResult } from '../shell-shard/dispatch-to-terminal';
90
105
  export interface VerbContext {
91
106
  shell: ShellApi;
92
107
  scrollback: Scrollback;
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.15.2";
2
+ export declare const VERSION = "0.15.4";
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.15.2';
2
+ export const VERSION = '0.15.4';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"