@cplieger/web-terminal-ui 5.4.6 → 5.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/css/30-tabs.css +4 -3
- package/css/31-switcher.css +38 -9
- package/package.json +1 -1
- package/src/features/tabs/attention.ts +234 -0
- package/src/features/tabs/index.ts +116 -4
- package/src/features/tabs/model.ts +70 -11
- package/src/kernel/kernel.ts +37 -1
- package/src/kernel/types.ts +15 -0
- package/src/presets/agent-tabbed.ts +3 -3
- package/src/presets/tabbed.ts +19 -3
- package/src/presets.ts +1 -0
package/css/30-tabs.css
CHANGED
|
@@ -875,9 +875,10 @@
|
|
|
875
875
|
opacity only, so it composites without repaints (a gradient background is
|
|
876
876
|
rasterized once, unlike an animated box-shadow spread, which repaints every
|
|
877
877
|
frame). It exists only in the three animated states — no stale layer to pin
|
|
878
|
-
when a dot changes status. (The switcher notification dot
|
|
879
|
-
them
|
|
880
|
-
|
|
878
|
+
when a dot changes status. (The switcher notification dot can now show one of
|
|
879
|
+
them, `failed`, since that status became cue-worthy; 31-switcher.css neutralises
|
|
880
|
+
this rule and the position: relative above for that surface, so its
|
|
881
|
+
position: absolute badge placement still holds.)
|
|
881
882
|
|
|
882
883
|
The final scale is deliberately capped at today's 27px footprint: the mobile
|
|
883
884
|
overview row (.wt-switcher-row-select) clips at overflow: hidden 12.5px from
|
package/css/31-switcher.css
CHANGED
|
@@ -326,22 +326,51 @@
|
|
|
326
326
|
/* Latest-wins background-tab notification dot on the switch button — the moved
|
|
327
327
|
attention cue: the input colour when a background terminal needs input, the
|
|
328
328
|
done colour when a background turn finished, the failed red when a background
|
|
329
|
-
process crashed. Those
|
|
330
|
-
want the user, like a notification", so the
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
the
|
|
334
|
-
(
|
|
329
|
+
process crashed or reported an error. Those four only (CueStatus) — the rule
|
|
330
|
+
the user set is "only states that want the user, like a notification", so the
|
|
331
|
+
ongoing progress states (working / warning) never raise it. `failed` DOES,
|
|
332
|
+
because OSC 9;4 state 2 is a result the program parked rather than a phase in
|
|
333
|
+
progress; the override below is what keeps it looking like a notification here.
|
|
334
|
+
Reuses the exact .wt-status-dot rules (30-tabs.css, --status-* tokens) so it
|
|
335
|
+
matches the per-tab dots. Hidden until the tabs feature sets data-status;
|
|
336
|
+
cleared when the list opens (expandSwitcher). */
|
|
335
337
|
:where(.wt-root) .wt-switcher-switch-dot {
|
|
336
338
|
position: absolute;
|
|
337
339
|
top: 6px;
|
|
338
340
|
right: 6px;
|
|
339
341
|
}
|
|
340
342
|
|
|
343
|
+
/* `failed` is one of 30-tabs.css's three ANIMATED states, and on this surface it
|
|
344
|
+
must not be. That rule set is why this override exists at all rather than the
|
|
345
|
+
dot simply inheriting:
|
|
346
|
+
|
|
347
|
+
- `.wt-status-dot[data-status="failed"]` is specificity (0,2,0) and sets
|
|
348
|
+
`position: relative`, which BEATS the (0,1,0) `position: absolute` above
|
|
349
|
+
(`:where()` contributes nothing), so the dot would leave its badge corner and
|
|
350
|
+
land in the button's normal flow, displacing the icon.
|
|
351
|
+
- It also attaches the glow ::before and the travelling-wave ::after, pinning a
|
|
352
|
+
perpetual animation to the switch button for a state that persists until the
|
|
353
|
+
program's next status change.
|
|
354
|
+
|
|
355
|
+
So `failed` renders here the way `crashed` already does: static solid red in the
|
|
356
|
+
badge corner. Colour needs no restating — background/border-color resolve
|
|
357
|
+
through --dot-color to the same --status-failed either way. */
|
|
358
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"] {
|
|
359
|
+
position: absolute;
|
|
360
|
+
animation: none;
|
|
361
|
+
transform: none;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"]::before,
|
|
365
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"]::after {
|
|
366
|
+
content: none;
|
|
367
|
+
}
|
|
368
|
+
|
|
341
369
|
/* The dot carries the .wt-status-dot class, so its size, shape, and colour come
|
|
342
|
-
straight from the shared .wt-status-dot[data-status="input"|"done"|"crashed"
|
|
343
|
-
rules (30-tabs.css) — the SAME dot colours every tab already uses,
|
|
344
|
-
once.
|
|
370
|
+
straight from the shared .wt-status-dot[data-status="input"|"done"|"crashed"|
|
|
371
|
+
"failed"] rules (30-tabs.css) — the SAME dot colours every tab already uses,
|
|
372
|
+
defined once. Three of the four animate nothing; `failed` is neutralised
|
|
373
|
+
directly above, so nothing here can pin a wave layer.
|
|
345
374
|
.wt-status-dot is display:none by default; force it visible only while a
|
|
346
375
|
notification is set (data-status present). */
|
|
347
376
|
:where(.wt-root) .wt-switcher-switch-dot[data-status] {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.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": "MPL-2.0",
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// tabs/attention.ts — the unseen-cue set, rendered onto the surfaces that exist
|
|
2
|
+
// OUTSIDE this page's chrome: the browser tab title, the installed app's icon
|
|
3
|
+
// badge, and the tab icon.
|
|
4
|
+
//
|
|
5
|
+
// Why these are needed at all: a tab holding a latched status paints a dot on its
|
|
6
|
+
// chip, and that dot is not reliably on screen. The desktop strip scrolls
|
|
7
|
+
// horizontally once it is full, a narrow coarse-pointer device hides the strip
|
|
8
|
+
// entirely (only the active row plus the switcher's aggregate dot remain), and a
|
|
9
|
+
// hidden page shows no chrome at all. notify.ts already covers the last case with
|
|
10
|
+
// a browser Notification, but that API is absent on iOS Safari outside an
|
|
11
|
+
// installed web app, which is exactly where this UI spends its time.
|
|
12
|
+
//
|
|
13
|
+
// STATE, not events. A notification fires once per occurrence and is deduped on a
|
|
14
|
+
// sequence number; everything here is a pure render of what is currently true, so
|
|
15
|
+
// it is safe to recompute on every status sweep and each sink no-ops when nothing
|
|
16
|
+
// changed. That split is why this module and notify.ts stay separate.
|
|
17
|
+
//
|
|
18
|
+
// The set is CueStatus and nothing else, so these surfaces cannot disagree with
|
|
19
|
+
// the switcher's aggregate dot about what wants the user; the fold and the raise
|
|
20
|
+
// share isUnseenCue (model.ts).
|
|
21
|
+
//
|
|
22
|
+
// DOM-free and global-free by construction, like notify.ts: every capability
|
|
23
|
+
// arrives through AttentionEnv, so the decisions are unit-testable with no
|
|
24
|
+
// document, no navigator and no icon assets, and browserAttentionEnv() below is
|
|
25
|
+
// the one place that touches globals.
|
|
26
|
+
|
|
27
|
+
import { type CueStatus, cueIconName, isUnseenCue, worseCue } from "./model.js";
|
|
28
|
+
|
|
29
|
+
/** What the fold reads per tab. A structural type, so the caller passes its own
|
|
30
|
+
* tab objects without this module knowing what else they carry. */
|
|
31
|
+
export interface CueCandidate {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly status: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The whole attention state, and the only thing the sinks are allowed to see. */
|
|
37
|
+
export interface Attention {
|
|
38
|
+
/** How many sessions hold an unacknowledged cue. */
|
|
39
|
+
readonly count: number;
|
|
40
|
+
/** The most severe of them, or "" when there is none. */
|
|
41
|
+
readonly worst: CueStatus | "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const NO_ATTENTION: Attention = { count: 0, worst: "" };
|
|
45
|
+
|
|
46
|
+
/** summarize folds the tab list and this viewer's acknowledgements into the one
|
|
47
|
+
* value every surface renders.
|
|
48
|
+
*
|
|
49
|
+
* A COUNT for the title and the badge, a single WORST for the icon. That split
|
|
50
|
+
* is deliberate: a count is set-valued, so it needs no rule for choosing among
|
|
51
|
+
* sessions, and severity is a total order, so the icon's choice is not arbitrary
|
|
52
|
+
* either. Neither surface can name a session, which is the standing constraint
|
|
53
|
+
* on anything written to a page-wide surface here (see the decision against
|
|
54
|
+
* per-session progress in the document title, index.ts). */
|
|
55
|
+
export function summarize(
|
|
56
|
+
tabs: readonly CueCandidate[],
|
|
57
|
+
seen: ReadonlyMap<string, CueStatus>,
|
|
58
|
+
): Attention {
|
|
59
|
+
let count = 0;
|
|
60
|
+
let worst: CueStatus | "" = "";
|
|
61
|
+
for (const tab of tabs) {
|
|
62
|
+
if (!isUnseenCue(tab.status, tab.id, seen)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
count += 1;
|
|
66
|
+
worst = worseCue(worst, tab.status);
|
|
67
|
+
}
|
|
68
|
+
return { count, worst };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The capabilities the sinks need, all injected, all optional except the title.
|
|
72
|
+
*
|
|
73
|
+
* An absent capability is an absent sink and therefore a silent no-op, the same
|
|
74
|
+
* contract notify.ts uses for a missing Notification constructor: an unsupported
|
|
75
|
+
* surface is a normal state of the world, not an error to report.
|
|
76
|
+
*
|
|
77
|
+
* Two of these can also fail INVISIBLY, and that is accepted rather than
|
|
78
|
+
* handled. `setBadge` resolves on Linux where the desktop paints no badge at
|
|
79
|
+
* all, and `setIcon` assigns an href that Safari ignores because it caches the
|
|
80
|
+
* first icon it fetched. Neither is detectable, and neither costs anything when
|
|
81
|
+
* it fails, because the title is not gated on any capability and so is the
|
|
82
|
+
* floor: every platform that renders a title at all gets the count. Do not
|
|
83
|
+
* arrange these into a fallback ladder where the title only appears if the
|
|
84
|
+
* others are missing — on those two platforms the detection would report
|
|
85
|
+
* success and the user would be left with nothing. */
|
|
86
|
+
export interface AttentionEnv {
|
|
87
|
+
/** Set (or clear, with "") the document-title prefix. The kernel's composing
|
|
88
|
+
* writer, so this cannot be erased by a program's OSC 0/2 window title. */
|
|
89
|
+
titlePrefix: (text: string) => void;
|
|
90
|
+
/** Set the installed app's icon badge to a count, or clear it at zero. */
|
|
91
|
+
setBadge?: ((count: number) => void) | undefined;
|
|
92
|
+
/** Point the page's icon links at a variant, or restore them with null. */
|
|
93
|
+
setIcon?: ((variant: "input" | "done" | "alert" | null) => void) | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AttentionSurfaces {
|
|
97
|
+
/** Render an attention state. Idempotent: a value equal to the last one
|
|
98
|
+
* applied touches nothing. */
|
|
99
|
+
apply(next: Attention): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** titlePrefixFor is the title text for a count, and the format is load-bearing
|
|
103
|
+
* enough to name: the count goes FIRST, because a browser tab strip truncates a
|
|
104
|
+
* title to its first few characters and a suffix would be the part that is cut.
|
|
105
|
+
* Parenthesised digits are also the convention every mail and chat client uses,
|
|
106
|
+
* so it needs no legend. */
|
|
107
|
+
export function titlePrefixFor(count: number): string {
|
|
108
|
+
return count > 0 ? `(${String(count)}) ` : "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createAttention(env: AttentionEnv): AttentionSurfaces {
|
|
112
|
+
// Last applied, so each sink is called only on a real change. This matters
|
|
113
|
+
// beyond cost: the document title doubles as the browser-tab label and the
|
|
114
|
+
// bookmark name, and re-assigning an icon href makes some browsers re-fetch it.
|
|
115
|
+
let applied: Attention = NO_ATTENTION;
|
|
116
|
+
let first = true;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
apply(next: Attention): void {
|
|
120
|
+
const countChanged = first || next.count !== applied.count;
|
|
121
|
+
const worstChanged = first || next.worst !== applied.worst;
|
|
122
|
+
first = false;
|
|
123
|
+
applied = next;
|
|
124
|
+
|
|
125
|
+
if (countChanged) {
|
|
126
|
+
env.titlePrefix(titlePrefixFor(next.count));
|
|
127
|
+
// The badge takes the SAME number as the title, which is the whole
|
|
128
|
+
// reason both read one fold: two surfaces disagreeing about how many
|
|
129
|
+
// things want you is worse than either being absent.
|
|
130
|
+
env.setBadge?.(next.count);
|
|
131
|
+
}
|
|
132
|
+
if (worstChanged) {
|
|
133
|
+
env.setIcon?.(next.worst === "" ? null : cueIconName(next.worst));
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** iconVariantHref rewrites an icon URL to its variant, by the convention the
|
|
140
|
+
* asset generator writes (scripts/gen-attention-icons.py): the `favicon` token
|
|
141
|
+
* of the filename gains `-<variant>`, so `/favicon.svg` becomes
|
|
142
|
+
* `/favicon-input.svg` and `/favicon-32x32.png` becomes
|
|
143
|
+
* `/favicon-input-32x32.png`. The extension is preserved, so each link keeps
|
|
144
|
+
* pointing at its own format and no `type` attribute has to change.
|
|
145
|
+
*
|
|
146
|
+
* A URL whose filename does not start with `favicon` is returned unchanged,
|
|
147
|
+
* which leaves that link alone rather than pointing it at a 404. Exported for
|
|
148
|
+
* the test that pins this against the generator's own naming. */
|
|
149
|
+
export function iconVariantHref(href: string, variant: string): string | null {
|
|
150
|
+
const match = /(^|\/)favicon(?=[-.])/.exec(href);
|
|
151
|
+
if (!match) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const at = match.index + match[0].length;
|
|
155
|
+
return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** browserAttentionEnv binds the sinks to the real browser, given the kernel's
|
|
159
|
+
* title setter. Everything optional is decided HERE, once, so the core never
|
|
160
|
+
* probes for a capability.
|
|
161
|
+
*
|
|
162
|
+
* `icons` opts the icon sink in. It is off unless the consumer asks for it,
|
|
163
|
+
* because it needs asset files the library cannot ship: the dot's colour comes
|
|
164
|
+
* from the app's own `--status-*` theme, so the variants are per-app artifacts.
|
|
165
|
+
*/
|
|
166
|
+
export function browserAttentionEnv(
|
|
167
|
+
titlePrefix: (text: string) => void,
|
|
168
|
+
icons: boolean,
|
|
169
|
+
): AttentionEnv {
|
|
170
|
+
const env: AttentionEnv = { titlePrefix };
|
|
171
|
+
|
|
172
|
+
// The Badging API, read through `unknown` for the same reason notify.ts reads
|
|
173
|
+
// Notification that way: it is absent on most browsers and must degrade rather
|
|
174
|
+
// than be asserted. Installed apps only — a badge lives on an app icon, which
|
|
175
|
+
// exists only after the app is installed.
|
|
176
|
+
const nav: unknown = globalThis.navigator;
|
|
177
|
+
const setAppBadge = (nav as { setAppBadge?: unknown } | undefined)?.setAppBadge;
|
|
178
|
+
const clearAppBadge = (nav as { clearAppBadge?: unknown } | undefined)?.clearAppBadge;
|
|
179
|
+
if (typeof setAppBadge === "function") {
|
|
180
|
+
env.setBadge = (count: number): void => {
|
|
181
|
+
// Always a NUMBER, never the spec's bare flag form: iOS renders nothing at
|
|
182
|
+
// all for `setAppBadge()` with no argument. Zero clears, via clearAppBadge
|
|
183
|
+
// where it exists (the documented way) and setAppBadge(0) otherwise.
|
|
184
|
+
//
|
|
185
|
+
// Both return promises that reject on an unsupported platform, so the
|
|
186
|
+
// rejection is swallowed: a badge the OS declines to paint is not an error
|
|
187
|
+
// this page can do anything about, and an unhandled rejection in a status
|
|
188
|
+
// sweep would be reported as a page fault.
|
|
189
|
+
try {
|
|
190
|
+
const call =
|
|
191
|
+
count > 0
|
|
192
|
+
? (setAppBadge as (n: number) => unknown).call(nav, count)
|
|
193
|
+
: typeof clearAppBadge === "function"
|
|
194
|
+
? (clearAppBadge as () => unknown).call(nav)
|
|
195
|
+
: (setAppBadge as (n: number) => unknown).call(nav, 0);
|
|
196
|
+
void Promise.resolve(call).catch(() => {
|
|
197
|
+
/* an OS that will not paint a badge is a title-only OS */
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
/* a synchronous throw is the same non-event */
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (icons) {
|
|
206
|
+
// Every icon link, not one of them: which link a browser picks differs
|
|
207
|
+
// between browsers (Chrome prefers the SVG), so mutating a single element is
|
|
208
|
+
// unreliable. apple-touch-icon is deliberately NOT matched — `rel~="icon"`
|
|
209
|
+
// does not select it — because that icon and the manifest's are cached by the
|
|
210
|
+
// OS when the app is installed and a swap cannot reach them.
|
|
211
|
+
const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')];
|
|
212
|
+
const originals = new Map<HTMLLinkElement, string>();
|
|
213
|
+
for (const link of links) {
|
|
214
|
+
originals.set(link, link.getAttribute("href") ?? "");
|
|
215
|
+
}
|
|
216
|
+
if (links.length > 0) {
|
|
217
|
+
env.setIcon = (variant): void => {
|
|
218
|
+
for (const link of links) {
|
|
219
|
+
const original = originals.get(link) ?? "";
|
|
220
|
+
if (variant === null) {
|
|
221
|
+
link.setAttribute("href", original);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const next = iconVariantHref(original, variant);
|
|
225
|
+
if (next !== null) {
|
|
226
|
+
link.setAttribute("href", next);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return env;
|
|
234
|
+
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
hasPinnedName,
|
|
33
33
|
isCueStatus,
|
|
34
34
|
isEndedStatus,
|
|
35
|
+
isUnseenCue,
|
|
35
36
|
normalizeProgress,
|
|
36
37
|
orderedInsertIndex,
|
|
37
38
|
parseCueSeen,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
statusPhrase,
|
|
42
43
|
tabAccessibleName,
|
|
43
44
|
} from "./model.js";
|
|
45
|
+
import { NO_ATTENTION, browserAttentionEnv, createAttention, summarize } from "./attention.js";
|
|
44
46
|
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
45
47
|
import {
|
|
46
48
|
REORDER_MOVE_EPS_PX,
|
|
@@ -133,6 +135,25 @@ export interface TabsOptions {
|
|
|
133
135
|
* then merely confirms. Default false (evidence-driven reveal: a plain
|
|
134
136
|
* shell keeps clean, label-only tabs). presetAgentTabbed enables this. */
|
|
135
137
|
presumeReports?: boolean;
|
|
138
|
+
/** Swap the page's icon links to a status variant while a background session
|
|
139
|
+
* holds an unacknowledged cue, so the browser tab icon carries the same colour
|
|
140
|
+
* its chip's dot does. Default false, because it needs asset files the library
|
|
141
|
+
* cannot ship: the dot's colour comes from the app's own `--status-*` theme, so
|
|
142
|
+
* the variants are per-app artifacts.
|
|
143
|
+
*
|
|
144
|
+
* Enabling it is a promise that those files exist. For every
|
|
145
|
+
* `link[rel=icon]` whose filename starts with `favicon`, three variants must
|
|
146
|
+
* be served alongside it with `-input`, `-done` and `-alert` inserted after
|
|
147
|
+
* that token (`/favicon.svg` needs `/favicon-input.svg`; `/favicon-32x32.png`
|
|
148
|
+
* needs `/favicon-input-32x32.png`). Generate them with
|
|
149
|
+
* `scripts/gen-attention-icons.py`, which writes exactly those names, and
|
|
150
|
+
* assert their presence in the APP's own tests — the library cannot check a
|
|
151
|
+
* file it does not ship, and a missing variant is a blank tab icon.
|
|
152
|
+
*
|
|
153
|
+
* Not every platform honours it: Safari caches the first icon it fetched and
|
|
154
|
+
* ignores later changes, and an installed app has no tab icon at all. The
|
|
155
|
+
* title count is not gated on this, so those cases lose nothing. */
|
|
156
|
+
attentionIcons?: boolean;
|
|
136
157
|
}
|
|
137
158
|
|
|
138
159
|
// looksLikeHardwareKey reports whether a keydown could only have come from a
|
|
@@ -486,8 +507,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
486
507
|
// by each qualifying background-tab event (applyStatus) and cleared when
|
|
487
508
|
// the list opens (expandSwitcher) or the raising tab is visited or closed
|
|
488
509
|
// (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden. The
|
|
489
|
-
// cue-worthy set is CueStatus (input / done / crashed — the states
|
|
490
|
-
// want the user), declared once in model.ts beside isCueStatus.
|
|
510
|
+
// cue-worthy set is CueStatus (input / done / crashed / failed — the states
|
|
511
|
+
// that want the user), declared once in model.ts beside isCueStatus.
|
|
491
512
|
let switchNotify: "" | CueStatus = "";
|
|
492
513
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
493
514
|
// swipe or any switch) acknowledges it without opening the list.
|
|
@@ -522,6 +543,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
522
543
|
cueSeen.delete(oldest);
|
|
523
544
|
}
|
|
524
545
|
writeCueSeen(cueSeen);
|
|
546
|
+
// An acknowledgement can change the unseen count without touching the tab
|
|
547
|
+
// list, so syncChrome's sweep is not enough on its own: opening the tray
|
|
548
|
+
// and arriving on a tab both land here and neither adds or removes a chip.
|
|
549
|
+
paintAttention();
|
|
525
550
|
}
|
|
526
551
|
// forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
|
|
527
552
|
// is a fresh cue: called when its status moves off the acknowledged value
|
|
@@ -529,6 +554,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
529
554
|
function forgetCueSeen(id: string): void {
|
|
530
555
|
if (cueSeen.delete(id)) {
|
|
531
556
|
writeCueSeen(cueSeen);
|
|
557
|
+
paintAttention();
|
|
532
558
|
}
|
|
533
559
|
}
|
|
534
560
|
// OSC 9 Form B notifications: the one signal the specs say to surface
|
|
@@ -538,6 +564,60 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
538
564
|
// degradation — lives in notify.ts; this feature only feeds it events and
|
|
539
565
|
// gestures.
|
|
540
566
|
const notifier = createNotifier(browserNotifierEnv());
|
|
567
|
+
// The out-of-page surfaces for the SAME unseen-cue set the switch dot
|
|
568
|
+
// shows: the document-title count, the installed app's icon badge, and the
|
|
569
|
+
// tab icon. Constructed here so every capability decision is made once,
|
|
570
|
+
// and fed only through paintAttention below.
|
|
571
|
+
const attention = createAttention(
|
|
572
|
+
browserAttentionEnv((text) => {
|
|
573
|
+
ctx.titlePrefix(text);
|
|
574
|
+
}, opts.attentionIcons === true),
|
|
575
|
+
);
|
|
576
|
+
// pageVisible is the "can the user see this page at all" test, and
|
|
577
|
+
// visibilityState is the only reliable one: document.hasFocus() is false for
|
|
578
|
+
// a visible-but-unfocused window, where the terminal IS on screen. Same
|
|
579
|
+
// signal notify.ts reads, for the same decision.
|
|
580
|
+
function pageVisible(): boolean {
|
|
581
|
+
return document.visibilityState !== "hidden";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// onPageVisible is the DEFERRED half of the active-tab acknowledgement in
|
|
585
|
+
// applyStatus: a cue that latched while the page was hidden was deliberately
|
|
586
|
+
// not acknowledged then, so it could raise the out-of-page surfaces, and this
|
|
587
|
+
// acknowledges it the moment the user can actually see the terminal.
|
|
588
|
+
//
|
|
589
|
+
// Scoped to the ACTIVE tab only. A wholesale clear on becoming visible is the
|
|
590
|
+
// obvious shortcut and it is wrong: the tab the user returns to is not
|
|
591
|
+
// necessarily the one that was waiting, and blanking a background tab's cue
|
|
592
|
+
// it never saw is how a viewer loses the thing it came back for.
|
|
593
|
+
function onPageVisible(): void {
|
|
594
|
+
if (!pageVisible() || activeId === null) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const active = tabList.find((t) => t.id === activeId);
|
|
598
|
+
if (active) {
|
|
599
|
+
markCueSeen(active.id, statusOf(active));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
document.addEventListener("visibilitychange", onPageVisible);
|
|
603
|
+
|
|
604
|
+
// paintAttention re-derives the whole attention state from the tab list and
|
|
605
|
+
// this viewer's acknowledgements, and hands it to the surfaces.
|
|
606
|
+
//
|
|
607
|
+
// A FOLD rather than incremental bookkeeping, for the reason applyServerOrder
|
|
608
|
+
// is one: there is no state of its own to get out of step, so no path can
|
|
609
|
+
// leave it stale, and it is cheap enough to run on every tick (a loop over a
|
|
610
|
+
// handful of tabs, then sinks that no-op when nothing changed). The dot is
|
|
611
|
+
// the store — statusOf reads each tab's status back off it — so this reads
|
|
612
|
+
// exactly what the user is looking at.
|
|
613
|
+
function paintAttention(): void {
|
|
614
|
+
attention.apply(
|
|
615
|
+
summarize(
|
|
616
|
+
tabList.map((t) => ({ id: t.id, status: statusOf(t) })),
|
|
617
|
+
cueSeen,
|
|
618
|
+
),
|
|
619
|
+
);
|
|
620
|
+
}
|
|
541
621
|
function paintSwitchDot(): void {
|
|
542
622
|
// Reuse the per-tab status-dot colours (single source, css/05-tabs.css
|
|
543
623
|
// .wt-status-dot[data-status="input"|"done"|"crashed"]) instead of
|
|
@@ -552,6 +632,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
552
632
|
swSwitchDot.dataset["status"] = switchNotify;
|
|
553
633
|
swSwitchDot.title = statusPhrase(switchNotify);
|
|
554
634
|
}
|
|
635
|
+
// The out-of-page surfaces answer to the same set, so they repaint
|
|
636
|
+
// wherever this does: a raise, and every clear through clearSwitchNotify.
|
|
637
|
+
paintAttention();
|
|
555
638
|
}
|
|
556
639
|
function clearSwitchNotify(): void {
|
|
557
640
|
switchNotify = "";
|
|
@@ -1154,6 +1237,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1154
1237
|
}
|
|
1155
1238
|
maybeSwipeHint();
|
|
1156
1239
|
applyServerOrder();
|
|
1240
|
+
// Hung off syncChrome for the reason applyServerOrder is: it is the one
|
|
1241
|
+
// function every list mutation already ends with (reorder, create, adopt,
|
|
1242
|
+
// close, reconcile), so no path can forget the out-of-page surfaces. The
|
|
1243
|
+
// fold is idempotent and the sinks no-op when unchanged, so running it
|
|
1244
|
+
// this often costs a comparison.
|
|
1245
|
+
paintAttention();
|
|
1157
1246
|
}
|
|
1158
1247
|
|
|
1159
1248
|
// The percentage is deliberately NOT written into the browser document
|
|
@@ -1165,6 +1254,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1165
1254
|
// the bookmark name, and it invites reading one session's progress as the
|
|
1166
1255
|
// window's. The per-chip prefix carries the same information without the
|
|
1167
1256
|
// conflict, because each chip shows its own session. Do not add it back.
|
|
1257
|
+
//
|
|
1258
|
+
// What DOES go there is the unseen-cue COUNT (see paintAttention), and it
|
|
1259
|
+
// is not a softening of this rule: a count names no session, so it needs no
|
|
1260
|
+
// arbitrary choice, and it changes only when a cue is raised or acknowledged
|
|
1261
|
+
// rather than on every progress tick. A per-session value in that prefix
|
|
1262
|
+
// would be this same mistake in a new place.
|
|
1168
1263
|
|
|
1169
1264
|
// applyServerOrder re-sorts the strip into the order the SERVER holds, and
|
|
1170
1265
|
// is the read half of tab-order sync: a drag on another device arrives as a
|
|
@@ -3162,11 +3257,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3162
3257
|
// acknowledgement so the next latch is a fresh cue.
|
|
3163
3258
|
if (!isCueStatus(rec.status)) {
|
|
3164
3259
|
forgetCueSeen(rec.id);
|
|
3165
|
-
} else if (rec.id === activeId) {
|
|
3260
|
+
} else if (rec.id === activeId && pageVisible()) {
|
|
3166
3261
|
// The user is looking at this terminal as it latches, so there is
|
|
3167
3262
|
// nothing to notify — and nothing to re-raise once they move away.
|
|
3263
|
+
//
|
|
3264
|
+
// BOTH halves are required, for the reason notify.ts states for its own
|
|
3265
|
+
// suppression rule: keyed on "is this the active tab" alone, this
|
|
3266
|
+
// swallowed the cue of the very session the user left running. That is
|
|
3267
|
+
// the single-session case — one tab is necessarily the active one — and
|
|
3268
|
+
// it is precisely the case the out-of-page surfaces exist for, so a
|
|
3269
|
+
// latch arriving on a HIDDEN page now raises them and is acknowledged
|
|
3270
|
+
// when the user comes back (see onPageVisible).
|
|
3168
3271
|
markCueSeen(rec.id, rec.status);
|
|
3169
|
-
} else if (
|
|
3272
|
+
} else if (isUnseenCue(rec.status, rec.id, cueSeen)) {
|
|
3170
3273
|
switchNotify = rec.status;
|
|
3171
3274
|
switchNotifyId = rec.id;
|
|
3172
3275
|
paintSwitchDot();
|
|
@@ -3977,6 +4080,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3977
4080
|
tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
|
|
3978
4081
|
},
|
|
3979
4082
|
teardown() {
|
|
4083
|
+
// Restore the out-of-page surfaces first, while the sinks still exist.
|
|
4084
|
+
// The kernel clears the title prefix on its own teardown, but the app
|
|
4085
|
+
// badge and the icon links are OUR state and nothing else will clear
|
|
4086
|
+
// them: the badge is OS-level and outlives the page entirely, and a
|
|
4087
|
+
// destroyed terminal in an embedded panel leaves the document's icon
|
|
4088
|
+
// pointed at a status variant for a tab that no longer exists.
|
|
4089
|
+
// Change-gated, so this is a no-op when nothing was lit.
|
|
4090
|
+
attention.apply(NO_ATTENTION);
|
|
3980
4091
|
tornDown = true;
|
|
3981
4092
|
resolveImpl = null;
|
|
3982
4093
|
offStatus?.();
|
|
@@ -3985,6 +4096,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3985
4096
|
offArmed?.();
|
|
3986
4097
|
offMenuKey();
|
|
3987
4098
|
document.removeEventListener("click", onDocClickMenu);
|
|
4099
|
+
document.removeEventListener("visibilitychange", onPageVisible);
|
|
3988
4100
|
document.removeEventListener("contextmenu", onDocContextMenu);
|
|
3989
4101
|
document.removeEventListener("pointerup", onDocTapDismiss, true);
|
|
3990
4102
|
document.removeEventListener("dragover", onDocTabDrop);
|
|
@@ -192,20 +192,79 @@ export const ACTIVE_TAB_KEY = "wt-active-session";
|
|
|
192
192
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
193
193
|
export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
|
|
194
194
|
|
|
195
|
-
/** The session statuses that raise the mobile switcher's
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
|
|
195
|
+
/** The session statuses that raise an attention cue: the mobile switcher's
|
|
196
|
+
* aggregate dot, and the three out-of-page surfaces derived from the same set
|
|
197
|
+
* (see attention.ts). The rule is "only states that want the user — it's like a
|
|
198
|
+
* notification": a background terminal blocked on the user ("input"), one whose
|
|
199
|
+
* turn finished ("done"), one whose process died badly ("crashed"), and one that
|
|
200
|
+
* reported an error ("failed").
|
|
201
|
+
*
|
|
202
|
+
* `working` and `warning` are excluded because they are ONGOING and
|
|
203
|
+
* informational: an animated dot pinned to the switch button would nag with
|
|
204
|
+
* nothing to act on. `failed` was excluded on the same reasoning and that was
|
|
205
|
+
* wrong — OSC 9;4 state 2 is not ongoing, it is a result the program parked and
|
|
206
|
+
* does not revisit until its next state change, which is exactly the shape of a
|
|
207
|
+
* thing to tell the user about. It is also the half of "red" a viewer cares most
|
|
208
|
+
* about: `crashed` was already here, so an errored turn raised nothing while a
|
|
209
|
+
* dead process raised everything.
|
|
210
|
+
*
|
|
211
|
+
* Declared once here so the raise test, the acknowledgement store and every
|
|
212
|
+
* attention sink cannot disagree about which statuses are cue-worthy. */
|
|
213
|
+
export type CueStatus = "input" | "done" | "crashed" | "failed";
|
|
214
|
+
|
|
215
|
+
/** Severity order over CueStatus, most severe first, AND the complete set: this
|
|
216
|
+
* array is what isCueStatus tests against, so the type and the runtime list
|
|
217
|
+
* cannot drift the way two hand-maintained four-way checks could.
|
|
218
|
+
*
|
|
219
|
+
* The order is read by the one surface that can show a single state (the browser
|
|
220
|
+
* tab icon), which paints the most severe unseen cue. That is a total order and
|
|
221
|
+
* so a defensible rule, unlike picking a session.
|
|
222
|
+
*
|
|
223
|
+
* A dead process outranks a reported error (the session is gone, not merely
|
|
224
|
+
* unhappy), an error outranks a request for input, and a finished turn is the
|
|
225
|
+
* mildest thing worth a cue at all. */
|
|
226
|
+
export const CUE_SEVERITY: readonly CueStatus[] = [STATUS_CRASHED, STATUS_FAILED, "input", "done"];
|
|
205
227
|
|
|
206
228
|
/** isCueStatus narrows a raw server status to a cue-worthy one. */
|
|
207
229
|
export function isCueStatus(status: string): status is CueStatus {
|
|
208
|
-
return
|
|
230
|
+
return (CUE_SEVERITY as readonly string[]).includes(status);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** worseCue returns whichever of two cues is more severe; "" means no cue. */
|
|
234
|
+
export function worseCue(a: CueStatus | "", b: CueStatus | ""): CueStatus | "" {
|
|
235
|
+
if (a === "") {
|
|
236
|
+
return b;
|
|
237
|
+
}
|
|
238
|
+
if (b === "") {
|
|
239
|
+
return a;
|
|
240
|
+
}
|
|
241
|
+
return CUE_SEVERITY.indexOf(a) <= CUE_SEVERITY.indexOf(b) ? a : b;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The icon variant a cue paints, which is NOT one per status: `crashed` and
|
|
245
|
+
* `failed` both render --status-failed, so they share one asset. An app ships
|
|
246
|
+
* one icon per name returned here (see scripts/gen-attention-icons.py). */
|
|
247
|
+
export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
|
|
248
|
+
if (status === "input" || status === "done") {
|
|
249
|
+
return status;
|
|
250
|
+
}
|
|
251
|
+
return "alert";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** isUnseenCue reports whether a session's CURRENT status is a cue this viewer
|
|
255
|
+
* has not acknowledged. The single predicate behind both the raise and the
|
|
256
|
+
* attention count, so a dot can never light for a session the count omits.
|
|
257
|
+
*
|
|
258
|
+
* Note what it does NOT consider: whether the session is the active tab. A
|
|
259
|
+
* latch arriving on the active tab is acknowledged at arrival (applyStatus), so
|
|
260
|
+
* it is already absent from the unseen set by the time anything folds over it,
|
|
261
|
+
* and no caller needs a special case. */
|
|
262
|
+
export function isUnseenCue(
|
|
263
|
+
status: string,
|
|
264
|
+
id: string,
|
|
265
|
+
seen: ReadonlyMap<string, CueStatus>,
|
|
266
|
+
): status is CueStatus {
|
|
267
|
+
return isCueStatus(status) && seen.get(id) !== status;
|
|
209
268
|
}
|
|
210
269
|
|
|
211
270
|
/** localStorage key for the cues this viewer has already SEEN: session id -> the
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -258,6 +258,31 @@ function buildTerminal(
|
|
|
258
258
|
narrowObserver.observe(root);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
// --- Document title (kernel-owned, composed from two inputs) ---
|
|
262
|
+
//
|
|
263
|
+
// The page has ONE title and two things want to write it: a program's OSC 0/2
|
|
264
|
+
// window title, and a feature's attention prefix (tabs' unseen-cue count). So
|
|
265
|
+
// the kernel holds both and is the only assigner. Two direct writers was the
|
|
266
|
+
// alternative and it cannot work: whichever ran last won, so a shell or an
|
|
267
|
+
// editor emitting OSC 2 silently erased the prefix, and there is no ordering
|
|
268
|
+
// that fixes it because both inputs change on their own schedule.
|
|
269
|
+
//
|
|
270
|
+
// The base starts as whatever the served document declared, so a consumer that
|
|
271
|
+
// never receives an OSC title (web-terminal-kiro sets kiro-cli's
|
|
272
|
+
// chat.terminalTitle=false) keeps its own <title> as the base rather than
|
|
273
|
+
// needing to declare it twice.
|
|
274
|
+
let baseTitle = document.title;
|
|
275
|
+
let titlePrefixText = "";
|
|
276
|
+
function paintTitle(): void {
|
|
277
|
+
const next = titlePrefixText + baseTitle;
|
|
278
|
+
// Idempotent: the title doubles as the browser-tab label and the bookmark
|
|
279
|
+
// name, and this runs on every status sweep, so an unchanged assignment must
|
|
280
|
+
// not churn it.
|
|
281
|
+
if (document.title !== next) {
|
|
282
|
+
document.title = next;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
261
286
|
// Toast surface (kernel-owned shared primitive, section 22.3).
|
|
262
287
|
const toastEl = document.createElement("div");
|
|
263
288
|
toastEl.className = "wt-toast";
|
|
@@ -607,7 +632,8 @@ function buildTerminal(
|
|
|
607
632
|
// when it redraws its prompt after idling, so hold the last-good browser
|
|
608
633
|
// title instead of flickering blank.
|
|
609
634
|
if (msg.title.trim() !== "") {
|
|
610
|
-
|
|
635
|
+
baseTitle = msg.title;
|
|
636
|
+
paintTitle();
|
|
611
637
|
}
|
|
612
638
|
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
613
639
|
} else if (msg.type === "modes") {
|
|
@@ -1216,6 +1242,10 @@ function buildTerminal(
|
|
|
1216
1242
|
// place rather than at each caller.
|
|
1217
1243
|
loadingStatus.reason(message);
|
|
1218
1244
|
},
|
|
1245
|
+
titlePrefix: (text) => {
|
|
1246
|
+
titlePrefixText = text;
|
|
1247
|
+
paintTitle();
|
|
1248
|
+
},
|
|
1219
1249
|
tablist: () => tablistController,
|
|
1220
1250
|
newLineStore: (sessionId) => {
|
|
1221
1251
|
// Registered on EVERY path, because this factory is the only way a store
|
|
@@ -1307,6 +1337,12 @@ function buildTerminal(
|
|
|
1307
1337
|
scrollbackKeeper?.flush();
|
|
1308
1338
|
scrollbackKeeper?.stop();
|
|
1309
1339
|
teardownFeatures();
|
|
1340
|
+
// A destroyed terminal must not leave its attention prefix on the page: the
|
|
1341
|
+
// tab it counted is gone, and the title outlives this runtime (an embedded
|
|
1342
|
+
// panel closing, a reload path). The base is left alone -- it belongs to the
|
|
1343
|
+
// document, not to us.
|
|
1344
|
+
titlePrefixText = "";
|
|
1345
|
+
paintTitle();
|
|
1310
1346
|
kernelAbort.abort();
|
|
1311
1347
|
narrowObserver?.disconnect();
|
|
1312
1348
|
narrowObserver = null;
|
package/src/kernel/types.ts
CHANGED
|
@@ -274,6 +274,21 @@ export interface TerminalContext {
|
|
|
274
274
|
* re-read the same sentence every few seconds. Also a no-op once the overlay
|
|
275
275
|
* has been dismissed, so a late retry cannot resurrect it. */
|
|
276
276
|
loadingReason(message: string): void;
|
|
277
|
+
/** Set the prefix the kernel composes onto the document title, or "" to clear
|
|
278
|
+
* it. The kernel is the only writer of `document.title`: it holds the base
|
|
279
|
+
* (the served `<title>`, replaced by a program's OSC 0/2 window title) and
|
|
280
|
+
* this prefix, and re-composes on either change. A feature that assigned
|
|
281
|
+
* `document.title` directly would be erased by the next OSC 2 from any shell
|
|
282
|
+
* or editor running in a session, with no ordering that avoids it.
|
|
283
|
+
*
|
|
284
|
+
* Idempotent and safe on every status tick: an unchanged result is not
|
|
285
|
+
* re-assigned, because the title doubles as the browser-tab label and the
|
|
286
|
+
* bookmark name. Cleared automatically when the terminal is destroyed.
|
|
287
|
+
*
|
|
288
|
+
* Keep it an AGGREGATE. A page has one title while this UI multiplexes many
|
|
289
|
+
* sessions, so any per-session value has to pick a session arbitrarily (see
|
|
290
|
+
* the standing decision against writing progress here in features/tabs). */
|
|
291
|
+
titlePrefix(text: string): void;
|
|
277
292
|
/** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
|
|
278
293
|
tablist(): TablistController;
|
|
279
294
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// presetAgentTabbed: the tabbed composition tuned for an agent shell.
|
|
2
2
|
|
|
3
3
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
4
|
-
import { buildTabbed } from "./tabbed.js";
|
|
4
|
+
import { type TabbedPresetOptions, buildTabbed } from "./tabbed.js";
|
|
5
5
|
|
|
6
6
|
/** Tabbed UI for an agent shell (web-terminal-kiro). Same features as
|
|
7
7
|
* presetTabbed, tuned for sessions that ARE agents:
|
|
@@ -15,6 +15,6 @@ import { buildTabbed } from "./tabbed.js";
|
|
|
15
15
|
* status classifier that maps OSC 9 notifications to done/needs-input, and the
|
|
16
16
|
* input-derived session name (terminal.WithInputTitle), which the agent host
|
|
17
17
|
* enables because its program's own window title is not worth showing. */
|
|
18
|
-
export function presetAgentTabbed(): TerminalFeature<unknown>[] {
|
|
19
|
-
return buildTabbed(true);
|
|
18
|
+
export function presetAgentTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
|
|
19
|
+
return buildTabbed(true, opts);
|
|
20
20
|
}
|
package/src/presets/tabbed.ts
CHANGED
|
@@ -27,7 +27,22 @@ import { presetSingle } from "./single.js";
|
|
|
27
27
|
* foreground-process/cwd inference — and both presets render what it reports. A
|
|
28
28
|
* browser that re-derived any of that could only disagree with the server and
|
|
29
29
|
* with every other client attached to the same session. */
|
|
30
|
-
|
|
30
|
+
/** Options shared by the two tabbed presets, passed through to the tabs feature.
|
|
31
|
+
* A preset that takes arguments is called as `features: () =>
|
|
32
|
+
* presetTabbed({...})`, which keeps the call inside createTerminal's failure
|
|
33
|
+
* boundary (see CreateTerminalOptions.features). */
|
|
34
|
+
export interface TabbedPresetOptions {
|
|
35
|
+
/** Swap the page's icon links to a status variant while a background session
|
|
36
|
+
* wants the user. Off by default, and enabling it is a promise that the
|
|
37
|
+
* variant assets are served — see TabsOptions.attentionIcons for the naming
|
|
38
|
+
* contract and scripts/gen-attention-icons.py, which writes them. */
|
|
39
|
+
attentionIcons?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildTabbed(
|
|
43
|
+
agentShell: boolean,
|
|
44
|
+
opts: TabbedPresetOptions = {},
|
|
45
|
+
): TerminalFeature<unknown>[] {
|
|
31
46
|
const kb = mobileToolbar({ externalToggle: true });
|
|
32
47
|
const monitor = activityMonitor();
|
|
33
48
|
return [
|
|
@@ -38,6 +53,7 @@ export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
|
|
|
38
53
|
keyboardToggle: kb,
|
|
39
54
|
activityMonitor: monitor,
|
|
40
55
|
presumeReports: agentShell,
|
|
56
|
+
attentionIcons: opts.attentionIcons === true,
|
|
41
57
|
}),
|
|
42
58
|
animations(),
|
|
43
59
|
];
|
|
@@ -49,6 +65,6 @@ export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
|
|
|
49
65
|
* activity dot stays hidden until its session reports activity via OSC 9;4
|
|
50
66
|
* progress (kiro-cli, Claude Code, …), so a plain bash/sh keeps clean,
|
|
51
67
|
* label-only tabs. */
|
|
52
|
-
export function presetTabbed(): TerminalFeature<unknown>[] {
|
|
53
|
-
return buildTabbed(false);
|
|
68
|
+
export function presetTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
|
|
69
|
+
return buildTabbed(false, opts);
|
|
54
70
|
}
|
package/src/presets.ts
CHANGED
|
@@ -19,4 +19,5 @@
|
|
|
19
19
|
export { presetSingle } from "./presets/single.js";
|
|
20
20
|
export { presetTouch } from "./presets/touch.js";
|
|
21
21
|
export { presetTabbed } from "./presets/tabbed.js";
|
|
22
|
+
export type { TabbedPresetOptions } from "./presets/tabbed.js";
|
|
22
23
|
export { presetAgentTabbed } from "./presets/agent-tabbed.js";
|