@hex1b/web-terminal 0.168.0 → 0.169.0-alpha.1608.1.d6a20d4

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.
Files changed (98) hide show
  1. package/README.md +645 -41
  2. package/dist/history-state.d.ts +1 -0
  3. package/dist/history-state.d.ts.map +1 -1
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +5954 -6
  7. package/dist/index.js.map +7 -1
  8. package/dist/link-detection.d.ts.map +1 -1
  9. package/dist/marker-pages.d.ts +9 -0
  10. package/dist/marker-pages.d.ts.map +1 -0
  11. package/dist/marker-state.d.ts +17 -0
  12. package/dist/marker-state.d.ts.map +1 -0
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/random-id.d.ts +2 -0
  15. package/dist/random-id.d.ts.map +1 -0
  16. package/dist/scrollbar-appearance.d.ts +26 -0
  17. package/dist/scrollbar-appearance.d.ts.map +1 -0
  18. package/dist/scrollbar-colors.d.ts +14 -0
  19. package/dist/scrollbar-colors.d.ts.map +1 -0
  20. package/dist/scrollbar-renderer.d.ts +11 -0
  21. package/dist/scrollbar-renderer.d.ts.map +1 -0
  22. package/dist/scrollbar-tooltip.d.ts +28 -0
  23. package/dist/scrollbar-tooltip.d.ts.map +1 -0
  24. package/dist/scrollbar-types.d.ts +140 -0
  25. package/dist/scrollbar-types.d.ts.map +1 -0
  26. package/dist/scrollbar.d.ts +91 -0
  27. package/dist/scrollbar.d.ts.map +1 -0
  28. package/dist/terminal-layout.d.ts +6 -0
  29. package/dist/terminal-layout.d.ts.map +1 -0
  30. package/dist/terminal-theme.d.ts +1 -1
  31. package/dist/terminal-theme.d.ts.map +1 -1
  32. package/dist/types.d.ts +37 -7
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/web-terminal.d.ts +13 -0
  35. package/dist/web-terminal.d.ts.map +1 -1
  36. package/dist/wire-types.d.ts +21 -0
  37. package/dist/wire-types.d.ts.map +1 -1
  38. package/dist/worker-url.d.ts +2 -0
  39. package/dist/worker-url.d.ts.map +1 -0
  40. package/package.json +3 -2
  41. package/dist/backend-selection.js +0 -23
  42. package/dist/backend-selection.js.map +0 -1
  43. package/dist/command-mark.js +0 -26
  44. package/dist/command-mark.js.map +0 -1
  45. package/dist/history-state.js +0 -194
  46. package/dist/history-state.js.map +0 -1
  47. package/dist/hyperlinks.js +0 -28
  48. package/dist/hyperlinks.js.map +0 -1
  49. package/dist/input-policy.js +0 -143
  50. package/dist/input-policy.js.map +0 -1
  51. package/dist/link-detection-worker.js +0 -100
  52. package/dist/link-detection-worker.js.map +0 -1
  53. package/dist/link-detection.js +0 -290
  54. package/dist/link-detection.js.map +0 -1
  55. package/dist/link-options.js +0 -108
  56. package/dist/link-options.js.map +0 -1
  57. package/dist/link-presentation.js +0 -125
  58. package/dist/link-presentation.js.map +0 -1
  59. package/dist/link-text.js +0 -92
  60. package/dist/link-text.js.map +0 -1
  61. package/dist/link-types.js +0 -2
  62. package/dist/link-types.js.map +0 -1
  63. package/dist/link-worker-protocol.js +0 -10
  64. package/dist/link-worker-protocol.js.map +0 -1
  65. package/dist/mouse-input.js +0 -427
  66. package/dist/mouse-input.js.map +0 -1
  67. package/dist/protocol.js +0 -337
  68. package/dist/protocol.js.map +0 -1
  69. package/dist/render-backend.js +0 -5
  70. package/dist/render-backend.js.map +0 -1
  71. package/dist/renderer-options.js +0 -6
  72. package/dist/renderer-options.js.map +0 -1
  73. package/dist/renderer.js +0 -473
  74. package/dist/renderer.js.map +0 -1
  75. package/dist/selection-input.js +0 -69
  76. package/dist/selection-input.js.map +0 -1
  77. package/dist/selection-ui.js +0 -113
  78. package/dist/selection-ui.js.map +0 -1
  79. package/dist/terminal-font.js +0 -85
  80. package/dist/terminal-font.js.map +0 -1
  81. package/dist/terminal-sizing.js +0 -38
  82. package/dist/terminal-sizing.js.map +0 -1
  83. package/dist/terminal-theme.js +0 -39
  84. package/dist/terminal-theme.js.map +0 -1
  85. package/dist/terminal-worker.js +0 -331
  86. package/dist/terminal-worker.js.map +0 -1
  87. package/dist/types.js +0 -2
  88. package/dist/types.js.map +0 -1
  89. package/dist/validation.js +0 -7
  90. package/dist/validation.js.map +0 -1
  91. package/dist/web-terminal.js +0 -1052
  92. package/dist/web-terminal.js.map +0 -1
  93. package/dist/webgl2-backend.js +0 -456
  94. package/dist/webgl2-backend.js.map +0 -1
  95. package/dist/webgpu-backend.js +0 -234
  96. package/dist/webgpu-backend.js.map +0 -1
  97. package/dist/wire-types.js +0 -2
  98. package/dist/wire-types.js.map +0 -1
package/README.md CHANGED
@@ -135,39 +135,71 @@ workloads on your target browsers and devices.
135
135
 
136
136
  ### Module and worker deployment
137
137
 
138
- The package contains browser ES modules, not a single bundle. For bare static
139
- hosting, copy **all of `dist/`**, preserving its directory structure, and import
140
- `/web-terminal/index.js` from a module script. `dist/web-terminal.js` also remains
141
- available for relative static imports. Package consumers should import only from
142
- `@hex1b/web-terminal`; internal protocol/renderer modules are not public exports.
143
-
144
- The worker is created with
145
- `new Worker(new URL("./terminal-worker.js", import.meta.url), { type: "module" })`.
146
- The default font is resolved relative to its module, not the host page.
147
- Bundlers differ in whether they discover and rewrite assets inside dependencies;
148
- this package does not claim universal or individually verified bundler support.
149
- The reliable static deployment layout is the complete emitted tree described
150
- above.
151
-
152
- If your bundler does not handle the dependency's worker URL, explicitly provide
153
- the entry point you deployed:
138
+ The package ships **one JavaScript file: `dist/index.js`**. This ESM bundle
139
+ contains the public main-thread API, terminal worker, and link-detection worker;
140
+ there is no runtime JavaScript module tree to copy. npm imports and public
141
+ exports are unchanged: import from `@hex1b/web-terminal`, not internal modules.
142
+
143
+ For static hosting or vendoring, copy `index.js` and `dist/fonts/` into the same
144
+ served directory, preserving the font paths and their license/provenance files.
145
+ Keep the package's MIT license too. Fonts are separate assets, not embedded in
146
+ the JavaScript. Alternatively, supply your own explicit `font.faces` URLs.
147
+ `index.js.map` is optional for runtime use; declarations and declaration maps
148
+ support TypeScript consumers.
149
+
150
+ With the bundle at `/web-terminal/index.js` and fonts at `/web-terminal/fonts/`,
151
+ this is a complete mount example (the host supplies the matching HWT1 endpoint):
152
+
153
+ ```html
154
+ <div id="terminal" style="width: 100%; height: 480px"></div>
155
+ <script type="module">
156
+ import { WebTerminal } from "/web-terminal/index.js";
157
+
158
+ const terminal = await WebTerminal.mount(document.getElementById("terminal"), {
159
+ url: "/ws/terminal",
160
+ sizing: { mode: "auto", fontSize: 16 },
161
+ onStatus(message, level) { console.log(level, message); }
162
+ });
163
+ terminal.focus();
164
+ window.addEventListener("pagehide", () => terminal.dispose(), { once: true });
165
+ </script>
166
+ ```
167
+
168
+ Workers load the **same bundle URL**, replacing its fragment with
169
+ `#hex1b-terminal-worker` or `#hex1b-link-detection-worker`. The actual filename,
170
+ path, and query string are preserved, so renaming the unmodified bundle works:
171
+ `/vendor/terminal.js?v=42` uses `/vendor/terminal.js?v=42#hex1b-terminal-worker`.
172
+ Worker entry selection runs only in dedicated worker contexts, not when importing
173
+ the API into a page. The default font resolves relative to the JavaScript URL,
174
+ not the page.
175
+
176
+ Both workers are module workers; they need neither Blob URLs nor `eval`.
177
+ Same-origin deployment works with `worker-src 'self'` without adding `blob:`.
178
+ Also configure JavaScript/WOFF2 MIME types and CSP permissions for scripts, fonts,
179
+ and the intended WebSocket endpoint. Browser worker-origin restrictions still apply.
180
+
181
+ **Rebundling into an application is a separate deployment choice.** Defaults
182
+ assume the unmodified ESM bundle's URL, not an arbitrary application bundle with
183
+ DOM startup side effects. A bundler may rewrite asset URLs or tree-shake worker
184
+ code. If needed, separately host the unmodified `index.js` from the same package
185
+ build and explicitly point both workers to it, with explicit font URLs:
154
186
 
155
187
  ```ts
156
188
  const terminal = await WebTerminal.mount(container, {
157
189
  url: "/ws/terminal",
158
- workerUrl: "/web-terminal/terminal-worker.js"
190
+ workerUrl: "/web-terminal/index.js#hex1b-terminal-worker",
191
+ linkDetectionWorkerUrl: "/web-terminal/index.js#hex1b-link-detection-worker",
192
+ font: {
193
+ family: "My Terminal Font",
194
+ faces: [{ url: "/fonts/my-terminal.woff2", weight: "100 900", style: "normal" }]
195
+ }
159
196
  });
160
197
  ```
161
198
 
162
- `workerUrl` accepts a nonempty string or URL and resolves relative strings against
163
- the page, not the package module. It still creates a **module** worker. Deploy its
164
- complete relative module tree, or provide a separately bundled worker entry from
165
- the same package build. The override does not automatically copy fonts: retain
166
- the default font asset URL or provide explicit `font.faces` URLs as shown below.
167
- The browser's worker origin and CSP restrictions still apply.
168
-
169
- Configure your server's JavaScript and WOFF2 MIME types and CSP to allow these
170
- workers, fonts, and the intended WebSocket endpoint.
199
+ `workerUrl` and `linkDetectionWorkerUrl` accept nonempty strings or URLs;
200
+ relative strings resolve against the **page**, not the package. Overrides do
201
+ not copy fonts. Bundlers differ in dependency and asset handling; this package
202
+ does not claim universal or individually verified bundler support.
171
203
 
172
204
  ## Configuration and state
173
205
 
@@ -183,6 +215,9 @@ workers, fonts, and the intended WebSocket endpoint.
183
215
  | `renderer` | `"auto"` (prefer WebGPU), `"webgpu"`, or `"webgl2"`; selected once per mount. |
184
216
  | `font` | One family and optional downloadable font faces; see below. |
185
217
  | `sizing` | `{ mode: "auto", fontSize?: number }` or `{ mode: "fixed", columns, rows, fontSize?: number }`. |
218
+ | `scrollbar` | Auto-hiding canvas overlay by default; `{ placement: "beside" }` reserves a gutter and stays visible while scrollable; `false` disables built-in chrome. |
219
+ | `padding` | Nonnegative CSS pixels: a uniform number or `{ top?, right?, bottom?, left? }`. Omitted edges are zero. |
220
+ | `onLayoutChange`, `onMarkersChange` | Local content/gutter geometry and authoritative retained marker inventory for host-owned chrome. |
186
221
  | `readOnly` | Initial per-view input policy; change it later with `setReadOnly(boolean)`. |
187
222
  | `label` | Accessible label for the terminal's hidden keyboard input. |
188
223
  | `onTitleChange` | Initial authoritative workload title, then distinct presented changes; see below. |
@@ -194,14 +229,15 @@ workers, fonts, and the intended WebSocket endpoint.
194
229
 
195
230
  Font size is an integer from 8–32, defaulting to 16. Import `MIN_FONT_SIZE` and
196
231
  `MAX_FONT_SIZE` from `@hex1b/web-terminal` for sizing controls. Requested fixed grids allow
197
- 20–300 columns and 10–100 rows. The producer still owns actual grid geometry.
232
+ 1–300 columns and 1–100 rows, matching `Hex1bTerminal`'s minimum of one cell
233
+ in each dimension. Automatic sizing uses the same bounds. The producer still owns actual grid geometry.
198
234
  `resize()`, `setSizing()`, and automatic resize requests require primary
199
235
  ownership. `requestPrimary()` explicitly requests ownership; inspect `peer` or
200
236
  `onRoleChange` to observe the result.
201
237
 
202
238
  The handle exposes `geometry`, `peer`, `connected`, `readOnly`, `title`, `progress`, `shellIntegration`,
203
239
  `workingDirectory`, `commandMark`, `stats`, `screenText`,
204
- `sizing`, `viewport`, `selection`, `inputBindings`, and `inputContext`.
240
+ `sizing`, `viewport`, `layout`, `padding`, `scrollbar`, `markers`, `selection`, `inputBindings`, and `inputContext`.
205
241
  Metrics start empty; check optional fields before using them. History may be
206
242
  unavailable, and selection can be unavailable, none, pending, valid, or
207
243
  invalidated. Narrow `viewport.available` and `selection.status` before using
@@ -360,9 +396,10 @@ only on a `finished` (D) marker. `rawParameters` is the verbatim
360
396
  `cmdline_url` extension on marker C), or `null` when none was present; use the
361
397
  exported `parseCommandMarkParameters(rawParameters)` helper to parse it into a
362
398
  `Map`, or `getCmdlineUrl(mark)` as a shortcut for the `cmdline_url` entry. This
363
- is **not** a command-mark history — only the latest marker is exposed, mirroring
364
- `shellIntegration`. A host that wants its own history should accumulate
365
- distinct values from `onCommandMarkChange` itself.
399
+ is **not** a command-mark history — this getter exposes only the latest marker,
400
+ mirroring `shellIntegration`. Use `markers` / `onMarkersChange` for the retained
401
+ inventory and `getCommandMarkDetails(id)` for raw parameters on demand. Do not
402
+ reconstruct history from coalesced `onCommandMarkChange` callbacks.
366
403
 
367
404
  All four getters return defensive copies. Their callbacks receive the first
368
405
  authoritative presented state before mount resolves, then distinct presented
@@ -444,6 +481,567 @@ supporting outer terminals. Required activity metadata needs the matching
444
481
  server build; invalid/missing wire fields fail the connection, not silently
445
482
  fall back to default state.
446
483
 
484
+ ## Scrollbars, padding, and retained markers
485
+
486
+ The terminal canvas remains GPU-rendered. Scrollbar chrome uses a separate,
487
+ transparent main-thread Canvas2D layer with the same behavior under WebGPU and
488
+ WebGL2. Change presentation without remounting or replacing the WebSocket:
489
+
490
+ ```ts
491
+ terminal.setPadding({ top: 8, right: 16, bottom: 12, left: 24 });
492
+ terminal.setScrollbar({ placement: "beside", width: 12, markers: true });
493
+ terminal.setScrollbar({ placement: "overlay", hideDelay: 900, fadeDuration: 300 });
494
+ terminal.setScrollbar(false); // Keep history/navigation; paint your own chrome.
495
+ ```
496
+
497
+ Padding is **outside** cells and scrollbar chrome, in CSS pixels, not rows or
498
+ backing-store pixels. `setPadding(8)` sets all four edges; omitted edges in an
499
+ object become zero. Beside mode stays visible without fading while scrollback is
500
+ available, and reserves its gutter even when there is no history. Its default
501
+ frame opacity is always `1`; `hideDelay` and `fadeDuration` apply only to overlay
502
+ mode. Idle beside scrollbars do not schedule fade timers or animation frames.
503
+ Overlay mode auto-hides and does not reduce columns. An auto-sized primary
504
+ can request a new producer grid when the available space changes. Fixed grids
505
+ and secondary/read-only views instead fit the authoritative grid; changing
506
+ chrome never claims primary. Tiny/hidden containers may have no drawable area.
507
+
508
+ `layout` / `onLayoutChange` expose immutable CSS-pixel measurements relative to
509
+ `terminal.element`: outer `width`/`height`, displayed `content` rectangle,
510
+ `scrollbar` rectangle (or `null`), normalized `padding`, and displayed
511
+ `cellWidth`/`cellHeight`. Use the content rectangle for host hit testing, not the
512
+ outer mount box. `padding` and `scrollbar` getters expose normalized current
513
+ settings; the latter is `false` when disabled. Selection, links, input, and
514
+ graphics remain aligned to content, not the added padding/gutter.
515
+
516
+ Layout and marker notifications may occur before `mount()` resolves. Read
517
+ their supplied snapshot during initialization, then initialize host-owned UI
518
+ from the returned handle as well. Related getters update before notification;
519
+ notifications may coalesce and do not form a change log. No notifications run
520
+ after disposal. Check `connected` and `viewport.available` before navigating
521
+ or synchronizing external chrome; retained display state on disconnect does
522
+ not imply an available producer.
523
+
524
+ ### Configure the default capsule painter
525
+
526
+ `createDefaultScrollbarRenderer(appearance?)` returns an ordinary synchronous
527
+ `TerminalScrollbarRenderer`. The built-in `renderDefaultScrollbar(frame)` uses
528
+ the same factory with no overrides; there is no separate rendering API or
529
+ controller path for styled defaults. Given a mounted `terminal`:
530
+
531
+ ```ts
532
+ import { createDefaultScrollbarRenderer } from "@hex1b/web-terminal";
533
+
534
+ const painter = createDefaultScrollbarRenderer({
535
+ track: { opacity: 0.12 },
536
+ thumb: { opacity: 0.7 },
537
+ markers: { opacity: 0.85 }
538
+ });
539
+ terminal.setScrollbar({ placement: "beside", render: painter });
540
+ ```
541
+
542
+ The readonly `TerminalScrollbarAppearance` has optional `track`, `thumb`, and
543
+ `markers` parts. Each accepts `color?: string` and `opacity?: number`;
544
+ `TerminalScrollbarMarkerAppearance` also accepts `errorColor?: string`.
545
+
546
+ | Part | Default opacity | Default color |
547
+ | --- | --- | --- |
548
+ | Track | `0.35` | `frame.colors.track` |
549
+ | Thumb | `1` | `frame.colors.thumb` |
550
+ | Markers | `1` | Each tick's resolved `color`, distinguishing its kind/outcome; falls back to `frame.colors.marker`/`error` |
551
+
552
+ The default canvas palette is monochrome: a grey thumb (`#999999`) over a
553
+ translucent dark track (`#202020`). Command input, execution, successful
554
+ completion, failed completion, and bookmarks use `#888888`, `#bbbbbb`,
555
+ `#999999`, `#eeeeee`, and `#dddddd`, respectively. Unknown outcomes use
556
+ `#aaaaaa`. These defaults do not inherit the embedding app's accent or danger
557
+ colors. Prompt marks remain omitted from the canvas rail.
558
+
559
+ Embedding CSS can override the live palette through `--cp-scrollbar-track`,
560
+ `--cp-scrollbar-thumb`, `--cp-scrollbar-marker`, `--cp-scrollbar-error`,
561
+ `--cp-scrollbar-command-line`, `--cp-scrollbar-executing`,
562
+ `--cp-scrollbar-success`, and `--cp-scrollbar-custom`. Explicit factory
563
+ `markers.color`/`errorColor` overrides remain available, as do per-marker
564
+ colors. Custom painters receive the resolved default shade on each
565
+ `TerminalScrollbarMarker.color`; `marker.color` is the explicit host override.
566
+
567
+ Part opacity must be finite and between `0` and `1`, inclusive. It **multiplies**
568
+ the frame's fade opacity and incoming `context.globalAlpha`; it does not replace
569
+ either. Color alpha is applied by Canvas2D as usual. A marker's explicit `color`
570
+ takes precedence over configured marker/error colors, then theme fallbacks.
571
+ Invalid per-marker CSS colors retain the safe fallback. The rounded focus
572
+ outline uses the configured thumb color or neutral thumb default and remains
573
+ independent of track/thumb/marker opacity. Thumb dragging suppresses both the
574
+ canvas outline and the DOM focus outline; keyboard focus remains visible.
575
+
576
+ Supplied appearance colors are validated when the factory is called. Use
577
+ nonempty **concrete CSS colors** supported by the browser's Canvas2D parser
578
+ (for example `#8b5cf6`, `rebeccapurple`, or `rgb(139 92 246 / 80%)`), at most
579
+ 256 characters. Unresolved `var()`, CSS-wide keywords such as `inherit`,
580
+ `currentColor`, system/context-dependent colors, escapes, and comments are
581
+ rejected rather than silently ignored. Resolve host CSS variables first if
582
+ you want to snapshot their values. **Omit a color to keep theme changes live**:
583
+ the painter reads that fallback from each frame.
584
+
585
+ Options are snapshotted; mutating your original object does not change an
586
+ existing painter. Create and install another renderer to change its overrides.
587
+ The visible thumb is a capsule, inset by `min(2, width / 4)` CSS pixels on each
588
+ side, with radius half its smaller painted dimension. Styling changes painting
589
+ only: track, thumb, marker hit regions, and all navigation APIs remain unchanged.
590
+
591
+ ### Custom synchronous painting and fade
592
+
593
+ This complete painter delegates geometry and colors to the default renderer but
594
+ uses a longer quadratic fade. It is the same policy demonstrated by
595
+ [the playground painter](../../samples/WebTerminalDemo/client/scrollbar-renderer.ts).
596
+ This example opts into its own fade policy for overlay placement. The playground
597
+ uses the non-fading default painter for this choice in beside mode.
598
+
599
+ ```ts
600
+ import {
601
+ WebTerminal, renderDefaultScrollbar, type TerminalScrollbarRenderer
602
+ } from "@hex1b/web-terminal";
603
+
604
+ const softFade: TerminalScrollbarRenderer = frame => {
605
+ const { interaction, now } = frame;
606
+ const active = interaction.near || interaction.hovered ||
607
+ interaction.dragging || interaction.focused;
608
+ const elapsed = Math.max(0, now - interaction.lastActivityAt - 1200);
609
+ const remaining = active ? 1 : Math.max(0, 1 - elapsed / 700);
610
+ const opacity = interaction.reducedMotion
611
+ ? (active || elapsed === 0 ? 1 : 0)
612
+ : remaining * remaining;
613
+ renderDefaultScrollbar({ ...frame, opacity });
614
+ return !active && opacity > 0; // Request the next animation frame until hidden.
615
+ };
616
+
617
+ const host = document.getElementById("terminal");
618
+ if (!host) throw new Error("Missing sized terminal host");
619
+ const terminal = await WebTerminal.mount(host, {
620
+ url: "/ws/terminal",
621
+ padding: 8,
622
+ scrollbar: { placement: "overlay", render: softFade }
623
+ });
624
+ // After changing host-owned painter state or theme colors:
625
+ terminal.refreshScrollbar();
626
+ // On component teardown: terminal.dispose();
627
+ ```
628
+
629
+ The callback is synchronous: never return a Promise. `frame.context` is prepared
630
+ for CSS-pixel drawing on `frame.canvas`; context state is isolated between
631
+ calls. The frame includes layout, viewport, nullable `pendingTarget` (the locally
632
+ desired row during navigation), track/thumb rectangles, marker rectangles,
633
+ nullable `hoveredMarker` (the hovered `TerminalScrollbarMarker`, including its bounds),
634
+ interaction state, monotonic `now` and `lastActivityAt`, default
635
+ `opacity`, and resolved theme colors. Return `true` only while another frame is
636
+ needed; an unconditional `true` creates an unnecessary animation loop.
637
+ `refreshScrollbar()` invalidates painting without sending terminal frames.
638
+ Painter failures are local scrollbar errors, not reasons to stop terminal output.
639
+
640
+ Painting does not redefine hit testing: the library owns thumb dragging,
641
+ track paging, marker clicks, proximity activation, and keyboard interaction.
642
+ The thumb takes precedence over overlapping marker ticks so dense markers cannot
643
+ prevent dragging. Alt+ArrowUp/ArrowDown navigates adjacent available markers.
644
+ The default painter respects reduced motion and the embedding theme.
645
+ `markers: false` hides ticks without removing the inventory or disabling
646
+ `scrollToMarker`. Inventory changes briefly reveal the scrollbar, including when
647
+ a bookmark is added after it has faded out. A custom painter can call `renderDefaultScrollbar(frame)` and
648
+ then add decoration; use `frame.colors` or your embedding theme rather than
649
+ assuming a dark terminal.
650
+
651
+ To draw completely different chrome, use the same callback without calling a
652
+ default painter. This snippet uses square geometry and keeps the normal fade;
653
+ the playground's **Custom Canvas2D** mode adds thumb grips and diamond markers.
654
+
655
+ ```ts
656
+ import { type TerminalScrollbarRenderer } from "@hex1b/web-terminal";
657
+
658
+ const squareScrollbar: TerminalScrollbarRenderer = frame => {
659
+ const { context, track, thumb, colors } = frame;
660
+ context.save();
661
+ try {
662
+ const alpha = context.globalAlpha * frame.opacity;
663
+ context.globalAlpha = alpha * 0.2;
664
+ context.fillStyle = colors.track;
665
+ context.fillRect(track.left, track.top, track.width, track.height);
666
+ context.globalAlpha = alpha;
667
+ context.fillStyle = colors.thumb;
668
+ context.fillRect(thumb.left, thumb.top, thumb.width, thumb.height);
669
+ for (const { marker, bounds } of frame.markers) {
670
+ context.fillStyle = marker.exitCode != null && marker.exitCode !== 0
671
+ ? colors.error : colors.marker;
672
+ if (marker.color) context.fillStyle = marker.color;
673
+ context.fillRect(bounds.left, bounds.top, bounds.width, bounds.height);
674
+ }
675
+ if (frame.interaction.focused) {
676
+ context.strokeStyle = colors.marker;
677
+ context.lineWidth = 1;
678
+ context.strokeRect(thumb.left + 0.5, thumb.top + 0.5,
679
+ Math.max(0, thumb.width - 1), Math.max(0, thumb.height - 1));
680
+ }
681
+ } finally {
682
+ context.restore();
683
+ }
684
+ };
685
+ terminal.setScrollbar({ render: squareScrollbar });
686
+ ```
687
+
688
+ ### Marker hover tooltips
689
+
690
+ Canvas marker tooltips are enabled by default. They show a custom bookmark's
691
+ label or retained command details: shell phase, exit code, decoded `cmdline_url`
692
+ when supplied, or raw parameters when no command text was provided. A shell
693
+ mark is **not** proof of a particular command line; the default never invents
694
+ command text. Labels, decoded text, raw parameters, and errors are rendered as
695
+ text, not HTML or navigation links.
696
+
697
+ Use `tooltip: false` to suppress hover content without hiding marker ticks,
698
+ removing the Marks inventory, or disabling marker navigation:
699
+
700
+ ```ts
701
+ terminal.setScrollbar({ render: painter, tooltip: false });
702
+ ```
703
+
704
+ `tooltip` also accepts a synchronous `TerminalScrollbarTooltipRenderer`:
705
+ `(context: TerminalScrollbarTooltipContext) => HTMLElement | null`. Its readonly
706
+ context contains:
707
+
708
+ | Field | Meaning |
709
+ | --- | --- |
710
+ | `marker` | The retained `TerminalMarker` being hovered. |
711
+ | `anchor` | Marker bounds in terminal-local CSS pixels. |
712
+ | `layout` | Current `TerminalLayout`. |
713
+ | `details` | `TerminalCommandMark` after successful lookup, otherwise `null`. |
714
+ | `loading` | Whether retained command details are being fetched. |
715
+ | `error` | Detail-fetch failure text, otherwise `null`. |
716
+ | `signal` | Aborted when this rendering is replaced or hidden. |
717
+
718
+ Return an element and the library mounts it in a **light-DOM overlay slot**,
719
+ positions it beside the marker, and clamps it inside the terminal. You do not
720
+ need to calculate viewport offsets or install mouse listeners. This is a
721
+ non-interactive hover preview, not a popover of clickable controls.
722
+
723
+ `renderDefaultScrollbarTooltip(context): HTMLElement` builds the same safe
724
+ content as the default. Decorate it without reimplementing command parsing:
725
+
726
+ ```ts
727
+ import {
728
+ renderDefaultScrollbarTooltip, type TerminalScrollbarTooltipRenderer
729
+ } from "@hex1b/web-terminal";
730
+
731
+ const tooltip: TerminalScrollbarTooltipRenderer = context => {
732
+ const element = renderDefaultScrollbarTooltip(context);
733
+ // These --cp-* variables belong to this example's embedding application.
734
+ element.style.background = "var(--cp-surface)";
735
+ element.style.color = "var(--cp-text)";
736
+ element.style.borderLeft = "3px solid var(--cp-accent)";
737
+ const heading = document.createElement("strong");
738
+ heading.textContent = context.marker.source === "custom" ? "Bookmark" : "Shell mark";
739
+ heading.style.display = "block";
740
+ element.prepend(heading);
741
+ return element;
742
+ };
743
+ terminal.setScrollbar({ render: painter, tooltip });
744
+ ```
745
+
746
+ Callbacks run initially (with `loading` for command details), again when details
747
+ or an error arrive, and when relevant geometry changes. **Do not make either
748
+ the painter or tooltip callback `async`, or return a Promise.** The library
749
+ fetches retained details on demand, with the same 8,192 UTF-16-unit bound as
750
+ `getCommandMarkDetails`. Oversized, unavailable, or expired details produce an
751
+ error state rather than truncated or guessed command text. The callback receives
752
+ that state; it does not need to issue its own detail request on every frame.
753
+
754
+ Tooltips are suppressed during thumb dragging and hidden when the pointer
755
+ leaves the marker/terminal, the view disconnects, configuration changes, or the
756
+ terminal is disposed. Replacement/hide aborts the rendering's `signal`, so late
757
+ work cannot resurrect an old tooltip. For externally owned UI, return `null`
758
+ and use that signal to clean up your node. For example, given a host-owned
759
+ `inspector` element:
760
+
761
+ ```ts
762
+ terminal.setScrollbar({
763
+ tooltip(context) {
764
+ const element = renderDefaultScrollbarTooltip(context);
765
+ inspector.replaceChildren(element);
766
+ context.signal.addEventListener("abort", () => element.remove(), { once: true });
767
+ return null; // Host owns mounting and placement, not the terminal overlay.
768
+ }
769
+ });
770
+ ```
771
+
772
+ These refinements do not change the external layout, viewport, marker,
773
+ navigation, or native-wrapper APIs. `markers: false`, native host chrome, and
774
+ `scrollbar: false` do not acquire canvas hover targets.
775
+
776
+ ### Navigation and marker lifetime
777
+
778
+ `scrollToRow(top)` requests an absolute row, clamped to the current scrollable
779
+ range. It does not calculate a relative delta from stale presented state.
780
+ `viewport.pending` distinguishes a requested view from a presented one;
781
+ `viewport.top`, `liveTop`, `totalRows`, and `rowIds` remain authoritative.
782
+ Rejected stale-position requests settle pending state and expose
783
+ `viewport.navigationError`; a new navigation request clears the previous error.
784
+ At the live end, following resumes. Reading history never claims primary,
785
+ and these operations are available in read-only views.
786
+
787
+ The retained `markers` array contains command and custom points with stable
788
+ string `id`, `source`, `buffer`, `row`, and `column`, plus optional shell
789
+ `phase`/`exitCode` or host `label`/`color`. A `null` row means unavailable, **not
790
+ row zero**. Filter by the current buffer before painting an external rail.
791
+ The inventory is not a command-execution event stream. Same-row shell marks
792
+ remain distinct; do not infer command starts from an isolated finish marker.
793
+ Raw command parameters are fetched on demand by command tooltips or an explicit
794
+ `getCommandMarkDetails(id)` call, not included in the marker inventory.
795
+ Details exceeding 8,192 UTF-16 units reject explicitly instead of truncating.
796
+ Large inventories are paged internally and published only after a coherent
797
+ replacement is complete. The accumulated serialized marker index is bounded
798
+ to 8 MiB; exceeding that transport budget fails explicitly rather than exposing
799
+ a silently incomplete inventory.
800
+
801
+ ```ts
802
+ const viewport = terminal.viewport;
803
+ if (viewport.available && viewport.rowIds.length) {
804
+ const bookmark = await terminal.addMarker({
805
+ position: {
806
+ generation: viewport.generation,
807
+ rowId: viewport.rowIds[0],
808
+ column: 0
809
+ },
810
+ label: "Review this output"
811
+ });
812
+ // The producer resolves the current anchor, including after retained-text reflow.
813
+ await terminal.scrollToMarker(bookmark.id);
814
+ await terminal.removeMarker(bookmark.id);
815
+ }
816
+ ```
817
+
818
+ Registration uses a **presented** producer-backed position. It can reject if
819
+ that position has expired before acceptance. Catch registration/navigation/
820
+ details errors and show them as text. Labels and colors stay in the browser;
821
+ labels and shell details are untrusted and must not be inserted as HTML.
822
+ Anchors track positions, not immutable search matches: recompute search hits
823
+ when their text changes.
824
+
825
+ Surviving text anchors follow supported producer reflow and horizontal character
826
+ insertion/deletion (ICH/DCH). Insertions at a marked cell move the anchor with that
827
+ cell; deleting it or pushing it beyond the right margin collects the marker.
828
+ Insert-mode typing into a blank marked cursor position binds the marker to the
829
+ newly printed text instead. An end-of-row position remains a boundary until the
830
+ following glyph wraps onto the next row. Wide-glyph positions follow the leading
831
+ cell and expire if editing splits and discards the glyph.
832
+
833
+ When backing text is evicted, destructively cleared, reset, or discarded by
834
+ reflow, its markers and
835
+ retained metadata are collected rather than accumulated as unavailable entries.
836
+ Navigation to a collected ID rejects rather than guessing. Main and alternate
837
+ buffers are isolated: switching away from retained main-buffer content does not
838
+ collect its markers; those markers are temporarily unavailable in the other
839
+ buffer. Custom markers belong to their owning view and are also released on
840
+ removal/disconnect/disposal, not carried into a reconnect. Browser-only labels
841
+ and colors are released when their markers leave the authoritative inventory.
842
+ The default server quota is 1,000 custom markers per view, configured through
843
+ `Hex1bTerminalOptions.CustomMarkerLimit`; zero disables registration and exceeding
844
+ the limit rejects explicitly. Collection reclaims custom-marker quota slots.
845
+ Shell markers also have a producer-configured count limit.
846
+ Late direct HWT1 attachment can see retained producer marks. HMP1 negotiates
847
+ **retained text** and **retained OSC 133 command marks** independently, so a late
848
+ relay or fresh reconnect can recover both when supported. Raw command details,
849
+ phase, exit status, and producer IDs are restored without replaying command events.
850
+ Historical graphics and custom/browser-owned markers are not transferred.
851
+ Custom markers still belong to their view and do not survive reconnect.
852
+
853
+ #### HMP1 relay history
854
+
855
+ An HMP1-backed replica needs its own `WithScrollback(capacity)` configuration,
856
+ even when the upstream producer retains history. Local capacity remains
857
+ authoritative: requesting more rows never increases it. For example, this server
858
+ configuration snippet assumes a connected bidirectional HMP1 `stream` and a
859
+ terminal builder named `replicaBuilder`:
860
+
861
+ ```csharp
862
+ replicaBuilder
863
+ .WithScrollback(1000)
864
+ .WithHmp1Stream(stream, options =>
865
+ {
866
+ options.ScrollbackHistoryRows = 10_000;
867
+ options.EnableCommandMarkHistory = true;
868
+ });
869
+ ```
870
+
871
+ `Hmp1ClientOptions.ScrollbackHistoryRows` defaults to 10,000, accepts 0..100,000,
872
+ and uses `0` to opt out. The producer must have scrollback storage and permit
873
+ transfer through `Hmp1ServerOptions.EnableScrollbackHistory` (or the direct
874
+ `Hmp1PresentationAdapter.EnableScrollbackHistory` property), both defaulting to
875
+ `true`. The first builder listener's setting wins for a shared adapter.
876
+ The separate `EnableCommandMarkHistory` option defaults to `true` on
877
+ `Hmp1ClientOptions`, `Hmp1ServerOptions`, and `Hmp1PresentationAdapter`.
878
+ The server setting is also captured from the first builder listener.
879
+
880
+ This extension does **not** change the HMP1 version. Optional `ClientHello`
881
+ history fields request version `1` and a row limit; `Hello` acknowledges them
882
+ only when supported and enabled. Missing fields preserve screen-only text replay
883
+ with existing HMP1 peers that support the current mandatory `ActivityState`
884
+ baseline, not ancient pre-`ActivityState` peers.
885
+ Command marks use their own `commandMarkHistoryVersion: 1` field in
886
+ `ClientHello` and `Hello`. Missing acknowledgement disables only command-mark
887
+ transfer; negotiated text history still works, and vice versa. Without transferred
888
+ history, only marks backed by the transferred active screen are eligible.
889
+
890
+ After each negotiated screen/activity checkpoint, the replica receives the
891
+ newest contiguous retained suffix, bounded by the requested/accepted row limit,
892
+ 32 MiB of row-chunk payloads (including row-length prefixes, not the 8-byte
893
+ header), and two million cells. Complete checkpoints are
894
+ validated before screen, activity, history, and negotiated command marks are atomically applied, then
895
+ graphics and live output resume. Reconnect/resync **replaces**, rather than
896
+ appends to, history, avoiding duplication. An available empty checkpoint clears
897
+ history; an unavailable checkpoint from a relay with a non-supporting upstream
898
+ does not perform an additional history replacement. Explicit history-clearing
899
+ operations in the screen replay still have their normal effect.
900
+ Main-buffer history also transfers while the alternate
901
+ screen is active but stays hidden until returning to the main buffer.
902
+
903
+ Transferred rows preserve graphemes, continuation cells, soft wraps, padding,
904
+ palette colors, and hyperlinks as inert data, never ANSI to execute. Normal
905
+ scrolling output afterward accumulates local history as before. If negotiation
906
+ is absent or disabled, a new replica still starts without pre-attachment history;
907
+ an existing replica retains history according to normal screen/output processing.
908
+ A direct producer-backed
909
+ HWT1 view reads shared retained history without this transport limit.
910
+
911
+ When negotiated, `CommandMarkState` follows activity and any text-history rows.
912
+ It transfers up to 10,000 newest eligible marks in an 8 MiB frame, preserving raw
913
+ OSC 133 parameters (at most 65,536 UTF-8 bytes each), statuses, phases, and IDs.
914
+ Positions refer to the accompanying retained text and active screen, not
915
+ producer-only row identities. Local command-mark and scrollback capacities
916
+ still apply; markers whose backing text was omitted or later redrawn/evicted
917
+ are not kept. Main-history marks remain available after returning from the
918
+ alternate screen, but marks on the untransferred saved main screen are omitted.
919
+
920
+ Available checkpoints replace command history rather than append duplicates;
921
+ unavailable checkpoints do not additionally replace it. ID high-water state
922
+ keeps subsequent live marks aligned even if the latest old record was collected.
923
+ No `CommandMarkAdded` events are synthesized for imported records. On close and
924
+ reattach, restored retained shell marks support normal inventory, details, hover,
925
+ and navigation; custom bookmarks from the closed view are not restored.
926
+ Disable `EnableCommandMarkHistory` to retain legacy locally observed command
927
+ marks independently of the scrollback setting. See
928
+ [CommandMarkState](../../docs/muxer-protocol.md#commandmarkstate-0x10) for the wire contract.
929
+
930
+ These are transport/retention rules, not differences between canvas and HTML
931
+ scrollbars. See the [HMP1 protocol](../../docs/muxer-protocol.md#scrollbackstate-0x0e-and-scrollbackrows-0x0f)
932
+ for wire layout, omitted-row counts, bounds, and failure behavior.
933
+
934
+ ### A native HTML scrollbar using only public APIs
935
+
936
+ Keep a fixed terminal mount **beside** an overflowing rail; never place the
937
+ terminal itself inside the spacer. This complete module example uses native
938
+ scrolling and separate clickable marker ticks. It caps the spacer below browser
939
+ scroll-height limits, then maps the browser's **actual** pixel range to rows.
940
+ For a reusable lifecycle-owned version and live mode switching, see
941
+ [`NativeScrollbar`](../../samples/WebTerminalDemo/client/native-scrollbar.ts).
942
+
943
+ ```html
944
+ <div id="terminal-wrapper" style="display:flex;width:100%;height:480px">
945
+ <div id="terminal" style="flex:1;min-width:0;overflow:hidden"></div>
946
+ <div id="history" style="display:flex;flex:0 0 32px;align-self:flex-start">
947
+ <div id="ticks" style="position:relative;width:12px"></div>
948
+ <div id="rail" tabindex="0" aria-label="Terminal history"
949
+ style="flex:1;min-width:0;overflow-y:scroll;overflow-x:hidden;overscroll-behavior:contain">
950
+ <div id="spacer" aria-hidden="true" style="width:1px"></div>
951
+ </div>
952
+ </div>
953
+ </div>
954
+ <p id="scroll-status" role="status"></p>
955
+ ```
956
+
957
+ ```js
958
+ import { WebTerminal } from "@hex1b/web-terminal";
959
+
960
+ const host = document.getElementById("terminal");
961
+ const history = document.getElementById("history");
962
+ const rail = document.getElementById("rail");
963
+ const spacer = document.getElementById("spacer");
964
+ const ticks = document.getElementById("ticks");
965
+ const status = document.getElementById("scroll-status");
966
+ const lifetime = new AbortController();
967
+ let terminal, frame = 0, desired, synchronizedTop = 0, markerKey = "";
968
+ const report = error => { status.textContent = String(error); };
969
+
970
+ function update() {
971
+ if (!terminal) return; // Initial notifications can precede mount resolution.
972
+ const { viewport: v, layout: l } = terminal;
973
+ history.style.marginTop = `${l.content.top}px`;
974
+ history.style.height = `${l.content.height}px`;
975
+ history.inert = !terminal.connected || !v.available;
976
+ const maximum = v.available ? v.liveTop : 0;
977
+ spacer.style.height = `${Math.min(8_000_000,
978
+ rail.clientHeight + maximum * l.cellHeight)}px`;
979
+ const range = Math.max(0, rail.scrollHeight - rail.clientHeight);
980
+ if (!v.pending && desired === undefined && !frame)
981
+ rail.scrollTop = maximum ? v.top / maximum * range : 0;
982
+ synchronizedTop = rail.scrollTop;
983
+
984
+ const markers = v.available
985
+ ? terminal.markers.filter(m => m.buffer === v.buffer && m.row !== null) : [];
986
+ const nextKey = JSON.stringify([markers, v.totalRows]);
987
+ if (markerKey === nextKey) return;
988
+ markerKey = nextKey;
989
+ ticks.replaceChildren(...markers.map(marker => {
990
+ const tick = document.createElement("button");
991
+ tick.type = "button";
992
+ tick.textContent = "–";
993
+ tick.title = marker.label ?? marker.phase ?? "Bookmark";
994
+ tick.setAttribute("aria-label", tick.title);
995
+ tick.style.cssText = "position:absolute;left:0;padding:0;border:0;" +
996
+ "width:12px;height:6px;line-height:6px;transform:translateY(-50%)";
997
+ tick.style.top = `${marker.row / Math.max(1, v.totalRows - 1) * 100}%`;
998
+ tick.onclick = () => terminal.scrollToMarker(marker.id).catch(report);
999
+ return tick;
1000
+ }));
1001
+ }
1002
+
1003
+ rail.addEventListener("scroll", () => {
1004
+ if (!terminal?.connected || !terminal.viewport.available ||
1005
+ Math.abs(rail.scrollTop - synchronizedTop) < .5) return;
1006
+ synchronizedTop = rail.scrollTop;
1007
+ const range = rail.scrollHeight - rail.clientHeight;
1008
+ desired = range > 0 ? Math.round(rail.scrollTop / range * terminal.viewport.liveTop) : 0;
1009
+ if (!frame) frame = requestAnimationFrame(() => {
1010
+ frame = 0;
1011
+ const target = desired;
1012
+ desired = undefined;
1013
+ if (terminal.connected && target !== undefined) terminal.scrollToRow(target);
1014
+ });
1015
+ }, { signal: lifetime.signal });
1016
+
1017
+ function dispose() {
1018
+ lifetime.abort();
1019
+ cancelAnimationFrame(frame);
1020
+ terminal?.dispose();
1021
+ ticks.replaceChildren();
1022
+ }
1023
+ window.addEventListener("pagehide", dispose, { signal: lifetime.signal });
1024
+ try {
1025
+ terminal = await WebTerminal.mount(host, {
1026
+ url: "/ws/terminal", signal: lifetime.signal, scrollbar: false, padding: 8,
1027
+ onLayoutChange: update, onViewportChange: update, onMarkersChange: update,
1028
+ onClose: () => { history.inert = true; },
1029
+ onStatus: (message, level) => { if (level === "error") report(message); }
1030
+ });
1031
+ update();
1032
+ } catch (error) { report(error); dispose(); }
1033
+ // On SPA component teardown, call dispose() explicitly.
1034
+ ```
1035
+
1036
+ Do not synchronize the native thumb to older presentations while navigation is
1037
+ pending; doing so causes feedback oscillation. Coalesce scroll events to one
1038
+ absolute request per animation frame. Native scrollbar appearance and visibility
1039
+ are platform preferences; the spacer does not force a permanently visible OS
1040
+ thumb. At extreme history sizes scaling trades subpixel precision for a bounded
1041
+ DOM extent. The separate marker rail and `scrollToMarker` retain precise
1042
+ producer-backed jumps. The terminal's wheel/key/history controls still work
1043
+ when no built-in scrollbar is painted.
1044
+
447
1045
  ## Input and clipboard
448
1046
 
449
1047
  Import `InputRoute`, `TerminalAction`, and `defaultInputBindings` to inspect and
@@ -721,22 +1319,24 @@ errors also use `onStatus`. Detection failure leaves the terminal running.
721
1319
  Activation failures instead use existing `onInputError`/status handling and
722
1320
  never fall back to navigation.
723
1321
 
724
- Deploy the complete package tree, including the detection worker and its
725
- relative dependencies. If your bundler requires explicit worker entries:
1322
+ The link-detection worker is included in the same `index.js` bundle as the
1323
+ terminal worker. If your deployment requires explicit worker URLs:
726
1324
 
727
1325
  ```ts
728
1326
  const terminal = await WebTerminal.mount(container, {
729
1327
  url: "/ws/terminal",
730
- workerUrl: "/web-terminal/terminal-worker.js",
731
- linkDetectionWorkerUrl: "/web-terminal/link-detection-worker.js",
1328
+ workerUrl: "/web-terminal/index.js#hex1b-terminal-worker",
1329
+ linkDetectionWorkerUrl: "/web-terminal/index.js#hex1b-link-detection-worker",
732
1330
  links: { detection: false }
733
1331
  });
734
1332
  ```
735
1333
 
736
1334
  `linkDetectionWorkerUrl` accepts `string | URL`, resolves relative strings
737
1335
  against the page like `workerUrl`, and is a mount-time override. Without it the
738
- entry resolves relative to the package module. Worker origin/CSP restrictions
739
- still apply. Rules and matched text are not sent to an external service.
1336
+ entry uses the actual bundle URL with the link-detection fragment, preserving its
1337
+ path, filename, and query. Worker origin/CSP restrictions still apply. For app
1338
+ rebundling and fonts, see [deployment](#module-and-worker-deployment).
1339
+ Rules and matched text are not sent to an external service.
740
1340
  See the [opt-in demo](../../samples/WebTerminalDemo/README.md#try-local-link-previews).
741
1341
 
742
1342
  ## Selection UI hooks
@@ -795,10 +1395,14 @@ npm test
795
1395
  npm pack
796
1396
  ```
797
1397
 
798
- The strict TypeScript build emits JavaScript, declarations, declaration maps,
799
- and source maps (with embedded sources), then copies fonts into `dist/`.
800
- `npm test` runs zero-dependency `node:test` tests against those emitted modules
801
- and strict public-consumer declaration checks in NodeNext and bundler modes.
1398
+ The strict TypeScript build emits private modules into ignored `.build/`;
1399
+ esbuild bundles the public API and both workers into `dist/index.js`.
1400
+ `dist/` contains that single JavaScript file, `index.js.map`, declarations,
1401
+ declaration maps, and font/license/provenance assets. `.build/` is not shipped.
1402
+ Run `npm run build` **before** `npm test`: private unit tests import `.build/`,
1403
+ while packaging checks exercise the actual `dist/` bundle. Tests use
1404
+ zero-dependency `node:test`, plus strict public-consumer declaration checks in
1405
+ NodeNext and bundler modes.
802
1406
  `npm run typecheck` validates sources without emitting.
803
1407
 
804
1408
  `prepack` rebuilds for `npm pack` and manual `npm publish` from this directory.