@cplieger/web-terminal-ui 4.3.1 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -17
- package/css/10-primitives.css +62 -1
- package/css/30-tabs.css +73 -27
- package/package.json +3 -3
- package/scaffold/index.html +5 -1
- package/src/features/tabs/index.ts +17 -4
- package/src/index.ts +1 -0
- package/src/kernel/kernel.ts +165 -59
- package/src/kernel/types.ts +20 -0
- package/src/presets/agent-tabbed.ts +13 -6
- package/src/presets/tabbed.ts +12 -5
package/README.md
CHANGED
|
@@ -128,31 +128,42 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
|
|
|
128
128
|
keeps clean, label-only tabs. Requires a server that speaks the session API
|
|
129
129
|
(`/api/sessions` and `/ws?session=`), such as `web-terminal-server`.
|
|
130
130
|
- `presetAgentTabbed()` — the same feature set as `presetTabbed()` (activity
|
|
131
|
-
monitor included),
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
monitor included), tuned for an agent shell such as `web-terminal-kiro`: with
|
|
132
|
+
`preferInputTitle`, each tab's label follows the latest submitted line
|
|
133
|
+
(persisted server-side and recovered on reload) and the program's non-empty
|
|
134
|
+
but useless OSC 0/2 title is ignored; and with `presumeReports`, the idle
|
|
135
|
+
activity dot shows from tab creation instead of waiting for the session's
|
|
136
|
+
first OSC 9;4 signal — every session is an agent, so there is nothing to
|
|
137
|
+
prove. Its status dots (idle / working / done / needs-input) come from the
|
|
138
|
+
same activity monitor, driven by the server's status SSE and its OSC-9
|
|
139
|
+
classifier.
|
|
137
140
|
|
|
138
141
|
### Options
|
|
139
142
|
|
|
140
|
-
| Option
|
|
141
|
-
|
|
|
142
|
-
| `features`
|
|
143
|
-
| `layout`
|
|
144
|
-
| `wsPath`
|
|
145
|
-
| `fontReady`
|
|
146
|
-
| `loading`
|
|
147
|
-
| `
|
|
143
|
+
| Option | Default | Purpose |
|
|
144
|
+
| -------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
145
|
+
| `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array. |
|
|
146
|
+
| `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). |
|
|
147
|
+
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
|
|
148
|
+
| `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
|
|
149
|
+
| `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. |
|
|
150
|
+
| `onFatalError` | _(built-in recovery)_ | Called after a fatal feature setup failure has stopped the connection, released the terminal runtime, and cleared the root. It receives `{ phase, feature, cause }`. Return `true` only after rendering replacement recovery UI into the root; otherwise the kernel shows its Reload page surface. |
|
|
151
|
+
| `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` (the working and input rings derive from their own tokens). The library ships neutral defaults. |
|
|
148
152
|
|
|
149
153
|
`createTerminal()` returns a handle: `focus()` re-focuses the terminal input
|
|
150
154
|
(and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
|
|
151
155
|
session through the kernel's sanitizing input funnel (the supported host path
|
|
152
156
|
for a "type this command" affordance); `reset()` drops the local scrollback and
|
|
153
157
|
screen without injecting keystrokes (send a redraw keystroke yourself if you
|
|
154
|
-
want one
|
|
155
|
-
the kernel.
|
|
158
|
+
want one, for example Ctrl+L); and `destroy()` tears every feature down and
|
|
159
|
+
releases the kernel.
|
|
160
|
+
|
|
161
|
+
If a feature's setup throws or rejects, the kernel stops the connection, tears
|
|
162
|
+
down every completed feature and core listener, clears the broken subtree, and
|
|
163
|
+
shows a reload surface. The surface is modal when the terminal owns the
|
|
164
|
+
viewport and non-modal when it fills an embedded container. `onFatalError`
|
|
165
|
+
receives the failure after cleanup; return `true` only when the host has rendered
|
|
166
|
+
replacement recovery UI into the terminal root.
|
|
156
167
|
|
|
157
168
|
## What ships
|
|
158
169
|
|
|
@@ -183,7 +194,7 @@ Consumers that ship this UI:
|
|
|
183
194
|
|
|
184
195
|
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
|
|
185
196
|
|
|
186
|
-
This project was built with AI-assisted tooling using [Claude
|
|
197
|
+
This project was built with AI-assisted tooling using [Claude](https://claude.com), [GPT](https://openai.com), and [Kiro](https://kiro.dev). The human maintainer defines architecture, supervises implementation, and makes all final decisions.
|
|
187
198
|
|
|
188
199
|
## License
|
|
189
200
|
|
package/css/10-primitives.css
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
motion is opt-in from one place). Cascade order is the manifest file order.
|
|
14
14
|
|
|
15
15
|
Primitives are the building blocks every feature reuses: the transparent
|
|
16
|
-
layout regions, the 44px touch button,
|
|
16
|
+
layout regions, the 44px touch button, the toast surface, and the kernel's
|
|
17
|
+
fatal-startup recovery surface. */
|
|
17
18
|
|
|
18
19
|
:where(.wt-root) .wt-region,
|
|
19
20
|
:where(.wt-root) .wt-slot {
|
|
@@ -96,3 +97,63 @@
|
|
|
96
97
|
display: block;
|
|
97
98
|
opacity: 1;
|
|
98
99
|
}
|
|
100
|
+
|
|
101
|
+
/* ===== Fatal startup recovery =====
|
|
102
|
+
Feature setup is part of the kernel composition contract. If it fails, the
|
|
103
|
+
kernel removes the half-live terminal and fills only its own layout boundary
|
|
104
|
+
with this recovery surface. In container mode that leaves the host app
|
|
105
|
+
outside the terminal fully visible and interactive. */
|
|
106
|
+
:where(.wt-root) .wt-fatal {
|
|
107
|
+
position: absolute;
|
|
108
|
+
inset: 0;
|
|
109
|
+
z-index: 100;
|
|
110
|
+
display: grid;
|
|
111
|
+
place-items: center;
|
|
112
|
+
overflow: auto;
|
|
113
|
+
padding: calc(var(--safe-top) + var(--sp-3)) var(--sp-3) calc(var(--safe-bottom) + var(--sp-3));
|
|
114
|
+
background: var(--bg);
|
|
115
|
+
color: var(--text);
|
|
116
|
+
font-family: var(--font-ui);
|
|
117
|
+
text-align: center;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
:where(.wt-root) .wt-fatal-card {
|
|
121
|
+
inline-size: min(100%, 28rem);
|
|
122
|
+
padding: clamp(1.25rem, 5vi, 2rem);
|
|
123
|
+
background: var(--surface);
|
|
124
|
+
border: 1px solid var(--border);
|
|
125
|
+
border-radius: var(--radius);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
:where(.wt-root) .wt-fatal-title {
|
|
129
|
+
margin: 0;
|
|
130
|
+
color: var(--text);
|
|
131
|
+
font-size: clamp(1.25rem, 5vi, 1.5rem);
|
|
132
|
+
line-height: 1.2;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
:where(.wt-root) .wt-fatal-message {
|
|
136
|
+
margin: var(--sp-3) 0 1.25rem;
|
|
137
|
+
color: color-mix(in oklch, var(--text), transparent 20%);
|
|
138
|
+
font-size: 0.9375rem;
|
|
139
|
+
line-height: 1.5;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
:where(.wt-root) .wt-fatal-reload {
|
|
143
|
+
inline-size: fit-content;
|
|
144
|
+
padding-inline: 1rem;
|
|
145
|
+
background: var(--accent);
|
|
146
|
+
color: var(--bg);
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@media (hover: hover) {
|
|
151
|
+
:where(.wt-root) .wt-fatal-reload:hover {
|
|
152
|
+
background: color-mix(in oklch, var(--accent), var(--text) 12%);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
:where(.wt-root) .wt-fatal-reload:active {
|
|
157
|
+
background: color-mix(in oklch, var(--accent), var(--bg) 12%);
|
|
158
|
+
transform: translateY(1px);
|
|
159
|
+
}
|
package/css/30-tabs.css
CHANGED
|
@@ -357,8 +357,11 @@
|
|
|
357
357
|
tabs feature adds .wt-reports to that dot (paintStatusDot). A program that
|
|
358
358
|
emits no OSC 9 signal (a plain bash/sh) has no activity concept, so its tabs
|
|
359
359
|
stay clean, label-only; a progress-reporting agent (kiro-cli, Claude Code)
|
|
360
|
-
lights up.
|
|
361
|
-
|
|
360
|
+
lights up. An agent-shell consumer can PRESUME the report instead (tabs'
|
|
361
|
+
presumeReports option, on in presetAgentTabbed): .wt-reports is then set
|
|
362
|
+
from tab creation, so the idle dot shows immediately rather than popping in
|
|
363
|
+
after the agent's first signal. The element stays in the DOM either way (JS
|
|
364
|
+
sets data-status regardless), so revealing it is a pure CSS switch. */
|
|
362
365
|
:where(.wt-root) .wt-status-dot {
|
|
363
366
|
display: none;
|
|
364
367
|
width: 9px;
|
|
@@ -391,19 +394,43 @@
|
|
|
391
394
|
border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
|
|
392
395
|
}
|
|
393
396
|
|
|
394
|
-
/* Agent working:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
397
|
+
/* Agent working: two INDEPENDENTLY animated layers on one element make the
|
|
398
|
+
"alive" read (the status-badge "processing" idiom — Ant Design's processing
|
|
399
|
+
badge, the classic CSS live-indicator ripple): the solid disc keeps full
|
|
400
|
+
opacity and gently GLOWS (its colors brighten mid-beat), while a ::after
|
|
401
|
+
ring — born exactly on the static 2px ring — RIPPLES outward and dissolves,
|
|
402
|
+
re-emitted each cycle. The static ring itself never moves, so working keeps
|
|
403
|
+
input's exact rest silhouette (a live turn ripples; a turn blocked on the
|
|
404
|
+
user is that same silhouette frozen — see input below). Motion separates
|
|
405
|
+
working from input; the ring separates both from done's bare disc. The old
|
|
406
|
+
single-layer pulse faded disc + ring together (opacity on the element),
|
|
407
|
+
which read as a blink and coupled the layers — an element's opacity always
|
|
408
|
+
multiplies onto its pseudos, so true independence requires animating disc
|
|
409
|
+
COLORS, not element opacity. Server-side this is driven by the OSC 9;4
|
|
410
|
+
progress signal, so it tracks agent activity, not the user typing at the
|
|
411
|
+
prompt. */
|
|
402
412
|
:where(.wt-root) .wt-status-dot[data-status="working"] {
|
|
413
|
+
position: relative;
|
|
403
414
|
background: var(--status-working);
|
|
404
415
|
border-color: var(--status-working);
|
|
405
416
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
|
|
406
|
-
animation: wt-
|
|
417
|
+
animation: wt-working-glow 1.2s ease-in-out infinite;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* The ripple layer: at scale(1) its 2px border overlays the static ring's
|
|
421
|
+
9->13px band exactly (inset -2px), then the wave detaches, expanding and
|
|
422
|
+
fading. transform + opacity only, so it composites without repaints. It
|
|
423
|
+
exists only in the working state — no stale layer to pin when a dot changes
|
|
424
|
+
status. (The switcher notification dot never shows working — input/done
|
|
425
|
+
only — so its position: absolute placement never meets this rule's
|
|
426
|
+
position: relative.) */
|
|
427
|
+
:where(.wt-root) .wt-status-dot[data-status="working"]::after {
|
|
428
|
+
content: "";
|
|
429
|
+
position: absolute;
|
|
430
|
+
inset: -2px;
|
|
431
|
+
border: 2px solid var(--status-working);
|
|
432
|
+
border-radius: 50%;
|
|
433
|
+
animation: wt-working-ripple 1.2s ease-in-out infinite;
|
|
407
434
|
}
|
|
408
435
|
|
|
409
436
|
/* Agent done / ready: solid disc (--status-done), no motion. The default green
|
|
@@ -436,39 +463,58 @@
|
|
|
436
463
|
opacity: 0.5;
|
|
437
464
|
}
|
|
438
465
|
|
|
439
|
-
|
|
466
|
+
/* The disc's glow beat: base -> brighter -> base, then a rest until the next
|
|
467
|
+
cycle. Colors are set at EVERY frame, which both drives the glow and pins
|
|
468
|
+
the paint — a dot entering working in the same frame input/done ends can
|
|
469
|
+
never re-pulse the previously rasterized paint (the hazard the old
|
|
470
|
+
opacity-only pulse had to pin box-shadow against). Element opacity stays 1
|
|
471
|
+
throughout; see the layer-independence note on the working rule. */
|
|
472
|
+
@keyframes wt-working-glow {
|
|
440
473
|
0%,
|
|
474
|
+
70%,
|
|
441
475
|
100% {
|
|
442
|
-
/* Pin the animated layer's paint as well as its opacity. When working begins
|
|
443
|
-
in the same frame that input/done ends, an opacity-only animation can
|
|
444
|
-
pulse the previously rasterized input/done paint (the input ring
|
|
445
|
-
included, hence the box-shadow pin). */
|
|
446
476
|
background-color: var(--status-working);
|
|
447
477
|
border-color: var(--status-working);
|
|
448
|
-
box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
|
|
449
|
-
opacity: 1;
|
|
450
478
|
}
|
|
451
479
|
|
|
452
|
-
|
|
453
|
-
background-color: var(--status-working);
|
|
454
|
-
border-color: var(--status-working);
|
|
455
|
-
|
|
480
|
+
35% {
|
|
481
|
+
background-color: color-mix(in oklch, var(--status-working) 78%, #fff);
|
|
482
|
+
border-color: color-mix(in oklch, var(--status-working) 78%, #fff);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* The ripple wave: one emission per cycle, launching off the static ring and
|
|
487
|
+
dissolving by ~2.1x. Opacity starts at the static ring's own 40% weight so
|
|
488
|
+
the wave reads as the ring letting go of a copy, not a new brighter object. */
|
|
489
|
+
@keyframes wt-working-ripple {
|
|
490
|
+
0% {
|
|
491
|
+
transform: scale(1);
|
|
456
492
|
opacity: 0.4;
|
|
457
493
|
}
|
|
494
|
+
|
|
495
|
+
100% {
|
|
496
|
+
transform: scale(2.1);
|
|
497
|
+
opacity: 0;
|
|
498
|
+
}
|
|
458
499
|
}
|
|
459
500
|
|
|
460
|
-
/* Reduced-motion
|
|
501
|
+
/* Reduced-motion: drop the working motion (glow + ripple), keep color. */
|
|
461
502
|
@media (prefers-reduced-motion: reduce) {
|
|
462
503
|
:where(.wt-root) .wt-status-dot[data-status="working"] {
|
|
463
504
|
animation: none;
|
|
464
|
-
/*
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
505
|
+
/* Motion is what separates working from input's frozen silhouette; with
|
|
506
|
+
it removed, punch an inner hole (dot -> donut) so working differs from
|
|
507
|
+
input (solid ringed disc) and done (bare disc) by shape, not hue alone
|
|
508
|
+
(WCAG 1.4.1). The outer ring stays. */
|
|
468
509
|
box-shadow:
|
|
469
510
|
inset 0 0 0 2px var(--bg),
|
|
470
511
|
0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
|
|
471
512
|
}
|
|
513
|
+
|
|
514
|
+
/* The ripple layer disappears entirely (not just paused mid-wave). */
|
|
515
|
+
:where(.wt-root) .wt-status-dot[data-status="working"]::after {
|
|
516
|
+
content: none;
|
|
517
|
+
}
|
|
472
518
|
}
|
|
473
519
|
|
|
474
520
|
/* ===== Catching-up cue (tabs feature, sections 12/13) =====
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
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": "GPL-3.0-or-later",
|
|
@@ -54,10 +54,10 @@
|
|
|
54
54
|
"html-validate": "11.5.6",
|
|
55
55
|
"knip": "6.27.0",
|
|
56
56
|
"prettier": "3.9.5",
|
|
57
|
-
"stylelint": "17.14.
|
|
57
|
+
"stylelint": "17.14.1",
|
|
58
58
|
"stylelint-config-standard": "40.0.0",
|
|
59
59
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
60
|
-
"typescript-eslint": "8.
|
|
60
|
+
"typescript-eslint": "8.65.0",
|
|
61
61
|
"vitest": "4.1.10"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
package/scaffold/index.html
CHANGED
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
#loading .bar{position:relative;inline-size:min(220px,62vw);block-size:5px;overflow:hidden;border-radius:999px;background:color-mix(in oklch,currentColor 16%,transparent)}
|
|
20
20
|
#loading .bar::before{content:"";position:absolute;inset-block:0;inline-size:42%;border-radius:inherit;background:linear-gradient(90deg,transparent,currentColor 28%,color-mix(in oklch,currentColor,#fff 45%) 50%,currentColor 72%,transparent);filter:drop-shadow(0 0 4px color-mix(in oklch,currentColor 55%,transparent));animation:wt-loading-slide 1.5s cubic-bezier(.65,0,.35,1) infinite}
|
|
21
21
|
@keyframes wt-loading-slide{0%{transform:translateX(-110%)}100%{transform:translateX(250%)}}
|
|
22
|
-
@
|
|
22
|
+
@keyframes wt-loading-pulse{0%,100%{opacity:1}50%{opacity:.55}}
|
|
23
|
+
/* Reduced motion: no translation sweep (the vestibular-trigger class). The bar
|
|
24
|
+
becomes a full-width solid fill whose only animation is a gentle opacity
|
|
25
|
+
pulse — liveness stays signaled, nothing moves. */
|
|
26
|
+
@media (prefers-reduced-motion:reduce){#loading .bar::before{inline-size:100%;background:currentColor;filter:none;animation:wt-loading-pulse 2s ease-in-out infinite}}
|
|
23
27
|
</style>
|
|
24
28
|
</head>
|
|
25
29
|
<body>
|
|
@@ -100,6 +100,13 @@ export interface TabsOptions {
|
|
|
100
100
|
* reload), and the unreliable OSC title is ignored entirely. presetAgentTabbed
|
|
101
101
|
* enables this. */
|
|
102
102
|
preferInputTitle?: boolean;
|
|
103
|
+
/** Presume every session reports activity (an agent shell, where the program
|
|
104
|
+
* always emits OSC 9;4 progress): each tab's dot is visible as idle from
|
|
105
|
+
* creation instead of popping in seconds later when the agent has booted
|
|
106
|
+
* far enough to first report — the server's sticky reportsActivity flag
|
|
107
|
+
* then merely confirms. Default false (evidence-driven reveal: a plain
|
|
108
|
+
* shell keeps clean, label-only tabs). presetAgentTabbed enables this. */
|
|
109
|
+
presumeReports?: boolean;
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
// looksLikeHardwareKey reports whether a keydown could only have come from a
|
|
@@ -135,6 +142,12 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
|
135
142
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
136
143
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
137
144
|
const preferInputTitle = opts.preferInputTitle ?? false;
|
|
145
|
+
const presumeReports = opts.presumeReports ?? false;
|
|
146
|
+
// reportsOf floors the server's per-session reportsActivity flag with the
|
|
147
|
+
// consumer's presumption (see TabsOptions.presumeReports): an agent shell
|
|
148
|
+
// shows the idle dot from tab creation instead of waiting out the agent's
|
|
149
|
+
// boot-to-first-OSC-9;4 window.
|
|
150
|
+
const reportsOf = (reports?: boolean): boolean => presumeReports || (reports ?? false);
|
|
138
151
|
// The session REST client (model.ts): every call timeout-bounded, list
|
|
139
152
|
// shape-guarded, title persistence fire-and-forget.
|
|
140
153
|
const api = createSessionAPI(apiBase);
|
|
@@ -912,7 +925,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
912
925
|
if (!label || !dot || !close) {
|
|
913
926
|
throw new Error("web-terminal-ui: tab chrome missing parts");
|
|
914
927
|
}
|
|
915
|
-
paintStatusDot(dot, info.status, info.reportsActivity
|
|
928
|
+
paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
|
|
916
929
|
const aria = tablist.registerTab(el);
|
|
917
930
|
// Append to the scroller's end: the tab list is ALL the scroller holds
|
|
918
931
|
// (the "+" and keyboard button are fixed bar items outside it).
|
|
@@ -941,7 +954,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
941
954
|
aria,
|
|
942
955
|
scrollTop: 0,
|
|
943
956
|
following: true,
|
|
944
|
-
reports: info.reportsActivity
|
|
957
|
+
reports: reportsOf(info.reportsActivity),
|
|
945
958
|
};
|
|
946
959
|
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
947
960
|
// once the tab is in tabList and syncChrome runs).
|
|
@@ -1669,7 +1682,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1669
1682
|
info.status,
|
|
1670
1683
|
info.title,
|
|
1671
1684
|
info.clientTitle,
|
|
1672
|
-
info.reportsActivity
|
|
1685
|
+
reportsOf(info.reportsActivity),
|
|
1673
1686
|
);
|
|
1674
1687
|
}
|
|
1675
1688
|
// A tab the server no longer lists was reaped/closed elsewhere (or
|
|
@@ -1703,7 +1716,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1703
1716
|
// SSE-adopted tab without its persisted title — in preferInputTitle
|
|
1704
1717
|
// mode the label then fell back to "New tab" on reload.
|
|
1705
1718
|
adoptSession(s);
|
|
1706
|
-
applyStatus(s.id, s.status, s.title, s.clientTitle, s.reportsActivity
|
|
1719
|
+
applyStatus(s.id, s.status, s.title, s.clientTitle, reportsOf(s.reportsActivity));
|
|
1707
1720
|
ensureActive();
|
|
1708
1721
|
syncChrome();
|
|
1709
1722
|
});
|
package/src/index.ts
CHANGED
package/src/kernel/kernel.ts
CHANGED
|
@@ -186,7 +186,13 @@ export function createTerminal(
|
|
|
186
186
|
return;
|
|
187
187
|
}
|
|
188
188
|
for (const fn of [...errorHandlers]) {
|
|
189
|
-
|
|
189
|
+
try {
|
|
190
|
+
fn(feature, err);
|
|
191
|
+
} catch (handlerErr) {
|
|
192
|
+
// Error reporting must never turn the original feature failure into an
|
|
193
|
+
// unhandled rejection or prevent fatal-startup cleanup from running.
|
|
194
|
+
console.error("web-terminal-ui: feature error handler failed", handlerErr);
|
|
195
|
+
}
|
|
190
196
|
}
|
|
191
197
|
}
|
|
192
198
|
|
|
@@ -508,6 +514,11 @@ export function createTerminal(
|
|
|
508
514
|
let pointerDownY = 0;
|
|
509
515
|
let pointerDownTime = 0;
|
|
510
516
|
function focusTerminal(): void {
|
|
517
|
+
// The public handle can outlive a fatal startup or explicit destroy. Never
|
|
518
|
+
// focus the detached textarea after the runtime has been released.
|
|
519
|
+
if (destroyed) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
511
522
|
input.focus({ preventScroll: true });
|
|
512
523
|
}
|
|
513
524
|
termWrap.addEventListener(
|
|
@@ -794,13 +805,19 @@ export function createTerminal(
|
|
|
794
805
|
}
|
|
795
806
|
|
|
796
807
|
// --- Feature lifecycle ---
|
|
808
|
+
// `destroyed` means the terminal runtime is no longer usable, whether that
|
|
809
|
+
// happened through the public destroy() handle or a fatal startup rollback.
|
|
810
|
+
// `rootReleased` is narrower: destroy() has also removed the recovery surface
|
|
811
|
+
// and boundary classes. Keeping the two facts separate lets a fatal terminal
|
|
812
|
+
// remain visible and still be explicitly destroyed later.
|
|
797
813
|
let destroyed = false;
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
814
|
+
let rootReleased = false;
|
|
815
|
+
let runtimeCleaned = false;
|
|
816
|
+
// Live read of `destroyed` for post-await re-checks: a plain variable read is
|
|
817
|
+
// narrowed to always-false by TS CFA because it cannot model destroy() firing
|
|
818
|
+
// while feature setup or session resolution is awaiting.
|
|
803
819
|
const isDestroyed = (): boolean => destroyed;
|
|
820
|
+
|
|
804
821
|
function teardownFeatures(): void {
|
|
805
822
|
for (let i = instances.length - 1; i >= 0; i--) {
|
|
806
823
|
const entry = instances[i];
|
|
@@ -816,49 +833,157 @@ export function createTerminal(
|
|
|
816
833
|
instances.length = 0;
|
|
817
834
|
}
|
|
818
835
|
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
|
|
823
|
-
|
|
836
|
+
// Release the complete live runtime while leaving the root boundary classes
|
|
837
|
+
// intact. Fatal startup uses the root for its recovery surface; destroy()
|
|
838
|
+
// follows this cleanup by removing the classes and any recovery UI.
|
|
839
|
+
function cleanupRuntime(): void {
|
|
840
|
+
if (runtimeCleaned) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
runtimeCleaned = true;
|
|
844
|
+
teardownFeatures();
|
|
845
|
+
kernelAbort.abort();
|
|
846
|
+
narrowObserver?.disconnect();
|
|
847
|
+
narrowObserver = null;
|
|
848
|
+
viewport.teardown();
|
|
849
|
+
// Reset the composition singleton: a failure during an IME cycle must not
|
|
850
|
+
// leave module state or a deferred send alive for a later terminal mount.
|
|
851
|
+
composition.teardown();
|
|
852
|
+
connection.disconnect();
|
|
853
|
+
connState.destroy();
|
|
854
|
+
if (toastTimer !== null) {
|
|
855
|
+
clearTimeout(toastTimer);
|
|
856
|
+
toastTimer = null;
|
|
857
|
+
}
|
|
858
|
+
for (const off of subscriptions) {
|
|
859
|
+
off();
|
|
860
|
+
}
|
|
861
|
+
subscriptions.length = 0;
|
|
862
|
+
inputTransforms.length = 0;
|
|
863
|
+
inputObservers.length = 0;
|
|
864
|
+
keydownHandlers.length = 0;
|
|
865
|
+
errorHandlers.clear();
|
|
866
|
+
apiMap.clear();
|
|
867
|
+
activeSession = null;
|
|
868
|
+
bus.clear();
|
|
869
|
+
announcer.destroy();
|
|
870
|
+
regions.destroy();
|
|
871
|
+
root.classList.remove("wt-narrow");
|
|
872
|
+
root.replaceChildren();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function renderFatalStartup(): void {
|
|
876
|
+
const surface = document.createElement("section");
|
|
877
|
+
surface.className = "wt-fatal";
|
|
878
|
+
surface.setAttribute("role", "alertdialog");
|
|
879
|
+
surface.setAttribute("aria-labelledby", "wt-fatal-title");
|
|
880
|
+
surface.setAttribute("aria-describedby", "wt-fatal-message");
|
|
881
|
+
if (layoutMode === "viewport") {
|
|
882
|
+
// A full-page terminal has no usable host UI behind it, so this is the
|
|
883
|
+
// page's modal recovery state. An embedded terminal is only one panel in
|
|
884
|
+
// a larger app and must not claim that the rest of the app is inert.
|
|
885
|
+
surface.setAttribute("aria-modal", "true");
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const card = document.createElement("div");
|
|
889
|
+
card.className = "wt-fatal-card";
|
|
890
|
+
const title = document.createElement("h2");
|
|
891
|
+
title.id = "wt-fatal-title";
|
|
892
|
+
title.className = "wt-fatal-title";
|
|
893
|
+
title.textContent = "Terminal failed to start";
|
|
894
|
+
const message = document.createElement("p");
|
|
895
|
+
message.id = "wt-fatal-message";
|
|
896
|
+
message.className = "wt-fatal-message";
|
|
897
|
+
message.textContent = "A required interface could not be loaded. Reload the page to try again.";
|
|
898
|
+
const reloadButton = document.createElement("button");
|
|
899
|
+
reloadButton.className = "wt-btn wt-fatal-reload";
|
|
900
|
+
reloadButton.type = "button";
|
|
901
|
+
reloadButton.textContent = "Reload page";
|
|
902
|
+
reloadButton.addEventListener("click", () => {
|
|
903
|
+
window.location.reload();
|
|
904
|
+
});
|
|
905
|
+
card.append(title, message, reloadButton);
|
|
906
|
+
surface.appendChild(card);
|
|
907
|
+
root.replaceChildren(surface);
|
|
908
|
+
|
|
909
|
+
// The terminal input held focus before setup failed. Move that focus to the
|
|
910
|
+
// only recovery action; container mode remains non-modal because no trap or
|
|
911
|
+
// aria-modal claim prevents the user from leaving the terminal panel.
|
|
912
|
+
reloadButton.focus();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function enterFatalStartup(feature: string, cause: unknown): void {
|
|
916
|
+
if (destroyed) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
destroyed = true;
|
|
920
|
+
cleanupRuntime();
|
|
921
|
+
dismissLoadingOverlay();
|
|
922
|
+
|
|
923
|
+
let handled = false;
|
|
924
|
+
try {
|
|
925
|
+
handled = opts.onFatalError?.({ phase: "feature-setup", feature, cause }) === true;
|
|
926
|
+
} catch (handlerErr) {
|
|
927
|
+
console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
|
|
928
|
+
}
|
|
929
|
+
// A handler may call destroy() while taking over, so do not recreate the
|
|
930
|
+
// default surface after the root has explicitly been released.
|
|
931
|
+
if (!handled && !rootReleased) {
|
|
932
|
+
renderFatalStartup();
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
type FeatureSetupOutcome =
|
|
937
|
+
| { readonly status: "ready" }
|
|
938
|
+
| { readonly status: "aborted" }
|
|
939
|
+
| {
|
|
940
|
+
readonly status: "failed";
|
|
941
|
+
readonly feature: string;
|
|
942
|
+
readonly cause: unknown;
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
// Distinguish intentional cancellation from a fatal feature failure. A
|
|
946
|
+
// boolean collapsed both into the same "not ready" outcome, which is why a
|
|
947
|
+
// failed setup previously left a half-live terminal and only wrote a log.
|
|
948
|
+
async function setupFeatures(): Promise<FeatureSetupOutcome> {
|
|
824
949
|
for (const feature of featureList) {
|
|
825
950
|
if (destroyed) {
|
|
826
|
-
return
|
|
951
|
+
return { status: "aborted" };
|
|
827
952
|
}
|
|
828
953
|
try {
|
|
829
954
|
const instance = await feature.setup(makeContext(feature.name));
|
|
830
|
-
// destroy() may have run during the await above;
|
|
831
|
-
// swept `instances`, and destroy() is one-shot, so a straight
|
|
832
|
-
// leave this instance's listeners/timers/observers
|
|
833
|
-
//
|
|
834
|
-
// registering it.
|
|
955
|
+
// destroy() may have run during the await above; cleanupRuntime() has
|
|
956
|
+
// already swept `instances`, and destroy() is one-shot, so a straight
|
|
957
|
+
// push here would leave this instance's listeners/timers/observers alive
|
|
958
|
+
// forever. Tear it down instead of registering it.
|
|
835
959
|
if (isDestroyed()) {
|
|
836
960
|
try {
|
|
837
961
|
instance.teardown();
|
|
838
962
|
} catch (err) {
|
|
839
963
|
reportError(feature.name, err);
|
|
840
964
|
}
|
|
841
|
-
return
|
|
965
|
+
return { status: "aborted" };
|
|
842
966
|
}
|
|
843
967
|
instances.push({ feature, instance });
|
|
844
968
|
// Populate the feature value's readonly api (consumer pattern:
|
|
845
969
|
// tabs.api?.create()) via a narrow cast, and the ctx.use lookup map.
|
|
846
970
|
(feature as { api?: unknown }).api = instance.api;
|
|
847
971
|
apiMap.set(feature, instance.api);
|
|
848
|
-
} catch (
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
console.error(
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
return
|
|
972
|
+
} catch (cause) {
|
|
973
|
+
if (isDestroyed()) {
|
|
974
|
+
return { status: "aborted" };
|
|
975
|
+
}
|
|
976
|
+
reportError(feature.name, cause);
|
|
977
|
+
console.error(`web-terminal-ui: feature "${feature.name}" setup failed`, cause);
|
|
978
|
+
if (isDestroyed()) {
|
|
979
|
+
return { status: "aborted" };
|
|
980
|
+
}
|
|
981
|
+
return { status: "failed", feature: feature.name, cause };
|
|
858
982
|
}
|
|
859
983
|
}
|
|
860
|
-
return
|
|
984
|
+
return { status: "ready" };
|
|
861
985
|
}
|
|
986
|
+
|
|
862
987
|
// Features set up in the background; first paint is never gated on them.
|
|
863
988
|
// Under a session owner the kernel then drives the first connect itself:
|
|
864
989
|
// await the owner's resolveInitialSession() and switch to the session it
|
|
@@ -870,14 +995,12 @@ export function createTerminal(
|
|
|
870
995
|
// alive, and the kernel — which now SEES the failure instead of inferring it
|
|
871
996
|
// from a missing side effect — dismisses the loading overlay so that chrome
|
|
872
997
|
// is visible instead of an eternal spinner.
|
|
873
|
-
void setupFeatures().then(async (
|
|
874
|
-
if (
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
dismissLoadingOverlay();
|
|
880
|
-
}
|
|
998
|
+
void setupFeatures().then(async (outcome) => {
|
|
999
|
+
if (outcome.status === "failed") {
|
|
1000
|
+
enterFatalStartup(outcome.feature, outcome.cause);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (outcome.status === "aborted") {
|
|
881
1004
|
return;
|
|
882
1005
|
}
|
|
883
1006
|
if (!sessionOwner || isDestroyed()) {
|
|
@@ -924,32 +1047,15 @@ export function createTerminal(
|
|
|
924
1047
|
render.resetScreen();
|
|
925
1048
|
},
|
|
926
1049
|
destroy() {
|
|
927
|
-
if (
|
|
1050
|
+
if (rootReleased) {
|
|
928
1051
|
return;
|
|
929
1052
|
}
|
|
1053
|
+
rootReleased = true;
|
|
930
1054
|
destroyed = true;
|
|
931
|
-
|
|
932
|
-
kernelAbort.abort();
|
|
933
|
-
narrowObserver?.disconnect();
|
|
934
|
-
viewport.teardown();
|
|
935
|
-
// Reset the composition singleton (kernel-driven, no feature teardown): a
|
|
936
|
-
// destroy() mid-IME-composition otherwise leaves module-level `composing`
|
|
937
|
-
// stuck true, so a remounted terminal swallows every keydown until an IME
|
|
938
|
-
// cycle resets it; also neutralizes a pending compositionend setTimeout.
|
|
939
|
-
composition.teardown();
|
|
940
|
-
connection.disconnect();
|
|
941
|
-
connState.destroy();
|
|
942
|
-
if (toastTimer !== null) {
|
|
943
|
-
clearTimeout(toastTimer);
|
|
944
|
-
}
|
|
945
|
-
for (const off of subscriptions) {
|
|
946
|
-
off();
|
|
947
|
-
}
|
|
948
|
-
subscriptions.length = 0;
|
|
949
|
-
bus.clear();
|
|
950
|
-
announcer.destroy();
|
|
951
|
-
regions.destroy();
|
|
1055
|
+
cleanupRuntime();
|
|
952
1056
|
root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
|
|
1057
|
+
// cleanupRuntime() removed the live subtree. A fatal handler may have
|
|
1058
|
+
// rendered replacement UI afterward, so destroy() clears once more.
|
|
953
1059
|
root.replaceChildren();
|
|
954
1060
|
},
|
|
955
1061
|
};
|
package/src/kernel/types.ts
CHANGED
|
@@ -303,6 +303,18 @@ export interface FeatureInstance<Api = void> {
|
|
|
303
303
|
|
|
304
304
|
// --- Entry point ---
|
|
305
305
|
|
|
306
|
+
/** A fatal failure while createTerminal is starting its feature composition.
|
|
307
|
+
* The kernel has already stopped the connection, released every listener and
|
|
308
|
+
* singleton, torn down completed features, and cleared the terminal root when
|
|
309
|
+
* it delivers this value to `onFatalError`. */
|
|
310
|
+
export interface TerminalStartupFailure {
|
|
311
|
+
readonly phase: "feature-setup";
|
|
312
|
+
/** Name of the feature whose setup threw or rejected. */
|
|
313
|
+
readonly feature: string;
|
|
314
|
+
/** The original thrown or rejected value. */
|
|
315
|
+
readonly cause: unknown;
|
|
316
|
+
}
|
|
317
|
+
|
|
306
318
|
/** Options for createTerminal. */
|
|
307
319
|
export interface CreateTerminalOptions {
|
|
308
320
|
/** The feature list; omitted or empty means the bare kernel (no chrome).
|
|
@@ -323,6 +335,14 @@ export interface CreateTerminalOptions {
|
|
|
323
335
|
fontReady?: string;
|
|
324
336
|
/** Optional pre-JS loading overlay the kernel fades out on first paint. */
|
|
325
337
|
loading?: HTMLElement;
|
|
338
|
+
/** Observe or replace the kernel's fatal feature-setup surface. Called only
|
|
339
|
+
* after the live terminal has been completely torn down and its root
|
|
340
|
+
* cleared. Return true after rendering a replacement recovery surface into
|
|
341
|
+
* that root; false or undefined keeps the built-in Reload page surface. If
|
|
342
|
+
* this callback throws, the kernel logs that error and shows the built-in
|
|
343
|
+
* surface, so a reporting failure cannot leave the page blank. */
|
|
344
|
+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- an observing handler returns nothing; only a literal `true` claims the recovery surface, so forcing `return undefined` on every observer is worse than the union
|
|
345
|
+
onFatalError?: (failure: TerminalStartupFailure) => boolean | void;
|
|
326
346
|
/** Theme overrides: CSS custom properties set on the terminal root so a
|
|
327
347
|
* consumer recolors the UI (accent, tab hover/active) without shipping CSS.
|
|
328
348
|
* Keys must be CSS custom-property names (start with "--"); values are any
|
|
@@ -4,12 +4,19 @@ import type { TerminalFeature } from "../kernel/types.js";
|
|
|
4
4
|
import { buildTabbed } from "./tabbed.js";
|
|
5
5
|
|
|
6
6
|
/** Tabbed UI for an agent shell (web-terminal-kiro). Same features as
|
|
7
|
-
* presetTabbed,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* presetTabbed, tuned for sessions that ARE agents:
|
|
8
|
+
* - `preferInputTitle`: the agent's program (kiro-cli) emits a non-empty but
|
|
9
|
+
* useless OSC 0/2 title, so each tab's label follows the latest submitted
|
|
10
|
+
* line (persisted server-side and recovered on reload) and the OSC title
|
|
11
|
+
* is ignored.
|
|
12
|
+
* - `presumeReports`: every session's program is an agent that WILL report
|
|
13
|
+
* OSC 9;4 progress, so the idle activity dot shows from tab creation
|
|
14
|
+
* instead of popping in seconds later when the agent has booted far enough
|
|
15
|
+
* to first report; the server's sticky reportsActivity flag then merely
|
|
16
|
+
* confirms. (presetTabbed keeps the evidence-driven reveal instead: a
|
|
17
|
+
* plain shell never grows a meaningless dot.)
|
|
18
|
+
* The agent-vs-generic status distinction remains server-side (a status
|
|
19
|
+
* classifier mapping OSC 9 notifications to done/needs-input). */
|
|
13
20
|
export function presetAgentTabbed(): TerminalFeature<unknown>[] {
|
|
14
21
|
return buildTabbed(true);
|
|
15
22
|
}
|
package/src/presets/tabbed.ts
CHANGED
|
@@ -14,19 +14,26 @@ import { presetSingle } from "./single.js";
|
|
|
14
14
|
* externally-driven mobile toolbar (its grid is opened from a keyboard button
|
|
15
15
|
* in the tab bar, not its own toggle), the activity monitor (the status-SSE
|
|
16
16
|
* data source), tabs wired to both, and animations. The activity dot reveals
|
|
17
|
-
* itself per tab only when a session reports activity (OSC 9;4)
|
|
18
|
-
*
|
|
17
|
+
* itself per tab only when a session reports activity (OSC 9;4) — or from tab
|
|
18
|
+
* creation when the agent composition presumes it — so the monitor is always
|
|
19
|
+
* included; a plain shell under presetTabbed just never shows a dot. The
|
|
19
20
|
* toolbar and monitor are ordered before tabs because tabs reads their APIs
|
|
20
21
|
* via ctx.use. Shared by presetTabbed and presetAgentTabbed (agent-tabbed.ts),
|
|
21
|
-
* which differ only in the
|
|
22
|
-
|
|
22
|
+
* which differ only in the agent-shell tuning: input-derived titles
|
|
23
|
+
* (preferInputTitle) + presumed activity reporting (presumeReports). */
|
|
24
|
+
export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
|
|
23
25
|
const kb = mobileToolbar({ externalToggle: true });
|
|
24
26
|
const monitor = activityMonitor();
|
|
25
27
|
return [
|
|
26
28
|
...presetSingle(),
|
|
27
29
|
kb,
|
|
28
30
|
monitor,
|
|
29
|
-
tabs({
|
|
31
|
+
tabs({
|
|
32
|
+
keyboardToggle: kb,
|
|
33
|
+
activityMonitor: monitor,
|
|
34
|
+
preferInputTitle: agentShell,
|
|
35
|
+
presumeReports: agentShell,
|
|
36
|
+
}),
|
|
30
37
|
animations(),
|
|
31
38
|
];
|
|
32
39
|
}
|