@cplieger/web-terminal-ui 1.0.1 → 2.1.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 CHANGED
@@ -48,6 +48,8 @@ export function setDimensions(c: number, r: number): void {
48
48
  if (r > 0) {
49
49
  rows = r;
50
50
  }
51
+ const prevCol = predCol;
52
+ const prevRow = predRow;
51
53
  // Clamp predicted position to new bounds so a resize mid-typing
52
54
  // doesn't leave predCol/predRow past the edge.
53
55
  if (predCol >= cols) {
@@ -56,6 +58,12 @@ export function setDimensions(c: number, r: number): void {
56
58
  if (predRow >= rows) {
57
59
  predRow = rows - 1;
58
60
  }
61
+ // Notify subscribers when the clamp moved the predicted cursor, so the
62
+ // renderer's overlay doesn't show a stale (possibly off-screen) position
63
+ // until the next server frame arrives.
64
+ if (predCol !== prevCol || predRow !== prevRow) {
65
+ onChange?.();
66
+ }
59
67
  }
60
68
 
61
69
  /** Reset all prediction state. Called on server restart to avoid
@@ -66,9 +74,7 @@ export function reset(): void {
66
74
  predActive = false;
67
75
  predPendingWrap = false;
68
76
  predFrozen = false;
69
- if (onChange) {
70
- onChange();
71
- }
77
+ onChange?.();
72
78
  }
73
79
 
74
80
  /** Reset prediction to the server's reported cursor and re-arm.
@@ -81,9 +87,7 @@ export function onScreenFrame(serverRow: number, serverCol: number, cursorHidden
81
87
  predPendingWrap = false;
82
88
  predFrozen = cursorHidden ?? false;
83
89
  predActive = !predFrozen;
84
- if (onChange) {
85
- onChange();
86
- }
90
+ onChange?.();
87
91
  }
88
92
 
89
93
  /** Apply locally-typed input bytes to the predicted cursor. Bails out
@@ -100,9 +104,7 @@ export function applyInput(bytes: Uint8Array): void {
100
104
  if (b === 0x1b) {
101
105
  // ESC — start of an escape sequence. Don't try to model it.
102
106
  predActive = false;
103
- if (onChange) {
104
- onChange();
105
- }
107
+ onChange?.();
106
108
  return;
107
109
  }
108
110
  if (b === 0x08 || b === 0x7f) {
@@ -137,9 +139,7 @@ export function applyInput(bytes: Uint8Array): void {
137
139
  if (b < 0x20) {
138
140
  // Other C0 — probably interpreted by the application; bail.
139
141
  predActive = false;
140
- if (onChange) {
141
- onChange();
142
- }
142
+ onChange?.();
143
143
  return;
144
144
  }
145
145
  // Printable ASCII or UTF-8 lead byte. Detect the codepoint length
@@ -169,9 +169,7 @@ export function applyInput(bytes: Uint8Array): void {
169
169
  predCol++;
170
170
  }
171
171
  }
172
- if (onChange) {
173
- onChange();
174
- }
172
+ onChange?.();
175
173
  }
176
174
 
177
175
  /** Current predicted cursor state. `active` is false when prediction
package/src/status.ts CHANGED
@@ -1,26 +1,49 @@
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).
1
+ // Connection-state banner. Shows "Reconnecting…" / "Offline" / "Server
2
+ // restarted" feedback so users on flaky links understand why typing is
3
+ // unresponsive. Hidden by default; appears only when the connection
4
+ // state diverges from "open" for more than a brief window (debounced to
5
+ // avoid flicker on healthy networks). A "slow connection" indicator is
6
+ // intentionally not implemented (see the state machine below).
6
7
  //
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.)
8
+ // State machine (matches the `State` union below):
9
+ // open → no banner.
10
+ // connecting → "Reconnecting…" (initial connect, before first open).
11
+ // reconnecting → "Reconnecting…" (after a close, during retry);
12
+ // escalates to offline after >3 consecutive failures.
13
+ // offline → "Offline".
14
+ // restarted → "Server restarted — recent input may have been lost".
15
+ // A "slow connection" indicator is intentionally not implemented: it would
16
+ // need an RTT signal the engine does not currently expose to consumers.
15
17
 
16
18
  const CONNECTING_GRACE_MS = 600;
17
19
 
20
+ // Consecutive failed *initial* connection attempts (before the first screen
21
+ // frame, i.e. while `loaded` is still false) after which the "Offline" banner
22
+ // is allowed to render anyway. Below this we stay silent and defer to the
23
+ // consumer's loading overlay, so a merely-slow first connect raises no false
24
+ // alarm; this many failures in a row means the connection genuinely isn't
25
+ // coming up and an indefinite silent "Loading…" would be worse than "Offline".
26
+ // Aligned with the post-load escalation threshold (consecutiveFailures > 3).
27
+ const INITIAL_FAILURE_LIMIT = 4;
28
+
18
29
  type State = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
19
30
 
20
31
  let banner: HTMLElement | null = null;
21
32
  let state: State = "connecting";
22
33
  let stableTimer: ReturnType<typeof setTimeout> | null = null;
23
34
  let consecutiveFailures = 0;
35
+ // True once the first screen frame has rendered (mount calls setLoaded()).
36
+ // Until then the initial loading overlay is up and we suppress the
37
+ // reconnect/offline banner so the two don't stack — this replaces the old
38
+ // `document.getElementById("loading")` probe.
39
+ let loaded = false;
40
+ // Optional callback supplied by init(): invoked once the initial-connect
41
+ // failure limit is reached while still !loaded, so mount can fade its opaque
42
+ // loading overlay and let the "Offline" banner underneath become visible.
43
+ // mount always supplies this (its dismissLoadingOverlay self-guards when no
44
+ // overlay was passed), so onGiveUp is null only when init() is called without
45
+ // the callback (e.g. the status unit tests).
46
+ let onGiveUp: (() => void) | null = null;
24
47
 
25
48
  /** Mark the connection as open. Hides the banner. */
26
49
  export function open(): void {
@@ -28,9 +51,16 @@ export function open(): void {
28
51
  setState("open", 0);
29
52
  }
30
53
 
54
+ /** Mark the terminal as past its initial load (first screen frame rendered).
55
+ * Until this is called the connection banner stays suppressed so it never
56
+ * stacks on top of the consumer's loading overlay. */
57
+ export function setLoaded(): void {
58
+ loaded = true;
59
+ }
60
+
31
61
  /** Mark a reconnect attempt in progress (after a close, before re-open). */
32
62
  export function reconnecting(): void {
33
- if (document.getElementById("loading")) {
63
+ if (!loaded) {
34
64
  return;
35
65
  }
36
66
  setState("reconnecting", CONNECTING_GRACE_MS);
@@ -39,9 +69,21 @@ export function reconnecting(): void {
39
69
  /** Mark the connection as closed; shows banner after grace period. */
40
70
  export function closed(): void {
41
71
  consecutiveFailures++;
42
- if (document.getElementById("loading")) {
72
+ // Before the first frame, stay suppressed (the loading overlay owns the
73
+ // screen) UNTIL we've failed to connect INITIAL_FAILURE_LIMIT times in a
74
+ // row — at that point the connection clearly isn't coming up, so let the
75
+ // "Offline" banner through rather than leave a silent "Loading…" forever.
76
+ if (!loaded && consecutiveFailures < INITIAL_FAILURE_LIMIT) {
43
77
  return;
44
78
  }
79
+ // Past the initial-failure limit while the loading overlay is still up: tell
80
+ // mount to fade it so the "Offline" banner below becomes visible rather than
81
+ // staying occluded behind the opaque overlay (which markReady would otherwise
82
+ // never remove on a never-connecting socket). Idempotent, so firing on each
83
+ // subsequent !loaded close is safe.
84
+ if (!loaded) {
85
+ onGiveUp?.();
86
+ }
45
87
  setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
46
88
  }
47
89
 
@@ -104,26 +146,28 @@ function dismissToast(): void {
104
146
  if (!banner) {
105
147
  return;
106
148
  }
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 = "";
149
+ // Restore the live connection state. applyState() hides the banner when
150
+ // "open" (identical to the old clear) and re-renders "Offline"/"Reconnecting…"
151
+ // when a toast was shown over a non-open status, so the status is never stranded.
152
+ if (banner.dataset["state"] === "toast") {
153
+ applyState();
111
154
  }
112
155
  }
113
156
 
114
- export function init(): void {
115
- banner = document.getElementById("conn-banner");
157
+ export function init(opts: { banner: HTMLElement; onGiveUp?: () => void }): void {
158
+ banner = opts.banner;
159
+ onGiveUp = opts.onGiveUp ?? null;
116
160
  applyState();
117
161
 
118
162
  // Hover-pause: stop the auto-dismiss timer while the user hovers
119
163
  // (gives them time to read longer messages or click actions).
120
- banner?.addEventListener("mouseenter", () => {
164
+ banner.addEventListener("mouseenter", () => {
121
165
  if (toastDismissTimer !== null) {
122
166
  clearTimeout(toastDismissTimer);
123
167
  toastDismissTimer = null;
124
168
  }
125
169
  });
126
- banner?.addEventListener("mouseleave", () => {
170
+ banner.addEventListener("mouseleave", () => {
127
171
  if (banner?.dataset["state"] === "toast" && banner.classList.contains("visible")) {
128
172
  scheduleToastDismiss(toastDismissMs);
129
173
  }
@@ -148,6 +192,17 @@ function applyState(): void {
148
192
  if (!banner) {
149
193
  return;
150
194
  }
195
+ // Stay fully suppressed until the initial load is over (matches the
196
+ // `loaded` guard in reconnecting()/closed()): the loading overlay, when
197
+ // present, owns the screen until then and a bare page has nothing to show.
198
+ // Exception: once INITIAL_FAILURE_LIMIT consecutive initial connects have
199
+ // failed, let "Offline" through even while !loaded so a never-connecting
200
+ // page doesn't sit on a silent "Loading…" indefinitely.
201
+ if (!loaded && !(state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT)) {
202
+ banner.classList.remove("visible");
203
+ banner.textContent = "";
204
+ return;
205
+ }
151
206
  switch (state) {
152
207
  case "open":
153
208
  banner.classList.remove("visible");