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.
- package/dist/Sh3.svelte +3 -1
- package/dist/actions/menuBarModel.js +8 -0
- package/dist/actions/menuBarModel.test.js +61 -0
- package/dist/app/admin/ApiKeysView.svelte +6 -5
- package/dist/app/store/PermissionConfirmModal.svelte +23 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
- package/dist/app/store/StoreView.svelte +6 -1
- package/dist/chrome/CompactChrome.svelte.test.js +7 -4
- package/dist/env/client.d.ts +5 -4
- package/dist/env/client.js +11 -17
- package/dist/env/serverUrl.d.ts +2 -0
- package/dist/env/serverUrl.js +8 -0
- package/dist/gestures/gestureRegistry.test.js +1 -0
- package/dist/gestures/index.d.ts +17 -0
- package/dist/gestures/index.js +27 -0
- package/dist/keys/client.js +6 -7
- package/dist/keys/revocation-bus.svelte.js +11 -1
- package/dist/layout/compact/CarouselTabs.svelte +152 -15
- package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
- package/dist/layout/compact/CompactRenderer.svelte +1 -1
- package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
- package/dist/layout/compact/derive.js +7 -16
- package/dist/layout/compact/derive.test.js +30 -9
- package/dist/layout/drag.svelte.js +16 -3
- package/dist/layout/inspection.d.ts +20 -9
- package/dist/layout/inspection.js +66 -11
- package/dist/layout/inspection.svelte.test.d.ts +1 -0
- package/dist/layout/inspection.svelte.test.js +114 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +11 -8
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.js +2 -2
- package/dist/overlays/FloatFrame.svelte +93 -22
- package/dist/primitives/ResizableSplitter.svelte +42 -8
- package/dist/registry/checkFetch.d.ts +6 -0
- package/dist/registry/checkFetch.js +23 -0
- package/dist/sh3/views/KeysAndPeers.svelte +4 -3
- package/dist/shards/activate-runtime.test.js +99 -1
- package/dist/shards/activate.svelte.js +20 -5
- package/dist/shards/ctx-fetch.test.js +70 -0
- package/dist/shards/registry.d.ts +8 -1
- package/dist/shards/registry.js +13 -2
- package/dist/shards/registry.test.js +25 -4
- package/dist/shards/types.d.ts +30 -1
- package/dist/shell-shard/ScrollbackView.svelte +145 -67
- package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
- package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
- package/dist/shell-shard/dispatch-gating.test.js +38 -2
- package/dist/shell-shard/dispatch.js +9 -1
- package/dist/shell-shard/registry-resolve.test.js +50 -0
- package/dist/shell-shard/registry.d.ts +2 -1
- package/dist/shell-shard/registry.js +12 -2
- package/dist/shell-shard/verbs/help.js +5 -4
- package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
- package/dist/verbs/types.d.ts +10 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/shards/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
34
|
-
if (!container) return;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// (
|
|
69
|
-
// driven approach (depending on entries.length
|
|
70
|
-
// streaming because props are mutated via Object.assign and the outer
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
<
|
|
101
|
-
{:else}
|
|
102
|
-
|
|
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
|
-
{/
|
|
105
|
-
|
|
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
|
|
21
|
+
// Real-shape resolver to exercise the new globalOnly + slash paths.
|
|
22
22
|
const resolver = {
|
|
23
23
|
resolve: (line, opts = {}) => {
|
|
24
|
-
const
|
|
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
|
-
|
|
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;
|