sh3-core 0.7.5 → 0.8.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 (129) hide show
  1. package/dist/api.d.ts +11 -2
  2. package/dist/api.js +13 -1
  3. package/dist/app/store/StoreView.svelte +36 -7
  4. package/dist/app/store/storeShard.svelte.js +9 -3
  5. package/dist/app/store/verbs.js +8 -2
  6. package/dist/apps/lifecycle.d.ts +11 -0
  7. package/dist/apps/lifecycle.js +21 -1
  8. package/dist/apps/lifecycle.test.js +50 -1
  9. package/dist/apps/types.d.ts +7 -2
  10. package/dist/createShell.d.ts +2 -0
  11. package/dist/createShell.js +9 -7
  12. package/dist/documents/handle.js +5 -0
  13. package/dist/documents/index.d.ts +1 -0
  14. package/dist/documents/index.js +1 -0
  15. package/dist/documents/journal-hook.d.ts +6 -0
  16. package/dist/documents/journal-hook.js +16 -0
  17. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  18. package/dist/documents/sync/activate-integration.test.js +37 -0
  19. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  20. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  21. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  22. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  23. package/dist/documents/sync/conflicts.d.ts +30 -0
  24. package/dist/documents/sync/conflicts.js +77 -0
  25. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  26. package/dist/documents/sync/conflicts.test.js +71 -0
  27. package/dist/documents/sync/engine.d.ts +19 -0
  28. package/dist/documents/sync/engine.js +188 -0
  29. package/dist/documents/sync/engine.test.d.ts +1 -0
  30. package/dist/documents/sync/engine.test.js +169 -0
  31. package/dist/documents/sync/handle.d.ts +11 -0
  32. package/dist/documents/sync/handle.js +79 -0
  33. package/dist/documents/sync/handle.test.d.ts +1 -0
  34. package/dist/documents/sync/handle.test.js +56 -0
  35. package/dist/documents/sync/hash.d.ts +1 -0
  36. package/dist/documents/sync/hash.js +13 -0
  37. package/dist/documents/sync/hash.test.d.ts +1 -0
  38. package/dist/documents/sync/hash.test.js +20 -0
  39. package/dist/documents/sync/index.d.ts +6 -0
  40. package/dist/documents/sync/index.js +12 -0
  41. package/dist/documents/sync/journal.d.ts +30 -0
  42. package/dist/documents/sync/journal.js +179 -0
  43. package/dist/documents/sync/journal.test.d.ts +1 -0
  44. package/dist/documents/sync/journal.test.js +87 -0
  45. package/dist/documents/sync/registry.d.ts +10 -0
  46. package/dist/documents/sync/registry.js +66 -0
  47. package/dist/documents/sync/registry.test.d.ts +1 -0
  48. package/dist/documents/sync/registry.test.js +42 -0
  49. package/dist/documents/sync/serialization.d.ts +5 -0
  50. package/dist/documents/sync/serialization.js +24 -0
  51. package/dist/documents/sync/serialization.test.d.ts +1 -0
  52. package/dist/documents/sync/serialization.test.js +26 -0
  53. package/dist/documents/sync/singleton.d.ts +11 -0
  54. package/dist/documents/sync/singleton.js +26 -0
  55. package/dist/documents/sync/tombstones.d.ts +19 -0
  56. package/dist/documents/sync/tombstones.js +58 -0
  57. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  58. package/dist/documents/sync/tombstones.test.js +37 -0
  59. package/dist/documents/sync/types.d.ts +116 -0
  60. package/dist/documents/sync/types.js +27 -0
  61. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  62. package/dist/documents/sync/write-hook.test.js +36 -0
  63. package/dist/env/client.d.ts +10 -5
  64. package/dist/env/client.js +12 -4
  65. package/dist/registry/installer.d.ts +10 -7
  66. package/dist/registry/installer.js +39 -35
  67. package/dist/registry/register.d.ts +17 -0
  68. package/dist/registry/register.js +22 -0
  69. package/dist/registry/register.test.d.ts +1 -0
  70. package/dist/registry/register.test.js +28 -0
  71. package/dist/shards/activate.svelte.js +23 -2
  72. package/dist/shards/types.d.ts +10 -1
  73. package/dist/shell-shard/Terminal.svelte +140 -33
  74. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  75. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  76. package/dist/shell-shard/auto-relocate.js +20 -0
  77. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  78. package/dist/shell-shard/auto-relocate.test.js +35 -0
  79. package/dist/shell-shard/dispatch.d.ts +15 -0
  80. package/dist/shell-shard/dispatch.js +56 -0
  81. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  82. package/dist/shell-shard/modes/builtin.js +18 -0
  83. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  84. package/dist/shell-shard/modes/prefs.js +31 -0
  85. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  86. package/dist/shell-shard/modes/prefs.test.js +46 -0
  87. package/dist/shell-shard/modes/registry.d.ts +7 -0
  88. package/dist/shell-shard/modes/registry.js +27 -0
  89. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  90. package/dist/shell-shard/modes/registry.test.js +35 -0
  91. package/dist/shell-shard/modes/types.d.ts +8 -0
  92. package/dist/shell-shard/modes/types.js +1 -0
  93. package/dist/shell-shard/protocol.d.ts +6 -0
  94. package/dist/shell-shard/shellShard.svelte.js +5 -1
  95. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  96. package/dist/shell-shard/tenant-fs-client.js +44 -0
  97. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  98. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  99. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  100. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  101. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  102. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  103. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  104. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  105. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  106. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  107. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  108. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  109. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  110. package/dist/shell-shard/toolbar/slots.js +26 -0
  111. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  112. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  113. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  114. package/dist/shell-shard/verbs/cat.js +34 -0
  115. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  116. package/dist/shell-shard/verbs/cd.test.js +56 -0
  117. package/dist/shell-shard/verbs/env.d.ts +2 -0
  118. package/dist/shell-shard/verbs/env.js +14 -0
  119. package/dist/shell-shard/verbs/index.js +6 -1
  120. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  121. package/dist/shell-shard/verbs/ls.js +29 -0
  122. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  123. package/dist/shell-shard/verbs/ls.test.js +49 -0
  124. package/dist/shell-shard/verbs/session.d.ts +0 -1
  125. package/dist/shell-shard/verbs/session.js +58 -26
  126. package/dist/verbs/types.d.ts +2 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/package.json +1 -1
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { ShellModeRegistry } from './registry';
3
+ const dev = { id: 'dev', label: 'Dev', requiresRole: 'admin', transport: 'ws', autoRelocate: false };
4
+ const user = { id: 'user', label: 'User', transport: 'none', autoRelocate: true };
5
+ const ssh = { id: 'ssh', label: 'SSH', requiresRole: 'admin', transport: 'custom', autoRelocate: false };
6
+ describe('ShellModeRegistry', () => {
7
+ let reg;
8
+ beforeEach(() => {
9
+ reg = new ShellModeRegistry();
10
+ });
11
+ it('registers and retrieves modes', () => {
12
+ reg.register(dev);
13
+ expect(reg.get('dev')).toEqual(dev);
14
+ });
15
+ it('list(user) excludes admin-only modes', () => {
16
+ reg.register(dev);
17
+ reg.register(user);
18
+ reg.register(ssh);
19
+ const ids = reg.list('user').map((m) => m.id);
20
+ expect(ids).toEqual(['user']);
21
+ });
22
+ it('list(admin) includes all modes', () => {
23
+ reg.register(dev);
24
+ reg.register(user);
25
+ reg.register(ssh);
26
+ const ids = reg.list('admin').map((m) => m.id).sort();
27
+ expect(ids).toEqual(['dev', 'ssh', 'user']);
28
+ });
29
+ it('re-registering same id replaces the mode', () => {
30
+ var _a;
31
+ reg.register(dev);
32
+ reg.register(Object.assign(Object.assign({}, dev), { label: 'Dev+' }));
33
+ expect((_a = reg.get('dev')) === null || _a === void 0 ? void 0 : _a.label).toBe('Dev+');
34
+ });
35
+ });
@@ -0,0 +1,8 @@
1
+ export type ShellRole = 'admin' | 'user';
2
+ export interface ShellMode {
3
+ id: string;
4
+ label: string;
5
+ requiresRole?: 'admin';
6
+ transport: 'ws' | 'none' | 'custom';
7
+ autoRelocate: boolean;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -23,6 +23,12 @@ export type ClientMessage =
23
23
  /** Ask the server for its current cwd. */
24
24
  | {
25
25
  t: 'cwd-query';
26
+ }
27
+ /** Push a cwd change from the frontend (docs tree, file explorer, etc.).
28
+ * Server resolves `path` relative to current cwd, validates, broadcasts. */
29
+ | {
30
+ t: 'setCwd';
31
+ path: string;
26
32
  };
27
33
  export type ServerMessage =
28
34
  /** Sent once on successful attach, immediately after hello. */
@@ -119,12 +119,16 @@ export const shellShard = {
119
119
  const shell = makeShellApi(ctx);
120
120
  const factory = {
121
121
  mount(container, _context) {
122
+ var _a;
122
123
  const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
123
124
  const host = typeof location !== 'undefined' ? location.host : 'localhost';
124
125
  const wsUrl = `${proto}://${host}/api/shell/session`;
126
+ const user = getUser();
127
+ const userId = (_a = user === null || user === void 0 ? void 0 : user.id) !== null && _a !== void 0 ? _a : 'guest';
128
+ const role = isAdmin() ? 'admin' : 'user';
125
129
  const instance = mount(Terminal, {
126
130
  target: container,
127
- props: { shell, wsUrl },
131
+ props: { shell, wsUrl, userId, role },
128
132
  });
129
133
  return {
130
134
  unmount() {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Client for /api/fs/*. Read-only today.
3
+ * Shared across all shell modes so verb implementations stay mode-agnostic.
4
+ */
5
+ export interface FsEntry {
6
+ name: string;
7
+ kind: 'file' | 'dir';
8
+ size: number;
9
+ mtime: number;
10
+ }
11
+ export interface FsStat {
12
+ name: string;
13
+ kind: 'file' | 'dir';
14
+ size: number;
15
+ mtime: number;
16
+ }
17
+ export declare class TenantFsClient {
18
+ #private;
19
+ private readonly base;
20
+ constructor(base?: string);
21
+ list(path: string): Promise<FsEntry[]>;
22
+ stat(path: string): Promise<FsStat>;
23
+ read(path: string): Promise<string>;
24
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Client for /api/fs/*. Read-only today.
3
+ * Shared across all shell modes so verb implementations stay mode-agnostic.
4
+ */
5
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
6
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
7
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
8
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
9
+ };
10
+ var _TenantFsClient_instances, _TenantFsClient_get;
11
+ export class TenantFsClient {
12
+ constructor(base = '') {
13
+ _TenantFsClient_instances.add(this);
14
+ this.base = base;
15
+ }
16
+ async list(path) {
17
+ const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/list', path);
18
+ const body = await res.json();
19
+ return body.entries;
20
+ }
21
+ async stat(path) {
22
+ const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/stat', path);
23
+ return await res.json();
24
+ }
25
+ async read(path) {
26
+ const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/read', path);
27
+ return await res.text();
28
+ }
29
+ }
30
+ _TenantFsClient_instances = new WeakSet(), _TenantFsClient_get = async function _TenantFsClient_get(route, path) {
31
+ const url = `${this.base}${route}?path=${encodeURIComponent(path)}`;
32
+ const res = await fetch(url, { credentials: 'include' });
33
+ if (!res.ok) {
34
+ let msg = `HTTP ${res.status}`;
35
+ try {
36
+ const j = await res.json();
37
+ if (j.error)
38
+ msg = j.error;
39
+ }
40
+ catch ( /* non-json body */_a) { /* non-json body */ }
41
+ throw new Error(msg);
42
+ }
43
+ return res;
44
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { TenantFsClient } from './tenant-fs-client';
3
+ beforeEach(() => {
4
+ vi.restoreAllMocks();
5
+ });
6
+ function mockFetchJson(status, body) {
7
+ globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(body), {
8
+ status,
9
+ headers: { 'content-type': 'application/json' },
10
+ }));
11
+ }
12
+ describe('TenantFsClient.list', () => {
13
+ it('GETs /api/fs/list with the encoded path', async () => {
14
+ const calls = [];
15
+ globalThis.fetch = vi.fn(async (url) => {
16
+ calls.push(String(url));
17
+ return new Response(JSON.stringify({ entries: [{ name: 'a', kind: 'file', size: 1, mtime: 0 }] }), { status: 200 });
18
+ });
19
+ const c = new TenantFsClient();
20
+ const entries = await c.list('sub dir');
21
+ expect(calls[0]).toContain('/api/fs/list?path=sub%20dir');
22
+ expect(entries).toHaveLength(1);
23
+ });
24
+ it('throws on non-2xx', async () => {
25
+ mockFetchJson(403, { error: 'forbidden' });
26
+ const c = new TenantFsClient();
27
+ await expect(c.list('..')).rejects.toThrow(/forbidden/);
28
+ });
29
+ });
30
+ describe('TenantFsClient.stat', () => {
31
+ it('returns kind and size', async () => {
32
+ mockFetchJson(200, { name: 'a.txt', kind: 'file', size: 5, mtime: 0 });
33
+ const c = new TenantFsClient();
34
+ const s = await c.stat('a.txt');
35
+ expect(s).toMatchObject({ kind: 'file', size: 5 });
36
+ });
37
+ });
38
+ describe('TenantFsClient.read', () => {
39
+ it('returns body text on 200', async () => {
40
+ globalThis.fetch = vi.fn(async () => new Response('hello', { status: 200 }));
41
+ const c = new TenantFsClient();
42
+ expect(await c.read('a.txt')).toBe('hello');
43
+ });
44
+ it('throws structured error on 413', async () => {
45
+ mockFetchJson(413, { error: 'file too large' });
46
+ const c = new TenantFsClient();
47
+ await expect(c.read('big.bin')).rejects.toThrow(/too large/);
48
+ });
49
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { makeDispatch } from './dispatch';
3
+ function scaffold(modeId) {
4
+ const sent = [];
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 };
9
+ const scrollback = { push: (e) => pushed.push(e) };
10
+ const session = {
11
+ history: { push: vi.fn() },
12
+ send: (m) => sent.push(m),
13
+ cwd: '/',
14
+ };
15
+ const fs = {};
16
+ const shell = {};
17
+ const resolver = {
18
+ resolve: (line) => line.startsWith('pwd')
19
+ ? { kind: 'local', verb: { name: 'pwd', run: async () => { } }, args: [], line }
20
+ : { kind: 'forward', line },
21
+ };
22
+ const dispatch = makeDispatch({
23
+ mode: () => mode,
24
+ resolver,
25
+ scrollback,
26
+ session,
27
+ shell,
28
+ fs,
29
+ cwd: () => '/',
30
+ });
31
+ return { dispatch, sent, pushed };
32
+ }
33
+ describe('dispatch — user mode', () => {
34
+ it('unknown verbs print error, do not send', async () => {
35
+ const { dispatch, sent, pushed } = scaffold('user');
36
+ await dispatch('foo');
37
+ expect(sent).toEqual([]);
38
+ expect(pushed.some((e) => e.kind === 'status' && /unknown verb/.test(e.text))).toBe(true);
39
+ });
40
+ it('$ escape prints error', async () => {
41
+ const { dispatch, sent, pushed } = scaffold('user');
42
+ await dispatch('$ ls');
43
+ expect(sent).toEqual([]);
44
+ expect(pushed.some((e) => e.kind === 'status' && /server shell not available/.test(e.text))).toBe(true);
45
+ });
46
+ });
47
+ describe('dispatch — dev mode', () => {
48
+ it('forwards unknown verbs to server', async () => {
49
+ const { dispatch, sent } = scaffold('dev');
50
+ await dispatch('foo');
51
+ expect(sent.some((m) => m.t === 'submit' && m.line === 'foo')).toBe(true);
52
+ });
53
+ });
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import type { ToolbarSlotRegistry, ShellSlotCtx } from './slots';
3
+
4
+ interface Props {
5
+ registry: ToolbarSlotRegistry;
6
+ ctx: ShellSlotCtx;
7
+ expanded: boolean;
8
+ onToggle: () => void;
9
+ slotProps?: Record<string, Record<string, unknown>>;
10
+ }
11
+
12
+ let { registry, ctx, expanded, onToggle, slotProps = {} }: Props = $props();
13
+
14
+ let slots = $derived(registry.list(ctx));
15
+ </script>
16
+
17
+ <div class="toolbar" class:collapsed={!expanded}>
18
+ <button class="toolbar-toggle" onclick={onToggle} title={expanded ? 'Collapse toolbar' : 'Expand toolbar'}>
19
+ {expanded ? '▲' : '▼'}
20
+ </button>
21
+ {#if expanded}
22
+ <div class="toolbar-slots">
23
+ {#each slots as s (s.id)}
24
+ {@const Slot = s.component}
25
+ <Slot {...(slotProps[s.id] ?? {})} />
26
+ {/each}
27
+ </div>
28
+ {/if}
29
+ </div>
30
+
31
+ <style>
32
+ .toolbar {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 6px;
36
+ padding: 2px 6px;
37
+ background: var(--shell-toolbar-bg, #1a1a1a);
38
+ border-bottom: 1px solid var(--shell-border, #333);
39
+ flex-shrink: 0;
40
+ min-height: 24px;
41
+ }
42
+
43
+ .toolbar-toggle {
44
+ background: none;
45
+ border: none;
46
+ color: var(--shell-fg-dim, #888);
47
+ cursor: pointer;
48
+ font-size: 0.7em;
49
+ padding: 0 2px;
50
+ line-height: 1;
51
+ }
52
+
53
+ .toolbar-toggle:hover {
54
+ color: var(--shell-fg, #ddd);
55
+ }
56
+
57
+ .toolbar-slots {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 8px;
61
+ }
62
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { ToolbarSlotRegistry, ShellSlotCtx } from './slots';
2
+ interface Props {
3
+ registry: ToolbarSlotRegistry;
4
+ ctx: ShellSlotCtx;
5
+ expanded: boolean;
6
+ onToggle: () => void;
7
+ slotProps?: Record<string, Record<string, unknown>>;
8
+ }
9
+ declare const Toolbar: import("svelte").Component<Props, {}, "">;
10
+ type Toolbar = ReturnType<typeof Toolbar>;
11
+ export default Toolbar;
@@ -0,0 +1,28 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ locked: boolean;
4
+ onToggle: () => void;
5
+ }
6
+
7
+ let { locked, onToggle }: Props = $props();
8
+ </script>
9
+
10
+ <button class="focus-lock-btn" onclick={onToggle} title={locked ? 'Unlock focus' : 'Lock focus'}>
11
+ {locked ? '🔒' : '🔓'}
12
+ </button>
13
+
14
+ <style>
15
+ .focus-lock-btn {
16
+ background: none;
17
+ border: 1px solid var(--shell-border, #444);
18
+ border-radius: 3px;
19
+ cursor: pointer;
20
+ padding: 2px 5px;
21
+ font-size: 0.9em;
22
+ line-height: 1;
23
+ }
24
+
25
+ .focus-lock-btn:hover {
26
+ background: var(--shell-hover, #222);
27
+ }
28
+ </style>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ locked: boolean;
3
+ onToggle: () => void;
4
+ }
5
+ declare const FocusLockSlot: import("svelte").Component<Props, {}, "">;
6
+ type FocusLockSlot = ReturnType<typeof FocusLockSlot>;
7
+ export default FocusLockSlot;
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import type { ShellMode, ShellRole } from '../../modes/types';
3
+ import type { ShellModeRegistry } from '../../modes/registry';
4
+
5
+ interface Props {
6
+ mode: ShellMode;
7
+ role: ShellRole;
8
+ registry: ShellModeRegistry;
9
+ onSelect: (id: string) => void;
10
+ }
11
+
12
+ let { mode, role, registry, onSelect }: Props = $props();
13
+
14
+ let open = $state(false);
15
+
16
+ function select(id: string) {
17
+ open = false;
18
+ onSelect(id);
19
+ }
20
+ </script>
21
+
22
+ {#if role === 'admin'}
23
+ <div class="mode-slot">
24
+ <button class="mode-btn" onclick={() => (open = !open)}>
25
+ {mode.label} ▾
26
+ </button>
27
+ {#if open}
28
+ <ul class="mode-menu" role="menu">
29
+ {#each registry.list(role) as m (m.id)}
30
+ <li role="menuitem">
31
+ <button
32
+ class="mode-option"
33
+ class:active={m.id === mode.id}
34
+ onclick={() => select(m.id)}
35
+ >
36
+ {m.label}
37
+ </button>
38
+ </li>
39
+ {/each}
40
+ </ul>
41
+ {/if}
42
+ </div>
43
+ {:else}
44
+ <span class="mode-label">{mode.label}</span>
45
+ {/if}
46
+
47
+ <style>
48
+ .mode-slot {
49
+ position: relative;
50
+ display: inline-block;
51
+ }
52
+
53
+ .mode-btn {
54
+ background: none;
55
+ border: 1px solid var(--shell-border, #444);
56
+ color: var(--shell-fg, #ddd);
57
+ padding: 2px 6px;
58
+ border-radius: 3px;
59
+ cursor: pointer;
60
+ font-size: 0.85em;
61
+ }
62
+
63
+ .mode-btn:hover {
64
+ background: var(--shell-hover, #222);
65
+ }
66
+
67
+ .mode-menu {
68
+ position: absolute;
69
+ top: 100%;
70
+ left: 0;
71
+ margin: 2px 0 0;
72
+ padding: 0;
73
+ list-style: none;
74
+ background: var(--shell-bg, #111);
75
+ border: 1px solid var(--shell-border, #444);
76
+ border-radius: 3px;
77
+ z-index: 100;
78
+ min-width: 100%;
79
+ }
80
+
81
+ .mode-option {
82
+ display: block;
83
+ width: 100%;
84
+ background: none;
85
+ border: none;
86
+ color: var(--shell-fg, #ddd);
87
+ padding: 4px 10px;
88
+ text-align: left;
89
+ cursor: pointer;
90
+ font-size: 0.85em;
91
+ }
92
+
93
+ .mode-option:hover,
94
+ .mode-option.active {
95
+ background: var(--shell-hover, #222);
96
+ }
97
+
98
+ .mode-label {
99
+ font-size: 0.85em;
100
+ color: var(--shell-fg-dim, #888);
101
+ }
102
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { ShellMode, ShellRole } from '../../modes/types';
2
+ import type { ShellModeRegistry } from '../../modes/registry';
3
+ interface Props {
4
+ mode: ShellMode;
5
+ role: ShellRole;
6
+ registry: ShellModeRegistry;
7
+ onSelect: (id: string) => void;
8
+ }
9
+ declare const ModeSlot: import("svelte").Component<Props, {}, "">;
10
+ type ModeSlot = ReturnType<typeof ModeSlot>;
11
+ export default ModeSlot;
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ target: string | null;
4
+ }
5
+
6
+ let { target }: Props = $props();
7
+ </script>
8
+
9
+ <span class="target-shard" title="Target shard">→ {target ?? '—'}</span>
10
+
11
+ <style>
12
+ .target-shard {
13
+ font-size: 0.85em;
14
+ color: var(--shell-fg-dim, #888);
15
+ font-family: monospace;
16
+ }
17
+ </style>
@@ -0,0 +1,6 @@
1
+ interface Props {
2
+ target: string | null;
3
+ }
4
+ declare const TargetShardSlot: import("svelte").Component<Props, {}, "">;
5
+ type TargetShardSlot = ReturnType<typeof TargetShardSlot>;
6
+ export default TargetShardSlot;
@@ -0,0 +1,17 @@
1
+ import type { Component } from 'svelte';
2
+ import type { ShellMode, ShellRole } from '../modes/types';
3
+ export interface ShellSlotCtx {
4
+ mode: ShellMode;
5
+ role: ShellRole;
6
+ }
7
+ export interface ToolbarSlot {
8
+ id: string;
9
+ order: number;
10
+ visible(ctx: ShellSlotCtx): boolean;
11
+ component: Component<any>;
12
+ }
13
+ export declare class ToolbarSlotRegistry {
14
+ #private;
15
+ register(slot: ToolbarSlot): void;
16
+ list(ctx: ShellSlotCtx): ToolbarSlot[];
17
+ }
@@ -0,0 +1,26 @@
1
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
2
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
3
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
+ };
6
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
7
+ if (kind === "m") throw new TypeError("Private method is not writable");
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
10
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11
+ };
12
+ var _ToolbarSlotRegistry_slots;
13
+ export class ToolbarSlotRegistry {
14
+ constructor() {
15
+ _ToolbarSlotRegistry_slots.set(this, []);
16
+ }
17
+ register(slot) {
18
+ __classPrivateFieldSet(this, _ToolbarSlotRegistry_slots, __classPrivateFieldGet(this, _ToolbarSlotRegistry_slots, "f").filter((s) => s.id !== slot.id).concat(slot), "f");
19
+ }
20
+ list(ctx) {
21
+ return __classPrivateFieldGet(this, _ToolbarSlotRegistry_slots, "f")
22
+ .filter((s) => s.visible(ctx))
23
+ .sort((a, b) => a.order - b.order);
24
+ }
25
+ }
26
+ _ToolbarSlotRegistry_slots = new WeakMap();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ToolbarSlotRegistry } from './slots';
3
+ const A = { id: 'a', order: 20, visible: () => true, component: {} };
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' };
8
+ describe('ToolbarSlotRegistry', () => {
9
+ it('lists visible slots in order', () => {
10
+ const r = new ToolbarSlotRegistry();
11
+ r.register(A);
12
+ r.register(B);
13
+ r.register(C);
14
+ expect(r.list(userCtx).map((s) => s.id)).toEqual(['b', 'a', 'c']);
15
+ });
16
+ it('filters by visible', () => {
17
+ const r = new ToolbarSlotRegistry();
18
+ r.register(A);
19
+ r.register(C);
20
+ expect(r.list(devCtx).map((s) => s.id)).toEqual(['a']);
21
+ });
22
+ it('re-registering by id replaces', () => {
23
+ const r = new ToolbarSlotRegistry();
24
+ r.register(A);
25
+ r.register(Object.assign(Object.assign({}, A), { order: 99 }));
26
+ expect(r.list(userCtx)[0].order).toBe(99);
27
+ });
28
+ });
@@ -0,0 +1,2 @@
1
+ import type { Verb } from '../../verbs/types';
2
+ export declare const catVerb: Verb;
@@ -0,0 +1,34 @@
1
+ export const catVerb = {
2
+ name: 'cat',
3
+ summary: 'Read a file into the scrollback.',
4
+ async run(ctx, args) {
5
+ if (args.length === 0) {
6
+ ctx.scrollback.push({
7
+ kind: 'status',
8
+ text: 'cat: missing file argument',
9
+ level: 'error',
10
+ ts: Date.now(),
11
+ });
12
+ return;
13
+ }
14
+ const path = args[0];
15
+ const target = ctx.session.cwd ? `${ctx.session.cwd}/${path}` : path;
16
+ try {
17
+ const text = await ctx.fs.read(target);
18
+ ctx.scrollback.push({
19
+ kind: 'text',
20
+ stream: 'stdout',
21
+ chunks: [text],
22
+ ts: Date.now(),
23
+ });
24
+ }
25
+ catch (err) {
26
+ ctx.scrollback.push({
27
+ kind: 'status',
28
+ text: `cat: ${err.message}`,
29
+ level: 'error',
30
+ ts: Date.now(),
31
+ });
32
+ }
33
+ },
34
+ };
@@ -0,0 +1 @@
1
+ export {};