@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/README.md +66 -28
- package/css/00-tokens.css +1 -0
- package/css/02-app.css +34 -10
- package/package.json +5 -5
- package/scaffold/index.html +12 -25
- package/src/composition.ts +4 -26
- package/src/index.ts +3 -2
- package/src/input-placeholder.ts +29 -0
- package/src/mount.ts +303 -222
- package/src/predict.ts +13 -15
- package/src/status.ts +78 -23
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" / "
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 =
|
|
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
|
|
164
|
+
banner.addEventListener("mouseenter", () => {
|
|
121
165
|
if (toastDismissTimer !== null) {
|
|
122
166
|
clearTimeout(toastDismissTimer);
|
|
123
167
|
toastDismissTimer = null;
|
|
124
168
|
}
|
|
125
169
|
});
|
|
126
|
-
banner
|
|
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");
|