sh3-core 0.15.1 → 0.15.2

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 (128) hide show
  1. package/dist/actions/ctx-actions.svelte.test.js +111 -0
  2. package/dist/actions/dispatcher.svelte.js +23 -2
  3. package/dist/actions/dispatcher.test.js +33 -0
  4. package/dist/actions/listActionsFromEntries.test.js +78 -0
  5. package/dist/actions/listActive.d.ts +2 -1
  6. package/dist/actions/listActive.js +43 -17
  7. package/dist/actions/listeners.d.ts +16 -0
  8. package/dist/actions/listeners.js +68 -14
  9. package/dist/actions/programmatic-dispatch.svelte.test.d.ts +1 -0
  10. package/dist/actions/programmatic-dispatch.svelte.test.js +98 -0
  11. package/dist/actions/types.d.ts +37 -0
  12. package/dist/api.d.ts +1 -1
  13. package/dist/app-appearance/appearanceShard.svelte.js +19 -6
  14. package/dist/app-appearance/appearanceState.svelte.js +3 -3
  15. package/dist/host.js +2 -1
  16. package/dist/layouts-shard/LayoutSaveModal.svelte +145 -0
  17. package/dist/layouts-shard/LayoutSaveModal.svelte.d.ts +12 -0
  18. package/dist/layouts-shard/LayoutsSection.svelte +142 -0
  19. package/dist/layouts-shard/LayoutsSection.svelte.d.ts +3 -0
  20. package/dist/layouts-shard/filter.d.ts +3 -0
  21. package/dist/layouts-shard/filter.js +66 -0
  22. package/dist/layouts-shard/filter.test.d.ts +1 -0
  23. package/dist/layouts-shard/filter.test.js +123 -0
  24. package/dist/layouts-shard/index.d.ts +1 -0
  25. package/dist/layouts-shard/index.js +1 -0
  26. package/dist/layouts-shard/layoutsApi.d.ts +12 -0
  27. package/dist/layouts-shard/layoutsApi.js +41 -0
  28. package/dist/layouts-shard/layoutsApi.test.d.ts +1 -0
  29. package/dist/layouts-shard/layoutsApi.test.js +74 -0
  30. package/dist/layouts-shard/layoutsShard.svelte.d.ts +11 -0
  31. package/dist/layouts-shard/layoutsShard.svelte.js +231 -0
  32. package/dist/layouts-shard/layoutsShard.svelte.test.d.ts +1 -0
  33. package/dist/layouts-shard/layoutsShard.svelte.test.js +215 -0
  34. package/dist/layouts-shard/layoutsState.svelte.d.ts +9 -0
  35. package/dist/layouts-shard/layoutsState.svelte.js +50 -0
  36. package/dist/layouts-shard/layoutsState.test.d.ts +1 -0
  37. package/dist/layouts-shard/layoutsState.test.js +43 -0
  38. package/dist/layouts-shard/types.d.ts +21 -0
  39. package/dist/layouts-shard/types.js +6 -0
  40. package/dist/{app-appearance/AppAppearanceModal.svelte → overlays/EntityAppearanceModal.svelte} +36 -31
  41. package/dist/overlays/EntityAppearanceModal.svelte.d.ts +19 -0
  42. package/dist/overlays/EntityAppearanceModal.test.d.ts +1 -0
  43. package/dist/overlays/EntityAppearanceModal.test.js +57 -0
  44. package/dist/overlays/FloatFrame.svelte +17 -0
  45. package/dist/overlays/float.d.ts +17 -1
  46. package/dist/overlays/float.js +16 -0
  47. package/dist/overlays/float.test.js +35 -0
  48. package/dist/sh3core-shard/ShellHome.svelte +3 -0
  49. package/dist/shards/activate.svelte.js +11 -2
  50. package/dist/shards/types.d.ts +33 -1
  51. package/dist/shell-shard/CommandLine.svelte +143 -0
  52. package/dist/shell-shard/CommandLine.svelte.d.ts +26 -0
  53. package/dist/shell-shard/CommandLine.svelte.test.d.ts +1 -0
  54. package/dist/shell-shard/CommandLine.svelte.test.js +43 -0
  55. package/dist/shell-shard/InputLine.svelte +17 -40
  56. package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
  57. package/dist/shell-shard/ScrollbackView.svelte +10 -3
  58. package/dist/shell-shard/ScrollbackView.svelte.d.ts +1 -0
  59. package/dist/shell-shard/Terminal.svelte +93 -22
  60. package/dist/shell-shard/buffer-store.d.ts +15 -0
  61. package/dist/shell-shard/buffer-store.js +124 -0
  62. package/dist/shell-shard/buffer-store.svelte.test.d.ts +1 -0
  63. package/dist/shell-shard/buffer-store.svelte.test.js +107 -0
  64. package/dist/shell-shard/buffer-zone-state.svelte.d.ts +38 -0
  65. package/dist/shell-shard/buffer-zone-state.svelte.js +31 -0
  66. package/dist/shell-shard/contract.d.ts +7 -0
  67. package/dist/shell-shard/dispatch-custom.test.js +3 -1
  68. package/dist/shell-shard/dispatch-gating.test.js +6 -2
  69. package/dist/shell-shard/dispatch-invoke.test.js +10 -8
  70. package/dist/shell-shard/dispatch.d.ts +7 -2
  71. package/dist/shell-shard/dispatch.js +23 -27
  72. package/dist/shell-shard/display-cwd.d.ts +1 -0
  73. package/dist/shell-shard/display-cwd.js +27 -0
  74. package/dist/shell-shard/display-cwd.test.d.ts +1 -0
  75. package/dist/shell-shard/display-cwd.test.js +29 -0
  76. package/dist/shell-shard/entries/StatusEntry.svelte +2 -0
  77. package/dist/shell-shard/manifest.js +2 -1
  78. package/dist/shell-shard/manifest.test.d.ts +1 -0
  79. package/dist/shell-shard/manifest.test.js +8 -0
  80. package/dist/shell-shard/mode-buffer.svelte.d.ts +8 -0
  81. package/dist/shell-shard/mode-buffer.svelte.js +19 -0
  82. package/dist/shell-shard/mode-buffer.svelte.test.d.ts +1 -0
  83. package/dist/shell-shard/mode-buffer.svelte.test.js +25 -0
  84. package/dist/shell-shard/modes/builtin.js +2 -0
  85. package/dist/shell-shard/modes/types.d.ts +8 -0
  86. package/dist/shell-shard/protocol.d.ts +12 -6
  87. package/dist/shell-shard/replay.d.ts +3 -0
  88. package/dist/shell-shard/replay.js +44 -0
  89. package/dist/shell-shard/replay.svelte.test.d.ts +1 -0
  90. package/dist/shell-shard/replay.svelte.test.js +47 -0
  91. package/dist/shell-shard/rich-registry.d.ts +5 -0
  92. package/dist/shell-shard/rich-registry.js +25 -0
  93. package/dist/shell-shard/rich-registry.test.d.ts +1 -0
  94. package/dist/shell-shard/rich-registry.test.js +31 -0
  95. package/dist/shell-shard/scrollback.svelte.d.ts +2 -0
  96. package/dist/shell-shard/scrollback.svelte.js +23 -0
  97. package/dist/shell-shard/scrollback.svelte.test.d.ts +1 -0
  98. package/dist/shell-shard/scrollback.svelte.test.js +51 -0
  99. package/dist/shell-shard/session-client.svelte.d.ts +18 -2
  100. package/dist/shell-shard/session-client.svelte.js +21 -4
  101. package/dist/shell-shard/shellApi.d.ts +2 -1
  102. package/dist/shell-shard/shellApi.js +31 -3
  103. package/dist/shell-shard/shellApi.svelte.test.d.ts +1 -0
  104. package/dist/shell-shard/shellApi.svelte.test.js +59 -0
  105. package/dist/shell-shard/shellShard.svelte.js +11 -1
  106. package/dist/shell-shard/terminal-dispatch.test.js +3 -1
  107. package/dist/shell-shard/verbs/apps.js +7 -0
  108. package/dist/shell-shard/verbs/env.js +4 -0
  109. package/dist/shell-shard/verbs/help.js +4 -0
  110. package/dist/shell-shard/verbs/history.js +8 -1
  111. package/dist/shell-shard/verbs/index.js +0 -8
  112. package/dist/shell-shard/verbs/shards.js +4 -0
  113. package/dist/shell-shard/verbs/views.js +4 -0
  114. package/dist/shell-shard/verbs/zones.js +7 -0
  115. package/dist/version.d.ts +1 -1
  116. package/dist/version.js +1 -1
  117. package/package.json +1 -1
  118. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +0 -8
  119. package/dist/shell-shard/verbs/cat.d.ts +0 -2
  120. package/dist/shell-shard/verbs/cat.js +0 -35
  121. package/dist/shell-shard/verbs/cd.test.js +0 -56
  122. package/dist/shell-shard/verbs/ls.d.ts +0 -2
  123. package/dist/shell-shard/verbs/ls.js +0 -30
  124. package/dist/shell-shard/verbs/ls.test.js +0 -49
  125. package/dist/shell-shard/verbs/session.d.ts +0 -4
  126. package/dist/shell-shard/verbs/session.js +0 -99
  127. /package/dist/{shell-shard/verbs/cd.test.d.ts → actions/ctx-actions.svelte.test.d.ts} +0 -0
  128. /package/dist/{shell-shard/verbs/ls.test.d.ts → actions/listActionsFromEntries.test.d.ts} +0 -0
@@ -1,58 +1,65 @@
1
1
  <script lang="ts">
2
2
  /*
3
- * Customizepick an icon and color override for a single app.
4
- * Reads the existing override on mount via untrack (Svelte 5 idiom for
5
- * one-shot prop reads), so editing the form doesn't subscribe to the
6
- * shard's reactive state. Save/Reset/Cancel are mutually exclusive.
3
+ * EntityAppearanceModalgeneralization of the per-entity Customize
4
+ * modal. Used by __app-appearance__ for apps and __layouts__ for saved
5
+ * layouts. Purely presentational: callers pass an
6
+ * onSave({ label, icon, color }) callback and decide what an empty
7
+ * label means (apps treat empty as "use manifest"; layouts mark the
8
+ * input required via requireLabel: true).
7
9
  */
8
10
 
9
11
  import { untrack } from 'svelte';
10
12
  import IconPicker from '../primitives/widgets/IconPicker.svelte';
11
13
  import ColorSwatch from '../primitives/widgets/ColorSwatch.svelte';
12
14
  import iconsUrl from '../assets/icons.svg';
13
- import { listRegisteredApps } from '../api';
14
- import { getAppearance, setAppearance } from './appearanceState.svelte';
15
15
 
16
16
  interface Props {
17
- appId: string;
18
- appLabel: string;
17
+ entityLabel: string;
18
+ initialAppearance?: { icon?: string; color?: string };
19
+ defaultIcon: string;
20
+ requireLabel: boolean;
21
+ onSave(next: { label: string; icon?: string; color?: string }): void;
22
+ onReset(): void;
19
23
  close: () => void;
20
24
  }
21
25
 
22
- let { appId, appLabel, close }: Props = $props();
26
+ let {
27
+ entityLabel,
28
+ initialAppearance,
29
+ defaultIcon,
30
+ requireLabel,
31
+ onSave,
32
+ onReset,
33
+ close,
34
+ }: Props = $props();
23
35
 
24
- const initial = untrack(() => getAppearance(appId));
25
- const manifestIcon = untrack(
26
- () => listRegisteredApps().find((m) => m.id === appId)?.icon,
27
- );
36
+ const initial = untrack(() => initialAppearance);
28
37
 
29
38
  let icon = $state<string | undefined>(initial?.icon);
30
39
  let color = $state<string | undefined>(initial?.color);
31
- let label = $state<string>(initial?.label ?? '');
40
+ let label = $state<string>(untrack(() => entityLabel));
32
41
  let pickerOpen = $state<boolean>(initial?.icon !== undefined);
33
42
 
43
+ const trimmed = $derived(label.trim());
44
+ const saveDisabled = $derived(requireLabel && trimmed === '');
45
+ const effectiveLabel = $derived(trimmed === '' ? entityLabel : trimmed);
46
+ const effectiveIcon = $derived(icon ?? defaultIcon);
34
47
  const hasOverride = $derived(initial !== undefined);
35
- const effectiveLabel = $derived(label.trim() === '' ? appLabel : label.trim());
36
- const effectiveIcon = $derived(icon ?? manifestIcon ?? 'box');
37
48
 
38
49
  function save() {
39
- const trimmed = label.trim();
40
- setAppearance(appId, {
41
- icon,
42
- color,
43
- label: trimmed === '' ? undefined : trimmed,
44
- });
50
+ if (saveDisabled) return;
51
+ onSave({ label: trimmed, icon, color });
45
52
  close();
46
53
  }
47
54
 
48
55
  function reset() {
49
- setAppearance(appId, undefined);
56
+ onReset();
50
57
  close();
51
58
  }
52
59
  </script>
53
60
 
54
61
  <div class="app-appearance">
55
- <h2>Customize {appLabel}</h2>
62
+ <h2>Customize {entityLabel}</h2>
56
63
 
57
64
  <div class="preview">
58
65
  <div
@@ -67,11 +74,11 @@
67
74
  </div>
68
75
  </div>
69
76
 
70
- <label class="row"><span>Name <em>(empty = default)</em></span>
77
+ <label class="row"><span>Name {#if !requireLabel}<em>(empty = default)</em>{/if}</span>
71
78
  <input
72
79
  type="text"
73
80
  bind:value={label}
74
- placeholder={appLabel}
81
+ placeholder={entityLabel}
75
82
  class="name-input"
76
83
  />
77
84
  </label>
@@ -95,9 +102,9 @@
95
102
  </label>
96
103
 
97
104
  <div class="actions">
98
- <button type="button" class="primary" onclick={save}>Save</button>
99
- <button type="button" onclick={reset} disabled={!hasOverride}>Reset</button>
100
- <button type="button" onclick={close}>Cancel</button>
105
+ <button type="button" class="primary" onclick={save} disabled={saveDisabled}>Save</button>
106
+ <button type="button" data-role="reset" onclick={reset} disabled={!hasOverride}>Reset</button>
107
+ <button type="button" onclick={() => close()}>Cancel</button>
101
108
  </div>
102
109
  </div>
103
110
 
@@ -158,8 +165,6 @@
158
165
  -webkit-line-clamp: 2;
159
166
  line-clamp: 2;
160
167
  }
161
- .preview-card-icon { width: 24px; height: 24px; color: var(--shell-fg); }
162
- .preview-card-label { font-weight: 600; padding: 0 4px; line-height: 1.2; }
163
168
  .actions { display: flex; gap: 8px; margin-top: 16px; }
164
169
  .actions button {
165
170
  background: var(--shell-bg-elevated);
@@ -0,0 +1,19 @@
1
+ interface Props {
2
+ entityLabel: string;
3
+ initialAppearance?: {
4
+ icon?: string;
5
+ color?: string;
6
+ };
7
+ defaultIcon: string;
8
+ requireLabel: boolean;
9
+ onSave(next: {
10
+ label: string;
11
+ icon?: string;
12
+ color?: string;
13
+ }): void;
14
+ onReset(): void;
15
+ close: () => void;
16
+ }
17
+ declare const EntityAppearanceModal: import("svelte").Component<Props, {}, "">;
18
+ type EntityAppearanceModal = ReturnType<typeof EntityAppearanceModal>;
19
+ export default EntityAppearanceModal;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { mount, flushSync } from 'svelte';
3
+ import EntityAppearanceModal from './EntityAppearanceModal.svelte';
4
+ function mountModal(props = {}) {
5
+ const target = document.createElement('div');
6
+ document.body.appendChild(target);
7
+ const onSave = vi.fn();
8
+ const onReset = vi.fn();
9
+ const close = vi.fn();
10
+ const instance = mount(EntityAppearanceModal, {
11
+ target,
12
+ props: Object.assign({ entityLabel: 'My App', defaultIcon: 'box', requireLabel: false, onSave,
13
+ onReset,
14
+ close }, props),
15
+ });
16
+ return { target, onSave, onReset, close, instance };
17
+ }
18
+ describe('EntityAppearanceModal', () => {
19
+ it('renders the entityLabel as title placeholder', () => {
20
+ const { target } = mountModal();
21
+ const input = target.querySelector('input.name-input');
22
+ expect(input.placeholder).toBe('My App');
23
+ });
24
+ it('shows the "(empty = default)" hint when requireLabel is false', () => {
25
+ const { target } = mountModal({ requireLabel: false });
26
+ expect(target.textContent).toContain('(empty = default)');
27
+ });
28
+ it('hides the "(empty = default)" hint when requireLabel is true', () => {
29
+ const { target } = mountModal({ requireLabel: true });
30
+ expect(target.textContent).not.toContain('(empty = default)');
31
+ });
32
+ it('disables Save when requireLabel is true and the input is empty', async () => {
33
+ const { target } = mountModal({ requireLabel: true, entityLabel: '' });
34
+ flushSync();
35
+ const save = target.querySelector('button.primary');
36
+ expect(save.disabled).toBe(true);
37
+ });
38
+ it('calls onSave with the trimmed label, icon, color', async () => {
39
+ const { target, onSave } = mountModal({
40
+ initialAppearance: { icon: 'cog', color: '#ff0000' },
41
+ });
42
+ const input = target.querySelector('input.name-input');
43
+ input.value = ' Renamed ';
44
+ input.dispatchEvent(new Event('input', { bubbles: true }));
45
+ flushSync();
46
+ target.querySelector('button.primary').click();
47
+ expect(onSave).toHaveBeenCalledWith({ label: 'Renamed', icon: 'cog', color: '#ff0000' });
48
+ });
49
+ it('calls onReset and close when Reset is clicked', async () => {
50
+ const { target, onReset, close } = mountModal({
51
+ initialAppearance: { icon: 'cog' },
52
+ });
53
+ target.querySelector('button[data-role="reset"]').click();
54
+ expect(onReset).toHaveBeenCalled();
55
+ expect(close).toHaveBeenCalled();
56
+ });
57
+ });
@@ -31,6 +31,20 @@
31
31
  import { registerDismissableFrame, unregisterDismissableFrame } from './floatDismiss';
32
32
  import { computeMinSize } from '../layout/floats';
33
33
  import type { FloatEntry } from '../layout/types';
34
+ import { shell } from '../shellRuntime.svelte';
35
+ import { makeSelectionApi } from '../actions/selection.svelte';
36
+
37
+ const floatHeaderSelection = makeSelectionApi('__layouts__');
38
+
39
+ function openHeaderContextMenu(e: MouseEvent): void {
40
+ e.preventDefault();
41
+ floatHeaderSelection.set({ type: 'float-header', ref: { floatId: entry.id } });
42
+ shell.actions.openContextMenu({
43
+ x: e.clientX,
44
+ y: e.clientY,
45
+ scope: { element: 'float-header' },
46
+ });
47
+ }
34
48
 
35
49
  interface Props {
36
50
  entry: FloatEntry;
@@ -183,11 +197,14 @@
183
197
  <!-- svelte-ignore a11y_no_static_element_interactions -->
184
198
  <header
185
199
  class="sh3-float-header"
200
+ data-sh3-scope="element:float-header"
201
+ data-float-id={entry.id}
186
202
  onpointerdown={onHeaderPointerDown}
187
203
  onpointermove={onHeaderPointerMove}
188
204
  onpointerup={onHeaderPointerUp}
189
205
  onpointercancel={onHeaderPointerUp}
190
206
  ondblclick={onHeaderDblClick}
207
+ oncontextmenu={openHeaderContextMenu}
191
208
  >
192
209
  <span class="sh3-float-title">{entry.title}</span>
193
210
  <span class="sh3-float-header-actions">
@@ -1,4 +1,4 @@
1
- import type { FloatEntry } from '../layout/types';
1
+ import type { LayoutNode, FloatEntry } from '../layout/types';
2
2
  import type { Size } from '../layout/floats';
3
3
  export interface FloatOptions {
4
4
  title?: string;
@@ -29,6 +29,22 @@ export interface FloatOptions {
29
29
  }
30
30
  export interface FloatManager {
31
31
  open(viewId: string, options?: FloatOptions): string;
32
+ /**
33
+ * Open a float with a pre-built content tree. Used by save/restore flows
34
+ * (e.g. saved layouts) where the caller has already arranged the
35
+ * structure rather than starting from a single view id. The `content`
36
+ * tree is stored verbatim — the caller is responsible for cloning if it
37
+ * shouldn't be aliased to a source.
38
+ */
39
+ openWithContent(options: {
40
+ content: LayoutNode;
41
+ size: Size;
42
+ title?: string;
43
+ position?: {
44
+ x: number;
45
+ y: number;
46
+ };
47
+ }): string;
32
48
  close(floatId: string): void;
33
49
  list(): FloatEntry[];
34
50
  focus(floatId: string): void;
@@ -137,6 +137,21 @@ function openFloat(viewId, options = {}) {
137
137
  store.push(entry);
138
138
  return id;
139
139
  }
140
+ function openFloatWithContent(options) {
141
+ var _a;
142
+ const store = activeStore();
143
+ const id = generateFloatId();
144
+ const position = (_a = options.position) !== null && _a !== void 0 ? _a : cascadePosition(store, getTreeBounds());
145
+ const entry = {
146
+ id,
147
+ content: options.content,
148
+ position,
149
+ size: options.size,
150
+ title: options.title,
151
+ };
152
+ store.push(entry);
153
+ return id;
154
+ }
140
155
  function closeFloat(floatId) {
141
156
  const store = activeStore();
142
157
  const idx = store.findIndex((f) => f.id === floatId);
@@ -211,6 +226,7 @@ function isMaximizedFloat(id) {
211
226
  }
212
227
  export const floatManager = {
213
228
  open: openFloat,
229
+ openWithContent: openFloatWithContent,
214
230
  close: closeFloat,
215
231
  list: listFloats,
216
232
  focus: focusFloat,
@@ -836,3 +836,38 @@ describe('floats — F.20 dismissable + grip', () => {
836
836
  expect(floatManager.list().some((f) => f.id === id)).toBe(true);
837
837
  });
838
838
  });
839
+ describe('floatManager.openWithContent', () => {
840
+ beforeEach(() => __resetFloatManagerForTest());
841
+ it('opens a float whose entry.content equals the supplied tree', () => {
842
+ const content = {
843
+ type: 'tabs',
844
+ activeTab: 0,
845
+ tabs: [{ slotId: 'restored:1', viewId: 'shell:terminal', label: 'Shell' }],
846
+ };
847
+ const id = floatManager.openWithContent({
848
+ content,
849
+ size: { w: 700, h: 500 },
850
+ title: 'My Layout',
851
+ });
852
+ const list = floatManager.list();
853
+ expect(list).toHaveLength(1);
854
+ expect(list[0].id).toBe(id);
855
+ expect(list[0].content).toEqual(content);
856
+ expect(list[0].size).toEqual({ w: 700, h: 500 });
857
+ expect(list[0].title).toBe('My Layout');
858
+ });
859
+ it('uses the cascade default position when not given one', () => {
860
+ const content = {
861
+ type: 'slot',
862
+ slotId: 'restored:2',
863
+ viewId: 'shell:terminal',
864
+ };
865
+ const id = floatManager.openWithContent({
866
+ content,
867
+ size: { w: 600, h: 400 },
868
+ });
869
+ const entry = floatManager.list().find((f) => f.id === id);
870
+ expect(typeof entry.position.x).toBe('number');
871
+ expect(typeof entry.position.y).toBe('number');
872
+ });
873
+ });
@@ -10,6 +10,7 @@
10
10
  import { listRegisteredApps, launchApp, isAdmin, VERSION } from '../api';
11
11
  import ShellTitle from './ShellTitle.svelte';
12
12
  import ProjectsSection from '../projects-shard/ProjectsSection.svelte';
13
+ import LayoutsSection from '../layouts-shard/LayoutsSection.svelte';
13
14
  import { sessionState } from '../projects/session-state.svelte';
14
15
  import { projectsState } from '../projects-shard/projectsShard.svelte';
15
16
  import { shell } from '../shellRuntime.svelte';
@@ -82,6 +83,8 @@
82
83
 
83
84
  <ProjectsSection />
84
85
 
86
+ <LayoutsSection />
87
+
85
88
  {#if userApps.length > 0}
86
89
  <section class="shell-home-section">
87
90
  <h2 class="shell-home-section-title">Apps</h2>
@@ -30,9 +30,11 @@ import { createShardKeysApi } from '../keys/client';
30
30
  import { PERMISSION_KEYS_MINT } from '../keys/types';
31
31
  import { subscribe } from '../keys/revocation-bus.svelte';
32
32
  import { register as contributionsRegister, list as contributionsList, listPoints as contributionsListPoints, onChange as contributionsOnChange, onAnyChange as contributionsOnAnyChange, } from '../contributions';
33
- import { registerAction } from '../actions/registry';
33
+ import { registerAction, listActions as listActionsFromRegistry } from '../actions/registry';
34
34
  import { makeSelectionApi, clearSelectionForShard } from '../actions/selection.svelte';
35
- import { openContextMenu as shellOpenContextMenu, openPalette as shellOpenPalette } from '../actions/listeners';
35
+ import { openContextMenu as shellOpenContextMenu, openPalette as shellOpenPalette, dispatchActionProgrammatic, } from '../actions/listeners';
36
+ import { listActionsFromEntries } from '../actions/listActive';
37
+ import { getLiveDispatcherState } from '../actions/state.svelte';
36
38
  /**
37
39
  * Reactive registry of every shard known to the host. Keys are shard ids.
38
40
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -217,6 +219,13 @@ export async function activateShard(id, opts) {
217
219
  runVerb(shardId, name, args, opts) {
218
220
  return runVerbProgrammatic(shardId, name, args, opts);
219
221
  },
222
+ listActions(opts) {
223
+ const all = listActionsFromEntries(listActionsFromRegistry(), getLiveDispatcherState());
224
+ return (opts === null || opts === void 0 ? void 0 : opts.activeOnly) ? all.filter((a) => a.active) : all;
225
+ },
226
+ runAction(actionId, opts) {
227
+ return dispatchActionProgrammatic(actionId, opts);
228
+ },
220
229
  };
221
230
  entry.ctx = ctx;
222
231
  // Wire onKeyRevoked hook: subscribe to the revocation bus for this shard.
@@ -7,7 +7,7 @@ import type { Verb, VerbSchema } from '../verbs/types';
7
7
  import type { ScrollbackEntry } from '../shell-shard/scrollback.svelte';
8
8
  import type { ShardContextKeys } from '../keys/types';
9
9
  import type { ContributionsApi } from '../contributions/types';
10
- import type { ActionsApi } from '../actions/types';
10
+ import type { ActionsApi, ActionDescriptor } from '../actions/types';
11
11
  import type { TreeRootRef } from '../layout/types';
12
12
  export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
13
13
  /**
@@ -296,6 +296,38 @@ export interface ShardContext {
296
296
  result: unknown;
297
297
  scrollback: ScrollbackEntry[];
298
298
  }>;
299
+ /**
300
+ * Read-only snapshot of every action registered across every shard.
301
+ * Returns one descriptor per action id; the `active` flag indicates
302
+ * whether `runAction(id)` would dispatch right now (scope live, not
303
+ * disabled, has a run handler).
304
+ *
305
+ * Pass `{ activeOnly: true }` to filter to currently-dispatchable
306
+ * actions. AI-class shards typically want this filter.
307
+ *
308
+ * No permission gate — actions are already enumerable through the
309
+ * keyboard / palette / context-menu surfaces.
310
+ */
311
+ listActions(opts?: {
312
+ activeOnly?: boolean;
313
+ }): ActionDescriptor[];
314
+ /**
315
+ * Programmatically dispatch a registered action by id. Synthesizes the
316
+ * same `ActionDispatchContext` the keyboard/palette/context-menu paths
317
+ * use, with `invokedVia: 'programmatic'` and `appId / viewId / selection`
318
+ * sourced from current live state. Resolves after the action's `run`
319
+ * settles. Rejects on:
320
+ * - unknown action id,
321
+ * - action exists but is inactive (out-of-scope, disabled, submenu
322
+ * parent without `run`),
323
+ * - any error thrown by the action's `run`.
324
+ *
325
+ * `opts.signal` is stored on the dispatch context for v1 parity with
326
+ * `runVerb`; today's actions don't read it.
327
+ */
328
+ runAction(id: string, opts?: {
329
+ signal?: AbortSignal;
330
+ }): Promise<void>;
299
331
  }
300
332
  /**
301
333
  * A shard module. Shards are the fundamental unit of contribution in SH3.
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ /**
5
+ * Single-line command entry widget. The chrome (focus ring, prefix slot,
6
+ * monospace font, anti-credential-fill attrs) is owned here; consumers
7
+ * supply the keybinding semantics via `onkeydown`.
8
+ *
9
+ * Two behaviors are baked in so every consumer gets them for free:
10
+ * - Selection-aware Ctrl+C: when the input has a non-empty selection,
11
+ * the keystroke is NOT forwarded to onkeydown — the browser does its
12
+ * native copy. This avoids the consumer mapping Ctrl+C to "clear
13
+ * draft" or "send SIGINT" eating a copy operation.
14
+ * - data-sh3-passthrough-modifiers on the row: the dispatcher's
15
+ * "block shortcuts in inputs" rule lets Ctrl/Alt/Meta-bearing
16
+ * shortcuts through this widget, so global bindings like Ctrl+K
17
+ * fire while the user is typing.
18
+ */
19
+ interface Props {
20
+ value: string;
21
+ prefix?: Snippet;
22
+ disabled?: boolean;
23
+ name?: string;
24
+ onkeydown?: (e: KeyboardEvent) => void;
25
+ }
26
+ let {
27
+ value = $bindable(''),
28
+ prefix,
29
+ disabled = false,
30
+ name,
31
+ onkeydown,
32
+ }: Props = $props();
33
+
34
+ let input: HTMLInputElement | null = $state(null);
35
+
36
+ function hasSelection(el: HTMLInputElement): boolean {
37
+ return el.selectionStart !== null
38
+ && el.selectionEnd !== null
39
+ && el.selectionStart !== el.selectionEnd;
40
+ }
41
+
42
+ function handleKeyDown(e: KeyboardEvent): void {
43
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === 'c'
44
+ && input && hasSelection(input)) {
45
+ return;
46
+ }
47
+ onkeydown?.(e);
48
+ }
49
+
50
+ $effect(() => {
51
+ if (!disabled && input) input.focus();
52
+ });
53
+ </script>
54
+
55
+ <div
56
+ class="sh3-cmdline"
57
+ class:sh3-cmdline--disabled={disabled}
58
+ data-sh3-passthrough-modifiers
59
+ >
60
+ {#if prefix}<span class="sh3-cmdline__prefix">{@render prefix()}</span>{/if}
61
+ <!--
62
+ Two hidden inputs sit before the real one so Firefox doesn't try to
63
+ autofill saved credentials into a single visible text field. Source:
64
+ https://stackoverflow.com/a/29852908 (CC BY-SA 3.0, Bob The Janitor).
65
+ -->
66
+ <input type="text" class="sh3-cmdline__decoy" tabindex="-1" aria-hidden="true" />
67
+ <input type="password" class="sh3-cmdline__decoy" tabindex="-1" aria-hidden="true" />
68
+ <input
69
+ bind:this={input}
70
+ bind:value
71
+ type="search"
72
+ {name}
73
+ {disabled}
74
+ onkeydown={handleKeyDown}
75
+ spellcheck="false"
76
+ autocomplete="off"
77
+ autocapitalize="off"
78
+ aria-autocomplete="none"
79
+ data-1p-ignore
80
+ data-lpignore="true"
81
+ class="sh3-cmdline__input"
82
+ />
83
+ </div>
84
+
85
+ <style>
86
+ .sh3-cmdline {
87
+ display: flex;
88
+ align-items: baseline;
89
+ gap: 6px;
90
+ padding: 4px 8px;
91
+ border: 1px solid transparent;
92
+ border-radius: var(--shell-radius);
93
+ font-family: var(--shell-font-mono, monospace);
94
+ font-size: var(--shell-font-size, 13px);
95
+ line-height: 1.4;
96
+ transition: border-color var(--shell-motion-fast) var(--shell-ease-standard),
97
+ box-shadow var(--shell-motion-fast) var(--shell-ease-standard);
98
+ }
99
+ .sh3-cmdline:focus-within {
100
+ border-color: var(--shell-input-border-focus);
101
+ box-shadow: var(--shell-focus-ring);
102
+ }
103
+ .sh3-cmdline__prefix {
104
+ flex-shrink: 0;
105
+ font: inherit;
106
+ line-height: inherit;
107
+ margin: 0;
108
+ }
109
+ .sh3-cmdline__input {
110
+ flex: 1 1 auto;
111
+ padding: 0;
112
+ margin: 0;
113
+ background: transparent;
114
+ border: 0;
115
+ outline: 0;
116
+ color: inherit;
117
+ font: inherit;
118
+ line-height: inherit;
119
+ -webkit-appearance: none;
120
+ appearance: none;
121
+ }
122
+ .sh3-cmdline__input::-webkit-search-cancel-button,
123
+ .sh3-cmdline__input::-webkit-search-decoration,
124
+ .sh3-cmdline__input::-webkit-search-results-button,
125
+ .sh3-cmdline__input::-webkit-search-results-decoration {
126
+ display: none;
127
+ }
128
+ /* The .sh3-cmdline row owns the focus ring; suppress base.css's global
129
+ input:focus-visible box-shadow so the rings don't double up. */
130
+ .sh3-cmdline__input:focus,
131
+ .sh3-cmdline__input:focus-visible {
132
+ outline: none;
133
+ box-shadow: none;
134
+ border: none;
135
+ }
136
+ .sh3-cmdline__decoy {
137
+ display: none;
138
+ }
139
+ .sh3-cmdline--disabled .sh3-cmdline__input {
140
+ opacity: 0.5;
141
+ cursor: default;
142
+ }
143
+ </style>
@@ -0,0 +1,26 @@
1
+ import type { Snippet } from 'svelte';
2
+ /**
3
+ * Single-line command entry widget. The chrome (focus ring, prefix slot,
4
+ * monospace font, anti-credential-fill attrs) is owned here; consumers
5
+ * supply the keybinding semantics via `onkeydown`.
6
+ *
7
+ * Two behaviors are baked in so every consumer gets them for free:
8
+ * - Selection-aware Ctrl+C: when the input has a non-empty selection,
9
+ * the keystroke is NOT forwarded to onkeydown — the browser does its
10
+ * native copy. This avoids the consumer mapping Ctrl+C to "clear
11
+ * draft" or "send SIGINT" eating a copy operation.
12
+ * - data-sh3-passthrough-modifiers on the row: the dispatcher's
13
+ * "block shortcuts in inputs" rule lets Ctrl/Alt/Meta-bearing
14
+ * shortcuts through this widget, so global bindings like Ctrl+K
15
+ * fire while the user is typing.
16
+ */
17
+ interface Props {
18
+ value: string;
19
+ prefix?: Snippet;
20
+ disabled?: boolean;
21
+ name?: string;
22
+ onkeydown?: (e: KeyboardEvent) => void;
23
+ }
24
+ declare const CommandLine: import("svelte").Component<Props, {}, "value">;
25
+ type CommandLine = ReturnType<typeof CommandLine>;
26
+ export default CommandLine;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import CommandLine from './CommandLine.svelte';
4
+ function realInput(container) {
5
+ // The widget renders two FF-credential decoy inputs ahead of the real one.
6
+ return container.querySelector('input.sh3-cmdline__input');
7
+ }
8
+ describe('CommandLine selection-aware Ctrl+C', () => {
9
+ it('does NOT forward Ctrl+C to onkeydown when input has a non-empty selection', async () => {
10
+ const onkeydown = vi.fn();
11
+ const { container } = render(CommandLine, { props: { value: 'hello world', onkeydown } });
12
+ const inp = realInput(container);
13
+ inp.focus();
14
+ inp.setSelectionRange(0, 5); // select "hello"
15
+ await fireEvent.keyDown(inp, { key: 'c', ctrlKey: true });
16
+ expect(onkeydown).not.toHaveBeenCalled();
17
+ });
18
+ it('forwards Ctrl+C to onkeydown when there is no selection', async () => {
19
+ const onkeydown = vi.fn();
20
+ const { container } = render(CommandLine, { props: { value: 'hello', onkeydown } });
21
+ const inp = realInput(container);
22
+ inp.focus();
23
+ inp.setSelectionRange(3, 3); // caret only, no selection
24
+ await fireEvent.keyDown(inp, { key: 'c', ctrlKey: true });
25
+ expect(onkeydown).toHaveBeenCalledTimes(1);
26
+ });
27
+ it('forwards non-Ctrl+C keys regardless of selection', async () => {
28
+ const onkeydown = vi.fn();
29
+ const { container } = render(CommandLine, { props: { value: 'hello', onkeydown } });
30
+ const inp = realInput(container);
31
+ inp.focus();
32
+ inp.setSelectionRange(0, 5);
33
+ await fireEvent.keyDown(inp, { key: 'k', ctrlKey: true });
34
+ expect(onkeydown).toHaveBeenCalledTimes(1);
35
+ });
36
+ });
37
+ describe('CommandLine modifier passthrough attribute', () => {
38
+ it('stamps data-sh3-passthrough-modifiers so the dispatcher lets modifier shortcuts through', () => {
39
+ const { container } = render(CommandLine, { props: { value: '' } });
40
+ const row = container.querySelector('.sh3-cmdline');
41
+ expect(row.hasAttribute('data-sh3-passthrough-modifiers')).toBe(true);
42
+ });
43
+ });