@cplieger/web-terminal-ui 2.1.4 → 3.0.1

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.
@@ -0,0 +1,64 @@
1
+ // activityMonitor feature: subscribes to the server's status SSE
2
+ // (/api/sessions/events via the engine's connectStatusStream) and exposes each
3
+ // session's live status through its API (design sections 7, 22.4, 22.5). It is a
4
+ // pure data source with no chrome of its own; the tabs feature consumes it via
5
+ // ctx.use to render per-tab activity dots and drop exited/removed tabs. The
6
+ // server pushes an initial snapshot on every (re)open, so no explicit resync is
7
+ // needed here.
8
+
9
+ import { connectStatusStream } from "@cplieger/web-terminal-engine";
10
+ import type { SessionStatus } from "@cplieger/web-terminal-engine";
11
+ import type { TerminalFeature, Unsubscribe } from "../kernel/types.js";
12
+
13
+ const DEFAULT_EVENTS_PATH = "/api/sessions/events";
14
+
15
+ export interface ActivityMonitorApi {
16
+ /** Subscribe to every status update (working/idle/input/exited, plus removed).
17
+ * Fired for the initial snapshot and each change. */
18
+ onStatus(cb: (s: SessionStatus) => void): Unsubscribe;
19
+ /** The last known status for a session, or undefined. */
20
+ current(id: string): SessionStatus | undefined;
21
+ }
22
+
23
+ export function activityMonitor(
24
+ opts: { eventsPath?: string } = {},
25
+ ): TerminalFeature<ActivityMonitorApi> {
26
+ return {
27
+ name: "activityMonitor",
28
+ setup() {
29
+ const path = opts.eventsPath ?? DEFAULT_EVENTS_PATH;
30
+ const statuses = new Map<string, SessionStatus>();
31
+ const subs = new Set<(s: SessionStatus) => void>();
32
+
33
+ const stream = connectStatusStream(path, {
34
+ onStatus(s) {
35
+ if (s.removed) {
36
+ statuses.delete(s.id);
37
+ } else {
38
+ statuses.set(s.id, s);
39
+ }
40
+ for (const cb of [...subs]) {
41
+ cb(s);
42
+ }
43
+ },
44
+ });
45
+
46
+ return {
47
+ api: {
48
+ onStatus(cb) {
49
+ subs.add(cb);
50
+ return () => subs.delete(cb);
51
+ },
52
+ current(id) {
53
+ return statuses.get(id);
54
+ },
55
+ },
56
+ teardown() {
57
+ stream.close();
58
+ subs.clear();
59
+ statuses.clear();
60
+ },
61
+ };
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,29 @@
1
+ // animations feature: opt-in motion (design sections 13, 22.4). Without it the
2
+ // UI is instant (no transitions); with it, and when the user has not asked for
3
+ // reduced motion, it enables the shared animation vocabulary by marking the
4
+ // terminal root, which the feature stylesheets key their transitions off. It
5
+ // tracks prefers-reduced-motion live so toggling the OS setting takes effect.
6
+
7
+ import type { TerminalFeature } from "../kernel/types.js";
8
+
9
+ export function animations(): TerminalFeature {
10
+ return {
11
+ name: "animations",
12
+ setup(ctx) {
13
+ // The root is the surface's parent (regions + surface are its children).
14
+ const root = ctx.surface().parentElement;
15
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
16
+ const apply = (): void => {
17
+ root?.classList.toggle("wt-animate", !mq.matches);
18
+ };
19
+ apply();
20
+ mq.addEventListener("change", apply);
21
+ return {
22
+ teardown() {
23
+ mq.removeEventListener("change", apply);
24
+ root?.classList.remove("wt-animate");
25
+ },
26
+ };
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,98 @@
1
+ // clipboard feature: copy/paste helpers, the desktop Ctrl+Shift+C/V shortcuts,
2
+ // and OSC 52 mirroring (design section 22.4). Exposes a typed API so contextMenu
3
+ // can offer Copy/Paste through it (ctx.use). With this feature absent, inbound
4
+ // OSC 52 has no subscriber and is intentionally a no-op.
5
+
6
+ import type { TerminalFeature } from "../kernel/types.js";
7
+
8
+ export interface ClipboardApi {
9
+ /** Write text to the system clipboard (surfaces a toast on success/failure). */
10
+ copy(text: string): void;
11
+ /** Read the system clipboard and paste it through the sanitizing funnel. */
12
+ paste(): void;
13
+ }
14
+
15
+ export function clipboard(): TerminalFeature<ClipboardApi> {
16
+ return {
17
+ name: "clipboard",
18
+ setup(ctx) {
19
+ function copy(text: string): void {
20
+ // navigator.clipboard is undefined outside a secure context (plain-HTTP
21
+ // non-loopback host, a supported web-terminal-server deployment), where a
22
+ // property access on it throws synchronously. Feature-detect first.
23
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- undefined outside secure contexts
24
+ if (!navigator.clipboard) {
25
+ ctx.toast("Clipboard unavailable");
26
+ return;
27
+ }
28
+ navigator.clipboard
29
+ .writeText(text)
30
+ .then(() => {
31
+ ctx.toast("Copied");
32
+ })
33
+ .catch(() => {
34
+ ctx.toast("Copy failed");
35
+ });
36
+ }
37
+
38
+ function paste(): void {
39
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- undefined outside secure contexts
40
+ if (!navigator.clipboard) {
41
+ ctx.toast("Clipboard unavailable");
42
+ return;
43
+ }
44
+ navigator.clipboard
45
+ .readText()
46
+ .then((text) => {
47
+ ctx.paste(text);
48
+ })
49
+ .catch(() => {
50
+ ctx.toast("Paste blocked");
51
+ });
52
+ }
53
+
54
+ // Desktop clipboard shortcuts, handled before the kernel's key mapping so
55
+ // they take the browser selection/clipboard rather than server-bound bytes.
56
+ const offKey = ctx.registerKeydown((ev) => {
57
+ if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
58
+ if (ev.code === "KeyC") {
59
+ const sel = window.getSelection()?.toString();
60
+ if (sel) {
61
+ copy(sel);
62
+ }
63
+ ev.preventDefault();
64
+ return true;
65
+ }
66
+ if (ev.code === "KeyV") {
67
+ paste();
68
+ ev.preventDefault();
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ });
74
+
75
+ // Inbound OSC 52: an app copied; mirror it to the system clipboard. This
76
+ // is an async push (not a user gesture), so some browsers reject it and
77
+ // copy() surfaces "Copy failed".
78
+ const offClip = ctx.on("wire:clipboard", (text) => {
79
+ copy(text);
80
+ });
81
+
82
+ // Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast.
83
+ const onCopy = (): void => {
84
+ ctx.toast("Copied");
85
+ };
86
+ document.addEventListener("copy", onCopy);
87
+
88
+ return {
89
+ api: { copy, paste },
90
+ teardown() {
91
+ offKey();
92
+ offClip();
93
+ document.removeEventListener("copy", onCopy);
94
+ },
95
+ };
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,53 @@
1
+ // connectionBanner feature: renders the kernel's connection state as a visible
2
+ // banner (design sections 22.3, 22.4). The state machine, the loading
3
+ // lifecycle, and the give-up hook are kernel-owned; this feature is a pure
4
+ // renderer subscribing to connection:state. It uses the kernel's single
5
+ // announcer (ctx.announce) rather than spawning its own aria-live region.
6
+
7
+ import type { ConnState, TerminalFeature } from "../kernel/types.js";
8
+
9
+ const TEXT: Partial<Record<ConnState, string>> = {
10
+ connecting: "Reconnecting\u2026",
11
+ reconnecting: "Reconnecting\u2026",
12
+ offline: "Offline",
13
+ restarted: "Server restarted \u2014 recent input may have been lost",
14
+ };
15
+
16
+ export function connectionBanner(): TerminalFeature {
17
+ return {
18
+ name: "connectionBanner",
19
+ setup(ctx) {
20
+ const slot = ctx.region("banner", "status");
21
+ const banner = document.createElement("div");
22
+ banner.className = "wt-conn-banner";
23
+ banner.setAttribute("role", "status");
24
+ slot.appendChild(banner);
25
+
26
+ let last: ConnState | null = null;
27
+ const off = ctx.on("connection:state", (s) => {
28
+ const text = TEXT[s];
29
+ if (text !== undefined) {
30
+ banner.textContent = text;
31
+ banner.dataset["state"] = s;
32
+ banner.classList.add("visible");
33
+ // Announce only on a real change, so a flaky link does not spam the
34
+ // screen reader with repeated "Reconnecting" for the same state.
35
+ if (s !== last) {
36
+ ctx.announce(text);
37
+ }
38
+ } else {
39
+ banner.classList.remove("visible");
40
+ banner.textContent = "";
41
+ }
42
+ last = s;
43
+ });
44
+
45
+ return {
46
+ teardown() {
47
+ off();
48
+ banner.remove();
49
+ },
50
+ };
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,207 @@
1
+ // contextMenu feature: the right-click / long-press menu (Copy, Select All,
2
+ // Paste) rendered into the overlay region (design section 22.4). Copy/Paste are
3
+ // offered only when the clipboard feature is present (ctx.use), routing Paste
4
+ // through the kernel's sanitizing funnel. Escape-to-close goes through the
5
+ // kernel keydown intercept; outside-click and long-press are surface gestures.
6
+ //
7
+ // Touch long-press opens this menu on every touch platform, iOS included. The
8
+ // terminal's keyboard target is a 1x1, pointer-events:none textarea, so iOS's
9
+ // own long-press callout never lands on an editable field and no native "Paste"
10
+ // ever appears (the reason tap-and-hold-to-paste felt broken). This menu is
11
+ // therefore the paste path on touch; Paste reads the system clipboard via the
12
+ // Clipboard API, which needs a secure context, so serve vibecli over HTTPS for
13
+ // touch paste.
14
+
15
+ import type { TerminalFeature } from "../kernel/types.js";
16
+ import type { ClipboardApi } from "./clipboard.js";
17
+
18
+ const LONG_PRESS_MS = 500;
19
+ const LONG_PRESS_MOVE_PX = 10;
20
+ const EDGE_MARGIN = 8;
21
+ // When the menu can't fit below the anchor (a long-press near the bottom edge or
22
+ // above the keyboard), it opens above the point. This gap lifts it clear of the
23
+ // fingertip so the touch does not cover the menu items.
24
+ const MENU_GAP = 16;
25
+
26
+ export interface ContextMenuOptions {
27
+ /** The clipboard feature value, so the menu can offer Copy/Paste through its
28
+ * API (ctx.use). Omitted: the menu shows only Select All. */
29
+ clipboard?: TerminalFeature<ClipboardApi>;
30
+ }
31
+
32
+ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
33
+ return {
34
+ name: "contextMenu",
35
+ setup(ctx) {
36
+ const surface = ctx.surface();
37
+ const menu = document.createElement("div");
38
+ menu.className = "wt-ctx-menu";
39
+ ctx.region("overlay", "menu").appendChild(menu);
40
+
41
+ let longPressTimer = 0;
42
+ let longPressOrigin = { x: 0, y: 0 };
43
+
44
+ const clip = (): ClipboardApi | undefined =>
45
+ opts.clipboard ? ctx.use(opts.clipboard) : undefined;
46
+
47
+ function focusInput(): void {
48
+ surface.querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
49
+ }
50
+
51
+ function hide(): void {
52
+ const refocus = menu.contains(document.activeElement);
53
+ menu.classList.remove("visible");
54
+ menu.replaceChildren();
55
+ if (refocus) {
56
+ focusInput();
57
+ }
58
+ }
59
+
60
+ function addButton(label: string, onClick: () => void): void {
61
+ const b = document.createElement("button");
62
+ b.type = "button";
63
+ b.textContent = label;
64
+ b.addEventListener("click", () => {
65
+ onClick();
66
+ hide();
67
+ });
68
+ menu.appendChild(b);
69
+ }
70
+
71
+ function show(x: number, y: number): void {
72
+ hide();
73
+ const clipboard = clip();
74
+ const sel = window.getSelection()?.toString();
75
+ if (sel) {
76
+ addButton("Copy", () => {
77
+ clipboard?.copy(sel);
78
+ });
79
+ }
80
+ addButton("Select All", () => {
81
+ const s = window.getSelection();
82
+ const output = surface.querySelector(".term-output");
83
+ if (s && output) {
84
+ s.selectAllChildren(output);
85
+ }
86
+ });
87
+ // Paste needs the clipboard feature. Offered on touch too (see the header
88
+ // note): the hidden textarea means iOS's native paste callout never
89
+ // appears, so this is the paste path there.
90
+ if (clipboard) {
91
+ addButton("Paste", () => {
92
+ clipboard.paste();
93
+ });
94
+ }
95
+ if (menu.childElementCount === 0) {
96
+ return; // nothing to offer
97
+ }
98
+ // Make visible (so it has measurable size), then place it within the
99
+ // VISIBLE viewport. position:fixed => x/y are viewport coordinates; the
100
+ // visual viewport (when present) is the area above the on-screen
101
+ // keyboard, so clamping to it keeps the menu off the keyboard too.
102
+ menu.classList.add("visible");
103
+ const vv = window.visualViewport;
104
+ const viewLeft = vv ? vv.offsetLeft : 0;
105
+ const viewTop = vv ? vv.offsetTop : 0;
106
+ const viewWidth = vv ? vv.width : window.innerWidth;
107
+ const viewHeight = vv ? vv.height : window.innerHeight;
108
+ const menuW = menu.offsetWidth;
109
+ const menuH = menu.offsetHeight;
110
+ const left = Math.max(
111
+ viewLeft + EDGE_MARGIN,
112
+ Math.min(x, viewLeft + viewWidth - menuW - EDGE_MARGIN),
113
+ );
114
+ // Open just below the point; but if that would overflow the visible
115
+ // bottom (a long-press near the bottom edge, where the user naturally
116
+ // taps), flip above the finger so the menu is neither clipped off-screen
117
+ // nor hidden under the fingertip.
118
+ let top = y;
119
+ if (y + menuH + EDGE_MARGIN > viewTop + viewHeight) {
120
+ top = y - menuH - MENU_GAP;
121
+ }
122
+ top = Math.max(
123
+ viewTop + EDGE_MARGIN,
124
+ Math.min(top, viewTop + viewHeight - menuH - EDGE_MARGIN),
125
+ );
126
+ menu.style.left = `${String(left)}px`;
127
+ menu.style.top = `${String(top)}px`;
128
+ }
129
+
130
+ const onContextMenu = (e: MouseEvent): void => {
131
+ e.preventDefault();
132
+ show(e.clientX, e.clientY);
133
+ };
134
+ surface.addEventListener("contextmenu", onContextMenu);
135
+
136
+ // Escape closes the menu without also sending ESC to the PTY.
137
+ const offKey = ctx.registerKeydown((ev) => {
138
+ if (ev.key === "Escape" && menu.classList.contains("visible")) {
139
+ ev.preventDefault();
140
+ hide();
141
+ return true;
142
+ }
143
+ return false;
144
+ });
145
+
146
+ const onDocClick = (): void => {
147
+ hide();
148
+ };
149
+ document.addEventListener("click", onDocClick);
150
+
151
+ // Long-press opens the menu on touch, all platforms (see the header note).
152
+ const clearLongPress = (): void => {
153
+ if (longPressTimer) {
154
+ clearTimeout(longPressTimer);
155
+ longPressTimer = 0;
156
+ }
157
+ };
158
+ const onTouchStart = (e: TouchEvent): void => {
159
+ if (e.touches.length !== 1) {
160
+ clearLongPress();
161
+ return;
162
+ }
163
+ const t = e.touches[0];
164
+ if (!t) {
165
+ return;
166
+ }
167
+ longPressOrigin = { x: t.clientX, y: t.clientY };
168
+ longPressTimer = window.setTimeout(() => {
169
+ longPressTimer = 0;
170
+ show(longPressOrigin.x, longPressOrigin.y);
171
+ }, LONG_PRESS_MS);
172
+ };
173
+ const onTouchMove = (e: TouchEvent): void => {
174
+ if (!longPressTimer || e.touches.length !== 1) {
175
+ return;
176
+ }
177
+ const t = e.touches[0];
178
+ if (!t) {
179
+ return;
180
+ }
181
+ const dx = t.clientX - longPressOrigin.x;
182
+ const dy = t.clientY - longPressOrigin.y;
183
+ if (dx * dx + dy * dy > LONG_PRESS_MOVE_PX * LONG_PRESS_MOVE_PX) {
184
+ clearLongPress();
185
+ }
186
+ };
187
+ surface.addEventListener("touchstart", onTouchStart, { passive: true });
188
+ surface.addEventListener("touchmove", onTouchMove, { passive: true });
189
+ surface.addEventListener("touchend", clearLongPress, { passive: true });
190
+ surface.addEventListener("touchcancel", clearLongPress, { passive: true });
191
+
192
+ return {
193
+ teardown() {
194
+ clearLongPress();
195
+ offKey();
196
+ surface.removeEventListener("contextmenu", onContextMenu);
197
+ document.removeEventListener("click", onDocClick);
198
+ surface.removeEventListener("touchstart", onTouchStart);
199
+ surface.removeEventListener("touchmove", onTouchMove);
200
+ surface.removeEventListener("touchend", clearLongPress);
201
+ surface.removeEventListener("touchcancel", clearLongPress);
202
+ menu.remove();
203
+ },
204
+ };
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,15 @@
1
+ // Small shared helper for features: build a chrome element from a static,
2
+ // trusted HTML string (icons, buttons) via a <template> clone, the same pattern
3
+ // the kernel uses for its core subtree. No interpolation ever passes through
4
+ // here, so it carries no injection surface.
5
+
6
+ /** Parse a static HTML string and return its first element. */
7
+ export function fromHTML(html: string): HTMLElement {
8
+ const tpl = document.createElement("template");
9
+ tpl.innerHTML = html.trim();
10
+ const el = tpl.content.firstElementChild;
11
+ if (!(el instanceof HTMLElement)) {
12
+ throw new Error("web-terminal-ui: fromHTML produced no element");
13
+ }
14
+ return el;
15
+ }
@@ -0,0 +1,82 @@
1
+ // mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
2
+ // collapse toggle and sticky-Ctrl) in the thumb-zone region (design section
3
+ // 22.4). The engine's keyboard.bindMobileToolbar wires the buttons (DECCKM-aware
4
+ // arrows, sticky-Ctrl state); this feature builds the toolbar chrome, routes its
5
+ // output through the kernel funnel, and registers sticky-Ctrl as an input
6
+ // transform so a typed character is rewritten to its Ctrl byte when armed.
7
+
8
+ import { keyboard } from "@cplieger/web-terminal-engine";
9
+ import type { TerminalFeature } from "../kernel/types.js";
10
+ import { fromHTML } from "./dom.js";
11
+
12
+ const { bindMobileToolbar } = keyboard;
13
+
14
+ // The key grid, without the scroll-to-bottom button (that is the scrollToBottom
15
+ // feature, composed into the same region's "scroll" slot).
16
+ const TOOLBAR_HTML = `
17
+ <div class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
18
+ <button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
19
+ <button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
20
+ <button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
21
+ <button type="button" id="kb-up" class="kb-key kb-r1c3" aria-label="Up"><svg viewBox="0 0 24 24"><polyline points="6 15 12 9 18 15"/></svg></button>
22
+ <button type="button" id="kb-enter" class="kb-key kb-r1c4" aria-label="Enter"><svg viewBox="0 0 24 24"><polyline points="9 10 4 15 9 20"/><polyline points="20 4 20 15 4 15"/></svg></button>
23
+ <button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
24
+ <button type="button" id="kb-left" class="kb-key kb-r2c2" aria-label="Left"><svg viewBox="0 0 24 24"><polyline points="15 6 9 12 15 18"/></svg></button>
25
+ <button type="button" id="kb-down" class="kb-key kb-r2c3" aria-label="Down"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></button>
26
+ <button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
27
+ </div>`;
28
+
29
+ export function mobileToolbar(): TerminalFeature {
30
+ return {
31
+ name: "mobileToolbar",
32
+ setup(ctx) {
33
+ const encoder = new TextEncoder();
34
+ const decoder = new TextDecoder();
35
+ const toolbar = fromHTML(TOOLBAR_HTML);
36
+ ctx.region("bottom-inset-end", "keys").appendChild(toolbar);
37
+
38
+ const ctrl = bindMobileToolbar({
39
+ toolbar,
40
+ send: (bytes) => {
41
+ ctx.send(encoder.encode(bytes));
42
+ },
43
+ });
44
+
45
+ // Sticky-Ctrl as an outbound-byte transform: when armed, a single typed
46
+ // character is rewritten to its Ctrl byte (and disarms). The toolbar's own
47
+ // arrow/Tab/Enter/Esc sends disarm before sending, so they pass through.
48
+ const offTransform = ctx.registerInputTransform((bytes) => {
49
+ if (!ctrl.isCtrlArmed()) {
50
+ return bytes;
51
+ }
52
+ const text = decoder.decode(bytes);
53
+ const mapped = ctrl.applyStickyCtrl(text);
54
+ return mapped === text ? bytes : encoder.encode(mapped);
55
+ });
56
+
57
+ // Prime the slide transition off for the first paint (no initial flash),
58
+ // then enable it after two frames.
59
+ requestAnimationFrame(() => {
60
+ requestAnimationFrame(() => {
61
+ toolbar.classList.remove("no-transition");
62
+ });
63
+ });
64
+
65
+ return {
66
+ onDetach() {
67
+ // A tab switch disarms a latched sticky-Ctrl so a pending Ctrl does
68
+ // not fire against the incoming session (e.g. an accidental Ctrl+C to
69
+ // the wrong agent, design 5.1 / ui-ux review D).
70
+ if (ctrl.isCtrlArmed()) {
71
+ ctrl.setCtrlArmed(false);
72
+ }
73
+ },
74
+ teardown() {
75
+ offTransform();
76
+ ctrl.dispose();
77
+ toolbar.remove();
78
+ },
79
+ };
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,63 @@
1
+ // predictiveEcho feature: Mosh-style local echo (design section 22.4). Wires the
2
+ // predict.ts mini-VT to the kernel input funnel: it advances a predicted cursor
3
+ // on accepted input (an input observer) and owns the col-0 backspace brake (an
4
+ // input transform that drops a lone DEL at the true origin, since the predicted
5
+ // cursor is the authoritative position for that decision). It re-pushes the
6
+ // predicted-cursor overlay on prediction changes and after each render flush.
7
+
8
+ import * as predict from "../predict.js";
9
+ import type { TerminalFeature } from "../kernel/types.js";
10
+
11
+ export function predictiveEcho(): TerminalFeature {
12
+ return {
13
+ name: "predictiveEcho",
14
+ setup(ctx) {
15
+ function push(): void {
16
+ const p = predict.get();
17
+ ctx.render.setPredictedCursor(p.row, p.col, p.active);
18
+ }
19
+ predict.subscribe(push);
20
+
21
+ // Col-0 backspace brake: suppress a lone DEL at the true origin (row 0,
22
+ // col 0) so held-Backspace on an empty line does not flood the server.
23
+ // Owned here because the predicted cursor is the position it keys off.
24
+ const offTransform = ctx.registerInputTransform((bytes) => {
25
+ const p = predict.get();
26
+ if (bytes.length === 1 && bytes[0] === 0x7f && p.active && p.row === 0 && p.col === 0) {
27
+ return new Uint8Array(0);
28
+ }
29
+ return bytes;
30
+ });
31
+ const offObserver = ctx.registerInputObserver((bytes) => {
32
+ predict.applyInput(bytes);
33
+ });
34
+
35
+ const offCursor = ctx.on("render:cursor", () => {
36
+ push();
37
+ });
38
+ const offScreen = ctx.on("wire:screen", (msg) => {
39
+ const sz = ctx.session.size();
40
+ predict.setDimensions(sz.cols, sz.rows);
41
+ predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
42
+ });
43
+ const offState = ctx.on("connection:state", (s) => {
44
+ if (s === "restarted") {
45
+ predict.reset();
46
+ }
47
+ });
48
+
49
+ return {
50
+ teardown() {
51
+ offTransform();
52
+ offObserver();
53
+ offCursor();
54
+ offScreen();
55
+ offState();
56
+ // Hide the overlay and drop stale prediction state.
57
+ predict.reset();
58
+ ctx.render.setPredictedCursor(0, 0, false);
59
+ },
60
+ };
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,49 @@
1
+ // scrollToBottom feature: a scroll-to-bottom control in the thumb-zone region,
2
+ // shown only while the user has scrolled up (design section 22.4). The
3
+ // scroll-state signal comes from the kernel's scroll:state event (the kernel
4
+ // owns scroll.init); this feature just renders the affordance and drives
5
+ // scroll.scrollToBottom.
6
+
7
+ import type { TerminalFeature } from "../kernel/types.js";
8
+ import { fromHTML } from "./dom.js";
9
+
10
+ const BUTTON_HTML = `
11
+ <button type="button" class="wt-btn wt-scroll-bottom" aria-label="Scroll to bottom">
12
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
13
+ </button>`;
14
+
15
+ export function scrollToBottom(): TerminalFeature {
16
+ return {
17
+ name: "scrollToBottom",
18
+ setup(ctx) {
19
+ const slot = ctx.region("bottom-inset-end", "scroll");
20
+ const btn = fromHTML(BUTTON_HTML);
21
+ slot.appendChild(btn);
22
+
23
+ // pointerdown (like the toolbar keys) so touch devices show press
24
+ // feedback; preventDefault keeps focus on the terminal. click is kept for
25
+ // keyboard activation. scrollToBottom is idempotent, so the pair is safe.
26
+ const onDown = (e: PointerEvent): void => {
27
+ e.preventDefault();
28
+ ctx.scroll.scrollToBottom();
29
+ };
30
+ const onClick = (): void => {
31
+ ctx.scroll.scrollToBottom();
32
+ };
33
+ btn.addEventListener("pointerdown", onDown);
34
+ btn.addEventListener("click", onClick);
35
+
36
+ // Visible only while scrolled up; the region reflects it via a class.
37
+ const off = ctx.on("scroll:state", ({ scrolledUp }) => {
38
+ slot.classList.toggle("scrolled-up", scrolledUp);
39
+ });
40
+
41
+ return {
42
+ teardown() {
43
+ off();
44
+ btn.remove();
45
+ },
46
+ };
47
+ },
48
+ };
49
+ }