sh3-core 0.13.4 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/api.d.ts +3 -0
  2. package/dist/api.js +3 -0
  3. package/dist/host.js +2 -0
  4. package/dist/layout/LayoutRenderer.svelte +1 -1
  5. package/dist/layout/tree-walk.js +6 -1
  6. package/dist/layout/types.d.ts +7 -0
  7. package/dist/migrations/mode-id-rename.d.ts +9 -0
  8. package/dist/migrations/mode-id-rename.js +39 -0
  9. package/dist/migrations/mode-id-rename.test.d.ts +1 -0
  10. package/dist/migrations/mode-id-rename.test.js +52 -0
  11. package/dist/overlays/FloatFrame.svelte +8 -2
  12. package/dist/overlays/float.js +6 -3
  13. package/dist/overlays/float.test.js +71 -0
  14. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
  15. package/dist/primitives/widgets/Segmented.svelte +4 -1
  16. package/dist/sh3core-shard/AppInfoView.svelte +154 -0
  17. package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
  18. package/dist/sh3core-shard/appActions.js +23 -5
  19. package/dist/shell-shard/ScrollbackView.svelte +40 -19
  20. package/dist/shell-shard/Terminal.svelte +140 -12
  21. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  22. package/dist/shell-shard/contract.d.ts +99 -0
  23. package/dist/shell-shard/contract.js +11 -0
  24. package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
  25. package/dist/shell-shard/dispatch-custom.test.js +152 -0
  26. package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
  27. package/dist/shell-shard/dispatch-gating.test.js +63 -0
  28. package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
  29. package/dist/shell-shard/dispatch-invoke.test.js +214 -0
  30. package/dist/shell-shard/dispatch.d.ts +23 -2
  31. package/dist/shell-shard/dispatch.js +130 -6
  32. package/dist/shell-shard/modes/builtin.d.ts +2 -2
  33. package/dist/shell-shard/modes/builtin.js +8 -8
  34. package/dist/shell-shard/modes/prefs.js +1 -1
  35. package/dist/shell-shard/modes/prefs.test.js +13 -13
  36. package/dist/shell-shard/modes/registry.test.js +13 -13
  37. package/dist/shell-shard/output.d.ts +10 -0
  38. package/dist/shell-shard/output.js +91 -0
  39. package/dist/shell-shard/output.test.d.ts +1 -0
  40. package/dist/shell-shard/output.test.js +73 -0
  41. package/dist/shell-shard/registerShellMode.d.ts +13 -0
  42. package/dist/shell-shard/registerShellMode.js +14 -0
  43. package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
  44. package/dist/shell-shard/registerShellMode.test.js +19 -0
  45. package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
  46. package/dist/shell-shard/registry-resolve.test.js +26 -0
  47. package/dist/shell-shard/registry.d.ts +12 -1
  48. package/dist/shell-shard/registry.js +12 -1
  49. package/dist/shell-shard/shellShard.svelte.js +8 -1
  50. package/dist/shell-shard/terminal-dispatch.test.js +19 -12
  51. package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
  52. package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
  53. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +11 -51
  54. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +2 -4
  55. package/dist/shell-shard/toolbar/slots.test.js +6 -6
  56. package/dist/shell-shard/verbs/clear.js +1 -0
  57. package/dist/shell-shard/verbs/index.js +2 -0
  58. package/dist/shell-shard/verbs/mode.d.ts +2 -0
  59. package/dist/shell-shard/verbs/mode.js +29 -0
  60. package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
  61. package/dist/shell-shard/verbs/mode.test.js +43 -0
  62. package/dist/verbs/types.d.ts +19 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +1 -1
package/dist/api.d.ts CHANGED
@@ -50,6 +50,9 @@ export type { Verb, VerbContext, ShellApi } from './verbs/types';
50
50
  export type { Scrollback } from './shell-shard/scrollback.svelte';
51
51
  export type { SessionClient } from './shell-shard/session-client.svelte';
52
52
  export { listVerbs } from './shards/registry';
53
+ export { registerShellMode } from './shell-shard/registerShellMode';
54
+ export type { ShellModeDescriptor, ShellModeOutput, ShellModeDispatchHandler, ShellModeDispatchInput, ShellModeRunsOn, RichEntryHandle, StreamHandle, } from './shell-shard/contract';
55
+ export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
53
56
  export { VERSION } from './version';
54
57
  export declare const FRAMEWORK_SHARD_IDS: readonly string[];
55
58
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
package/dist/api.js CHANGED
@@ -56,6 +56,9 @@ export const capabilities = {
56
56
  hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
57
57
  };
58
58
  export { listVerbs } from './shards/registry';
59
+ // Shell mode contributions (external shards extend the shell with new modes).
60
+ export { registerShellMode } from './shell-shard/registerShellMode';
61
+ export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
59
62
  // Package version.
60
63
  export { VERSION } from './version';
61
64
  // Framework shard IDs — shards that are always present (built-in to sh3-core).
package/dist/host.js CHANGED
@@ -31,6 +31,7 @@ import { storeApp } from './app/store/storeApp';
31
31
  import { adminShard } from './app/admin/adminShard.svelte';
32
32
  import { adminApp } from './app/admin/adminApp';
33
33
  import { runShellRenameMigration, } from './migrations/shell-rename';
34
+ import { runModeIdRenameMigration } from './migrations/mode-id-rename';
34
35
  import { setLifecycleHandlers } from './navigation/back-stack';
35
36
  import { installWebEmitter } from './navigation/platform-web';
36
37
  import { returnToHome } from './apps/lifecycle';
@@ -60,6 +61,7 @@ export async function bootstrap(config) {
60
61
  // already in place when shards activate.
61
62
  if (typeof globalThis.localStorage !== 'undefined') {
62
63
  runShellRenameMigration(createWorkspaceZoneAdapter(), globalThis.localStorage);
64
+ runModeIdRenameMigration(globalThis.localStorage);
63
65
  // Per ADR-002 amendment, app workspace state is keyed by (scopeId, appId).
64
66
  // Rewrite legacy unkeyed entries to the personal scope namespace.
65
67
  const { migrateLegacyWorkspaceKeys } = await import('./apps/workspace-rekey');
@@ -230,7 +230,7 @@
230
230
  {:else}
231
231
  {@const slot = asSlot(node)!}
232
232
  <div class="leaf-slot-wrapper">
233
- <SlotContainer node={slot} />
233
+ <SlotContainer node={slot} meta={slot.meta} />
234
234
  <SlotDropZone {rootRef} path={path} />
235
235
  </div>
236
236
  {/if}
@@ -15,7 +15,12 @@ export function collectSlotRefs(tree) {
15
15
  const out = [];
16
16
  const walk = (node) => {
17
17
  if (node.type === 'slot') {
18
- out.push({ slotId: node.slotId, viewId: node.viewId, label: node.viewId || node.slotId });
18
+ out.push({
19
+ slotId: node.slotId,
20
+ viewId: node.viewId,
21
+ label: node.viewId || node.slotId,
22
+ meta: node.meta,
23
+ });
19
24
  return;
20
25
  }
21
26
  if (node.type === 'tabs') {
@@ -86,6 +86,13 @@ export interface SlotNode {
86
86
  slotId: string;
87
87
  /** View id to mount into this slot, or null for an empty slot. */
88
88
  viewId: string | null;
89
+ /**
90
+ * Caller-supplied instance data, threaded to `MountContext.meta`.
91
+ * Ephemeral — not serialized with the layout tree. Mirrors
92
+ * `TabEntry.meta` for the bare-slot case (e.g. dismissable floats whose
93
+ * content is a single slot rather than a TabsNode).
94
+ */
95
+ meta?: Record<string, unknown>;
89
96
  }
90
97
  /**
91
98
  * Union of all layout node kinds. The recursive tree is composed entirely of
@@ -0,0 +1,9 @@
1
+ interface MinimalStorage {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ removeItem(key: string): void;
5
+ }
6
+ export declare function runModeIdRenameMigration(storage: MinimalStorage & {
7
+ _keys?: () => string[];
8
+ }): void;
9
+ export {};
@@ -0,0 +1,39 @@
1
+ /*
2
+ * One-shot migration: rewrites persisted shell-mode preferences from the
3
+ * pre-rename ids (`dev`, `user`) to the new ids (`bash`, `sh3`). Idempotent —
4
+ * gated by a localStorage flag, safe to call on every boot.
5
+ *
6
+ * Persistence shape: localStorage keys of the form `sh3.shell.lastMode.<userId>`
7
+ * (see packages/sh3-core/src/shell-shard/modes/prefs.ts).
8
+ */
9
+ const FLAG_KEY = 'sh3:migrations:mode-id-rename:done';
10
+ const KEY_PREFIX = 'sh3.shell.lastMode.';
11
+ const REWRITES = { dev: 'bash', user: 'sh3' };
12
+ function listKeys(storage) {
13
+ if (typeof storage._keys === 'function')
14
+ return storage._keys();
15
+ const ls = storage;
16
+ if (typeof ls.length === 'number' && typeof ls.key === 'function') {
17
+ const out = [];
18
+ for (let i = 0; i < ls.length; i++) {
19
+ const k = ls.key(i);
20
+ if (k !== null)
21
+ out.push(k);
22
+ }
23
+ return out;
24
+ }
25
+ return [];
26
+ }
27
+ export function runModeIdRenameMigration(storage) {
28
+ if (storage.getItem(FLAG_KEY))
29
+ return;
30
+ for (const key of listKeys(storage)) {
31
+ if (!key.startsWith(KEY_PREFIX))
32
+ continue;
33
+ const value = storage.getItem(key);
34
+ if (value && Object.prototype.hasOwnProperty.call(REWRITES, value)) {
35
+ storage.setItem(key, REWRITES[value]);
36
+ }
37
+ }
38
+ storage.setItem(FLAG_KEY, '1');
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { runModeIdRenameMigration } from './mode-id-rename';
3
+ function makeStorage(initial = {}) {
4
+ const map = new Map(Object.entries(initial));
5
+ return {
6
+ getItem: (k) => (map.has(k) ? map.get(k) : null),
7
+ setItem: (k, v) => { map.set(k, v); },
8
+ removeItem: (k) => { map.delete(k); },
9
+ _dump: () => Object.fromEntries(map),
10
+ _keys: () => [...map.keys()],
11
+ };
12
+ }
13
+ describe('runModeIdRenameMigration', () => {
14
+ it('rewrites dev → bash for every user-keyed pref', () => {
15
+ const s = makeStorage({
16
+ 'sh3.shell.lastMode.alice': 'dev',
17
+ 'sh3.shell.lastMode.bob': 'dev',
18
+ });
19
+ runModeIdRenameMigration(s);
20
+ expect(s._dump()).toMatchObject({
21
+ 'sh3.shell.lastMode.alice': 'bash',
22
+ 'sh3.shell.lastMode.bob': 'bash',
23
+ });
24
+ });
25
+ it('rewrites user → sh3 for every user-keyed pref', () => {
26
+ const s = makeStorage({ 'sh3.shell.lastMode.alice': 'user' });
27
+ runModeIdRenameMigration(s);
28
+ expect(s.getItem('sh3.shell.lastMode.alice')).toBe('sh3');
29
+ });
30
+ it('leaves unknown values untouched', () => {
31
+ const s = makeStorage({ 'sh3.shell.lastMode.alice': 'gemini' });
32
+ runModeIdRenameMigration(s);
33
+ expect(s.getItem('sh3.shell.lastMode.alice')).toBe('gemini');
34
+ });
35
+ it('is idempotent (gated by a done flag)', () => {
36
+ const s = makeStorage({ 'sh3.shell.lastMode.alice': 'dev' });
37
+ runModeIdRenameMigration(s);
38
+ s.setItem('sh3.shell.lastMode.alice', 'dev');
39
+ runModeIdRenameMigration(s);
40
+ expect(s.getItem('sh3.shell.lastMode.alice')).toBe('dev');
41
+ });
42
+ it('ignores unrelated keys', () => {
43
+ const s = makeStorage({
44
+ 'sh3.shell.lastMode.alice': 'dev',
45
+ 'sh3.unrelated': 'dev',
46
+ 'random': 'user',
47
+ });
48
+ runModeIdRenameMigration(s);
49
+ expect(s.getItem('sh3.unrelated')).toBe('dev');
50
+ expect(s.getItem('random')).toBe('user');
51
+ });
52
+ });
@@ -42,14 +42,20 @@
42
42
  // stacking context — so a picker opened from inside a modal stacks above
43
43
  // that modal without writing any z-index. The Svelte component lifecycle
44
44
  // is unaffected; we're only relocating the rendered DOM node.
45
+ //
46
+ // The cleanup removes frameEl directly: Svelte's keyed-each iteration
47
+ // removal walks the anchor range left in FloatLayer to clear the DOM, and
48
+ // the portal moved frameEl out of that range — re-parenting it back to
49
+ // FloatLayer would orphan it (Svelte's removal pass clears the empty
50
+ // anchor range and misses the re-deposited frame). Effect deps are stable
51
+ // (frameEl, entry.id), so cleanup only fires on destroy.
45
52
  $effect(() => {
46
53
  if (!frameEl) return;
47
54
  const host = getFloatParentHost(entry.id);
48
55
  if (!host) return;
49
- const original = frameEl.parentNode;
50
56
  host.appendChild(frameEl);
51
57
  return () => {
52
- if (frameEl?.parentNode === host && original) original.appendChild(frameEl);
58
+ if (frameEl?.parentNode === host) frameEl.remove();
53
59
  };
54
60
  });
55
61
 
@@ -86,13 +86,16 @@ function openFloat(viewId, options = {}) {
86
86
  let content;
87
87
  if (options.dismissable) {
88
88
  // Picker float: render the view directly as a leaf slot. No tab strip,
89
- // no tabs wrapper, no drag-to-dock handle. Note: options.meta cannot
90
- // thread through a bare SlotNode — only TabEntry carries meta.
91
- content = {
89
+ // no tabs wrapper, no drag-to-dock handle. SlotNode.meta carries
90
+ // options.meta through to MountContext, mirroring TabEntry.meta.
91
+ const slot = {
92
92
  type: 'slot',
93
93
  slotId,
94
94
  viewId,
95
95
  };
96
+ if (options.meta)
97
+ slot.meta = options.meta;
98
+ content = slot;
96
99
  }
97
100
  else {
98
101
  const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
@@ -388,6 +388,32 @@ describe('floats — F.7 anchor portals to enclosing overlay host', () => {
388
388
  const frame = container.querySelector('[role="dialog"][aria-label="NoAnchor"]');
389
389
  expect(frame).toBeTruthy();
390
390
  });
391
+ // Regression: Svelte's keyed-each iteration removal walks the anchor range
392
+ // left in FloatLayer to clear the DOM. The portal moved frameEl out of that
393
+ // range, so a re-parent-back-to-original cleanup orphans the empty frame
394
+ // in FloatLayer when the float is dismissed. Cleanup must remove frameEl
395
+ // directly.
396
+ it('removes the portaled frame on dismiss (no orphan in FloatLayer)', async () => {
397
+ const { container } = renderWithShell(FloatLayer, {});
398
+ const fakeModalHost = document.createElement('div');
399
+ fakeModalHost.className = 'fake-modal-host';
400
+ fakeModalHost.dataset.shellOverlayHost = 'modal';
401
+ const anchor = document.createElement('button');
402
+ fakeModalHost.appendChild(anchor);
403
+ document.body.appendChild(fakeModalHost);
404
+ const id = floatManager.open('test:view', {
405
+ dismissable: true,
406
+ anchor,
407
+ title: 'Picker',
408
+ });
409
+ await tick();
410
+ expect(fakeModalHost.querySelector('.sh3-float-frame')).not.toBeNull();
411
+ floatManager.close(id);
412
+ await tick();
413
+ const layerDiv = container.querySelector('.sh3-float-layer');
414
+ expect(layerDiv === null || layerDiv === void 0 ? void 0 : layerDiv.querySelector('.sh3-float-frame')).toBeNull();
415
+ expect(document.querySelectorAll('.sh3-float-frame').length).toBe(0);
416
+ });
391
417
  });
392
418
  // ---------------------------------------------------------------------------
393
419
  // F.8 — overlay host marker on FloatFrame
@@ -406,3 +432,48 @@ describe('floats — F.8 overlay host marker', () => {
406
432
  expect(frame.dataset.shellOverlayHost).toBe('float');
407
433
  });
408
434
  });
435
+ // ---------------------------------------------------------------------------
436
+ // F.9 — meta threading from floatManager.open into MountContext
437
+ // ---------------------------------------------------------------------------
438
+ import { registerView } from '../shards/registry';
439
+ describe('floats — F.9 meta threads to MountContext', () => {
440
+ beforeEach(() => {
441
+ resetFramework();
442
+ bindManagerToStore();
443
+ });
444
+ it('non-dismissable: ctx.meta receives options.meta on mount', async () => {
445
+ let captured;
446
+ registerView('test:meta-view', {
447
+ mount(_container, ctx) {
448
+ captured = ctx.meta;
449
+ return { unmount: () => { } };
450
+ },
451
+ });
452
+ renderWithShell(FloatLayer, {});
453
+ floatManager.open('test:meta-view', { title: 'M', meta: { kind: 'tab', n: 1 } });
454
+ await tick();
455
+ // acquireSlotHost defers factory.mount via queueMicrotask — flush it.
456
+ await Promise.resolve();
457
+ await tick();
458
+ expect(captured).toEqual({ kind: 'tab', n: 1 });
459
+ });
460
+ it('dismissable: ctx.meta receives options.meta on mount', async () => {
461
+ let captured;
462
+ registerView('test:meta-view', {
463
+ mount(_container, ctx) {
464
+ captured = ctx.meta;
465
+ return { unmount: () => { } };
466
+ },
467
+ });
468
+ renderWithShell(FloatLayer, {});
469
+ floatManager.open('test:meta-view', {
470
+ dismissable: true,
471
+ title: 'P',
472
+ meta: { kind: 'picker', color: '#abc' },
473
+ });
474
+ await tick();
475
+ await Promise.resolve();
476
+ await tick();
477
+ expect(captured).toEqual({ kind: 'picker', color: '#abc' });
478
+ });
479
+ });
@@ -78,7 +78,10 @@
78
78
  color: var(--shell-fg);
79
79
  filter: none;
80
80
  }
81
- .sh3-itg__btn--active {
81
+ /* Selector includes `.sh3-itg button` so specificity (0,2,1) beats the
82
+ base `.sh3-itg button` rule (0,1,1) — otherwise `background: transparent`
83
+ and `color: var(--shell-fg-muted)` would shadow the accent fill. */
84
+ .sh3-itg button.sh3-itg__btn--active {
82
85
  background: var(--shell-accent);
83
86
  color: var(--shell-fg-on-accent);
84
87
  font-weight: 600;
@@ -75,7 +75,10 @@
75
75
  color: var(--shell-fg);
76
76
  filter: none;
77
77
  }
78
- .sh3-seg__btn--active {
78
+ /* Selector includes `.sh3-seg button` so specificity (0,2,1) beats the
79
+ base `.sh3-seg button` rule (0,1,1) — otherwise `background: transparent`
80
+ and `color: var(--shell-fg-muted)` would shadow the accent fill. */
81
+ .sh3-seg button.sh3-seg__btn--active {
79
82
  background: var(--shell-accent);
80
83
  color: var(--shell-fg-on-accent);
81
84
  font-weight: 600;
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Read-only details modal for an app, opened by the home-card `app.info`
4
+ * action. Pulls the manifest from listRegisteredApps() and — when the app
5
+ * was installed from a registry — augments it with the catalog entry
6
+ * (description, author) and the InstalledPackage record (source registry,
7
+ * install date, contract version). Built-in apps show only manifest fields.
8
+ */
9
+
10
+ import type { AppManifest } from '../apps/types';
11
+ import type { InstalledPackage, PackageEntry } from '../registry/types';
12
+
13
+ interface Props {
14
+ manifest: AppManifest;
15
+ installed: InstalledPackage | null;
16
+ catalogEntry: PackageEntry | null;
17
+ close: () => void;
18
+ }
19
+
20
+ let { manifest, installed, catalogEntry, close }: Props = $props();
21
+
22
+ let installedDate = $derived.by(() => {
23
+ if (!installed) return null;
24
+ const d = new Date(installed.installedAt);
25
+ return isNaN(d.getTime()) ? installed.installedAt : d.toLocaleString();
26
+ });
27
+ </script>
28
+
29
+ <div class="app-info-modal">
30
+ <header>
31
+ <h2>{manifest.label}</h2>
32
+ <code class="id">{manifest.id}</code>
33
+ </header>
34
+
35
+ <p class="version">
36
+ <span class="label">Version</span>
37
+ <code>v{manifest.version}</code>
38
+ {#if !installed}<span class="badge built-in">built-in</span>{/if}
39
+ </p>
40
+
41
+ {#if catalogEntry?.description}
42
+ <section class="description">
43
+ <span class="label">Description</span>
44
+ <p>{catalogEntry.description}</p>
45
+ </section>
46
+ {/if}
47
+
48
+ {#if catalogEntry?.author?.name}
49
+ <p class="row">
50
+ <span class="label">Author</span>
51
+ <span>{catalogEntry.author.name}</span>
52
+ </p>
53
+ {/if}
54
+
55
+ {#if manifest.requiredShards.length > 0}
56
+ <section class="row">
57
+ <span class="label">Required shards</span>
58
+ <ul class="chips">
59
+ {#each manifest.requiredShards as shard}
60
+ <li><code>{shard}</code></li>
61
+ {/each}
62
+ </ul>
63
+ </section>
64
+ {/if}
65
+
66
+ {#if manifest.permissions && manifest.permissions.length > 0}
67
+ <section class="row">
68
+ <span class="label">Permissions</span>
69
+ <ul class="chips">
70
+ {#each manifest.permissions as perm}
71
+ <li><code>{perm}</code></li>
72
+ {/each}
73
+ </ul>
74
+ </section>
75
+ {/if}
76
+
77
+ {#if installed}
78
+ <section class="installed">
79
+ <p class="row"><span class="label">Source registry</span><code>{installed.sourceRegistry}</code></p>
80
+ {#if installedDate}
81
+ <p class="row"><span class="label">Installed</span><span>{installedDate}</span></p>
82
+ {/if}
83
+ <p class="row"><span class="label">Contract</span><code>v{installed.contractVersion}</code></p>
84
+ </section>
85
+ {/if}
86
+
87
+ <div class="actions">
88
+ <button type="button" onclick={close}>Close</button>
89
+ </div>
90
+ </div>
91
+
92
+ <style>
93
+ .app-info-modal {
94
+ padding: 16px 20px;
95
+ min-width: 360px;
96
+ max-width: 520px;
97
+ color: var(--shell-fg);
98
+ background: var(--shell-bg);
99
+ font: inherit;
100
+ }
101
+ header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
102
+ header h2 { margin: 0; font-size: 16px; }
103
+ header .id {
104
+ font-size: 12px;
105
+ color: var(--shell-fg-muted);
106
+ }
107
+ .version { margin: 4px 0 12px; font-size: 13px; display: flex; align-items: center; gap: 8px; }
108
+ .description { margin: 8px 0 12px; }
109
+ .description p {
110
+ margin: 4px 0 0;
111
+ font-size: 13px;
112
+ line-height: 1.5;
113
+ white-space: pre-wrap;
114
+ }
115
+ .row { margin: 6px 0; font-size: 13px; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
116
+ .label {
117
+ color: var(--shell-fg-muted);
118
+ font-size: 11px;
119
+ text-transform: uppercase;
120
+ letter-spacing: 0.04em;
121
+ flex: 0 0 auto;
122
+ min-width: 90px;
123
+ }
124
+ .chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px; }
125
+ .chips code {
126
+ padding: 1px 6px;
127
+ border: 1px solid var(--shell-border);
128
+ border-radius: var(--shell-radius-sm, 3px);
129
+ }
130
+ .badge {
131
+ font-size: 11px;
132
+ padding: 1px 6px;
133
+ border-radius: var(--shell-radius-sm, 3px);
134
+ background: var(--shell-bg-elevated);
135
+ color: var(--shell-fg-muted);
136
+ border: 1px solid var(--shell-border);
137
+ }
138
+ .installed { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--shell-border); }
139
+ code {
140
+ font-family: var(--shell-font-mono, monospace);
141
+ background: var(--shell-bg-elevated);
142
+ padding: 0 4px;
143
+ border-radius: var(--shell-radius-sm, 3px);
144
+ }
145
+ .actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
146
+ .actions button {
147
+ background: var(--shell-bg-elevated);
148
+ color: var(--shell-fg);
149
+ border: 1px solid var(--shell-border);
150
+ border-radius: var(--shell-radius-sm, 3px);
151
+ padding: 6px 14px; font: inherit; cursor: pointer;
152
+ }
153
+ .actions button:hover { border-color: var(--shell-accent); }
154
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { AppManifest } from '../apps/types';
2
+ import type { InstalledPackage, PackageEntry } from '../registry/types';
3
+ interface Props {
4
+ manifest: AppManifest;
5
+ installed: InstalledPackage | null;
6
+ catalogEntry: PackageEntry | null;
7
+ close: () => void;
8
+ }
9
+ declare const AppInfoView: import("svelte").Component<Props, {}, "">;
10
+ type AppInfoView = ReturnType<typeof AppInfoView>;
11
+ export default AppInfoView;
@@ -5,7 +5,8 @@
5
5
  * 'app' (see ShellHome.svelte).
6
6
  *
7
7
  * Three actions are registered here:
8
- * - app.info : info-only row showing "<id> v<version>" (always disabled).
8
+ * - app.info : open the AppInfoView modal with manifest + (when
9
+ * installed) catalog/install metadata. Always enabled.
9
10
  * - app.checkUpdate : refresh catalog, then prompt update or toast up-to-date.
10
11
  * - app.uninstall : open uninstall confirm dialog.
11
12
  *
@@ -25,6 +26,7 @@ import { modalManager } from '../overlays/modal';
25
26
  import { toastManager } from '../overlays/toast';
26
27
  import AppUpdateAvailableModal from '../app/store/AppUpdateAvailableModal.svelte';
27
28
  import UninstallAppDialog from '../app/store/UninstallAppDialog.svelte';
29
+ import AppInfoView from './AppInfoView.svelte';
28
30
  export function computeAppActionDisabled(g) {
29
31
  return !g.admin || g.builtin;
30
32
  }
@@ -50,6 +52,22 @@ function isBuiltin(appId) {
50
52
  function gateFor(appId) {
51
53
  return { admin: isAdmin(), builtin: isBuiltin(appId) };
52
54
  }
55
+ function runShowInfo(_ctx) {
56
+ var _a, _b, _c;
57
+ const ref = readSelection();
58
+ if (!ref)
59
+ return;
60
+ const manifest = findApp(ref.appId);
61
+ if (!manifest)
62
+ return;
63
+ const installed = (_a = storeContext.state.ephemeral.installed.find((p) => p.id === ref.appId)) !== null && _a !== void 0 ? _a : null;
64
+ // Catalog entry only exists for apps fetched from a registry — built-in
65
+ // apps will fall through to null and the modal will hide registry-only
66
+ // fields (description, author).
67
+ const catalogEntry = (_c = (_b = storeContext.state.ephemeral.catalog.find((p) => p.entry.id === ref.appId)) === null || _b === void 0 ? void 0 : _b.entry) !== null && _c !== void 0 ? _c : null;
68
+ const props = { manifest, installed, catalogEntry };
69
+ modalManager.open(AppInfoView, props);
70
+ }
53
71
  async function runCheckUpdate(_ctx) {
54
72
  var _a, _b;
55
73
  const ref = readSelection();
@@ -124,11 +142,11 @@ export function registerAppActions(ctx) {
124
142
  const infoLabel = () => {
125
143
  const ref = readSelection();
126
144
  if (!ref)
127
- return '';
145
+ return 'Info…';
128
146
  const m = findApp(ref.appId);
129
147
  if (!m)
130
- return ref.appId;
131
- return `${m.id} v${m.version}`;
148
+ return `Info — ${ref.appId}`;
149
+ return `Info — ${m.label} v${m.version}`;
132
150
  };
133
151
  const updateLabel = () => {
134
152
  const ref = readSelection();
@@ -155,7 +173,7 @@ export function registerAppActions(ctx) {
155
173
  scope: { element: 'app' },
156
174
  contextItem: true,
157
175
  group: 'info',
158
- disabled: true,
176
+ run: runShowInfo,
159
177
  },
160
178
  {
161
179
  id: 'app.checkUpdate',
@@ -11,30 +11,51 @@
11
11
  let { scrollback }: Props = $props();
12
12
 
13
13
  let container: HTMLDivElement | null = $state(null);
14
+ let content: HTMLDivElement | null = $state(null);
15
+ let stuck = true;
14
16
 
15
- // Auto-scroll to bottom on new entries
17
+ // scrollHeight - scrollTop - clientHeight can settle on small non-zero
18
+ // values from sub-pixel rounding even when visually at the bottom.
19
+ const STICK_THRESHOLD_PX = 4;
20
+
21
+ function isAtBottom(el: HTMLElement): boolean {
22
+ return el.scrollHeight - el.scrollTop - el.clientHeight <= STICK_THRESHOLD_PX;
23
+ }
24
+
25
+ function handleScroll(): void {
26
+ if (container) stuck = isAtBottom(container);
27
+ }
28
+
29
+ // ResizeObserver on the inner content wrapper fires on any layout-affecting
30
+ // change regardless of source: text-chunk pushes, mutated rich-entry props
31
+ // (output.stream() / output.rich() handles), image or font load. A reactivity-
32
+ // driven approach (depending on entries.length or chunk counts) misses rich
33
+ // streaming because props are mutated via Object.assign and the outer view
34
+ // never reads them.
16
35
  $effect(() => {
17
- // Depend on entries length so the effect re-runs
18
- const _len = scrollback.entries.length;
19
- void _len;
20
- if (container) {
21
- container.scrollTop = container.scrollHeight;
22
- }
36
+ if (!content || !container) return;
37
+ const ro = new ResizeObserver(() => {
38
+ if (container && stuck) container.scrollTop = container.scrollHeight;
39
+ });
40
+ ro.observe(content);
41
+ return () => ro.disconnect();
23
42
  });
24
43
  </script>
25
44
 
26
- <div class="shell-scrollback" bind:this={container}>
27
- {#each scrollback.entries as entry (entry.id)}
28
- {#if entry.kind === 'text'}
29
- <TextEntry stream={entry.stream} chunks={entry.chunks} />
30
- {:else if entry.kind === 'prompt'}
31
- <PromptEntry cwd={entry.cwd} line={entry.line} />
32
- {:else if entry.kind === 'status'}
33
- <StatusEntry text={entry.text} level={entry.level} />
34
- {:else if entry.kind === 'rich'}
35
- <RichEntry component={entry.component} componentProps={entry.props} />
36
- {/if}
37
- {/each}
45
+ <div class="shell-scrollback" bind:this={container} onscroll={handleScroll}>
46
+ <div class="content" bind:this={content}>
47
+ {#each scrollback.entries as entry (entry.id)}
48
+ {#if entry.kind === 'text'}
49
+ <TextEntry stream={entry.stream} chunks={entry.chunks} />
50
+ {:else if entry.kind === 'prompt'}
51
+ <PromptEntry cwd={entry.cwd} line={entry.line} />
52
+ {:else if entry.kind === 'status'}
53
+ <StatusEntry text={entry.text} level={entry.level} />
54
+ {:else if entry.kind === 'rich'}
55
+ <RichEntry component={entry.component} componentProps={entry.props} />
56
+ {/if}
57
+ {/each}
58
+ </div>
38
59
  </div>
39
60
 
40
61
  <style>