@cplieger/web-terminal-ui 1.0.0

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/src/predict.ts ADDED
@@ -0,0 +1,183 @@
1
+ // Predictive local echo (INPUT-01) — Mosh-style mini-VT that advances
2
+ // a "predicted cursor" optimistically as the user types, before the
3
+ // server's next screen frame confirms or contradicts the prediction.
4
+ //
5
+ // On the wire we still send everything through the connection module;
6
+ // this is purely a local-render optimisation. The visible effect is
7
+ // that the cursor moves immediately on each keystroke even on a 200 ms
8
+ // link, so typing feels responsive instead of laggy.
9
+ //
10
+ // Scope (deliberately conservative — wrong predictions are worse than
11
+ // missing predictions, since they look like the terminal is broken):
12
+ // - printable ASCII / UTF-8 codepoints: advance one cell per
13
+ // codepoint (approximate for fullwidth / combining marks; snaps
14
+ // to truth on next server frame).
15
+ // - Backspace (0x08) and DEL (0x7f): cursor back one cell.
16
+ // - CR (0x0d): col = 0.
17
+ // - LF (0x0a): row += 1 (clamped to height-1).
18
+ // - Anything else (ESC sequences, TAB, other C0): suspend prediction
19
+ // until the next server frame. Suspending hides the predicted
20
+ // cursor so the user sees only the server's truth.
21
+ //
22
+ // Each server screen frame resets the predicted cursor to the server's
23
+ // reported position and re-arms prediction. So a wrong prediction is
24
+ // at most one flushInterval (50 ms) old before correction.
25
+
26
+ let predRow = 0;
27
+ let predCol = 0;
28
+ let predActive = false;
29
+ let predPendingWrap = false; // matches server VT pendingWrap semantics
30
+ let predFrozen = false; // true when server cursor is hidden (selection prompts)
31
+ let cols = 80;
32
+ let rows = 30;
33
+
34
+ let onChange: (() => void) | null = null;
35
+
36
+ /** Subscribe to predicted-cursor changes (the renderer registers
37
+ * this so it can move its predicted-cursor overlay). */
38
+ export function subscribe(cb: () => void): void {
39
+ onChange = cb;
40
+ }
41
+
42
+ /** Update the screen dimensions used for wrap calculations. Should be
43
+ * called whenever the renderer learns a new size from the server. */
44
+ export function setDimensions(c: number, r: number): void {
45
+ if (c > 0) {
46
+ cols = c;
47
+ }
48
+ if (r > 0) {
49
+ rows = r;
50
+ }
51
+ // Clamp predicted position to new bounds so a resize mid-typing
52
+ // doesn't leave predCol/predRow past the edge.
53
+ if (predCol >= cols) {
54
+ predCol = cols - 1;
55
+ }
56
+ if (predRow >= rows) {
57
+ predRow = rows - 1;
58
+ }
59
+ }
60
+
61
+ /** Reset all prediction state. Called on server restart to avoid
62
+ * stale cursor overlay from the previous session. */
63
+ export function reset(): void {
64
+ predRow = 0;
65
+ predCol = 0;
66
+ predActive = false;
67
+ predPendingWrap = false;
68
+ predFrozen = false;
69
+ if (onChange) {
70
+ onChange();
71
+ }
72
+ }
73
+
74
+ /** Reset prediction to the server's reported cursor and re-arm.
75
+ * When the server cursor is hidden (e.g. during a selection prompt),
76
+ * prediction is frozen — the ghost cursor disappears and typed input
77
+ * doesn't move it. */
78
+ export function onScreenFrame(serverRow: number, serverCol: number, cursorHidden?: boolean): void {
79
+ predRow = serverRow;
80
+ predCol = serverCol;
81
+ predPendingWrap = false;
82
+ predFrozen = cursorHidden ?? false;
83
+ predActive = !predFrozen;
84
+ if (onChange) {
85
+ onChange();
86
+ }
87
+ }
88
+
89
+ /** Apply locally-typed input bytes to the predicted cursor. Bails out
90
+ * (suspending prediction) on the first byte we can't model. Also
91
+ * bails when frozen (cursor hidden — user is in a selection prompt). */
92
+ export function applyInput(bytes: Uint8Array): void {
93
+ if (!predActive || predFrozen) {
94
+ return;
95
+ }
96
+ let i = 0;
97
+ while (i < bytes.length) {
98
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index checked by loop condition
99
+ const b = bytes[i]!;
100
+ if (b === 0x1b) {
101
+ // ESC — start of an escape sequence. Don't try to model it.
102
+ predActive = false;
103
+ if (onChange) {
104
+ onChange();
105
+ }
106
+ return;
107
+ }
108
+ if (b === 0x08 || b === 0x7f) {
109
+ // BS / DEL — backspace one cell.
110
+ predPendingWrap = false;
111
+ if (predCol > 0) {
112
+ predCol--;
113
+ } else if (predRow > 0) {
114
+ // wrap to end of previous row (terminals typically don't,
115
+ // but kiro-cli's prompt frequently does on Backspace at col
116
+ // 0, so this is closer to expected behavior than staying put)
117
+ predRow--;
118
+ predCol = cols - 1;
119
+ }
120
+ i++;
121
+ continue;
122
+ }
123
+ if (b === 0x0d) {
124
+ // CR
125
+ predPendingWrap = false;
126
+ predCol = 0;
127
+ i++;
128
+ continue;
129
+ }
130
+ if (b === 0x0a) {
131
+ // LF
132
+ predPendingWrap = false;
133
+ predRow = Math.min(predRow + 1, rows - 1);
134
+ i++;
135
+ continue;
136
+ }
137
+ if (b < 0x20) {
138
+ // Other C0 — probably interpreted by the application; bail.
139
+ predActive = false;
140
+ if (onChange) {
141
+ onChange();
142
+ }
143
+ return;
144
+ }
145
+ // Printable ASCII or UTF-8 lead byte. Detect the codepoint length
146
+ // and advance one cell. Mirror the server's pendingWrap semantics
147
+ // so the predicted cursor matches the server VT exactly: when
148
+ // pendingWrap is set we wrap to (row+1, 0) BEFORE writing, then
149
+ // record pendingWrap if the new col is the last one.
150
+ let len = 1;
151
+ if (b >= 0xc0 && b < 0xe0) {
152
+ len = 2;
153
+ } else if (b >= 0xe0 && b < 0xf0) {
154
+ len = 3;
155
+ } else if (b >= 0xf0) {
156
+ len = 4;
157
+ }
158
+ i += len;
159
+ if (predPendingWrap) {
160
+ predCol = 0;
161
+ predRow = Math.min(predRow + 1, rows - 1);
162
+ predPendingWrap = false;
163
+ }
164
+ if (predCol >= cols - 1) {
165
+ // Last column: don't advance, set pendingWrap for the next char.
166
+ predCol = cols - 1;
167
+ predPendingWrap = true;
168
+ } else {
169
+ predCol++;
170
+ }
171
+ }
172
+ if (onChange) {
173
+ onChange();
174
+ }
175
+ }
176
+
177
+ /** Current predicted cursor state. `active` is false when prediction
178
+ * is suspended (e.g. after an unmodelled byte). When the predicted
179
+ * position equals the server's position, the renderer should hide
180
+ * the overlay so the user sees only one cursor. */
181
+ export function get(): { row: number; col: number; active: boolean } {
182
+ return { row: predRow, col: predCol, active: predActive };
183
+ }
package/src/status.ts ADDED
@@ -0,0 +1,173 @@
1
+ // Connection-state banner. Shows "Reconnecting…" / "Offline" / "Slow
2
+ // connection" feedback so users on flaky links understand why typing
3
+ // is unresponsive. Hidden by default; appears only when the
4
+ // connection state diverges from "open" for more than a brief window
5
+ // (debounced to avoid flicker on healthy networks).
6
+ //
7
+ // State machine:
8
+ // open → no banner.
9
+ // closed → "Reconnecting…" after CONNECTING_GRACE_MS, "Offline"
10
+ // after several backoff failures.
11
+ // reconnecting (during a connect retry) → "Reconnecting…".
12
+ // slow → "Slow connection" overlaid on top of "open" when the
13
+ // server's pingstat reports a capped RTO. (Not wired
14
+ // yet; placeholder for a future ping-RTT feedback path.)
15
+
16
+ const CONNECTING_GRACE_MS = 600;
17
+
18
+ type State = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
19
+
20
+ let banner: HTMLElement | null = null;
21
+ let state: State = "connecting";
22
+ let stableTimer: ReturnType<typeof setTimeout> | null = null;
23
+ let consecutiveFailures = 0;
24
+
25
+ /** Mark the connection as open. Hides the banner. */
26
+ export function open(): void {
27
+ consecutiveFailures = 0;
28
+ setState("open", 0);
29
+ }
30
+
31
+ /** Mark a reconnect attempt in progress (after a close, before re-open). */
32
+ export function reconnecting(): void {
33
+ if (document.getElementById("loading")) {
34
+ return;
35
+ }
36
+ setState("reconnecting", CONNECTING_GRACE_MS);
37
+ }
38
+
39
+ /** Mark the connection as closed; shows banner after grace period. */
40
+ export function closed(): void {
41
+ consecutiveFailures++;
42
+ if (document.getElementById("loading")) {
43
+ return;
44
+ }
45
+ setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
46
+ }
47
+
48
+ /** Surface that the server has restarted (epoch mismatch). The
49
+ * caller has already reset bytesSent/bytesAcked; this just shows
50
+ * a brief banner so the user knows old input may have been lost. */
51
+ export function restarted(): void {
52
+ setState("restarted", 0);
53
+ // Auto-clear the banner after a few seconds; "open" will re-arrive
54
+ // when the next screen frame renders, but we don't want to wait.
55
+ setTimeout(() => {
56
+ if (state === "restarted") {
57
+ setState("open", 0);
58
+ }
59
+ }, 4000);
60
+ }
61
+
62
+ function setState(next: State, delay: number): void {
63
+ if (stableTimer !== null) {
64
+ clearTimeout(stableTimer);
65
+ stableTimer = null;
66
+ }
67
+ if (delay === 0) {
68
+ state = next;
69
+ applyState();
70
+ return;
71
+ }
72
+ stableTimer = setTimeout(() => {
73
+ stableTimer = null;
74
+ state = next;
75
+ applyState();
76
+ }, delay);
77
+ }
78
+
79
+ /** Surface a brief transient message (e.g. "Copied") using the same
80
+ * banner system as connection status. Auto-clears after `ms`. */
81
+ export function toast(msg: string, ms = 3000): void {
82
+ if (!banner) {
83
+ return;
84
+ }
85
+ banner.textContent = msg;
86
+ banner.dataset["state"] = "toast";
87
+ banner.classList.add("visible");
88
+ scheduleToastDismiss(ms);
89
+ }
90
+
91
+ let toastDismissTimer: ReturnType<typeof setTimeout> | null = null;
92
+ let toastDismissMs = 3000;
93
+
94
+ function scheduleToastDismiss(ms: number): void {
95
+ toastDismissMs = ms;
96
+ if (toastDismissTimer !== null) {
97
+ clearTimeout(toastDismissTimer);
98
+ }
99
+ toastDismissTimer = setTimeout(dismissToast, ms);
100
+ }
101
+
102
+ function dismissToast(): void {
103
+ toastDismissTimer = null;
104
+ if (!banner) {
105
+ return;
106
+ }
107
+ // Only clear if still showing a toast (not overridden by connection state).
108
+ if (state === "open" && banner.dataset["state"] === "toast") {
109
+ banner.classList.remove("visible");
110
+ banner.textContent = "";
111
+ }
112
+ }
113
+
114
+ export function init(): void {
115
+ banner = document.getElementById("conn-banner");
116
+ applyState();
117
+
118
+ // Hover-pause: stop the auto-dismiss timer while the user hovers
119
+ // (gives them time to read longer messages or click actions).
120
+ banner?.addEventListener("mouseenter", () => {
121
+ if (toastDismissTimer !== null) {
122
+ clearTimeout(toastDismissTimer);
123
+ toastDismissTimer = null;
124
+ }
125
+ });
126
+ banner?.addEventListener("mouseleave", () => {
127
+ if (banner?.dataset["state"] === "toast" && banner.classList.contains("visible")) {
128
+ scheduleToastDismiss(toastDismissMs);
129
+ }
130
+ });
131
+
132
+ // Escape-dismiss: pressing Escape while a toast is visible clears it.
133
+ document.addEventListener("keydown", (ev) => {
134
+ if (
135
+ ev.key === "Escape" &&
136
+ banner?.classList.contains("visible") &&
137
+ banner.dataset["state"] === "toast"
138
+ ) {
139
+ if (toastDismissTimer !== null) {
140
+ clearTimeout(toastDismissTimer);
141
+ }
142
+ dismissToast();
143
+ }
144
+ });
145
+ }
146
+
147
+ function applyState(): void {
148
+ if (!banner) {
149
+ return;
150
+ }
151
+ switch (state) {
152
+ case "open":
153
+ banner.classList.remove("visible");
154
+ banner.textContent = "";
155
+ break;
156
+ case "connecting":
157
+ case "reconnecting":
158
+ banner.textContent = "Reconnecting…";
159
+ banner.dataset["state"] = "reconnecting";
160
+ banner.classList.add("visible");
161
+ break;
162
+ case "offline":
163
+ banner.textContent = "Offline";
164
+ banner.dataset["state"] = "offline";
165
+ banner.classList.add("visible");
166
+ break;
167
+ case "restarted":
168
+ banner.textContent = "Server restarted — recent input may have been lost";
169
+ banner.dataset["state"] = "restarted";
170
+ banner.classList.add("visible");
171
+ break;
172
+ }
173
+ }
@@ -0,0 +1,98 @@
1
+ // Viewport stability tracker.
2
+ //
3
+ // Coordinates iOS keyboard transitions, window resizes, font-load reflows,
4
+ // and ResizeObserver fires into a single "transition → settle" lifecycle
5
+ // so the rest of the app can act on stable viewport state without each
6
+ // piece reinventing its own debounce/suppress timers.
7
+ //
8
+ // Behavior:
9
+ // 1. Any viewport-affecting event marks "in transition".
10
+ // 2. While in transition: autoscroll is suppressed, input dimensions
11
+ // may shift, no resize is sent to the server.
12
+ // 3. After SETTLE_MS of no further events, fire onSettled — the caller
13
+ // then sends the final size, snaps to bottom if was-at-bottom, etc.
14
+
15
+ import { scroll } from "@cplieger/web-terminal-engine";
16
+
17
+ // Time to wait after the last viewport-changing event before declaring
18
+ // the viewport "settled". Long enough to bridge the iOS keyboard slide
19
+ // (~250ms) with margin for fonts and reflow.
20
+ const SETTLE_MS = 350;
21
+
22
+ let termWrap: HTMLElement;
23
+ let onSettled: ((wasAtBottom: boolean) => void) | null = null;
24
+ let inTransition = false;
25
+ let wasAtBottomAtStart = true;
26
+ let settleTimer: ReturnType<typeof setTimeout> | null = null;
27
+
28
+ function startTransition(): void {
29
+ // Capture pre-transition scroll state at the *start* of the transition,
30
+ // not on every event in the burst.
31
+ if (!inTransition) {
32
+ inTransition = true;
33
+ wasAtBottomAtStart = !scroll.isUserScrolledUp();
34
+ }
35
+
36
+ if (settleTimer !== null) {
37
+ clearTimeout(settleTimer);
38
+ }
39
+ settleTimer = setTimeout(() => {
40
+ settleTimer = null;
41
+ inTransition = false;
42
+ if (wasAtBottomAtStart) {
43
+ scroll.scrollToBottom();
44
+ }
45
+ if (onSettled) {
46
+ onSettled(wasAtBottomAtStart);
47
+ }
48
+ }, SETTLE_MS);
49
+ }
50
+
51
+ export function init(opts: {
52
+ termWrap: HTMLElement;
53
+ onSettled: (wasAtBottom: boolean) => void;
54
+ }): void {
55
+ termWrap = opts.termWrap;
56
+ onSettled = opts.onSettled;
57
+
58
+ // iOS soft keyboard. interactive-widget=resizes-content makes the
59
+ // layout viewport shrink; we still apply a manual bottom inset as
60
+ // fallback for older iOS / other mobile browsers.
61
+ if (window.visualViewport) {
62
+ const vv = window.visualViewport;
63
+ const onChange = (): void => {
64
+ const inset = Math.max(0, Math.round(window.innerHeight - vv.height));
65
+ termWrap.style.bottom = inset > 0 ? `${inset}px` : "";
66
+ // Expose viewport geometry as CSS vars so fixed chrome stays in view:
67
+ // --kb-inset lifts the bottom banner above the keyboard; --vv-top lets
68
+ // the top toolbar counter iOS shifting content up to reveal the
69
+ // bottom-pinned input (otherwise the toolbar scrolls off the top).
70
+ const root = document.documentElement.style;
71
+ root.setProperty("--kb-inset", `${inset}px`);
72
+ root.setProperty("--vv-top", `${Math.max(0, Math.round(vv.offsetTop))}px`);
73
+ startTransition();
74
+ };
75
+ vv.addEventListener("resize", onChange);
76
+ vv.addEventListener("scroll", onChange);
77
+ onChange();
78
+ }
79
+
80
+ // Term wrap dimension changes: window resize, font load, devtools dock.
81
+ const ro = new ResizeObserver(startTransition);
82
+ ro.observe(termWrap);
83
+ window.addEventListener("resize", startTransition);
84
+
85
+ // Orientation change on mobile. iOS Safari often emits the
86
+ // window.resize event late or not at all on rotation while
87
+ // visualViewport.resize fires reliably; screen.orientation.change is
88
+ // the canonical signal that survives both. Modern browsers expose it
89
+ // on screen.orientation; older Safari falls back to the deprecated
90
+ // window.orientationchange event.
91
+ const orientation = (screen as Screen & { orientation?: ScreenOrientation }).orientation;
92
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for older Safari without screen.orientation
93
+ if (orientation) {
94
+ orientation.addEventListener("change", startTransition);
95
+ } else if ("onorientationchange" in window) {
96
+ window.addEventListener("orientationchange", startTransition);
97
+ }
98
+ }