sh3-core 0.19.0 → 0.19.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 (58) hide show
  1. package/dist/Sh3.svelte +3 -1
  2. package/dist/actions/menuBarModel.js +8 -0
  3. package/dist/actions/menuBarModel.test.js +61 -0
  4. package/dist/app/admin/ApiKeysView.svelte +6 -5
  5. package/dist/app/store/PermissionConfirmModal.svelte +23 -0
  6. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
  7. package/dist/app/store/StoreView.svelte +6 -1
  8. package/dist/chrome/CompactChrome.svelte.test.js +7 -4
  9. package/dist/env/client.d.ts +5 -4
  10. package/dist/env/client.js +11 -17
  11. package/dist/env/serverUrl.d.ts +2 -0
  12. package/dist/env/serverUrl.js +8 -0
  13. package/dist/gestures/gestureRegistry.test.js +1 -0
  14. package/dist/gestures/index.d.ts +17 -0
  15. package/dist/gestures/index.js +27 -0
  16. package/dist/keys/client.js +6 -7
  17. package/dist/keys/revocation-bus.svelte.js +11 -1
  18. package/dist/layout/compact/CarouselTabs.svelte +152 -15
  19. package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
  20. package/dist/layout/compact/CompactRenderer.svelte +1 -1
  21. package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
  22. package/dist/layout/compact/derive.js +7 -16
  23. package/dist/layout/compact/derive.test.js +30 -9
  24. package/dist/layout/drag.svelte.js +16 -3
  25. package/dist/layout/inspection.d.ts +20 -9
  26. package/dist/layout/inspection.js +66 -11
  27. package/dist/layout/inspection.svelte.test.d.ts +1 -0
  28. package/dist/layout/inspection.svelte.test.js +114 -0
  29. package/dist/layout/store.schemaVersion.test.js +2 -2
  30. package/dist/layout/types.d.ts +11 -8
  31. package/dist/layout/types.js +1 -1
  32. package/dist/layout/types.test.js +2 -2
  33. package/dist/overlays/FloatFrame.svelte +93 -22
  34. package/dist/primitives/ResizableSplitter.svelte +42 -8
  35. package/dist/registry/checkFetch.d.ts +6 -0
  36. package/dist/registry/checkFetch.js +23 -0
  37. package/dist/sh3/views/KeysAndPeers.svelte +4 -3
  38. package/dist/shards/activate-runtime.test.js +99 -1
  39. package/dist/shards/activate.svelte.js +20 -5
  40. package/dist/shards/ctx-fetch.test.js +70 -0
  41. package/dist/shards/registry.d.ts +8 -1
  42. package/dist/shards/registry.js +13 -2
  43. package/dist/shards/registry.test.js +25 -4
  44. package/dist/shards/types.d.ts +30 -1
  45. package/dist/shell-shard/ScrollbackView.svelte +145 -67
  46. package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
  47. package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
  48. package/dist/shell-shard/dispatch-gating.test.js +38 -2
  49. package/dist/shell-shard/dispatch.js +9 -1
  50. package/dist/shell-shard/registry-resolve.test.js +50 -0
  51. package/dist/shell-shard/registry.d.ts +2 -1
  52. package/dist/shell-shard/registry.js +12 -2
  53. package/dist/shell-shard/verbs/help.js +5 -4
  54. package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
  55. package/dist/verbs/types.d.ts +10 -5
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/package.json +1 -1
@@ -158,6 +158,16 @@ export interface ShardManifest {
158
158
  * registry visibility (observer-class shards, e.g. file-explorer).
159
159
  */
160
160
  permissions?: string[];
161
+ /**
162
+ * Namespace used as the prefix for verbs this shard registers in the
163
+ * terminal. Defaults to `id`. Two shards may attempt to claim the same
164
+ * namespace; collisions on `${verbNamespace}:${verbName}` are resolved
165
+ * first-wins with a `console.warn`. The shell shard's namespace is
166
+ * implicit (empty prefix) — `verbNamespace` is ignored for `id === 'shell'`.
167
+ * Reserved values (`'sh3'`, `'core'`, `'shell'`, empty string) are flagged
168
+ * by sh3-validate at build time.
169
+ */
170
+ verbNamespace?: string;
161
171
  }
162
172
  /**
163
173
  * Source-declared shape of a shard manifest — what external package authors
@@ -197,7 +207,10 @@ export interface ShardContext {
197
207
  registerView(viewId: string, factory: ViewFactory): void;
198
208
  /**
199
209
  * Register a verb that users can invoke from the sh3 terminal.
200
- * The verb name is auto-prefixed with `shardId:` for non-sh3 shards.
210
+ * The verb name is auto-prefixed with `${ns}:` where `ns` is the
211
+ * shard's `manifest.verbNamespace ?? manifest.id` (the shell shard
212
+ * keeps bare names). Collisions on the resulting full name log a
213
+ * `console.warn` and skip the second registration — first-wins.
201
214
  * Automatically unregistered when the shard deactivates.
202
215
  *
203
216
  * @param verb - The verb definition (name, summary, run function).
@@ -218,6 +231,22 @@ export interface ShardContext {
218
231
  * @param init - Standard RequestInit.
219
232
  */
220
233
  fetch(path: string, init?: RequestInit): Promise<Response>;
234
+ /**
235
+ * The configured server base URL (e.g. `https://my-sh3.example.com`).
236
+ * Empty string when running local-only (no remote server).
237
+ * Use this as the `BaseAddress` for non-fetch HTTP clients (e.g. WASM .NET
238
+ * `HttpClient`) that cannot go through `ctx.fetch`.
239
+ */
240
+ readonly serverUrl: string;
241
+ /**
242
+ * Resolve a path to an absolute URL against the configured server, using
243
+ * the same rules as `ctx.fetch` but without making a request. Useful for
244
+ * WebSocket URLs and any transport that bypasses `ctx.fetch`.
245
+ *
246
+ * @param path - Relative `/api/...` path or fully-qualified URL.
247
+ * @returns Absolute URL string.
248
+ */
249
+ resolveUrl(path: string): string;
221
250
  /**
222
251
  * Declare environment state for this shard and receive a hydrated snapshot.
223
252
  * Env state is server-authoritative, fetched once at activation, and
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Scrollback } from './scrollback.svelte';
3
3
  import { isAtBottom } from './scrollback-stick';
4
+ import Button from '../primitives/Button.svelte';
4
5
  import TextEntry from './entries/TextEntry.svelte';
5
6
  import PromptEntry from './entries/PromptEntry.svelte';
6
7
  import StatusEntry from './entries/StatusEntry.svelte';
@@ -16,64 +17,80 @@
16
17
  let container: HTMLDivElement | null = $state(null);
17
18
  let content: HTMLDivElement | null = $state(null);
18
19
 
19
- // Stick-to-bottom is driven by user intent, not by inferring intent from
20
- // scroll events. A scroll event tells us scrollTop changed but not WHY:
21
- // it may be our programmatic snap, a browser scroll-anchor adjustment
22
- // after a layout-affecting markdown re-render, or a real user wheel/touch.
23
- // Conflating browser-induced shifts with user intent silently dropped the
24
- // snap mid-stream. We now only surrender stick on actual input events
25
- // (wheel / touchstart / keydown) and reacquire once the viewport reaches
26
- // the bottom geometrically.
27
- let userScrolling = false;
20
+ // Single source of truth for whether new output should snap the viewport
21
+ // to the bottom. Toggled by user-driven scroll movement (handled in
22
+ // handleScroll) and by an explicit click on the jump-to-bottom pill.
23
+ // No more inferring intent from wheel/touchstart/keydown the new
24
+ // contract is: at-bottom-after-scroll means following; off-bottom means
25
+ // not following. Predecessor used `userScrolling` as a flag set by input
26
+ // listeners; the inference was fragile under streaming layout shifts.
27
+ let followBottom = $state(true);
28
+
29
+ // Count of content-growth events that fired while followBottom was false.
30
+ // Reset when the user (or click handler) returns to the bottom. Approximate
31
+ // — one ResizeObserver tick may correspond to multiple logical entries —
32
+ // but the pill label only needs to say "you have unread output," not the
33
+ // exact number of items.
34
+ let unreadCount = $state(0);
28
35
 
29
36
  // scrollHeight − scrollTop − clientHeight can settle on small non-zero
30
37
  // values from sub-pixel rounding even when visually at the bottom.
31
38
  const STICK_THRESHOLD_PX = 4;
32
39
 
33
- function handleScroll(): void {
34
- if (!container) return;
35
- if (
36
- isAtBottom({
37
- scrollTop: container.scrollTop,
38
- scrollHeight: container.scrollHeight,
39
- clientHeight: container.clientHeight,
40
- threshold: STICK_THRESHOLD_PX,
41
- })
42
- ) {
43
- userScrolling = false;
44
- }
40
+ function atBottom(): boolean {
41
+ if (!container) return true;
42
+ return isAtBottom({
43
+ scrollTop: container.scrollTop,
44
+ scrollHeight: container.scrollHeight,
45
+ clientHeight: container.clientHeight,
46
+ threshold: STICK_THRESHOLD_PX,
47
+ });
45
48
  }
46
49
 
47
- // Intent listeners are attached imperatively so wheel/touchstart can be
48
- // passive (no main-thread block on scroll) and so the template stays a
49
- // plain scroll container — putting touchstart/keydown attributes on the
50
- // <div> tripped Svelte's a11y_no_static_element_interactions check.
51
- $effect(() => {
50
+ function jumpToBottom(): void {
52
51
  if (!container) return;
53
- const mark = () => {
54
- userScrolling = true;
55
- };
56
- container.addEventListener('wheel', mark, { passive: true });
57
- container.addEventListener('touchstart', mark, { passive: true });
58
- container.addEventListener('keydown', mark);
59
- return () => {
60
- container?.removeEventListener('wheel', mark);
61
- container?.removeEventListener('touchstart', mark);
62
- container?.removeEventListener('keydown', mark);
63
- };
64
- });
52
+ container.scrollTop = container.scrollHeight;
53
+ followBottom = true;
54
+ unreadCount = 0;
55
+ }
56
+
57
+ // Any scroll event — user gesture or browser scroll-anchor adjustment —
58
+ // is judged by where the viewport ends up, not what triggered it. The
59
+ // programmatic snap below lands at the (new) bottom, so it keeps
60
+ // followBottom true. A user scroll that leaves the viewport away from
61
+ // the bottom disengages follow. This is the entire intent contract.
62
+ function handleScroll(): void {
63
+ if (atBottom()) {
64
+ followBottom = true;
65
+ unreadCount = 0;
66
+ } else {
67
+ followBottom = false;
68
+ }
69
+ }
65
70
 
66
- // ResizeObserver on the inner content wrapper fires on any layout-affecting
67
- // change regardless of source: text-chunk pushes, mutated rich-entry props
68
- // (output.stream() / output.rich() handles), image or font load. A reactivity-
69
- // driven approach (depending on entries.length or chunk counts) misses rich
70
- // streaming because props are mutated via Object.assign and the outer view
71
- // never reads them.
71
+ // Snap on content growth, only when following. Increment unread otherwise.
72
+ // ResizeObserver fires on any layout-affecting change regardless of source
73
+ // (text-chunk pushes, mutated rich-entry props, image/font load) a
74
+ // reactivity-driven approach (depending on entries.length) misses rich
75
+ // streaming because props are mutated via Object.assign and the outer
76
+ // view never reads them.
77
+ //
78
+ // We track lastScrollHeight so window/panel resizes (which fire RO but
79
+ // don't actually add new output — they reflow existing content) don't
80
+ // bump the unread counter. Only strict growth in scrollHeight counts.
81
+ let lastScrollHeight = 0;
72
82
  $effect(() => {
73
83
  if (!content || !container) return;
84
+ lastScrollHeight = container.scrollHeight;
74
85
  const ro = new ResizeObserver(() => {
75
- if (container && !userScrolling) {
76
- container.scrollTop = container.scrollHeight;
86
+ if (!container) return;
87
+ const h = container.scrollHeight;
88
+ const grew = h > lastScrollHeight;
89
+ lastScrollHeight = h;
90
+ if (followBottom) {
91
+ container.scrollTop = h;
92
+ } else if (grew) {
93
+ unreadCount++;
77
94
  }
78
95
  });
79
96
  ro.observe(content);
@@ -81,34 +98,69 @@
81
98
  });
82
99
  </script>
83
100
 
84
- <div
85
- class="shell-scrollback"
86
- bind:this={container}
87
- onscroll={handleScroll}
88
- >
89
- <div class="content" bind:this={content}>
90
- {#each scrollback.entries as entry (entry.id)}
91
- {#if entry.kind === 'text'}
92
- <TextEntry stream={entry.stream} chunks={entry.chunks} />
93
- {:else if entry.kind === 'prompt'}
94
- <PromptEntry cwd={showPromptCwd ? entry.cwd : ''} line={entry.line} />
95
- {:else if entry.kind === 'status'}
96
- <StatusEntry text={entry.text} level={entry.level} />
97
- {:else if entry.kind === 'rich'}
98
- {@const comp = entry.component ?? (entry.componentKey ? lookupRichComponent(entry.componentKey) : null)}
99
- {#if comp}
100
- <RichEntry component={comp} componentProps={entry.props} />
101
- {:else}
102
- <StatusEntry text={`<rich entry: ${entry.componentKey ?? 'unknown'} not registered>`} level="info" />
101
+ <!-- Wrapper exists so the pill can sit OUTSIDE the scrolling area. Absolute
102
+ positioning inside an overflow:auto container has flaky cross-browser
103
+ behavior — the pill can end up scrolling with content or clipped to
104
+ a layout-time bottom that's miles below the viewport. Putting it as a
105
+ sibling of .shell-scrollback (sharing the same relative parent) makes
106
+ the position rock-solid: the pill is anchored to the visible area
107
+ regardless of scroll state. -->
108
+ <div class="shell-scrollback-wrap">
109
+ <div class="shell-scrollback" bind:this={container} onscroll={handleScroll}>
110
+ <div class="content" bind:this={content}>
111
+ {#each scrollback.entries as entry (entry.id)}
112
+ {#if entry.kind === 'text'}
113
+ <TextEntry stream={entry.stream} chunks={entry.chunks} />
114
+ {:else if entry.kind === 'prompt'}
115
+ <PromptEntry cwd={showPromptCwd ? entry.cwd : ''} line={entry.line} />
116
+ {:else if entry.kind === 'status'}
117
+ <StatusEntry text={entry.text} level={entry.level} />
118
+ {:else if entry.kind === 'rich'}
119
+ {@const comp = entry.component ?? (entry.componentKey ? lookupRichComponent(entry.componentKey) : null)}
120
+ {#if comp}
121
+ <RichEntry component={comp} componentProps={entry.props} />
122
+ {:else}
123
+ <StatusEntry text={`<rich entry: ${entry.componentKey ?? 'unknown'} not registered>`} level="info" />
124
+ {/if}
103
125
  {/if}
104
- {/if}
105
- {/each}
126
+ {/each}
127
+ </div>
106
128
  </div>
129
+
130
+ {#if !followBottom}
131
+ <div class="jump-pill" data-sh3-jump-pill>
132
+ <Button
133
+ variant="default"
134
+ icon="chevron-down"
135
+ title={unreadCount > 0 ? `${unreadCount} New — jump to bottom` : 'Jump to bottom'}
136
+ ariaLabel={unreadCount > 0 ? `Jump to bottom, ${unreadCount} New` : 'Jump to bottom'}
137
+ onclick={jumpToBottom}
138
+ >
139
+ {#if unreadCount > 0}
140
+ <span class="jump-pill__count">{unreadCount} New</span>
141
+ {/if}
142
+ </Button>
143
+ </div>
144
+ {/if}
107
145
  </div>
108
146
 
109
147
  <style>
148
+ .shell-scrollback-wrap {
149
+ flex: 1 1 auto;
150
+ position: relative;
151
+ min-height: 0; /* allow .shell-scrollback to shrink inside flex column */
152
+ display: flex;
153
+ flex-direction: column;
154
+ }
155
+
110
156
  .shell-scrollback {
111
157
  flex: 1 1 auto;
158
+ /* min-height: 0 is the flexbox escape hatch — without it, a flex
159
+ child's default `min-height: auto` lets it grow to fit its content
160
+ and the overflow-y: auto never engages (the scrollable element ends
161
+ up being some ancestor). Mandatory whenever you nest scrollable
162
+ content inside a flex column. */
163
+ min-height: 0;
112
164
  overflow-y: auto;
113
165
  /* Disable browser scroll-anchor: when markdown re-renders shift content
114
166
  above the viewport (a code fence completing, a heading appearing), the
@@ -118,4 +170,30 @@
118
170
  background: var(--sh3-bg, #111);
119
171
  color: var(--sh3-fg, #ddd);
120
172
  }
173
+
174
+ .jump-pill {
175
+ /* Anchored to the wrapper, NOT to the scrollback — the pill is a
176
+ sibling of the scrolling element, so it never scrolls with the
177
+ content and renders predictably across browsers. The Button
178
+ primitive's default variant supplies an accent background; the
179
+ icon-variant was transparent and disappeared into the dark
180
+ scrollback.
181
+
182
+ Right offset clears the scrollback's vertical scrollbar (the
183
+ wrapper's width includes the scrollbar gutter, so right: 0.75rem
184
+ would sit ON the scrollbar). 1.5rem gives comfortable breathing
185
+ room across platforms (~14-17px scrollbars on desktop, narrower
186
+ on touch). */
187
+ position: absolute;
188
+ bottom: 0.75rem;
189
+ right: 1.5rem;
190
+ z-index: 1;
191
+ border-radius: var(--sh3-radius);
192
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
193
+ }
194
+
195
+ .jump-pill__count {
196
+ font-size: 0.75rem;
197
+ line-height: 1;
198
+ }
121
199
  </style>
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,182 @@
1
+ /*
2
+ * DOM smoke tests for ScrollbackView's follow-bottom state + jump-to-bottom
3
+ * pill. jsdom does not compute layout, so scroll geometry is stubbed via
4
+ * Object.defineProperty on the container, and ResizeObserver is replaced
5
+ * with a deterministic fake that captures the callback for manual firing.
6
+ */
7
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import { mount, unmount, flushSync, tick } from 'svelte';
9
+ import ScrollbackView from './ScrollbackView.svelte';
10
+ import { Scrollback } from './scrollback.svelte';
11
+ const ScrollbackViewAny = ScrollbackView;
12
+ let mounted = null;
13
+ let host = null;
14
+ let originalRO;
15
+ let roFire = null;
16
+ beforeEach(() => {
17
+ originalRO = globalThis.ResizeObserver;
18
+ class FakeResizeObserver {
19
+ constructor(cb) {
20
+ this.target = null;
21
+ this.callback = cb;
22
+ roFire = () => {
23
+ if (!this.target)
24
+ return;
25
+ this.callback([{ contentRect: { width: 0, height: 0 }, target: this.target }], this);
26
+ };
27
+ }
28
+ observe(t) { this.target = t; }
29
+ unobserve() { }
30
+ disconnect() { roFire = null; }
31
+ }
32
+ globalThis.ResizeObserver = FakeResizeObserver;
33
+ });
34
+ afterEach(() => {
35
+ if (mounted) {
36
+ unmount(mounted);
37
+ mounted = null;
38
+ }
39
+ if (host) {
40
+ host.remove();
41
+ host = null;
42
+ }
43
+ globalThis.ResizeObserver = originalRO;
44
+ roFire = null;
45
+ });
46
+ /**
47
+ * Stub the scroll geometry of the .shell-scrollback container so atBottom
48
+ * checks make sense in jsdom. scrollTop is real (settable). scrollHeight
49
+ * and clientHeight are stubbed to a starting layout where bottom == 1000.
50
+ */
51
+ function geomStubs(container, init) {
52
+ var _a;
53
+ let _scrollHeight = init.scrollHeight;
54
+ let _clientHeight = init.clientHeight;
55
+ let _scrollTop = (_a = init.scrollTop) !== null && _a !== void 0 ? _a : 0;
56
+ Object.defineProperty(container, 'scrollHeight', {
57
+ configurable: true,
58
+ get: () => _scrollHeight,
59
+ });
60
+ Object.defineProperty(container, 'clientHeight', {
61
+ configurable: true,
62
+ get: () => _clientHeight,
63
+ });
64
+ Object.defineProperty(container, 'scrollTop', {
65
+ configurable: true,
66
+ get: () => _scrollTop,
67
+ set: (v) => { _scrollTop = v; },
68
+ });
69
+ return {
70
+ setHeight: (h) => { _scrollHeight = h; },
71
+ setScrollTop: (t) => { _scrollTop = t; },
72
+ };
73
+ }
74
+ function mountView() {
75
+ host = document.createElement('div');
76
+ host.style.width = '600px';
77
+ host.style.height = '400px';
78
+ document.body.appendChild(host);
79
+ const scrollback = new Scrollback(2000);
80
+ mounted = mount(ScrollbackViewAny, { target: host, props: { scrollback, showPromptCwd: false } });
81
+ flushSync();
82
+ const container = host.querySelector('.shell-scrollback');
83
+ return { scrollback, container };
84
+ }
85
+ describe('ScrollbackView — followBottom + jump-to-bottom pill', () => {
86
+ it('starts at the bottom with the pill hidden', async () => {
87
+ const { container } = mountView();
88
+ geomStubs(container, { scrollHeight: 500, clientHeight: 500, scrollTop: 0 });
89
+ // No scroll event yet — initial state should already have followBottom=true.
90
+ expect(host.querySelector('[data-sh3-jump-pill]')).toBeNull();
91
+ });
92
+ it('shows the pill when scrollTop moves off bottom', async () => {
93
+ const { container } = mountView();
94
+ const geom = geomStubs(container, { scrollHeight: 2000, clientHeight: 500, scrollTop: 1500 });
95
+ // scroll up: scrollTop=200; bottom would be scrollHeight - clientHeight = 1500.
96
+ geom.setScrollTop(200);
97
+ container.dispatchEvent(new Event('scroll'));
98
+ await tick();
99
+ expect(host.querySelector('[data-sh3-jump-pill]')).not.toBeNull();
100
+ });
101
+ it('increments unread count while detached, then resets on jump', async () => {
102
+ const { container } = mountView();
103
+ const geom = geomStubs(container, { scrollHeight: 2000, clientHeight: 500, scrollTop: 1500 });
104
+ // Detach: user scrolls up.
105
+ geom.setScrollTop(200);
106
+ container.dispatchEvent(new Event('scroll'));
107
+ await tick();
108
+ // Three content-grow ticks while detached.
109
+ geom.setHeight(2200);
110
+ roFire === null || roFire === void 0 ? void 0 : roFire();
111
+ await tick();
112
+ geom.setHeight(2400);
113
+ roFire === null || roFire === void 0 ? void 0 : roFire();
114
+ await tick();
115
+ geom.setHeight(2600);
116
+ roFire === null || roFire === void 0 ? void 0 : roFire();
117
+ await tick();
118
+ const pill = host.querySelector('[data-sh3-jump-pill]');
119
+ expect(pill).not.toBeNull();
120
+ expect(pill.textContent).toMatch(/3 New/);
121
+ // Click the pill: jumpToBottom sets scrollTop = scrollHeight and clears state.
122
+ // The Button primitive renders a real <button> with our onclick handler.
123
+ const btn = pill.querySelector('button');
124
+ btn.click();
125
+ // The handler set scrollTop directly; mirror that in the stub so the
126
+ // subsequent scroll handler (if any) sees the new position.
127
+ geom.setScrollTop(2600);
128
+ container.dispatchEvent(new Event('scroll'));
129
+ await tick();
130
+ expect(host.querySelector('[data-sh3-jump-pill]')).toBeNull();
131
+ });
132
+ it('re-engages follow when the user scrolls back to the bottom by hand', async () => {
133
+ const { container } = mountView();
134
+ const geom = geomStubs(container, { scrollHeight: 2000, clientHeight: 500, scrollTop: 1500 });
135
+ // Detach.
136
+ geom.setScrollTop(400);
137
+ container.dispatchEvent(new Event('scroll'));
138
+ await tick();
139
+ expect(host.querySelector('[data-sh3-jump-pill]')).not.toBeNull();
140
+ // Unread accumulates.
141
+ geom.setHeight(2300);
142
+ roFire === null || roFire === void 0 ? void 0 : roFire();
143
+ await tick();
144
+ expect(host.querySelector('[data-sh3-jump-pill]').textContent).toMatch(/1 New/);
145
+ // User scrolls back to the bottom organically.
146
+ geom.setScrollTop(2300 - 500);
147
+ container.dispatchEvent(new Event('scroll'));
148
+ await tick();
149
+ expect(host.querySelector('[data-sh3-jump-pill]')).toBeNull();
150
+ });
151
+ it('window resize while detached does NOT increment the unread count', async () => {
152
+ const { container } = mountView();
153
+ const geom = geomStubs(container, { scrollHeight: 2000, clientHeight: 500, scrollTop: 1500 });
154
+ // Detach.
155
+ geom.setScrollTop(200);
156
+ container.dispatchEvent(new Event('scroll'));
157
+ await tick();
158
+ // One real growth — accumulates one unread.
159
+ geom.setHeight(2200);
160
+ roFire === null || roFire === void 0 ? void 0 : roFire();
161
+ await tick();
162
+ expect(host.querySelector('[data-sh3-jump-pill]').textContent).toMatch(/1 New/);
163
+ // Two ResizeObserver fires with scrollHeight unchanged (simulates a
164
+ // window resize that reflows content without adding any) — counter
165
+ // must stay at 1.
166
+ roFire === null || roFire === void 0 ? void 0 : roFire();
167
+ await tick();
168
+ roFire === null || roFire === void 0 ? void 0 : roFire();
169
+ await tick();
170
+ expect(host.querySelector('[data-sh3-jump-pill]').textContent).toMatch(/1 New/);
171
+ });
172
+ it('snaps to bottom on content growth while following', async () => {
173
+ const { container } = mountView();
174
+ const geom = geomStubs(container, { scrollHeight: 1000, clientHeight: 500, scrollTop: 500 });
175
+ // Initial state is followBottom=true. A content-grow tick should snap.
176
+ geom.setHeight(1200);
177
+ roFire === null || roFire === void 0 ? void 0 : roFire();
178
+ await tick();
179
+ expect(container.scrollTop).toBe(1200);
180
+ expect(host.querySelector('[data-sh3-jump-pill]')).toBeNull();
181
+ });
182
+ });
@@ -18,10 +18,19 @@ function scaffold(mode) {
18
18
  const session = { history: { push: vi.fn() }, send: (m) => sent.push(m), cwd: '/', connected: true, connect: vi.fn() };
19
19
  const fs = {};
20
20
  const sh3 = {};
21
- // Real-shape resolver to exercise the new globalOnly path.
21
+ // Real-shape resolver to exercise the new globalOnly + slash paths.
22
22
  const resolver = {
23
23
  resolve: (line, opts = {}) => {
24
- const head = line.trim().split(/\s+/)[0];
24
+ const trimmed = line.trim();
25
+ // Mirror the real resolver's leading-slash branch: re-resolve the body
26
+ // with globalOnly disabled.
27
+ if (trimmed.startsWith('/')) {
28
+ const body = trimmed.slice(1).trimStart();
29
+ if (!body)
30
+ return { kind: 'forward', line: '' };
31
+ return resolver.resolve(body, { globalOnly: false });
32
+ }
33
+ const head = trimmed.split(/\s+/)[0];
25
34
  const verb = head === 'apps' ? appsVerb : head === 'clear' ? clearVerb : null;
26
35
  if (!verb)
27
36
  return { kind: 'forward', line };
@@ -64,4 +73,31 @@ describe('dispatch — mode-gated verb resolution', () => {
64
73
  expect(sent.every((m) => m.t !== 'submit')).toBe(true);
65
74
  expect(pushed.some((e) => e.kind === 'status' && e.text === 'cleared')).toBe(true);
66
75
  });
76
+ it('bash mode: /apps runs the sh3 verb locally even though it is not globalVerb', async () => {
77
+ const { dispatch, sent } = scaffold(bashMode);
78
+ await dispatch('/apps');
79
+ // Slash routed through the sh3 path — no `submit` forward to bash.
80
+ expect(sent.every((m) => m.t !== 'submit')).toBe(true);
81
+ // The local-resolution branch logs a per-mode history entry under the
82
+ // current mode (bash); slash doesn't pretend the user switched modes.
83
+ expect(sent.some((m) => m.t === 'history-log' && m.mode === 'bash')).toBe(true);
84
+ });
85
+ it('/-invoked lines do NOT push a prompt entry to scrollback', async () => {
86
+ const { dispatch, pushed, sent } = scaffold(bashMode);
87
+ await dispatch('/clear');
88
+ // No prompt echo — the user explicitly escaped, no need to render the
89
+ // line back to them ahead of the verb's output.
90
+ expect(pushed.every((e) => e.kind !== 'prompt')).toBe(true);
91
+ // The verb itself ran (clearVerb pushes a status), proving the dispatch
92
+ // path executed, not just early-exited.
93
+ expect(pushed.some((e) => e.kind === 'status' && e.text === 'cleared')).toBe(true);
94
+ // History still logs the typed form so up-arrow recall reproduces what
95
+ // the user typed (with the slash).
96
+ expect(sent.some((m) => m.t === 'history-log' && m.line === '/clear')).toBe(true);
97
+ });
98
+ it('plain verbs still push a prompt entry', async () => {
99
+ const { dispatch, pushed } = scaffold(sh3Mode);
100
+ await dispatch('apps');
101
+ expect(pushed.some((e) => e.kind === 'prompt' && e.line === 'apps')).toBe(true);
102
+ });
67
103
  });
@@ -90,7 +90,15 @@ export function makeDispatch(deps) {
90
90
  // flushes on reconnect; sh3/custom modes don't depend on a live
91
91
  // bash WS for history persistence.
92
92
  deps.session.send({ t: 'history-log', line, mode: mode.id });
93
- deps.buffer().scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
93
+ // Suppress the prompt echo for `/`-invoked lines. The slash is an
94
+ // explicit "treat this as a sh3 verb" escape; echoing `/foo` ahead
95
+ // of the verb's own output reads as noise when invoked from inside
96
+ // a custom-mode session. History (above) still records the typed
97
+ // form so up-arrow recall works.
98
+ const isSlashInvocation = line.trimStart().startsWith('/');
99
+ if (!isSlashInvocation) {
100
+ deps.buffer().scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
101
+ }
94
102
  try {
95
103
  await resolution.verb.run({
96
104
  sh3: deps.sh3,
@@ -24,3 +24,53 @@ describe('VerbRegistry.resolve — globalOnly option', () => {
24
24
  expect(r.resolve('$ ls', { globalOnly: true }).kind).toBe('forward');
25
25
  });
26
26
  });
27
+ describe('VerbRegistry.resolve — leading-slash escape', () => {
28
+ beforeEach(() => {
29
+ __resetViewRegistryForTest();
30
+ registerVerb('apps', sh3Verb, 'shell');
31
+ registerVerb('clear', globalVerb, 'shell');
32
+ });
33
+ it('/<verb> resolves locally even when globalOnly is set', () => {
34
+ const r = new VerbRegistry();
35
+ const res = r.resolve('/apps', { globalOnly: true });
36
+ expect(res.kind).toBe('local');
37
+ if (res.kind === 'local') {
38
+ expect(res.verb.name).toBe('apps');
39
+ expect(res.args).toEqual([]);
40
+ }
41
+ });
42
+ it('/<verb> args preserves arguments', () => {
43
+ const r = new VerbRegistry();
44
+ const res = r.resolve('/apps one two', { globalOnly: true });
45
+ expect(res.kind).toBe('local');
46
+ if (res.kind === 'local') {
47
+ expect(res.verb.name).toBe('apps');
48
+ expect(res.args).toEqual(['one', 'two']);
49
+ }
50
+ });
51
+ it('/ tolerates whitespace after the slash', () => {
52
+ const r = new VerbRegistry();
53
+ expect(r.resolve('/ apps', { globalOnly: true }).kind).toBe('local');
54
+ });
55
+ it('a bare / forwards with an empty line', () => {
56
+ const r = new VerbRegistry();
57
+ const res = r.resolve('/', { globalOnly: true });
58
+ expect(res.kind).toBe('forward');
59
+ if (res.kind === 'forward')
60
+ expect(res.line).toBe('');
61
+ });
62
+ it('/<unknown> forwards (no fallback to mode dispatch)', () => {
63
+ const r = new VerbRegistry();
64
+ const res = r.resolve('/nope', { globalOnly: true });
65
+ expect(res.kind).toBe('forward');
66
+ });
67
+ it('/<verb> in sh3 mode is a redundant but valid escape', () => {
68
+ const r = new VerbRegistry();
69
+ expect(r.resolve('/apps').kind).toBe('local');
70
+ });
71
+ it('/$ does NOT compose escapes — $ is treated as the verb head and not found', () => {
72
+ const r = new VerbRegistry();
73
+ const res = r.resolve('/$ ls', { globalOnly: true });
74
+ expect(res.kind).toBe('forward');
75
+ });
76
+ });
@@ -10,7 +10,8 @@ export declare class VerbRegistry {
10
10
  * @param opts.globalOnly When true, only verbs declared with `globalVerb:
11
11
  * true` resolve locally; everything else forwards. Used by the dispatch
12
12
  * path to gate sh3-domain verbs to sh3 mode while keeping framework
13
- * controls (clear, mode) reachable from every mode.
13
+ * controls (clear, mode) reachable from every mode. The leading-`/`
14
+ * escape ignores this flag — slash means "resolve as if in sh3 mode."
14
15
  */
15
16
  resolve(line: string, opts?: {
16
17
  globalOnly?: boolean;