@cplieger/web-terminal-ui 8.3.0 → 8.3.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.
- package/README.md +65 -612
- package/css/01-scope.css +3 -9
- package/package.json +1 -1
- package/src/features/connection-banner.ts +2 -2
- package/src/features/tabs/index.ts +10 -10
- package/src/features/tabs/model.ts +2 -3
- package/src/kernel/loading-status.ts +4 -4
package/README.md
CHANGED
|
@@ -2,645 +2,102 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@cplieger/web-terminal-ui) [](https://jsr.io/@cplieger/web-terminal-ui) [](https://github.com/cplieger/web-terminal-ui/issues?q=label%3Astryker-tracker)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
terminal
|
|
14
|
-
|
|
15
|
-
- a
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- a **mobile key toolbar** (Tab / Esc / arrows / Enter / sticky-Ctrl) and a
|
|
22
|
-
scroll-to-bottom control
|
|
23
|
-
- a **context menu** (Copy / Select All / Paste), on a right-click and on a
|
|
24
|
-
touch long-press. Paste is the reason it exists: the keyboard target is a
|
|
25
|
-
hidden `<textarea>`, so no platform can offer a native paste over the output.
|
|
26
|
-
On touch the platform keeps its long-press: word selection and the OS copy
|
|
27
|
-
callout run untouched, and the menu appears on release only when the press
|
|
28
|
-
selected nothing
|
|
29
|
-
- **predictive local echo**, **IME / composition** (CJK, dictation,
|
|
30
|
-
autocorrect), and **viewport + keyboard-inset** handling for the iOS soft
|
|
31
|
-
keyboard, rotation, and font-load reflows
|
|
32
|
-
- a connection-status banner, including a persistent protocol-incompatibility
|
|
33
|
-
state, and a copy toast
|
|
34
|
-
|
|
35
|
-
It is published as TypeScript source (no build step) to npm and JSR, alongside
|
|
36
|
-
the CSS bundle and a reference HTML page. Consumers who want a different UI should
|
|
37
|
-
depend on the engine directly and skip this package.
|
|
5
|
+
web-terminal-ui builds a browser terminal on [web-terminal-engine](https://github.com/cplieger/web-terminal-engine) that works on a phone as well as a desktop, with tabs, a split view and an on-screen key bar.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
It replaces the tabs, key bar, paste menu, IME handling and soft-keyboard resizing you would otherwise build around the engine's renderer. It ships as TypeScript source on npm and JSR, with CSS bundles and a reference page. Its one dependency is the peer `@cplieger/web-terminal-engine` 6.x, and it is licensed under MPL-2.0.
|
|
10
|
+
|
|
11
|
+
## Why use it
|
|
12
|
+
|
|
13
|
+
web-terminal-ui is built for a shell or a coding agent served by web-terminal-engine, used from a phone as often as from a desktop.
|
|
14
|
+
|
|
15
|
+
- One `createTerminal()` call builds the UI inside an element you provide. Four presets cover a single, a touch and two tabbed layouts.
|
|
16
|
+
- On a touchscreen it adds a key bar with Tab, Esc, arrows, Enter and a sticky Ctrl, a long-press paste menu and iOS soft-keyboard handling.
|
|
17
|
+
- Tabs show activity dots from the program's `OSC 9;4` progress reports and raise browser notifications. Two tabs can sit side by side.
|
|
18
|
+
- Native text selection, IME input and dictation work, and predictive echo hides latency.
|
|
19
|
+
|
|
20
|
+
Consider [xterm.js](https://github.com/xtermjs/xterm.js) if you want a terminal component for any PTY backend, such as node-pty. It has an optional GPU-accelerated renderer and a set of addons, and VS Code uses it. To build a UI of your own on web-terminal-engine, depend on the engine alone.
|
|
38
21
|
|
|
39
22
|
## Install
|
|
40
23
|
|
|
41
24
|
```sh
|
|
42
25
|
npm install @cplieger/web-terminal-ui @cplieger/web-terminal-engine
|
|
26
|
+
npx jsr add @cplieger/web-terminal-ui @cplieger/web-terminal-engine
|
|
43
27
|
```
|
|
44
28
|
|
|
45
|
-
|
|
46
|
-
engine, so the consumer pins the engine version explicitly. Pairing compatibility
|
|
47
|
-
is governed by the engine's
|
|
48
|
-
[directional wire contract](https://github.com/cplieger/web-terminal-engine#wire-protocol),
|
|
49
|
-
not strict package-version equality; the UI surfaces a terminal incompatibility
|
|
50
|
-
through its connection banner.
|
|
29
|
+
The engine is a peer dependency, so install both.
|
|
51
30
|
|
|
52
31
|
## Usage
|
|
53
32
|
|
|
54
|
-
|
|
55
|
-
page that has one empty container element, then call
|
|
56
|
-
`createTerminal(target, { features })` from your entry module.
|
|
57
|
-
|
|
58
|
-
**Full-page host** (the terminal IS the page, as in `web-terminal-server` and
|
|
59
|
-
`web-terminal-kiro`): concatenate `css/MANIFEST` into the `style.css` your page
|
|
60
|
-
links. That reference bundle is `css/page.css` (the page kit: `html/body`
|
|
61
|
-
reset + the `@font-face` rules for two families, expecting the font files at
|
|
62
|
-
`/vendor/fonts/`) plus the complete component set. The set is the four
|
|
63
|
-
`MonaspaceNeonNF-*.woff2` faces, which back both the terminal display and the
|
|
64
|
-
chrome text, plus `WebTerminalGlyphs.woff2` — a tiling overlay that supplies the
|
|
65
|
-
box-drawing, block, braille and powerline cells the terminal draws — served
|
|
66
|
-
alongside that font's `LICENSE` and `NOTICE`. Serving the Monaspace faces is not
|
|
67
|
-
optional for a full-page host. A host that has not yet added the overlay file
|
|
68
|
-
degrades cleanly rather than breaking: the face errors out, and the terminal
|
|
69
|
-
renders exactly as it did before, with the tiling ranges coming from Monaspace
|
|
70
|
-
and the system font.
|
|
71
|
-
|
|
72
|
-
**Embedder** (the terminal lives inside your app's layout, as a panel or pane):
|
|
73
|
-
concatenate the per-preset manifest matching your composition instead:
|
|
74
|
-
`css/MANIFEST.single`, `css/MANIFEST.touch`, or `css/MANIFEST.tabbed`. These
|
|
75
|
-
contain ONLY root-scoped component styles: no page reset, no fonts, no
|
|
76
|
-
document-level rules, nothing to quarantine. Chrome text then falls back to your
|
|
77
|
-
platform's monospace unless you declare the `@font-face` yourself; ship it if you
|
|
78
|
-
want the terminal panel to match the full-page product. Pass `layout: "container"`
|
|
79
|
-
so the terminal fills (and positions its chrome against) your container element
|
|
80
|
-
instead of the viewport.
|
|
81
|
-
|
|
82
|
-
Feature bundles (presets) live at the `./presets` sub-path, with per-preset
|
|
83
|
-
entry modules beside it:
|
|
84
|
-
|
|
85
|
-
```html
|
|
86
|
-
<div id="terminal"></div>
|
|
87
|
-
<div id="loading">Loading…</div>
|
|
88
|
-
<script type="importmap">
|
|
89
|
-
{
|
|
90
|
-
"imports": {
|
|
91
|
-
"@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
|
|
92
|
-
"@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js",
|
|
93
|
-
"@cplieger/web-terminal-ui/presets": "/vendor/cplieger-web-terminal-ui/presets.js"
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
</script>
|
|
97
|
-
<script type="module">
|
|
98
|
-
import { createTerminal } from "@cplieger/web-terminal-ui";
|
|
99
|
-
import { presetTabbed } from "@cplieger/web-terminal-ui/presets";
|
|
100
|
-
createTerminal("#terminal", {
|
|
101
|
-
features: presetTabbed,
|
|
102
|
-
loading: document.getElementById("loading"),
|
|
103
|
-
});
|
|
104
|
-
// or, for a server that exposes the WebSocket elsewhere / a custom font:
|
|
105
|
-
// createTerminal("#terminal", { features: presetTabbed, wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
|
|
106
|
-
</script>
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
`createTerminal(target, opts?)` builds the entire terminal subtree (the kernel
|
|
110
|
-
plus every feature's chrome) inside the target element itself. `target` is a CSS
|
|
111
|
-
selector or an element: pass a **selector** from a page (`"#terminal"`) and pass
|
|
112
|
-
an **element** only when you already hold one you created yourself. The
|
|
113
|
-
difference matters; see "Startup failures" below. There is no element-id
|
|
114
|
-
contract for the host page to reproduce, and every style and CSS custom
|
|
115
|
-
property is scoped to the `wt-root` class it stamps on your element (removed
|
|
116
|
-
again by `destroy()`). Call it at most once per document while the previous
|
|
117
|
-
terminal is alive: the document title, the loading overlay, the status stream and
|
|
118
|
-
the notification permission are properties of the document, so one terminal owns
|
|
119
|
-
them, and a second call reports a `kernel-init` failure and throws without
|
|
120
|
-
touching the page (see "Startup failures"); `destroy()` the first terminal before
|
|
121
|
-
building another, or use the `split` option for a second pane.
|
|
122
|
-
`scaffold/index.html` is a complete reference page to copy and adapt.
|
|
123
|
-
|
|
124
|
-
Four presets are provided; each is a plain feature-array factory, so you can
|
|
125
|
-
spread and edit it. Import the barrel (`@cplieger/web-terminal-ui/presets`) for
|
|
126
|
-
convenience, or a per-preset entry module (`…/presets/single`, `…/presets/touch`,
|
|
127
|
-
`…/presets/tabbed`, `…/presets/agent-tabbed`) for the minimal delivered import
|
|
128
|
-
graph; the barrel statically reaches every feature, while the touch entry, for
|
|
129
|
-
example, never imports the tabs module. Individual features
|
|
130
|
-
are importable from `…/features/<name>` (`clipboard`, `context-menu`,
|
|
131
|
-
`scroll-to-bottom`, `predictive-echo`, `connection-banner`, `mobile-toolbar`,
|
|
132
|
-
`tabs`, `activity-monitor`, `animations`) for hand-picked compositions:
|
|
133
|
-
|
|
134
|
-
- `presetSingle()`: single-pane desktop UI (context menu, clipboard,
|
|
135
|
-
scroll-to-bottom, predictive echo, connection banner).
|
|
136
|
-
- `presetTouch()`: `presetSingle()` plus the mobile key toolbar.
|
|
137
|
-
- `presetTabbed()`: the generic tabbed UI, `presetTouch()` plus tabs, the
|
|
138
|
-
activity monitor, and animations. Each tab's title is OSC-first: it follows
|
|
139
|
-
the process window title (OSC 0/2) when the program sets one and keeps it
|
|
140
|
-
updated, otherwise a name the server infers from the foreground process or the
|
|
141
|
-
working directory. The per-tab activity dot
|
|
142
|
-
reveals itself only when a session reports OSC 9;4 progress, so a plain shell
|
|
143
|
-
keeps clean, label-only tabs. Requires a server that speaks the session API
|
|
144
|
-
(`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
|
|
145
|
-
such as `web-terminal-server`.
|
|
146
|
-
- `presetAgentTabbed()`: the same feature set as `presetTabbed()`, tuned for an
|
|
147
|
-
agent shell such as `web-terminal-kiro`, and differing from `presetTabbed()`
|
|
148
|
-
only in presumed activity reporting. With `presumeReports`, the idle activity
|
|
149
|
-
dot shows from tab creation instead of waiting for the session's first
|
|
150
|
-
OSC 9;4 signal. Its status dots come from the same activity
|
|
151
|
-
monitor and cover eight states: the three animated OSC 9;4 progress states
|
|
152
|
-
(working, warning, error), the frozen ringed needs-input dot, two static discs
|
|
153
|
-
(a green finished turn, a red crashed process), and two hollow ones (idle and a
|
|
154
|
-
dim ended session). A
|
|
155
|
-
reported percentage additionally renders a 2px determinate bar on the chip,
|
|
156
|
-
shown only while one of those three progress states is the session's current
|
|
157
|
-
status. The number itself is announced in the tab's accessible name and drawn
|
|
158
|
-
nowhere: no terminal emulator puts a percentage next to a tab label, and a chip
|
|
159
|
-
that shrinks toward a 100px floor has no width to spare. It is likewise never
|
|
160
|
-
written to the browser document title, since one page title cannot represent
|
|
161
|
-
several sessions.
|
|
162
|
-
|
|
163
|
-
Beside that dot, every chip carries a second mark for a host-reported
|
|
164
|
-
background activity that outlives a turn (a workflow run, in the
|
|
165
|
-
`web-terminal-kiro` case). It is a rounded-square ring rather than a disc, so
|
|
166
|
-
it does not read as another status dot, and band width carries its three
|
|
167
|
-
states: a 2px band whose centre closes and reopens while a background task is running, a
|
|
168
|
-
1px band with a halo while one is paused and resumable, and a 2px band with
|
|
169
|
-
that same halo while one is blocked on the user. The state and the count are
|
|
170
|
-
announced in the tab's accessible name and repeated as the mark's hover
|
|
171
|
-
tooltip. The mark is independent of the dot's reveal gate, so a tab can show
|
|
172
|
-
one and not the other, and it collapses to no width when there is nothing to
|
|
173
|
-
report: a chip with no background activity measures exactly as it did before
|
|
174
|
-
the mark existed. It draws from `--status-working` and `--status-input`, so
|
|
175
|
-
theming the activity-dot palette themes it too and no extra token is needed.
|
|
176
|
-
|
|
177
|
-
An OSC 9 notification is posted as a browser notification when the user is not
|
|
178
|
-
already looking at that terminal (permission is requested on a user gesture; a
|
|
179
|
-
denial degrades silently to the tab dots).
|
|
180
|
-
Clicking one switches to the session that raised it: the Notifications API's
|
|
181
|
-
own click default already focuses the page, and the handler supplies the
|
|
182
|
-
in-page half. These are non-persistent notifications, so per the API the
|
|
183
|
-
constructor throws on most mobile browsers, where the notification degrades to
|
|
184
|
-
the tab dots.
|
|
185
|
-
|
|
186
|
-
### Options
|
|
187
|
-
|
|
188
|
-
| Option | Default | Purpose |
|
|
189
|
-
| ------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
190
|
-
| `features` | _(none; bare kernel)_ | A FUNCTION returning the feature list. Omitted builds only the terminal (no chrome). Pass a preset by name (`features: presetTabbed`) or a factory of your own; it is called inside the startup-failure boundary, so a preset that throws is handled rather than escaping. |
|
|
191
|
-
| `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
|
|
192
|
-
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
|
|
193
|
-
| `fontReady` | `'14px "Monaspace Neon NF"'` | 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. Only the family that CARRIES those metrics is named: the tiling overlay copies them and holds no letter for the width probe to measure, and naming it as well would open the gate on the overlay alone in WebKit (Safari, every iOS browser), while Monaspace was still loading. The wait is bounded at 3s whatever the fonts do, the `font-display: block` period the bundled faces declare, so a request that stalls leaves the terminal sized on fallback metrics rather than never sized at all, and a font that lands after the bound costs one corrective resize. |
|
|
194
|
-
| `scrollbackLines` | _(engine default)_ | Retained scrollback lines per terminal, and per tab under the tabs feature (every per-tab cache is built through the same budget). This is the page's dominant memory dial: it bounds the styled-run arrays each session retains AND the DOM rows the renderer keeps (one row element per retained line), so a memory-constrained host (iOS Safari, where the content process is reclaimed under system pressure) passes a smaller budget at the price of a shorter scroll-back. A history budget floored at the live screen: the engine never evicts the current window, so a value at or below the terminal height keeps the full screen with no scrollback, but choose a value comfortably above the largest expected terminal height (a few multiples), since near or below it the batched eviction degrades to per-line churn. Non-integer or non-positive values are ignored. Left unset, the engine decides, and it decides twice: 5000 against a server that cannot serve history back, dropping to a 1500-line resident tail plus an on-demand cache once a server declares demand-paged scrollback. Prefer leaving it unset: the depth then lives on the server and the phone holds a working set. An explicit value opts OUT of that flip: it is an explicit memory decision and holds in both states. |
|
|
195
|
-
| `persistScrollback` | _(off)_ | Persist each session's scrollback across a page discard, through storage YOU supply. A page that is discarded and reloaded otherwise resumes holding nothing, so it asks the server for everything and refills its whole buffer over the wire (the normal case on iOS, where Safari evicts backgrounded tabs under memory pressure and returning to one re-runs the page). With a snapshot restored, the resume asks only for what was printed while the tab was gone. It helps the FRESH-LOAD case only: a warm reconnect and an in-page tab switch already replay nothing. Off by default because `localStorage` is a shared, origin-wide, quota-limited resource and this library is embedded in applications that keep their own state there: an application decides durability for its own users, a library does not decide it for an embedder. Applies to a single terminal and to every tab alike. See below. |
|
|
196
|
-
| `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
|
|
197
|
-
| `onFatalError` | _(built-in recovery)_ | Called with a `TerminalStartupFailure` after a fatal startup failure, in either phase (`feature-setup` or `kernel-init`); behavior below. |
|
|
198
|
-
| `onSessionEnded` | _(none)_ | Called when the active session's process has ended and nothing is retrying: the fact the banner renders as "Session ended". Wire it only if you can act on it: the recovery move is `handle.reattach()`, and whether that helps depends on what your endpoint does on the next connect (see "When a session ends"). Observation only; everything the kernel does about the end happens first and happens regardless, and a handler that throws is logged rather than allowed to take the banner down with it. |
|
|
199
|
-
| `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input`, `--status-warning`, `--status-failed`. The library ships neutral defaults. If you retheme the animated trio (`--status-working` / `--status-warning` / `--status-failed`), keep their LIGHTNESS spread: they differ only in hue, so equal-lightness replacements collapse into one another in greyscale and under deuteranopia. |
|
|
200
|
-
| `split` | _(off)_ | Two panes side by side, opened and closed from the tab chrome (see "Split view"). Requires the tabs feature (`tabs()` or a tabbed preset) in `features`; a terminal without it, or a value other than `true` or `false`, fails at `kernel-init`. Without the option the DOM is exactly what it was before the option existed. |
|
|
201
|
-
|
|
202
|
-
`createTerminal()` returns a handle: `focus()` re-focuses the terminal input
|
|
203
|
-
(and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
|
|
204
|
-
session through the kernel's sanitizing input funnel (the supported host path
|
|
205
|
-
for a "type this command" affordance); `reset()` drops the local scrollback and
|
|
206
|
-
screen without injecting keystrokes (send a redraw keystroke yourself if you
|
|
207
|
-
want one, for example Ctrl+L); `reattach()` attaches again to whatever the server
|
|
208
|
-
serves now, for a session that has ended (see "When a session ends"); and
|
|
209
|
-
`destroy()` tears every feature down and releases the kernel. With `split: true`
|
|
210
|
-
the handle also carries `split`, the controller of the "Split view" section;
|
|
211
|
-
`focus()`, `send()`, `reset()` and `reattach()` act on the selected pane.
|
|
212
|
-
|
|
213
|
-
### Mouse and focus
|
|
214
|
-
|
|
215
|
-
A terminal application that turns mouse tracking on receives the clicks, drags
|
|
216
|
-
and wheel gestures a user makes in the browser, as SGR reports. The pointer says
|
|
217
|
-
so while that is happening: at rest the grid carries a text cursor, and while an
|
|
218
|
-
application holds the mouse the grid and any links in it carry an arrow instead,
|
|
219
|
-
because a click is reported to that application rather than selecting text or
|
|
220
|
-
opening the link. Shift-drag still selects and shift-click still opens a link,
|
|
221
|
-
which is the convention xterm set, so nothing becomes unreachable.
|
|
222
|
-
|
|
223
|
-
Mouse input is best-effort, as it is in every terminal: a report says where the
|
|
224
|
-
pointer is at that moment and carries nothing a receiver could use to notice it
|
|
225
|
-
had gone stale, so a report made while the socket is down is dropped rather than
|
|
226
|
-
delivered late against a screen that has since been repainted. Typed input keeps
|
|
227
|
-
its delivery guarantee and is unaffected.
|
|
228
|
-
|
|
229
|
-
Focus reporting is the server's answer rather than this UI's. The kernel tells
|
|
230
|
-
the server whether its own terminal widget holds focus and the server decides
|
|
231
|
-
what the application is told, so several devices attached to one session cannot
|
|
232
|
-
contradict each other. A blur that a press causes and that the same gesture's
|
|
233
|
-
click undoes is not reported, because the terminal did not lose focus.
|
|
234
|
-
|
|
235
|
-
`persistScrollback` takes the storage, not a switch, because storage is a
|
|
236
|
-
decision this library should not make for you. Chrome's page-lifecycle guidance
|
|
237
|
-
is to close IndexedDB connections on freeze, since a held connection costs
|
|
238
|
-
bfcache eligibility, and this kernel depends on bfcache, so it must not own a
|
|
239
|
-
database. `localStorage` is also a shared, origin-wide, ~5 MB resource, and this
|
|
240
|
-
package is embedded in applications that keep their own state there: consuming it
|
|
241
|
-
uninvited would risk the host's writes failing, and how that degrades is theirs to
|
|
242
|
-
design. Ours degrades gracefully: nothing restored, terminal unaffected.
|
|
243
|
-
|
|
244
|
-
So the option is off until you pass storage, and `localScrollbackStorage()` is the
|
|
245
|
-
ready-made answer for consumers who want the ordinary one (one import, and it
|
|
246
|
-
handles the quota sweep, the age bound and the `localStorage`-unavailable case):
|
|
33
|
+
The terminal connects to a server that speaks the engine's [wire protocol](https://github.com/cplieger/web-terminal-engine#wire-protocol), such as one built on the engine's Go `terminal` package or [web-terminal-server](https://github.com/cplieger/web-terminal-server). A plain PTY-over-WebSocket server does not speak it.
|
|
247
34
|
|
|
248
35
|
```ts
|
|
249
|
-
import { createTerminal
|
|
36
|
+
import { createTerminal } from "@cplieger/web-terminal-ui";
|
|
37
|
+
import { presetTabbed } from "@cplieger/web-terminal-ui/presets";
|
|
250
38
|
|
|
251
|
-
createTerminal("#terminal", {
|
|
252
|
-
features: presetTabbed,
|
|
253
|
-
persistScrollback: localScrollbackStorage(),
|
|
254
|
-
});
|
|
39
|
+
createTerminal("#terminal", { features: presetTabbed });
|
|
255
40
|
```
|
|
256
41
|
|
|
257
|
-
|
|
258
|
-
panel) enable it that way; web-terminal-server additionally exposes an operator
|
|
259
|
-
opt-out, because it runs a command its operator chooses.
|
|
42
|
+
Pass the mount target as a selector and the preset as a function, as above. A missing element or a throwing preset then reaches the library's startup-failure handling instead of escaping at your call site. Call `createTerminal` once per document, or `destroy()` the first terminal before you build another.
|
|
260
43
|
|
|
261
|
-
|
|
262
|
-
IndexedDB, a server round-trip, an in-memory cache for a test. The rest of this
|
|
263
|
-
section is that case:
|
|
44
|
+
No prebuilt JavaScript ships, so your build compiles the TypeScript source. Serve the CSS too. For a full-page terminal, join the files listed in `css/MANIFEST` into one stylesheet and serve the Monaspace Neon NF font files at `/vendor/fonts/`. For a terminal inside your own layout, use `css/MANIFEST.single`, `css/MANIFEST.touch` or `css/MANIFEST.tabbed` instead and pass `layout: "container"`. `scaffold/index.html` is a complete page with an import map.
|
|
264
45
|
|
|
265
|
-
|
|
266
|
-
import { createTerminal } from "@cplieger/web-terminal-ui";
|
|
267
|
-
import type { PersistedScrollback } from "@cplieger/web-terminal-ui";
|
|
268
|
-
|
|
269
|
-
// Read your snapshots into memory BEFORE mounting; see the note below.
|
|
270
|
-
const cache = new Map<string, PersistedScrollback>(await loadAllFromIndexedDB());
|
|
271
|
-
|
|
272
|
-
createTerminal("#terminal", {
|
|
273
|
-
features: presetTabbed,
|
|
274
|
-
persistScrollback: {
|
|
275
|
-
load: (sessionId) => cache.get(sessionId) ?? null,
|
|
276
|
-
save: (sessionId, entry) => {
|
|
277
|
-
cache.set(sessionId, entry);
|
|
278
|
-
void writeToIndexedDB(sessionId, entry); // fire and forget
|
|
279
|
-
},
|
|
280
|
-
drop: (sessionId) => {
|
|
281
|
-
cache.delete(sessionId);
|
|
282
|
-
void deleteFromIndexedDB(sessionId);
|
|
283
|
-
},
|
|
284
|
-
},
|
|
285
|
-
});
|
|
286
|
-
```
|
|
46
|
+
The four presets:
|
|
287
47
|
|
|
288
|
-
`
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
read into memory first, which is also the shape that holds no database
|
|
293
|
-
connection open.
|
|
294
|
-
|
|
295
|
-
`sessionId` is a real session id, never a key of your invention: the id the tabs
|
|
296
|
-
feature uses, or (for a single terminal) the engine's own per-tab id. That is
|
|
297
|
-
load-bearing rather than tidy, because the same id is what the persisted server
|
|
298
|
-
boot epoch is checked against. Prefix it however you like inside your storage.
|
|
299
|
-
|
|
300
|
-
Knobs, all optional. On the option itself: `lines` (default 200) bounds how much of
|
|
301
|
-
each session's tail is written, `maxAgeMs` (default 7 days) bounds how long an entry
|
|
302
|
-
may be used, and `saveIntervalMs` (default 10s) sets the background save cadence.
|
|
303
|
-
`localScrollbackStorage` adds `prefix` and `maxBytes` (default 512 KiB of
|
|
304
|
-
characters, roughly 1 MiB of quota since `localStorage` is accounted in UTF-16;
|
|
305
|
-
about a fifth of the ~5 MiB floor, leaving the rest to the host application).
|
|
306
|
-
|
|
307
|
-
The 200-line default is a measurement rather than a guess: VS Code's own
|
|
308
|
-
`terminal.integrated.persistentSessionScrollback` restores 100 lines by default, and
|
|
309
|
-
at 200 a coloured session serialises to ~60 K characters in under a millisecond,
|
|
310
|
-
against ~300 K and ~4 ms at 1000. A write happens on every backgrounding and on a
|
|
311
|
-
timer while output advances, so that cost is paid repeatedly on the device this
|
|
312
|
-
exists for.
|
|
313
|
-
|
|
314
|
-
The library writes on `visibilitychange` to hidden, on `pagehide`, on `destroy()`,
|
|
315
|
-
and on that timer; in every case only for a session whose content has advanced
|
|
316
|
-
since its last recorded save. That predicate is not an optimisation: two pages of
|
|
317
|
-
the same app hold the same session ids, and an unconditional write let a background
|
|
318
|
-
page roll a foreground page's newer entry back. `pagehide` is documented as not
|
|
319
|
-
guaranteed, which is why the timer exists.
|
|
320
|
-
|
|
321
|
-
Every callback may throw or return nonsense without consequence: a failure
|
|
322
|
-
degrades to "nothing was restored", exactly as if the option were absent. An
|
|
323
|
-
entry is also discarded, and dropped, when it is too old, unreadable, or from a
|
|
324
|
-
different server process: absolute line indices only mean anything within one
|
|
325
|
-
server boot, so an entry that cannot be checked against the live server is
|
|
326
|
-
thrown away rather than shown. A slow restore is a much better failure than a
|
|
327
|
-
terminal that is confidently wrong.
|
|
328
|
-
|
|
329
|
-
No permission prompt is involved: `localStorage` is not in the Permissions API, so
|
|
330
|
-
there is nothing for a user to grant or refuse. (`navigator.storage.persist()` is
|
|
331
|
-
the call that can prompt, and this library never makes it; these snapshots are
|
|
332
|
-
disposable by design, so asking a user to exempt them from eviction would be asking
|
|
333
|
-
for the wrong thing.) Storage can still be _unavailable_ with no prompt either way,
|
|
334
|
-
and the failures differ: a browser set to block site data makes
|
|
335
|
-
`window.localStorage` **throw on access** (Chrome, Firefox), while Safari
|
|
336
|
-
historically allowed access in private browsing and threw on the **write**; Firefox
|
|
337
|
-
can clear it on close; Safari's ITP evicts script-writable storage after seven days
|
|
338
|
-
without interaction; and a cross-origin iframe embed gets **partitioned, ephemeral**
|
|
339
|
-
storage rather than the top-level origin's (all three engines partition; Safari has
|
|
340
|
-
the strictest lifetime). `localScrollbackStorage` guards access as well as writes, so
|
|
341
|
-
the read side always degrades to "nothing restored" and the terminal is unaffected; a
|
|
342
|
-
refused write is reported instead, so the library never records a save that did not
|
|
343
|
-
happen.
|
|
344
|
-
|
|
345
|
-
Cleanup, since a store that only ever writes fills up. Four mechanisms, and the
|
|
346
|
-
first is the one that matters day to day: the kernel calls `drop` when a session
|
|
347
|
-
closes, so a tab the user closes (or one the server reaps while the page is open)
|
|
348
|
-
takes its entry with it. On top of that, an entry past `maxAgeMs` is deleted when it
|
|
349
|
-
is next read; `localScrollbackStorage` sweeps both at construction and after every
|
|
350
|
-
write (expired entries first, then the oldest until the byte budget fits); and a
|
|
351
|
-
rejected entry is always dropped rather than left to be re-read. What no mechanism
|
|
352
|
-
can catch promptly is a session that disappeared while the page was CLOSED. Nothing
|
|
353
|
-
runs then, and there is no reliable "this tab is closing for good" event to hook, so
|
|
354
|
-
that case is the sweep's, which is why a budget exists rather than the age bound
|
|
355
|
-
alone.
|
|
356
|
-
|
|
357
|
-
The budget is bytes rather than an entry count deliberately, because a count bounds
|
|
358
|
-
the wrong thing. Measured on the real stored value at the 200-line default, a plain
|
|
359
|
-
80-column session is ~24 K characters and a wide coloured one ~112 K, so a 20-entry
|
|
360
|
-
allowance would have permitted over 4 MiB of a ~5 MiB quota; the cap would have
|
|
361
|
-
guaranteed the refusal it was meant to prevent. A single snapshot larger than the
|
|
362
|
-
whole budget is refused outright rather than evicting everything for something that
|
|
363
|
-
still would not fit.
|
|
364
|
-
|
|
365
|
-
### Themes that provably apply
|
|
366
|
-
|
|
367
|
-
`theme` is an open `Record<string, string>` (the kernel sets every key on the
|
|
368
|
-
terminal root verbatim), so a key this library renamed or retired becomes a live
|
|
369
|
-
declaration nothing reads: no error, just the library's neutral defaults where
|
|
370
|
-
your brand should be. The supported keys are therefore published as data:
|
|
48
|
+
- `presetSingle()` is a desktop terminal with the context menu, clipboard, scroll-to-bottom, predictive echo and connection banner.
|
|
49
|
+
- `presetTouch()` adds the key bar.
|
|
50
|
+
- `presetTabbed()` adds tabs, activity dots and animations. It needs the engine's session API, which web-terminal-server serves.
|
|
51
|
+
- `presetAgentTabbed()` is the same, with each tab's dot shown from the start, for an agent shell.
|
|
371
52
|
|
|
372
|
-
|
|
373
|
-
import { PUBLIC_THEME_TOKENS } from "@cplieger/web-terminal-ui/style-contract";
|
|
374
|
-
// or from the package root; the subpath imports nothing and touches no DOM,
|
|
375
|
-
// so a Node script can read it too.
|
|
53
|
+
[Setting up the host page](docs/host-page.md) covers the CSS, the fonts and the presets in full.
|
|
376
54
|
|
|
377
|
-
|
|
378
|
-
expect(PUBLIC_THEME_TOKENS).toContain(key); // your test, our list
|
|
379
|
-
}
|
|
380
|
-
```
|
|
55
|
+
## API
|
|
381
56
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
`LOADING_OVERLAY_CLASSES` is published from the same module for the same reason:
|
|
389
|
-
your pre-JS overlay markup opts into `css/page.css`'s styling by class name, and
|
|
390
|
-
that markup usually lives in a static HTML file no compiler reads.
|
|
391
|
-
|
|
392
|
-
### Startup failures
|
|
393
|
-
|
|
394
|
-
You do not need your own startup-failure UI. Every way starting up can fail ends
|
|
395
|
-
at one recovery surface this package owns (a "Terminal failed to start" panel
|
|
396
|
-
with a Reload button), and your loading overlay is lowered so that panel is
|
|
397
|
-
visible.
|
|
398
|
-
|
|
399
|
-
Two phases can fail. If a feature's setup throws or rejects
|
|
400
|
-
(`phase: "feature-setup"`), the kernel stops the connection, tears down every
|
|
401
|
-
completed feature and core listener, clears the broken subtree, and shows the
|
|
402
|
-
panel. If `createTerminal` itself throws (`phase: "kernel-init"`: an
|
|
403
|
-
unresolvable mount selector, a preset that throws, an invalid feature list, a DOM
|
|
404
|
-
invariant), it shows the same panel and then rethrows, so a caller with its own
|
|
405
|
-
error handling still sees the error.
|
|
406
|
-
|
|
407
|
-
The panel is a native `<dialog>`. In `viewport` layout it opens with
|
|
408
|
-
`showModal()`, so the rest of your document is inert while it is up; in
|
|
409
|
-
`container` layout it opens non-modally, so the application around an embedded
|
|
410
|
-
terminal keeps working and keeps its focusables. Escape does not dismiss it in
|
|
411
|
-
either mode: the terminal is already gone, so there would be nothing behind it
|
|
412
|
-
and no way back. Reload is the recovery.
|
|
413
|
-
|
|
414
|
-
One visible consequence in `viewport` layout: a modal dialog is in the browser's
|
|
415
|
-
top layer, above every `z-index` in the document, so the panel now paints over
|
|
416
|
-
your loading overlay instead of under it. It appears at once and the overlay's
|
|
417
|
-
fade-out finishes behind it.
|
|
418
|
-
|
|
419
|
-
**This is why `target` takes a selector and `features` takes a function.** Both
|
|
420
|
-
are resolved INSIDE that boundary. Written the other way round
|
|
421
|
-
(`createTerminal(document.getElementById("terminal"), { features: presetTabbed() })`),
|
|
422
|
-
the lookup and the preset call both happen at your call site, before
|
|
423
|
-
`createTerminal` is entered, so a missing element or a throwing preset escapes
|
|
424
|
-
the library entirely and leaves the page spinning under your overlay with nothing
|
|
425
|
-
but a console error. Those were the two failures every consumer used to
|
|
426
|
-
hand-build its own dialog for.
|
|
427
|
-
|
|
428
|
-
Two cases have no panel by design, both `kernel-init` with `surface: undefined`
|
|
429
|
-
to say there is nowhere to render. An embedded terminal (`layout: "container"`)
|
|
430
|
-
whose mount target does not exist: it is one panel inside a host application that
|
|
431
|
-
is otherwise working, so claiming the viewport to report its own failure would
|
|
432
|
-
break a healthy page. And a second `createTerminal` while the document already
|
|
433
|
-
holds a live terminal, in either layout: the root it names may be the first
|
|
434
|
-
terminal's, so nothing is written to the page and no overlay is faded. Both are
|
|
435
|
-
still delivered to `onFatalError` and still rethrown.
|
|
436
|
-
|
|
437
|
-
Under `split: true` a failure in the SECOND pane (its kernel or one of its pane
|
|
438
|
-
features, at the first open) is not the terminal's failure: the first pane keeps
|
|
439
|
-
running, the panel renders into the failed pane's own root, non-modally, with its
|
|
440
|
-
one Reload button, and `onFatalError` receives it with that pane root as
|
|
441
|
-
`surface`. The shared split button in the tab row closes the split and discards
|
|
442
|
-
the failed pane, and the next open builds a fresh one. A pane feature that
|
|
443
|
-
rejects only after the split has already closed finds no place for a panel: the
|
|
444
|
-
healthy pane fills the view again, takes over the tab the failed pane was
|
|
445
|
-
showing, and the failed pane is discarded at once, so `onFatalError` receives
|
|
446
|
-
that failure with `surface: undefined` and nothing is rendered.
|
|
447
|
-
|
|
448
|
-
`onFatalError` receives the failure after cleanup. Discriminate on `phase`:
|
|
449
|
-
`feature-setup` names the offending `feature`, `kernel-init` does not, because
|
|
450
|
-
feature composition never began. `surface` names the element the built-in panel
|
|
451
|
-
would fill, and is the element to render into if you claim it; it is `undefined`
|
|
452
|
-
in the cases above where nothing is rendered, so check it first. Return `true`
|
|
453
|
-
only when you have rendered replacement recovery UI there.
|
|
454
|
-
|
|
455
|
-
If your page also carries an inline bootstrap watchdog (a script that reports
|
|
456
|
-
"the JS bundle never loaded at all", a rung below `import`), it cannot import
|
|
457
|
-
anything by definition. Take its wording from `STARTUP_FAILURE_COPY` (exported at
|
|
458
|
-
the package root and at `@cplieger/web-terminal-ui/startup-copy`, which imports
|
|
459
|
-
nothing and touches no DOM so a build script can read it) and substitute the
|
|
460
|
-
strings into your HTML at build time, rather than restating them by hand.
|
|
461
|
-
|
|
462
|
-
### Split view
|
|
463
|
-
|
|
464
|
-
`split: true` lets the person show two tabs side by side. It requires the tabs
|
|
465
|
-
feature, because everything that drives it lives there: the shared tab row, the
|
|
466
|
-
split button, the snap items and the layout record. Your root becomes the shell
|
|
467
|
-
root holding one root per pane and the shared chrome; each pane is a complete
|
|
468
|
-
terminal with its own engine, so an open split holds up to two WebSocket
|
|
469
|
-
connections, one per shown pane. Closing and resizing the split is a display
|
|
470
|
-
re-arrangement only: no tab or session is ever created or closed by it, and a tab
|
|
471
|
-
that leaves a pane stays in the row as an ordinary tab. Opening it is one too,
|
|
472
|
-
except with a single tab: the split button, a snap or a drop that opens it then
|
|
473
|
-
creates the second tab.
|
|
474
|
-
|
|
475
|
-
Three ways in, all in the tab chrome, none a key chord:
|
|
476
|
-
|
|
477
|
-
- **The split button** at the right edge of the tab row (and in the mobile
|
|
478
|
-
switcher bar on a touchscreen) toggles the split, and never opens an empty
|
|
479
|
-
pane. The current tab stays in the left pane and selected, and the right pane
|
|
480
|
-
shows the tab used before it, or with no such tab the next one in the row, else
|
|
481
|
-
the previous one. With a single tab it creates a new tab and shows it on the
|
|
482
|
-
right, selected; the split opens once the new tab exists. Closing keeps the
|
|
483
|
-
SELECTED pane's tab and hides the other pane. Opening slides the divider in
|
|
484
|
-
from the right edge and closing slides it toward the hidden side, both instant
|
|
485
|
-
under reduced motion. It is an ordinary button: Tab
|
|
486
|
-
reaches it, Enter or Space toggles it, `aria-expanded` is its state.
|
|
487
|
-
- **"Snap to left" and "Snap to right"** in a tab's context menu, on a
|
|
488
|
-
right-click, a touch long-press, or the keyboard (the context-menu key or
|
|
489
|
-
Shift+F10 on the focused tab, where the browser raises the menu event; Safari
|
|
490
|
-
binds no key to it, so a keyboard-only person there reaches the menu through
|
|
491
|
-
an assistive technology's own command). A snapped tab shows on the named side
|
|
492
|
-
and its pane is selected. When the snap opens the split, the other pane keeps
|
|
493
|
-
the tab the single view showed (or, when that is the tab being moved, its
|
|
494
|
-
split partner), so neither pane opens empty. With a single tab, a snap that
|
|
495
|
-
opens the split creates a new tab for the other pane, and the split opens
|
|
496
|
-
once that tab exists; a failed create leaves the single view as it was. A tab
|
|
497
|
-
the other pane shows trades places with the tab on the named side, so both
|
|
498
|
-
stay on screen (into an empty pane it simply moves); any other tab there
|
|
499
|
-
becomes an ordinary tab. In an open split, snapping a tab onto the side that
|
|
500
|
-
already shows it changes nothing.
|
|
501
|
-
- **Dragging a tab** out of the row onto either pane of the terminal area,
|
|
502
|
-
following the same rule. By touch or pen, hold the tab still for a moment
|
|
503
|
-
before moving it; a swipe that moves straight away scrolls the row. While
|
|
504
|
-
the drag lasts the pane under the pointer is
|
|
505
|
-
highlighted; each highlight covers that pane's terminal area at the divider's
|
|
506
|
-
position (the two halves while the split is closed), inset a little with the
|
|
507
|
-
tab chips' rounded border, and a release decides by the divider's centre.
|
|
508
|
-
|
|
509
|
-
A tab click follows one rule: an empty pane fills first, otherwise the selected
|
|
510
|
-
pane's tab is replaced. A new tab ("+") also fills an empty pane first, but with
|
|
511
|
-
both panes showing a tab it replaces the UNSELECTED pane's tab and its pane
|
|
512
|
-
becomes the selected one, so the tab typed in before stays on screen. A session
|
|
513
|
-
created in another browser lands the same way while the split is open; with the
|
|
514
|
-
split closed it joins the row as an ordinary tab. Closing a
|
|
515
|
-
shown tab closes the split: the other pane's tab fills the view and is selected,
|
|
516
|
-
or, when the other pane was empty, the neighbouring tab is shown as it is with
|
|
517
|
-
one pane. The selected pane is the one that receives typing, the
|
|
518
|
-
last one clicked, touched or typed in. The divider between the panes shows it:
|
|
519
|
-
an 8 px bar in the 10 px gutter holding a short accent pill that sits against
|
|
520
|
-
the selected pane's side and slides across when the selection changes (it jumps
|
|
521
|
-
under reduced motion). The selected pane keeps the filled blinking cursor while
|
|
522
|
-
the other pane's is hollow and steady, and in the tab row both shown tabs render
|
|
523
|
-
active, the selected pane's a step brighter. A screen reader hears "Left
|
|
524
|
-
terminal selected" or "Right terminal selected" as a polite announcement, and
|
|
525
|
-
each pane's tabpanel is described by its side and whether it is selected.
|
|
526
|
-
|
|
527
|
-
While the split is open each shown pane's input is a stop in the Tab order, so
|
|
528
|
-
Tab runs left pane, divider, right pane, then the tab row; an empty pane adds no
|
|
529
|
-
stop. With the split closed the one pane is entered by typing or a click and Tab
|
|
530
|
-
from outside the terminal lands on the tab row.
|
|
531
|
-
|
|
532
|
-
The handle is a focusable `role="separator"` with `aria-valuenow`, `aria-valuemin`
|
|
533
|
-
and `aria-valuemax` as percentages of the left share. Dragging it (mouse or
|
|
534
|
-
touch; the hit area is 24 px wide for a fine pointer and 44 px for a coarse one,
|
|
535
|
-
overlapping the pane edges) resizes both panes as the pointer moves; the shells
|
|
536
|
-
are told their new size at most every 100 ms and once on release. Past the
|
|
537
|
-
360 px minimum the pane holds at exactly 360 px for 120 px of further pointer
|
|
538
|
-
travel, and a release there leaves it at 360 px. Beyond that the pane follows
|
|
539
|
-
the pointer again and dims, and releasing there closes it; coming back above
|
|
540
|
-
360 px after such a trip and releasing snaps the handle back to where the drag
|
|
541
|
-
started. From the
|
|
542
|
-
keyboard ArrowLeft and ArrowRight nudge the divider by 16 px and Home and End take
|
|
543
|
-
it to the bounds, and every key stops AT the 360 px minimum rather than closing a
|
|
544
|
-
pane; the record is written when the key is released. The default is 50/50.
|
|
545
|
-
|
|
546
|
-
Under 730 px of pane-row width (two minimum panes and the gutter) the split
|
|
547
|
-
buttons hide, the snap items are disabled, a drop does nothing, and an OPEN split
|
|
548
|
-
collapses to the selected pane, restoring itself when the row is wide again. At
|
|
549
|
-
730 px or wider a window resize keeps both panes at or over 360 px by clamping the
|
|
550
|
-
displayed share; the share the person chose is remembered and comes back when
|
|
551
|
-
the row is wide enough for it.
|
|
552
|
-
|
|
553
|
-
The arrangement is remembered on the server, beside the tab order, as
|
|
554
|
-
`GET`/`PUT /api/sessions/layout`: the session in each pane, the handle share, the
|
|
555
|
-
selected pane and whether the split is open. It is read once at load, written on
|
|
556
|
-
every change, and dies with the session list, so a reload or another device
|
|
557
|
-
restores the same panes; the active tab is the selected pane's session and is no
|
|
558
|
-
longer kept in `localStorage`. Against a server without the route the split still
|
|
559
|
-
works and nothing is remembered; one console warning says so.
|
|
560
|
-
|
|
561
|
-
Two rules for a `features` function under `split: true`, both checked at
|
|
562
|
-
`kernel-init`. It is invoked once per pane and must return FRESH feature objects
|
|
563
|
-
every time, because a feature object's `api` and its `ctx.use` identity belong to
|
|
564
|
-
one pane; a saved array (`const f = presetTabbed(); features: () => f`) is
|
|
565
|
-
refused, naming the reused feature. A feature that must exist once per terminal
|
|
566
|
-
rather than once per pane (the built-in tabs, mobile toolbar, activity monitor
|
|
567
|
-
and animations) declares `scope: "shell"`: it is set up once, from the first
|
|
568
|
-
invocation, with a context whose `surface`, `render`, `scroll`, `modes`,
|
|
569
|
-
`session`, `send` and `paste` resolve to the selected pane at call time and whose
|
|
570
|
-
`ctx.shell.pane(side)` reaches one pane explicitly.
|
|
571
|
-
|
|
572
|
-
### When a session ends
|
|
573
|
-
|
|
574
|
-
A session whose process exits is over: the engine closes with its definitive
|
|
575
|
-
process-exited code, the banner reads "Session ended", and no reconnect is
|
|
576
|
-
attempted. That refusal is deliberate. On a server that hands each session its own
|
|
577
|
-
endpoint, reconnecting could only collect the same close again, which is an
|
|
578
|
-
endless "Reconnecting…" flap over a screen that will never change.
|
|
579
|
-
|
|
580
|
-
Some hosts are the other shape. If your endpoint hands out a NEW session on the
|
|
581
|
-
next connect (one shared PTY the server replaces once it is spent, an endpoint
|
|
582
|
-
that spawns on attach), then the connection the engine declines to make is exactly
|
|
583
|
-
what would produce a working terminal, and you are the only party who knows that.
|
|
584
|
-
So the library gives you the fact and the move, and keeps the policy out of it:
|
|
57
|
+
- `createTerminal(target, options)` builds the terminal and returns a handle with `focus()`, `send(bytes)`, `reset()`, `reattach()` and `destroy()`, plus `split` under `split: true`. [Options and the terminal handle](docs/configuration.md) lists every option.
|
|
58
|
+
- Presets come from `@cplieger/web-terminal-ui/presets`, or one at a time from `/presets/single`, `/presets/touch`, `/presets/tabbed` and `/presets/agent-tabbed`. Single features come from `/features/<name>`.
|
|
59
|
+
- `localScrollbackStorage()` is the ready-made storage for the `persistScrollback` option.
|
|
60
|
+
- `PUBLIC_THEME_TOKENS` and `LOADING_OVERLAY_CLASSES`, also at `/style-contract`, list the theme keys and the overlay classes. `STARTUP_FAILURE_COPY`, also at `/startup-copy`, holds the failure panel's wording.
|
|
61
|
+
- Types cover the options, the handle, features and their context, startup failures and the split controller.
|
|
585
62
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
wsPath: "/api/shell/ws",
|
|
590
|
-
onSessionEnded: () => {
|
|
591
|
-
// Your endpoint's own restart call goes here if it needs one, then:
|
|
592
|
-
term.reattach();
|
|
593
|
-
},
|
|
594
|
-
});
|
|
595
|
-
```
|
|
63
|
+
The full reference is on [JSR](https://jsr.io/@cplieger/web-terminal-ui/doc).
|
|
64
|
+
|
|
65
|
+
## Startup failures and ended sessions
|
|
596
66
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
drops history the server may have evicted since.
|
|
607
|
-
|
|
608
|
-
**Bound your own retries.** A shell that dies as fast as it is spawned will end
|
|
609
|
-
again the moment it is replaced, so a handler that reattaches unconditionally is a
|
|
610
|
-
hot loop. Count consecutive ends, back off, and stop after a few: the banner is
|
|
611
|
-
the honest outcome when a session cannot stay up. The library will not do this for
|
|
612
|
-
you, because how many attempts is worth making, and whether any are, is a fact
|
|
613
|
-
about your server rather than about this terminal.
|
|
614
|
-
|
|
615
|
-
## What ships
|
|
616
|
-
|
|
617
|
-
| Path | Purpose |
|
|
618
|
-
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
619
|
-
| `src/**/*.ts` | The UI modules: the kernel (`kernel/`), opt-in features (`features/`), per-preset entries (`presets/`), IME, predictive echo, viewport. |
|
|
620
|
-
| `css/*.css` + manifests | Root-scoped component styles. `MANIFEST` = the reference full-page bundle (`page.css` + the tabbed set); `MANIFEST.single/touch/tabbed` = component-only per-preset bundles for embedders. Concatenate in manifest order. |
|
|
621
|
-
| `css/page.css` | The page kit (full-page hosts only): `html/body` reset + the `@font-face` rules for the terminal's two web fonts, `Monaspace Neon NF` and the `Web Terminal Glyphs` tiling overlay. |
|
|
622
|
-
| `scaffold/index.html` | A reference full-page HTML host: `<head>` + one empty root element + importmap. |
|
|
67
|
+
When a startup failure has a place to render, the library shows one "Terminal failed to start" panel with a **Reload** button and lowers your loading overlay. `onFatalError` lets you observe the failure or render your own recovery UI. Two failures draw no panel. One is an embedded terminal whose mount target is missing. The other is a second terminal in a document that already holds a live one.
|
|
68
|
+
|
|
69
|
+
A session whose process exits shows "Session ended" and does not reconnect. If your endpoint hands out a new session on the next connect, call `reattach()` from `onSessionEnded`, and bound your retries. [Startup failures and ended sessions](docs/failure-handling.md) has the details.
|
|
70
|
+
|
|
71
|
+
## Security and storage
|
|
72
|
+
|
|
73
|
+
The library has no login of its own. Authentication and origin checks belong to the server that serves the WebSocket and the session API. Paste reads the Clipboard API, which needs a secure context, so serve the page over HTTPS for touch paste.
|
|
74
|
+
|
|
75
|
+
Scrollback persistence is off by default, because turning it on writes terminal output to browser storage. An open split view holds up to two WebSocket connections, one per shown pane.
|
|
623
76
|
|
|
624
77
|
## Related projects
|
|
625
78
|
|
|
626
|
-
|
|
79
|
+
- [web-terminal-engine](https://github.com/cplieger/web-terminal-engine) is the Go session engine and TypeScript renderer this UI is built on.
|
|
80
|
+
- [web-terminal-server](https://github.com/cplieger/web-terminal-server) is a ready-to-run container that serves this UI over HTTP and WebSocket for any command.
|
|
81
|
+
- [web-terminal-kiro](https://github.com/cplieger/web-terminal-kiro) serves the Kiro CLI in the browser through this UI, as a full page.
|
|
82
|
+
- [marotte](https://github.com/cplieger/marotte) is a self-hosted agentic IDE that embeds this UI as its shell panel, in `container` layout.
|
|
83
|
+
|
|
84
|
+
## Documentation
|
|
627
85
|
|
|
628
|
-
- [
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
- [
|
|
632
|
-
|
|
633
|
-
|
|
86
|
+
- [Setting up the host page](docs/host-page.md) covers the CSS, fonts, import map, presets and loading overlay.
|
|
87
|
+
- [Options and the terminal handle](docs/configuration.md) lists every option and the theme tokens.
|
|
88
|
+
- [Tabs, activity dots and notifications](docs/tabs.md) describes what the tabbed presets show.
|
|
89
|
+
- [Split view](docs/split-view.md) covers two panes side by side.
|
|
90
|
+
- [Keeping scrollback across a reload](docs/scrollback-persistence.md) covers the `persistScrollback` option.
|
|
91
|
+
- [Startup failures and ended sessions](docs/failure-handling.md) covers the Reload panel and `reattach()`.
|
|
92
|
+
- [Keyboard, mouse and touch input](docs/input.md) covers focus, paste and mouse reporting.
|
|
634
93
|
|
|
635
|
-
|
|
94
|
+
## Credits
|
|
636
95
|
|
|
637
|
-
|
|
638
|
-
- [`web-terminal-kiro`](https://github.com/cplieger/web-terminal-kiro)
|
|
96
|
+
The IME module follows the design of [xterm.js](https://github.com/xtermjs/xterm.js)'s `CompositionHelper`. On a phone, the tab switcher's flick thresholds take [@use-gesture](https://github.com/pmndrs/use-gesture)'s drag defaults, and the five-second composition idle bound comes from [Slate](https://github.com/ianstormtaylor/slate)'s Android input manager. [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) names the lines that follow each.
|
|
639
97
|
|
|
640
98
|
## Contributing
|
|
641
99
|
|
|
642
|
-
Issues and
|
|
643
|
-
conventions and how to run the checks locally.
|
|
100
|
+
Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the conventions and how to run the checks locally.
|
|
644
101
|
|
|
645
102
|
## Disclaimer
|
|
646
103
|
|
|
@@ -652,10 +109,6 @@ This project was built with AI-assisted tooling using [Claude](https://claude.co
|
|
|
652
109
|
|
|
653
110
|
MPL-2.0. See [LICENSE](LICENSE).
|
|
654
111
|
|
|
655
|
-
The `Web Terminal Glyphs` tiling overlay named in `css/page.css` is a separate
|
|
656
|
-
Apache-2.0 work, and its licence travels with the FONT FILE — which this package
|
|
657
|
-
does not ship, it only names the URL. So no new obligation lands on an npm or JSR
|
|
658
|
-
consumer; the obligation is the serving host's, which is why the full-page host
|
|
659
|
-
notes above ask for the font's `LICENSE` and `NOTICE` beside the `.woff2`.
|
|
112
|
+
The `Web Terminal Glyphs` tiling overlay named in `css/page.css` is a separate Apache-2.0 work, and its licence travels with the FONT FILE, which this package does not ship. It only names the URL. So no new obligation lands on an npm or JSR consumer. The obligation is the serving host's, which is why the full-page host notes in [Setting up the host page](docs/host-page.md) ask for the font's `LICENSE` and `NOTICE` beside the `.woff2`.
|
|
660
113
|
|
|
661
114
|
Third-party attributions are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
package/css/01-scope.css
CHANGED
|
@@ -15,15 +15,9 @@
|
|
|
15
15
|
.wt-root.wt-viewport {
|
|
16
16
|
position: fixed;
|
|
17
17
|
inset: 0;
|
|
18
|
-
/* Clip like the container mode below, so the two
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
position:fixed descendants escape a clip regardless. It exists so the
|
|
22
|
-
asymmetry cannot be mistaken for a deliberate difference, and so a future
|
|
23
|
-
transform on .term (see docs/tab-switch-repaint.md) is bounded by a
|
|
24
|
-
declaration rather than by the viewport happening to coincide with the box.
|
|
25
|
-
overflow:clip specifically, not hidden: no scroll container, no block
|
|
26
|
-
formatting context, nothing programmatically scrollable. */
|
|
18
|
+
/* Clip like the container mode below, so the two modes agree. Inert while
|
|
19
|
+
this box is the viewport, but it bounds a future transform on .term by a
|
|
20
|
+
declaration. clip, not hidden: no scroll container, no BFC. */
|
|
27
21
|
overflow: clip;
|
|
28
22
|
}
|
|
29
23
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.3.
|
|
3
|
+
"version": "8.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -8,11 +8,11 @@ const TEXT: Partial<Record<ConnState, string>> = {
|
|
|
8
8
|
connecting: "Reconnecting\u2026",
|
|
9
9
|
reconnecting: "Reconnecting\u2026",
|
|
10
10
|
offline: "Offline",
|
|
11
|
-
restarted: "Server restarted
|
|
11
|
+
restarted: "Server restarted. Recent input may be lost",
|
|
12
12
|
// The session's process exited (definitive; nothing is retrying). The final
|
|
13
13
|
// screen stays rendered behind the banner; open a new tab to keep working.
|
|
14
14
|
ended: "Session ended",
|
|
15
|
-
incompatible: "Terminal protocol mismatch
|
|
15
|
+
incompatible: "Terminal protocol mismatch. Update the server or reload this page",
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
/** Build the connectionBanner feature. Exposes no API: it owns one element in
|
|
@@ -247,12 +247,12 @@ async function createSessionHonouringRetry(
|
|
|
247
247
|
if (lastAnnouncedAt === null || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
|
|
248
248
|
lastAnnouncedAt = elapsed;
|
|
249
249
|
const reason = err.serverMessage ?? "Server is not ready yet";
|
|
250
|
-
ctx.toast(`${reason}
|
|
251
|
-
ctx.announce(`${reason}
|
|
250
|
+
ctx.toast(`${reason}, waiting…`, 8000);
|
|
251
|
+
ctx.announce(`${reason}, waiting…`);
|
|
252
252
|
}
|
|
253
253
|
// Unthrottled: the overlay is the only surface visible before the first
|
|
254
254
|
// frame, and it replaces text in place.
|
|
255
|
-
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}
|
|
255
|
+
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}, waiting…`);
|
|
256
256
|
await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
|
|
257
257
|
continue;
|
|
258
258
|
}
|
|
@@ -2432,8 +2432,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2432
2432
|
// became ready: say so with the server's own words when it gave any.
|
|
2433
2433
|
ctx.toast(
|
|
2434
2434
|
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
2435
|
-
? `
|
|
2436
|
-
: "
|
|
2435
|
+
? `Could not open a terminal. ${err.serverMessage}`
|
|
2436
|
+
: "Could not open a terminal",
|
|
2437
2437
|
);
|
|
2438
2438
|
return;
|
|
2439
2439
|
}
|
|
@@ -2573,7 +2573,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2573
2573
|
await api.close(id);
|
|
2574
2574
|
} catch {
|
|
2575
2575
|
if (!tornDown()) {
|
|
2576
|
-
ctx.toast("
|
|
2576
|
+
ctx.toast("Could not close the terminal on the server");
|
|
2577
2577
|
}
|
|
2578
2578
|
}
|
|
2579
2579
|
}
|
|
@@ -2655,7 +2655,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2655
2655
|
await api.close(t.id);
|
|
2656
2656
|
} catch {
|
|
2657
2657
|
if (!tornDown()) {
|
|
2658
|
-
ctx.toast("
|
|
2658
|
+
ctx.toast("Could not close a terminal on the server");
|
|
2659
2659
|
}
|
|
2660
2660
|
}
|
|
2661
2661
|
}
|
|
@@ -2773,7 +2773,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2773
2773
|
}
|
|
2774
2774
|
cur.pinnedTitle = before;
|
|
2775
2775
|
syncChrome();
|
|
2776
|
-
ctx.toast("
|
|
2776
|
+
ctx.toast("Could not save the terminal name");
|
|
2777
2777
|
})
|
|
2778
2778
|
.finally(() => {
|
|
2779
2779
|
const left = (namesInFlight.get(id) ?? 0) - 1;
|
|
@@ -4184,8 +4184,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4184
4184
|
// can retry, as the runtime create() does.
|
|
4185
4185
|
ctx.toast(
|
|
4186
4186
|
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
4187
|
-
? `
|
|
4188
|
-
: "
|
|
4187
|
+
? `Could not open a terminal. ${err.serverMessage}`
|
|
4188
|
+
: "Could not open a terminal",
|
|
4189
4189
|
);
|
|
4190
4190
|
}
|
|
4191
4191
|
}
|
|
@@ -167,10 +167,9 @@ export function tabAccessibleName(v: {
|
|
|
167
167
|
}): string {
|
|
168
168
|
const state = statusPhrase(v.status);
|
|
169
169
|
const progress = v.progress ?? PROGRESS_ABSENT;
|
|
170
|
-
const head =
|
|
171
|
-
progress < 0 ? `${v.label} — ${state}` : `${v.label} — ${state}, ${String(progress)}%`;
|
|
170
|
+
const head = progress < 0 ? `${v.label}, ${state}` : `${v.label}, ${state}, ${String(progress)}%`;
|
|
172
171
|
const activity = activityPhrase(v.activity ?? "", v.activityCount ?? 0);
|
|
173
|
-
return activity === "" ? head : `${head}
|
|
172
|
+
return activity === "" ? head : `${head}, ${activity}`;
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
@@ -19,10 +19,10 @@ export interface LoadingMessages {
|
|
|
19
19
|
export const DEFAULT_LOADING_MESSAGES: LoadingMessages = {
|
|
20
20
|
initial: "Loading terminal…",
|
|
21
21
|
waiting: [
|
|
22
|
-
"Still working
|
|
23
|
-
"Still working
|
|
24
|
-
"Still working
|
|
25
|
-
"Still working
|
|
22
|
+
"Still working. This can take a while on first start.",
|
|
23
|
+
"Still working, preparing the environment.",
|
|
24
|
+
"Still working, almost there.",
|
|
25
|
+
"Still working, thanks for your patience.",
|
|
26
26
|
],
|
|
27
27
|
};
|
|
28
28
|
|