@cplieger/web-terminal-ui 1.0.0 → 2.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/README.md +66 -28
- package/package.json +5 -5
- package/scaffold/index.html +12 -25
- package/src/mount.ts +122 -144
- package/src/status.ts +18 -6
package/README.md
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
# @cplieger/web-terminal-ui
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@cplieger/web-terminal-ui)
|
|
4
|
+
[](https://jsr.io/@cplieger/web-terminal-ui)
|
|
5
|
+
[](https://github.com/cplieger/web-terminal-ui/actions/workflows/coverage.yml)
|
|
6
|
+
[](https://www.bestpractices.dev/projects/13431)
|
|
7
|
+
[](https://scorecard.dev/viewer/?uri=github.com/cplieger/web-terminal-ui)
|
|
8
|
+
|
|
3
9
|
The reference touch-first browser UI for
|
|
4
10
|
[`@cplieger/web-terminal-engine`](https://github.com/cplieger/web-terminal-engine) — the part
|
|
5
11
|
that turns the engine's render/scroll/connection/keyboard modules into a
|
|
6
12
|
usable terminal on a phone as well as a desktop.
|
|
7
13
|
|
|
8
|
-
One `mount()` call
|
|
9
|
-
everything above the raw terminal:
|
|
14
|
+
One `mount(root)` call builds the entire terminal UI inside a single container
|
|
15
|
+
element you provide and owns everything above the raw terminal:
|
|
10
16
|
|
|
11
|
-
- a **display-only**
|
|
17
|
+
- a **display-only** terminal output (native text selection survives redraws)
|
|
12
18
|
- a hidden `<textarea>` that owns the keyboard, the local typing buffer, and IME
|
|
13
19
|
- a **mobile key toolbar** (Tab / Esc / arrows / Enter / sticky-Ctrl) and a
|
|
14
20
|
scroll-to-bottom control
|
|
@@ -20,7 +26,7 @@ everything above the raw terminal:
|
|
|
20
26
|
- a connection-status banner and a copy toast
|
|
21
27
|
|
|
22
28
|
It is published as TypeScript source (no build step) to npm and JSR, alongside
|
|
23
|
-
the CSS bundle and
|
|
29
|
+
the CSS bundle and a reference HTML page. Consumers who want a different UI should
|
|
24
30
|
depend on the engine directly and skip this package.
|
|
25
31
|
|
|
26
32
|
## Install
|
|
@@ -35,41 +41,73 @@ pins the engine version explicitly.
|
|
|
35
41
|
|
|
36
42
|
## Usage
|
|
37
43
|
|
|
38
|
-
Serve the
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
Serve the bundled CSS (`css/` concatenated per `css/MANIFEST` into the
|
|
45
|
+
`style.css` your page links) plus a minimal HTML page that has one empty
|
|
46
|
+
container element for the terminal, then call `mount(root)` from your entry
|
|
47
|
+
module:
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<div id="terminal"></div>
|
|
51
|
+
<div id="loading">Loading…</div>
|
|
52
|
+
<script type="importmap">
|
|
53
|
+
{
|
|
54
|
+
"imports": {
|
|
55
|
+
"@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
|
|
56
|
+
"@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
<script type="module">
|
|
61
|
+
import { mount } from "@cplieger/web-terminal-ui";
|
|
62
|
+
mount(document.getElementById("terminal"), {
|
|
63
|
+
loading: document.getElementById("loading"),
|
|
64
|
+
});
|
|
65
|
+
// or, for a server that exposes the WebSocket elsewhere / a custom font:
|
|
66
|
+
// mount(root, { wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
|
|
67
|
+
</script>
|
|
48
68
|
```
|
|
49
69
|
|
|
50
|
-
`mount()`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
`mount(root, opts?)` builds the entire terminal subtree (the display output,
|
|
71
|
+
the hidden keyboard textarea, the mobile key toolbar, the status banner, and
|
|
72
|
+
the context menu) inside `root` itself — there is no element-id contract for
|
|
73
|
+
the host page to reproduce. Call it exactly once; the module state is
|
|
74
|
+
single-instance per page. `scaffold/index.html` is a complete reference page to
|
|
75
|
+
copy and adapt.
|
|
55
76
|
|
|
56
77
|
### Options
|
|
57
78
|
|
|
58
|
-
| Option | Default | Purpose
|
|
59
|
-
| ----------- | -------------------------- |
|
|
60
|
-
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to.
|
|
61
|
-
| `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback.
|
|
79
|
+
| Option | Default | Purpose |
|
|
80
|
+
| ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
81
|
+
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
|
|
82
|
+
| `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
|
|
83
|
+
| `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); mount fades it out and removes it once the first frame renders. |
|
|
62
84
|
|
|
63
85
|
`mount()` returns a small handle: `{ focus() }` re-focuses the terminal input
|
|
64
86
|
(and opens the soft keyboard on touch).
|
|
65
87
|
|
|
66
88
|
## What ships
|
|
67
89
|
|
|
68
|
-
| Path | Purpose
|
|
69
|
-
| ------------------------ |
|
|
70
|
-
| `src/*.ts` | The UI modules (`mount`, IME, predictive echo, viewport, status).
|
|
71
|
-
| `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order.
|
|
72
|
-
| `scaffold/index.html` | A reference HTML
|
|
90
|
+
| Path | Purpose |
|
|
91
|
+
| ------------------------ | --------------------------------------------------------------------- |
|
|
92
|
+
| `src/*.ts` | The UI modules (`mount`, IME, predictive echo, viewport, status). |
|
|
93
|
+
| `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order. |
|
|
94
|
+
| `scaffold/index.html` | A reference HTML page: `<head>` + one empty root element + importmap. |
|
|
95
|
+
|
|
96
|
+
## Related projects
|
|
97
|
+
|
|
98
|
+
The web-terminal family:
|
|
99
|
+
|
|
100
|
+
- [`web-terminal-engine`](https://github.com/cplieger/web-terminal-engine) — the
|
|
101
|
+
Go session engine + TypeScript browser renderer this UI is built on (peer
|
|
102
|
+
dependency).
|
|
103
|
+
- [`web-terminal-server`](https://github.com/cplieger/web-terminal-server) — a
|
|
104
|
+
ready-to-run container that serves this UI over HTTP + WebSocket for any
|
|
105
|
+
command.
|
|
106
|
+
|
|
107
|
+
Consumers that ship this UI:
|
|
108
|
+
|
|
109
|
+
- [`vibekit`](https://github.com/cplieger/vibekit)
|
|
110
|
+
- [`vibecli`](https://github.com/cplieger/vibecli)
|
|
73
111
|
|
|
74
112
|
## License
|
|
75
113
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: textarea input model, mobile key toolbar, context menu, IME, predictive echo, viewport/keyboard-inset handling, and CSS. One mount() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"@eslint/js": "10.0.1",
|
|
28
28
|
"@types/node": "25.9.4",
|
|
29
29
|
"@vitest/coverage-v8": "4.1.9",
|
|
30
|
-
"eslint": "10.
|
|
30
|
+
"eslint": "10.6.0",
|
|
31
31
|
"fast-check": "4.8.0",
|
|
32
32
|
"happy-dom": "20.10.6",
|
|
33
33
|
"html-validate": "11.5.3",
|
|
34
|
-
"knip": "6.
|
|
35
|
-
"prettier": "3.8.
|
|
36
|
-
"stylelint": "17.
|
|
34
|
+
"knip": "6.21.0",
|
|
35
|
+
"prettier": "3.8.5",
|
|
36
|
+
"stylelint": "17.14.0",
|
|
37
37
|
"stylelint-config-standard": "40.0.0",
|
|
38
38
|
"typescript": "6.0.3",
|
|
39
39
|
"typescript-eslint": "8.62.0",
|
package/scaffold/index.html
CHANGED
|
@@ -16,37 +16,24 @@
|
|
|
16
16
|
</style>
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<div id="pred-cursor" class="pred-cursor" aria-hidden="true"></div>
|
|
24
|
-
</div>
|
|
19
|
+
<!-- mount() builds the entire terminal UI inside this single root element.
|
|
20
|
+
The loading overlay is a sibling (not a child of the root) so mount never
|
|
21
|
+
clears it; mount fades it out once the first frame renders. -->
|
|
22
|
+
<div id="terminal"></div>
|
|
25
23
|
<div id="loading"><div class="spin"></div>Loading…</div>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<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>
|
|
29
|
-
<button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
|
|
30
|
-
<button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
|
|
31
|
-
<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>
|
|
32
|
-
<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>
|
|
33
|
-
<button type="button" id="scroll-bottom" class="kb-scroll-bottom" aria-label="Scroll to bottom"><svg viewBox="0 0 24 24"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg></button>
|
|
34
|
-
<button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
|
|
35
|
-
<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>
|
|
36
|
-
<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>
|
|
37
|
-
<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>
|
|
38
|
-
</div>
|
|
39
|
-
<div id="ctx-menu" class="ctx-menu"></div>
|
|
40
|
-
<!-- Deployment-specific: the importmap resolves the bare engine specifier to
|
|
41
|
-
the engine's compiled JS, and /app.js is your bundle that calls
|
|
42
|
-
`mount()` from @cplieger/web-terminal-ui. Adjust paths to your build. -->
|
|
24
|
+
<!-- Deployment-specific: the importmap resolves the bare package specifiers to
|
|
25
|
+
the compiled JS your build emits. Adjust the paths to your build layout. -->
|
|
43
26
|
<script type="importmap">
|
|
44
27
|
{
|
|
45
28
|
"imports": {
|
|
46
|
-
"@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js"
|
|
29
|
+
"@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
|
|
30
|
+
"@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js"
|
|
47
31
|
}
|
|
48
32
|
}
|
|
49
33
|
</script>
|
|
50
|
-
<script type="module"
|
|
34
|
+
<script type="module">
|
|
35
|
+
import { mount } from "@cplieger/web-terminal-ui";
|
|
36
|
+
mount(document.getElementById("terminal"), { loading: document.getElementById("loading") });
|
|
37
|
+
</script>
|
|
51
38
|
</body>
|
|
52
39
|
</html>
|
package/src/mount.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// @cplieger/web-terminal-ui — reference touch-first browser terminal UI.
|
|
2
2
|
//
|
|
3
|
-
// mount()
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
3
|
+
// mount(root) builds the entire terminal subtree inside a single host-provided
|
|
4
|
+
// container element and owns the touch-first input model: a display-only
|
|
5
|
+
// terminal output, a hidden <textarea> that owns the keyboard + IME + local
|
|
6
|
+
// typing buffer, a mobile key toolbar, a viewport-clamped context menu,
|
|
7
|
+
// predictive echo, and viewport/keyboard-inset handling. The consumer's HTML
|
|
8
|
+
// is just <head> + one empty root element (+ an optional loading overlay) +
|
|
9
|
+
// the importmap; mount creates everything else, so there is no shared
|
|
10
|
+
// element-id contract to keep in sync. A consumer that wants a different UI
|
|
11
|
+
// builds on the engine directly instead.
|
|
11
12
|
//
|
|
12
13
|
// Single-instance per page: the module holds the one terminal's DOM refs and
|
|
13
14
|
// state, matching a terminal-per-page model. Call mount() exactly once.
|
|
@@ -28,6 +29,11 @@ export interface MountOptions {
|
|
|
28
29
|
* server is sized against the real web font's cell metrics rather than a
|
|
29
30
|
* fallback. Default '14px "MonaspiceNe NFM"'. */
|
|
30
31
|
fontReady?: string;
|
|
32
|
+
/** Optional pre-JS loading overlay element (lives in the served HTML so it
|
|
33
|
+
* paints before this module loads). mount fades it out and removes it once
|
|
34
|
+
* the first screen frame renders. The consumer owns the element; mount only
|
|
35
|
+
* dismisses it. */
|
|
36
|
+
loading?: HTMLElement;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export interface TerminalUI {
|
|
@@ -61,10 +67,13 @@ let termWrap!: HTMLElement;
|
|
|
61
67
|
let input!: HTMLTextAreaElement;
|
|
62
68
|
let compositionViewEl!: HTMLElement;
|
|
63
69
|
let ctxMenu!: HTMLElement;
|
|
64
|
-
let ctrlBtn: HTMLElement | null = null;
|
|
65
70
|
|
|
66
71
|
// --- Mutable UI state ---
|
|
67
|
-
|
|
72
|
+
// Sticky-Ctrl state + the C0 mapping table + the toolbar button wiring all
|
|
73
|
+
// live in the engine's keyboard.bindMobileToolbar; mount() binds it against
|
|
74
|
+
// the required #key-toolbar and keeps the controller so the input handler
|
|
75
|
+
// can route typed text through its applyStickyCtrl.
|
|
76
|
+
let toolbarCtrl!: keyboard.MobileToolbarController;
|
|
68
77
|
let fontsLoaded = false;
|
|
69
78
|
let wsOpen = false;
|
|
70
79
|
let lastPointerType = "mouse";
|
|
@@ -81,10 +90,43 @@ let longPressOrigin = { x: 0, y: 0 };
|
|
|
81
90
|
// the Paste button from our custom menu on iOS to steer them there.
|
|
82
91
|
let isIOS = false;
|
|
83
92
|
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
// The terminal subtree mount() builds inside the host-provided root. Static,
|
|
94
|
+
// trusted markup (no interpolation) parsed once via a <template>. Element refs
|
|
95
|
+
// are then picked out by class with scoped queries — no global getElementById,
|
|
96
|
+
// no element-id contract the consumer must reproduce. The only ids are on the
|
|
97
|
+
// kb-* toolbar buttons, which the engine's bindMobileToolbar resolves with a
|
|
98
|
+
// scoped `toolbar.querySelector('#id')` (not a document-global lookup). The
|
|
99
|
+
// predicted-cursor overlay is created and owned by the engine renderer; the
|
|
100
|
+
// loading overlay stays in the consumer's served HTML (it must paint before
|
|
101
|
+
// this module loads).
|
|
102
|
+
const TERMINAL_TEMPLATE = `
|
|
103
|
+
<div class="term">
|
|
104
|
+
<div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
|
|
105
|
+
<textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
|
|
106
|
+
<div class="composition-view" aria-hidden="true"></div>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="conn-banner" role="status" aria-live="polite"></div>
|
|
109
|
+
<div class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
|
|
110
|
+
<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>
|
|
111
|
+
<button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
|
|
112
|
+
<button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
|
|
113
|
+
<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>
|
|
114
|
+
<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>
|
|
115
|
+
<button type="button" class="kb-scroll-bottom" aria-label="Scroll to bottom"><svg viewBox="0 0 24 24"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg></button>
|
|
116
|
+
<button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
|
|
117
|
+
<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>
|
|
118
|
+
<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>
|
|
119
|
+
<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>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="ctx-menu"></div>`;
|
|
122
|
+
|
|
123
|
+
// Pick a freshly-built element out of the root by class. Throws if the
|
|
124
|
+
// template and the selector ever drift apart (a build-time invariant, not a
|
|
125
|
+
// host-scaffold dependency).
|
|
126
|
+
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
127
|
+
const el = root.querySelector<HTMLElement>(selector);
|
|
86
128
|
if (!el) {
|
|
87
|
-
throw new Error(`web-terminal-ui:
|
|
129
|
+
throw new Error(`web-terminal-ui: mount() failed to build element ${selector}`);
|
|
88
130
|
}
|
|
89
131
|
return el;
|
|
90
132
|
}
|
|
@@ -103,61 +145,6 @@ function send(bytes: string): void {
|
|
|
103
145
|
}
|
|
104
146
|
}
|
|
105
147
|
|
|
106
|
-
// --- Sticky Ctrl modifier ---
|
|
107
|
-
// The iOS virtual keyboard has no Ctrl key, so control sequences
|
|
108
|
-
// (Ctrl+C, Ctrl+L = clear screen, Ctrl+X, ...) are otherwise
|
|
109
|
-
// unreachable on touch. The toolbar's Ctrl button arms a one-shot
|
|
110
|
-
// modifier: tap it, then tap a letter on the virtual keyboard and that
|
|
111
|
-
// keystroke is sent as its C0 control byte. Auto-disarms after one
|
|
112
|
-
// printable character.
|
|
113
|
-
function setCtrlArmed(on: boolean): void {
|
|
114
|
-
ctrlArmed = on;
|
|
115
|
-
ctrlBtn?.classList.toggle("armed", on);
|
|
116
|
-
ctrlBtn?.setAttribute("aria-pressed", on ? "true" : "false");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Map one printable character to its Ctrl+<char> C0 control byte,
|
|
120
|
-
// mirroring the Ctrl handling in @cplieger/web-terminal-engine's keyboard mapper.
|
|
121
|
-
function ctrlByteFor(ch: string): string | null {
|
|
122
|
-
const code = ch.toLowerCase().charCodeAt(0);
|
|
123
|
-
if (code >= 97 && code <= 122) {
|
|
124
|
-
return String.fromCharCode(code - 96); // a–z → 0x01–0x1a
|
|
125
|
-
}
|
|
126
|
-
switch (ch) {
|
|
127
|
-
case " ":
|
|
128
|
-
case "@":
|
|
129
|
-
return "\x00";
|
|
130
|
-
case "[":
|
|
131
|
-
return "\x1b";
|
|
132
|
-
case "\\":
|
|
133
|
-
return "\x1c";
|
|
134
|
-
case "]":
|
|
135
|
-
return "\x1d";
|
|
136
|
-
case "^":
|
|
137
|
-
return "\x1e";
|
|
138
|
-
case "_":
|
|
139
|
-
return "\x1f";
|
|
140
|
-
case "?":
|
|
141
|
-
return "\x7f";
|
|
142
|
-
default:
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Apply a one-shot armed Ctrl to freshly-typed text: a single printable
|
|
148
|
-
// character becomes its control byte; longer input (paste) just disarms
|
|
149
|
-
// and passes through unchanged.
|
|
150
|
-
function applyStickyCtrl(data: string): string {
|
|
151
|
-
if (!ctrlArmed) {
|
|
152
|
-
return data;
|
|
153
|
-
}
|
|
154
|
-
setCtrlArmed(false);
|
|
155
|
-
if (data.length === 1) {
|
|
156
|
-
return ctrlByteFor(data) ?? data;
|
|
157
|
-
}
|
|
158
|
-
return data;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
148
|
function resetInputPlaceholder(): void {
|
|
162
149
|
input.value = INPUT_PLACEHOLDER;
|
|
163
150
|
// Cursor at the end so the next typed char appends after the
|
|
@@ -317,24 +304,50 @@ function showCtxMenu(x: number, y: number): void {
|
|
|
317
304
|
ctxMenu.style.top = `${top}px`;
|
|
318
305
|
}
|
|
319
306
|
|
|
320
|
-
export function mount(opts: MountOptions = {}): TerminalUI {
|
|
307
|
+
export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
321
308
|
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
322
309
|
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
323
310
|
|
|
324
|
-
// ---
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
311
|
+
// --- Build the terminal subtree inside the host-provided root ---
|
|
312
|
+
const tpl = document.createElement("template");
|
|
313
|
+
tpl.innerHTML = TERMINAL_TEMPLATE;
|
|
314
|
+
root.replaceChildren(tpl.content);
|
|
315
|
+
|
|
316
|
+
// --- DOM refs (picked from the just-built subtree by class) ---
|
|
317
|
+
termWrap = pick(root, ".term");
|
|
318
|
+
outputEl = pick(root, ".term-output");
|
|
319
|
+
input = pick(root, ".term-input") as HTMLTextAreaElement;
|
|
320
|
+
compositionViewEl = pick(root, ".composition-view");
|
|
321
|
+
ctxMenu = pick(root, ".ctx-menu");
|
|
322
|
+
const banner = pick(root, ".conn-banner");
|
|
323
|
+
const toolbar = pick(root, ".key-toolbar");
|
|
331
324
|
|
|
332
325
|
isIOS =
|
|
333
326
|
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
334
327
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
335
328
|
|
|
329
|
+
// markReady fires once: when the first screen frame has rendered AND the web
|
|
330
|
+
// font is loaded. It tells the status banner the initial load is over (so it
|
|
331
|
+
// may show reconnect state from here on) and fades out the consumer's
|
|
332
|
+
// loading overlay if one was passed.
|
|
333
|
+
let ready = false;
|
|
334
|
+
function markReady(): void {
|
|
335
|
+
if (ready) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
ready = true;
|
|
339
|
+
status.setLoaded();
|
|
340
|
+
const ld = opts.loading;
|
|
341
|
+
if (ld) {
|
|
342
|
+
ld.classList.add("fade");
|
|
343
|
+
ld.addEventListener("transitionend", () => {
|
|
344
|
+
ld.remove();
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
336
349
|
// --- Initialize layers ---
|
|
337
|
-
status.init();
|
|
350
|
+
status.init({ banner });
|
|
338
351
|
|
|
339
352
|
render.init({
|
|
340
353
|
output: outputEl,
|
|
@@ -370,10 +383,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
370
383
|
onUserScrollChange(scrolledUp) {
|
|
371
384
|
// Toggle .scrolled-up on the toolbar so CSS can show/hide the
|
|
372
385
|
// scroll-bottom button and grow the pill vertically.
|
|
373
|
-
|
|
374
|
-
if (toolbar) {
|
|
375
|
-
toolbar.classList.toggle("scrolled-up", scrolledUp);
|
|
376
|
-
}
|
|
386
|
+
toolbar.classList.toggle("scrolled-up", scrolledUp);
|
|
377
387
|
},
|
|
378
388
|
});
|
|
379
389
|
|
|
@@ -386,13 +396,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
386
396
|
if (msg.type === "screen") {
|
|
387
397
|
render.handleScreen(msg);
|
|
388
398
|
if (fontsLoaded) {
|
|
389
|
-
|
|
390
|
-
if (ld) {
|
|
391
|
-
ld.classList.add("fade");
|
|
392
|
-
ld.addEventListener("transitionend", () => {
|
|
393
|
-
ld.remove();
|
|
394
|
-
});
|
|
395
|
-
}
|
|
399
|
+
markReady();
|
|
396
400
|
}
|
|
397
401
|
predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
|
|
398
402
|
} else if (msg.type === "scroll") {
|
|
@@ -482,16 +486,18 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
482
486
|
// space byte. (Native paste is bracketed in composition.ts's paste
|
|
483
487
|
// handler before this fires; the insertFromPaste data here is a
|
|
484
488
|
// fallback for browsers that don't raise a separate paste event.)
|
|
485
|
-
send(applyStickyCtrl(ev.data.replace(/\u00A0/g, " ")));
|
|
489
|
+
send(toolbarCtrl.applyStickyCtrl(ev.data.replace(/\u00A0/g, " ")));
|
|
486
490
|
} else {
|
|
487
491
|
// Fallback: anything in the textarea past the placeholder is new
|
|
488
492
|
// content. Covers browsers that don't populate inputType / data
|
|
489
493
|
// (older WebKit).
|
|
490
494
|
const v = input.value;
|
|
491
495
|
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
492
|
-
send(
|
|
496
|
+
send(
|
|
497
|
+
toolbarCtrl.applyStickyCtrl(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " ")),
|
|
498
|
+
);
|
|
493
499
|
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
494
|
-
send(applyStickyCtrl(v.replace(/\u00A0/g, " ")));
|
|
500
|
+
send(toolbarCtrl.applyStickyCtrl(v.replace(/\u00A0/g, " ")));
|
|
495
501
|
}
|
|
496
502
|
}
|
|
497
503
|
resetInputPlaceholder();
|
|
@@ -651,21 +657,19 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
651
657
|
});
|
|
652
658
|
|
|
653
659
|
// --- Scroll-to-bottom (inside toolbar grid) ---
|
|
654
|
-
const scrollBtn =
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
});
|
|
668
|
-
}
|
|
660
|
+
const scrollBtn = pick(root, ".kb-scroll-bottom");
|
|
661
|
+
// pointerdown (like the other toolbar keys) so touch devices enter
|
|
662
|
+
// :active and show press feedback; preventDefault keeps focus on the
|
|
663
|
+
// terminal. click is kept for keyboard activation of the desktop FAB.
|
|
664
|
+
// scrollToBottom() is idempotent, so the pointerdown+click pair on a
|
|
665
|
+
// mouse press is harmless.
|
|
666
|
+
scrollBtn.addEventListener("pointerdown", (e) => {
|
|
667
|
+
e.preventDefault();
|
|
668
|
+
scroll.scrollToBottom();
|
|
669
|
+
});
|
|
670
|
+
scrollBtn.addEventListener("click", () => {
|
|
671
|
+
scroll.scrollToBottom();
|
|
672
|
+
});
|
|
669
673
|
|
|
670
674
|
// --- Context menu ---
|
|
671
675
|
termWrap.addEventListener("contextmenu", (e: MouseEvent) => {
|
|
@@ -750,44 +754,18 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
750
754
|
});
|
|
751
755
|
|
|
752
756
|
// --- Mobile key toolbar ---
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const keyMap: Record<string, string> = {
|
|
767
|
-
"kb-up": "\x1b[A",
|
|
768
|
-
"kb-down": "\x1b[B",
|
|
769
|
-
"kb-left": "\x1b[D",
|
|
770
|
-
"kb-right": "\x1b[C",
|
|
771
|
-
"kb-esc": "\x1b",
|
|
772
|
-
"kb-tab": "\t",
|
|
773
|
-
"kb-enter": "\r",
|
|
774
|
-
};
|
|
775
|
-
|
|
776
|
-
for (const [id, seq] of Object.entries(keyMap)) {
|
|
777
|
-
document.getElementById(id)?.addEventListener("pointerdown", (e) => {
|
|
778
|
-
e.preventDefault();
|
|
779
|
-
setCtrlArmed(false);
|
|
780
|
-
send(seq);
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Sticky Ctrl: tap to arm/disarm. preventDefault keeps focus on the
|
|
785
|
-
// terminal so the iOS virtual keyboard stays up for the next tap.
|
|
786
|
-
ctrlBtn?.addEventListener("pointerdown", (e) => {
|
|
787
|
-
e.preventDefault();
|
|
788
|
-
setCtrlArmed(!ctrlArmed);
|
|
789
|
-
});
|
|
790
|
-
}
|
|
757
|
+
// The engine's keyboard.bindMobileToolbar wires the collapse toggle, the
|
|
758
|
+
// arrows (DECCKM-aware — SS3 under application-cursor mode, matching
|
|
759
|
+
// mapKeyboardEvent), Tab / Enter / Esc, and the sticky-Ctrl button on the
|
|
760
|
+
// built toolbar. It returns the controller whose applyStickyCtrl the input
|
|
761
|
+
// handler above applies to typed text. The no-transition priming below
|
|
762
|
+
// stays local (a UI-specific concern).
|
|
763
|
+
toolbarCtrl = keyboard.bindMobileToolbar({ toolbar, send });
|
|
764
|
+
requestAnimationFrame(() =>
|
|
765
|
+
requestAnimationFrame(() => {
|
|
766
|
+
toolbar.classList.remove("no-transition");
|
|
767
|
+
}),
|
|
768
|
+
);
|
|
791
769
|
|
|
792
770
|
// --- Copy feedback toast ---
|
|
793
771
|
document.addEventListener("copy", () => {
|
package/src/status.ts
CHANGED
|
@@ -21,6 +21,11 @@ let banner: HTMLElement | null = null;
|
|
|
21
21
|
let state: State = "connecting";
|
|
22
22
|
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
23
|
let consecutiveFailures = 0;
|
|
24
|
+
// True once the first screen frame has rendered (mount calls setLoaded()).
|
|
25
|
+
// Until then the initial loading overlay is up and we suppress the
|
|
26
|
+
// reconnect/offline banner so the two don't stack — this replaces the old
|
|
27
|
+
// `document.getElementById("loading")` probe.
|
|
28
|
+
let loaded = false;
|
|
24
29
|
|
|
25
30
|
/** Mark the connection as open. Hides the banner. */
|
|
26
31
|
export function open(): void {
|
|
@@ -28,9 +33,16 @@ export function open(): void {
|
|
|
28
33
|
setState("open", 0);
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** Mark the terminal as past its initial load (first screen frame rendered).
|
|
37
|
+
* Until this is called the connection banner stays suppressed so it never
|
|
38
|
+
* stacks on top of the consumer's loading overlay. */
|
|
39
|
+
export function setLoaded(): void {
|
|
40
|
+
loaded = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
/** Mark a reconnect attempt in progress (after a close, before re-open). */
|
|
32
44
|
export function reconnecting(): void {
|
|
33
|
-
if (
|
|
45
|
+
if (!loaded) {
|
|
34
46
|
return;
|
|
35
47
|
}
|
|
36
48
|
setState("reconnecting", CONNECTING_GRACE_MS);
|
|
@@ -39,7 +51,7 @@ export function reconnecting(): void {
|
|
|
39
51
|
/** Mark the connection as closed; shows banner after grace period. */
|
|
40
52
|
export function closed(): void {
|
|
41
53
|
consecutiveFailures++;
|
|
42
|
-
if (
|
|
54
|
+
if (!loaded) {
|
|
43
55
|
return;
|
|
44
56
|
}
|
|
45
57
|
setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
|
|
@@ -111,19 +123,19 @@ function dismissToast(): void {
|
|
|
111
123
|
}
|
|
112
124
|
}
|
|
113
125
|
|
|
114
|
-
export function init(): void {
|
|
115
|
-
banner =
|
|
126
|
+
export function init(opts: { banner: HTMLElement }): void {
|
|
127
|
+
banner = opts.banner;
|
|
116
128
|
applyState();
|
|
117
129
|
|
|
118
130
|
// Hover-pause: stop the auto-dismiss timer while the user hovers
|
|
119
131
|
// (gives them time to read longer messages or click actions).
|
|
120
|
-
banner
|
|
132
|
+
banner.addEventListener("mouseenter", () => {
|
|
121
133
|
if (toastDismissTimer !== null) {
|
|
122
134
|
clearTimeout(toastDismissTimer);
|
|
123
135
|
toastDismissTimer = null;
|
|
124
136
|
}
|
|
125
137
|
});
|
|
126
|
-
banner
|
|
138
|
+
banner.addEventListener("mouseleave", () => {
|
|
127
139
|
if (banner?.dataset["state"] === "toast" && banner.classList.contains("visible")) {
|
|
128
140
|
scheduleToastDismiss(toastDismissMs);
|
|
129
141
|
}
|