@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 CHANGED
@@ -2,645 +2,102 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@cplieger/web-terminal-ui)](https://www.npmjs.com/package/@cplieger/web-terminal-ui) [![JSR](https://jsr.io/badges/@cplieger/web-terminal-ui)](https://jsr.io/@cplieger/web-terminal-ui) [![Mutation (TS)](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/web-terminal-ui/badges/mutation-ts.json)](https://github.com/cplieger/web-terminal-ui/issues?q=label%3Astryker-tracker)
4
4
 
5
- The reference touch-first browser UI for
6
- [`@cplieger/web-terminal-engine`](https://github.com/cplieger/web-terminal-engine).
7
- It turns the engine's render/scroll/connection/keyboard modules into a usable
8
- terminal on a phone as well as a desktop.
9
-
10
- One `createTerminal(target, { features })` call builds the entire terminal UI
11
- inside a single container element you provide. A small always-present kernel
12
- composes with opt-in feature modules that own everything above the raw
13
- terminal:
14
-
15
- - a **display-only** terminal output (native text selection survives redraws)
16
- and a hidden `<textarea>` that owns the keyboard and IME (the kernel)
17
- - **tabs**: multiple independent terminals with a desktop strip, a mobile
18
- bottom switcher, a modal overview sheet, and an opt-in two-pane split view
19
- - an **activity monitor** that drives per-tab status dots (working / idle /
20
- needs-input / exited) from the server's status stream
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
+ ![The terminal UI with six tabs carrying progress, working and error indicators, the split view open with a git log on the left and a build and fuzz run on the right, and the tab menu open on Snap to left](docs/images/header.png)
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
- `@cplieger/web-terminal-engine` is a peer dependency: the UI is built on the
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
- Serve a CSS bundle matching how you embed the terminal, plus a minimal HTML
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, localScrollbackStorage, presetTabbed } from "@cplieger/web-terminal-ui";
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
- All three reference apps (web-terminal-kiro, web-terminal-server, marotte's shell
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
- Supply your own storage instead when the snapshots belong somewhere else: a shared
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
- ```ts
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
- `load` is synchronous, which is the one constraint worth understanding rather
289
- than working around: a restore has to be in place before the resume announces
290
- what the client already holds, and a resume cannot be taken back. A store
291
- hydrated after that has already lost the argument. So an asynchronous store is
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
- ```ts
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
- for (const key of Object.keys(MY_THEME)) {
378
- expect(PUBLIC_THEME_TOKENS).toContain(key); // your test, our list
379
- }
380
- ```
55
+ ## API
381
56
 
382
- Every token on that list is guaranteed by this package's own suite to be both
383
- declared AND read by a shipped rule, so setting it changes what renders. The
384
- second half is the one you cannot check from outside: a token that is declared
385
- but that no rule reads accepts your override and applies it to nothing. Anything
386
- NOT on the list is internal and may be renamed without a release note.
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
- ```ts
587
- const term = createTerminal("#terminal", {
588
- features: presetTouch,
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
- `reattach()` drops the local scrollback and screen, moves the connection state
598
- off `ended`, and reconnects. The order is the reason it belongs here: the local
599
- buffer holds the dead session's content and a line index the replacement has
600
- never reached, so a bare reconnect would resume against the wrong index space, and
601
- a bare reset would leave "Session ended" standing over a screen it just blanked.
602
-
603
- It reconnects and nothing more. It starts no process and calls no API of yours, so
604
- if your server needs to be told to make a new session, tell it first and reattach
605
- after. Do not call it on a live session: that is a needless full replay, and it
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
- The web-terminal family:
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
- - [`web-terminal-engine`](https://github.com/cplieger/web-terminal-engine): the
629
- Go session engine + TypeScript browser renderer this UI is built on (peer
630
- dependency).
631
- - [`web-terminal-server`](https://github.com/cplieger/web-terminal-server): a
632
- ready-to-run container that serves this UI over HTTP + WebSocket for any
633
- command.
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
- Consumers that ship this UI:
94
+ ## Credits
636
95
 
637
- - [`marotte`](https://github.com/cplieger/marotte)
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 PRs are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
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 layout modes agree. Inert
19
- today: the root's box IS the viewport here and page.css already hides
20
- document overflow, so nothing can currently paint outside it, and
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.0",
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; recent input may be lost",
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; update the server or reload this page",
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}; waiting…`, 8000);
251
- ctx.announce(`${reason}; waiting…`);
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"}; waiting…`);
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
- ? `Couldn't open a terminal: ${err.serverMessage}`
2436
- : "Couldn't open a terminal",
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("Couldn't close the terminal on the server");
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("Couldn't close a terminal on the server");
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("Couldn't save the terminal name");
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
- ? `Couldn't open a terminal: ${err.serverMessage}`
4188
- : "Couldn't open a terminal",
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} (${activity})`;
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 — 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.",
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