@cplieger/web-terminal-ui 3.3.0 → 3.4.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 +13 -9
- package/css/00-tokens.css +9 -2
- package/css/02-app.css +15 -141
- package/css/03-primitives.css +1 -0
- package/css/04-feedback.css +20 -6
- package/css/05-tabs.css +106 -23
- package/css/06-mobile.css +104 -38
- package/css/07-animations.css +22 -13
- package/package.json +2 -2
- package/src/composition.ts +29 -4
- package/src/features/activity-monitor.ts +8 -1
- package/src/features/animations.ts +10 -5
- package/src/features/clipboard.ts +8 -2
- package/src/features/connection-banner.ts +0 -1
- package/src/features/context-menu.ts +32 -0
- package/src/features/predictive-echo.ts +10 -0
- package/src/features/tabs.ts +602 -112
- package/src/input-placeholder.ts +2 -2
- package/src/kernel/kernel.ts +82 -10
- package/src/presets.ts +18 -15
- package/src/viewport.ts +80 -3
package/src/features/tabs.ts
CHANGED
|
@@ -39,6 +39,19 @@ const PREVIEW_DRAG_RATIO = 0.1;
|
|
|
39
39
|
const PREVIEW_PEEK_MAX = 10;
|
|
40
40
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
41
41
|
const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
|
|
42
|
+
// localStorage key for the last active session id, so a page reload reopens the
|
|
43
|
+
// tab the user left on rather than always defaulting to the oldest one.
|
|
44
|
+
const ACTIVE_TAB_KEY = "wt-active-session";
|
|
45
|
+
// The mobile bottom-switcher (a single full-width active-tab chip + swipe) is
|
|
46
|
+
// used ONLY on a narrow coarse-pointer device (a phone). A wide touchscreen (an
|
|
47
|
+
// iPad) and every fine-pointer device (a desktop, or an iPad with a trackpad /
|
|
48
|
+
// Magic Keyboard) get the multi-tab top strip instead — the switcher's
|
|
49
|
+
// single-giant-tab layout wastes a big screen (an iPad was getting the phone
|
|
50
|
+
// UI). MUST stay in lockstep with the identical media query in css/06-mobile.css
|
|
51
|
+
// (.wt-switcher shown / .wt-tab-bar hidden) and its complement in css/05-tabs.css
|
|
52
|
+
// (the desktop-strip terminal clearance). Width chosen to sit above every phone
|
|
53
|
+
// portrait width and below iPad portrait (768).
|
|
54
|
+
const SWITCHER_MEDIA = "(pointer: coarse) and (max-width: 600px)";
|
|
42
55
|
// Default cadence for the no-activityMonitor polling fallback.
|
|
43
56
|
const DEFAULT_POLL_MS = 4000;
|
|
44
57
|
// Tab context-menu viewport clamp (mirrors context-menu.ts): keep this margin
|
|
@@ -52,6 +65,10 @@ const TAB_MENU_GAP = 16;
|
|
|
52
65
|
interface SessionInfo {
|
|
53
66
|
id: string;
|
|
54
67
|
title: string;
|
|
68
|
+
/** The raw client-set title (the value pushed via PUT .../title), before the
|
|
69
|
+
* server's OSC fallback baked into `title`. A consumer that treats the
|
|
70
|
+
* program's OSC window title as unreliable (an agent shell) reads this. */
|
|
71
|
+
clientTitle?: string | undefined;
|
|
55
72
|
createdAt: string;
|
|
56
73
|
status: string;
|
|
57
74
|
/** Sticky: true once the session has emitted a genuine activity signal
|
|
@@ -89,6 +106,12 @@ interface Tab {
|
|
|
89
106
|
* cwd-only title disabled. Undefined until the first non-empty submission; see
|
|
90
107
|
* baseLabel. */
|
|
91
108
|
derived?: string;
|
|
109
|
+
/** The persisted client title reported by the server (its `clientTitle` wire
|
|
110
|
+
* field): the last derived title pushed via PUT .../title, surviving reloads.
|
|
111
|
+
* In `preferInputTitle` mode this is the reload-recovery source (the live
|
|
112
|
+
* `derived` is lost on reload, and `title` is the unreliable OSC value there).
|
|
113
|
+
* In the default mode it is the lowest-priority fallback after title/derived. */
|
|
114
|
+
clientTitle?: string | undefined;
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
export interface TabsApi {
|
|
@@ -120,19 +143,88 @@ export interface TabsOptions {
|
|
|
120
143
|
* toolbar should be built with { externalToggle: true } so its own top-right
|
|
121
144
|
* toggle is hidden and the grid opens above the bar. */
|
|
122
145
|
keyboardToggle?: TerminalFeature<MobileToolbarApi>;
|
|
146
|
+
/** Prefer the input-derived title over the process OSC 0/2 title. Default
|
|
147
|
+
* false (OSC-first: a program that sets its own window title wins, else the
|
|
148
|
+
* last submitted line). Set true for an agent shell whose program emits a
|
|
149
|
+
* non-empty but useless OSC title (kiro-cli under vibecli): the label then
|
|
150
|
+
* follows the latest submitted line (live) or the persisted client title (on
|
|
151
|
+
* reload), and the unreliable OSC title is ignored entirely. presetAgentTabbed
|
|
152
|
+
* enables this. */
|
|
153
|
+
preferInputTitle?: boolean;
|
|
123
154
|
}
|
|
124
155
|
|
|
125
|
-
// The +/x glyphs are inline SVG (not font glyphs) so they center
|
|
126
|
-
// their
|
|
156
|
+
// The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
|
|
157
|
+
// exactly in their flex-centered buttons and stay symmetric regardless of the UI
|
|
158
|
+
// font's metrics. Each is defined ONCE here and shared by every chip site and
|
|
159
|
+
// control below (rather than duplicated across the desktop and mobile markup).
|
|
160
|
+
const CLOSE_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg>`;
|
|
161
|
+
const NEW_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`;
|
|
162
|
+
const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3.5"/><path d="M15 15 9.5 9.5M9.5 13V9.5H13"/></svg>`;
|
|
163
|
+
// Two-arrows "switch" glyph for the mobile switcher's dedicated open/close
|
|
164
|
+
// button (a latest-wins background-tab notification dot rides on it — see
|
|
165
|
+
// switchButtonHTML). Same viewBox + stroke=currentcolor treatment as the others.
|
|
166
|
+
const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9h13M14 6l3 3-3 3M20 15H7M10 12l-3 3 3 3"/></svg>`;
|
|
167
|
+
|
|
168
|
+
// chipContent is the ONE builder for a tab chip's content — a status dot, a
|
|
169
|
+
// label, and a close (x) — shared by all three chip sites: the desktop strip
|
|
170
|
+
// (.wt-tab), the mobile active row (.wt-switcher-current), and each expanded
|
|
171
|
+
// mobile list row (.wt-switcher-row). Each site passes its OWN class set (never
|
|
172
|
+
// renamed) so every existing selector — and thus all CSS and all tests — still
|
|
173
|
+
// matches. The one structural difference is WHERE the close sits: the desktop
|
|
174
|
+
// chip nests it flat inside .wt-tab, while the two mobile chips place it as a
|
|
175
|
+
// sibling of the select/swipe button (a button can't nest in a button). So the
|
|
176
|
+
// builder returns two fragments — the dot+label pair and the close — that each
|
|
177
|
+
// site drops into its own structure.
|
|
178
|
+
function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
|
|
179
|
+
dotLabel: string;
|
|
180
|
+
close: string;
|
|
181
|
+
} {
|
|
182
|
+
return {
|
|
183
|
+
dotLabel:
|
|
184
|
+
`<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
|
|
185
|
+
`<span class="${v.label}"></span>`,
|
|
186
|
+
close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The ONE "+" (new-terminal) and keyboard button factories, shared by the
|
|
191
|
+
// desktop strip and the mobile switcher — only the class set differs. These
|
|
192
|
+
// build the markup; element construction + event wiring live in makeNewButton /
|
|
193
|
+
// makeKbButton (in setup), which close over create() and the key-grid toggle.
|
|
194
|
+
function newButtonHTML(cls: string): string {
|
|
195
|
+
return `<button type="button" class="${cls}" aria-label="New terminal">${NEW_SVG}</button>`;
|
|
196
|
+
}
|
|
197
|
+
function kbButtonHTML(cls: string): string {
|
|
198
|
+
return `<button type="button" class="${cls}" aria-label="Keyboard keys" aria-expanded="false" hidden>${KB_SVG}</button>`;
|
|
199
|
+
}
|
|
200
|
+
// The mobile switcher's dedicated open/close button. It toggles the tab list and
|
|
201
|
+
// carries a latest-wins notification dot (a child span) — amber when a background
|
|
202
|
+
// terminal needs input, green when a background turn finished — cleared when the
|
|
203
|
+
// list opens. Only the mobile switcher builds it (element construction + event
|
|
204
|
+
// wiring live in makeSwitchButton in setup); the desktop strip already shows
|
|
205
|
+
// every tab's own status dot, so it needs no aggregate cue.
|
|
206
|
+
function switchButtonHTML(cls: string): string {
|
|
207
|
+
return (
|
|
208
|
+
`<button type="button" class="${cls}" aria-label="Open tab switcher">${SWITCH_SVG}` +
|
|
209
|
+
`<span class="wt-status-dot wt-switcher-switch-dot" aria-hidden="true"></span></button>`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Desktop strip chip: dot + label + close all flat inside .wt-tab (the whole
|
|
214
|
+
// chip is the click/switch target; the close is a nested button). tabindex="-1"
|
|
215
|
+
// keeps the close out of the tab order.
|
|
216
|
+
const TAB_CHIP = chipContent({
|
|
217
|
+
dot: "wt-tab-dot",
|
|
218
|
+
label: "wt-tab-label",
|
|
219
|
+
close: "wt-tab-close",
|
|
220
|
+
closeAttr: ' tabindex="-1"',
|
|
221
|
+
});
|
|
127
222
|
const TAB_HTML = `
|
|
128
223
|
<div class="wt-tab">
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<button type="button" class="wt-tab-close" aria-label="Close terminal" tabindex="-1"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg></button>
|
|
224
|
+
${TAB_CHIP.dotLabel}
|
|
225
|
+
${TAB_CHIP.close}
|
|
132
226
|
</div>`;
|
|
133
227
|
|
|
134
|
-
const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>`;
|
|
135
|
-
|
|
136
228
|
// Mobile bottom bar. One element, two parts stacked in a column: the always-
|
|
137
229
|
// visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
|
|
138
230
|
// and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
|
|
@@ -147,23 +239,24 @@ const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New termi
|
|
|
147
239
|
// (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
|
|
148
240
|
// right, mirroring the listed rows. No "n / m" counter: the list below is a
|
|
149
241
|
// rotating circular queue, so an absolute position number is meaningless.
|
|
150
|
-
// - .wt-switcher-kb
|
|
151
|
-
// a keyboardToggle feature is provided)
|
|
152
|
-
//
|
|
242
|
+
// - The keyboard button (.wt-switcher-kb, opens the key grid above the bar,
|
|
243
|
+
// only wired + shown when a keyboardToggle feature is provided) and the
|
|
244
|
+
// accent "+" (.wt-switcher-new, spawns a terminal) are appended to the bar
|
|
245
|
+
// row from the shared factories in setup (see makeKbButton / makeNewButton).
|
|
246
|
+
const CURRENT_CHIP = chipContent({
|
|
247
|
+
dot: "wt-switcher-dot",
|
|
248
|
+
label: "wt-switcher-label",
|
|
249
|
+
close: "wt-switcher-current-close wt-btn",
|
|
250
|
+
});
|
|
153
251
|
const SWITCHER_HTML = `
|
|
154
252
|
<div class="wt-switcher" role="group" aria-label="Terminal tabs">
|
|
155
253
|
<div class="wt-switcher-bar">
|
|
156
254
|
<div class="wt-switcher-current-wrap">
|
|
157
255
|
<button type="button" class="wt-switcher-current" aria-haspopup="true" aria-expanded="false">
|
|
158
|
-
<span class="wt-switcher-current-inner">
|
|
159
|
-
<span class="wt-switcher-dot wt-status-dot" aria-hidden="true"></span>
|
|
160
|
-
<span class="wt-switcher-label"></span>
|
|
161
|
-
</span>
|
|
256
|
+
<span class="wt-switcher-current-inner">${CURRENT_CHIP.dotLabel}</span>
|
|
162
257
|
</button>
|
|
163
|
-
|
|
258
|
+
${CURRENT_CHIP.close}
|
|
164
259
|
</div>
|
|
165
|
-
<button type="button" class="wt-switcher-kb wt-btn" aria-label="Keyboard keys" aria-expanded="false" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3.5"/><path d="M15 15 9.5 9.5M9.5 13V9.5H13"/></svg></button>
|
|
166
|
-
<button type="button" class="wt-switcher-new wt-btn wt-switcher-new-btn" aria-label="New terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
167
260
|
</div>
|
|
168
261
|
<ul class="wt-switcher-list" role="list"></ul>
|
|
169
262
|
</div>`;
|
|
@@ -171,34 +264,114 @@ const SWITCHER_HTML = `
|
|
|
171
264
|
// One row per OTHER tab in the expanded list: a stretched select target (dot +
|
|
172
265
|
// label) with the close (x) laid inside it at the right (two buttons can't nest,
|
|
173
266
|
// so the x is a sibling overlapping the select's reserved right padding).
|
|
267
|
+
const ROW_CHIP = chipContent({
|
|
268
|
+
dot: "wt-switcher-row-dot",
|
|
269
|
+
label: "wt-switcher-row-label",
|
|
270
|
+
close: "wt-switcher-row-close wt-btn",
|
|
271
|
+
});
|
|
174
272
|
const SWITCHER_ROW_HTML = `
|
|
175
273
|
<li class="wt-switcher-row">
|
|
176
|
-
<button type="button" class="wt-switcher-row-select">
|
|
177
|
-
|
|
178
|
-
<span class="wt-switcher-row-label"></span>
|
|
179
|
-
</button>
|
|
180
|
-
<button type="button" class="wt-switcher-row-close wt-btn" aria-label="Close terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg></button>
|
|
274
|
+
<button type="button" class="wt-switcher-row-select">${ROW_CHIP.dotLabel}</button>
|
|
275
|
+
${ROW_CHIP.close}
|
|
181
276
|
</li>`;
|
|
182
277
|
|
|
278
|
+
// looksLikeHardwareKey reports whether a keydown could only have come from a
|
|
279
|
+
// PHYSICAL keyboard on a touch device. There is no web API that directly says "a
|
|
280
|
+
// hardware keyboard is attached" (navigator.keyboard is layout/lock only and
|
|
281
|
+
// unsupported on iOS Safari; navigator.virtualKeyboard is Chromium-only), so we
|
|
282
|
+
// infer it: the iOS on-screen keyboard has no modifier keys and no
|
|
283
|
+
// arrows/Escape/Tab/nav/function keys, so any of these means real hardware. Used
|
|
284
|
+
// to latch a "physical keyboard present" flag that also covers a keyboard-only
|
|
285
|
+
// Smart Keyboard Folio (which, unlike a Magic Keyboard, adds no trackpad and so
|
|
286
|
+
// does not match `any-pointer: fine`).
|
|
287
|
+
function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
288
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
switch (ev.key) {
|
|
292
|
+
case "ArrowUp":
|
|
293
|
+
case "ArrowDown":
|
|
294
|
+
case "ArrowLeft":
|
|
295
|
+
case "ArrowRight":
|
|
296
|
+
case "Escape":
|
|
297
|
+
case "Tab":
|
|
298
|
+
case "Home":
|
|
299
|
+
case "End":
|
|
300
|
+
case "PageUp":
|
|
301
|
+
case "PageDown":
|
|
302
|
+
return true;
|
|
303
|
+
default:
|
|
304
|
+
return /^F\d{1,2}$/.test(ev.key); // F1–F12
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
183
308
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
184
309
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
310
|
+
const preferInputTitle = opts.preferInputTitle ?? false;
|
|
311
|
+
// fetch has no default timeout; a stalled-but-open server would otherwise leave a
|
|
312
|
+
// boot apiList/apiCreate await pending forever, so setup() never resolves and the
|
|
313
|
+
// kernel's post-setup overlay-dismiss never fires (permanent loading overlay). Bound
|
|
314
|
+
// every session-REST call; on expiry the existing catch paths recover.
|
|
315
|
+
const SESSION_API_TIMEOUT_MS = 15000;
|
|
185
316
|
|
|
186
317
|
async function apiList(): Promise<SessionInfo[]> {
|
|
187
|
-
const r = await fetch(apiBase, {
|
|
318
|
+
const r = await fetch(apiBase, {
|
|
319
|
+
headers: { Accept: "application/json" },
|
|
320
|
+
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
321
|
+
});
|
|
188
322
|
if (!r.ok) {
|
|
189
323
|
throw new Error(`web-terminal-ui: session list failed (${String(r.status)})`);
|
|
190
324
|
}
|
|
191
|
-
|
|
325
|
+
const data: unknown = await r.json();
|
|
326
|
+
// A 200 with a non-array body -- a proxy error object, or a Go server
|
|
327
|
+
// marshaling a nil session slice as JSON `null` -- must not reach the
|
|
328
|
+
// bootstrap's `sessions.length` / `for...of` (or the poll's `list.map`)
|
|
329
|
+
// uncaught: it would throw out of setup(), trip the kernel's 'terminal
|
|
330
|
+
// has no chrome' teardown, and (managesSessions skipped the startup
|
|
331
|
+
// connect) leave connectionInitiated false -> a permanently dead
|
|
332
|
+
// terminal. Reject a non-array so the callers' existing catch paths
|
|
333
|
+
// recover (bootstrap -> [], poll -> skip the tick).
|
|
334
|
+
if (!Array.isArray(data)) {
|
|
335
|
+
throw new Error("web-terminal-ui: session list returned a non-array body");
|
|
336
|
+
}
|
|
337
|
+
return data as SessionInfo[];
|
|
192
338
|
}
|
|
193
339
|
async function apiCreate(): Promise<SessionInfo> {
|
|
194
|
-
const r = await fetch(apiBase, {
|
|
340
|
+
const r = await fetch(apiBase, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
343
|
+
});
|
|
195
344
|
if (!r.ok) {
|
|
196
345
|
throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
|
|
197
346
|
}
|
|
198
347
|
return (await r.json()) as SessionInfo;
|
|
199
348
|
}
|
|
200
349
|
async function apiClose(id: string): Promise<void> {
|
|
201
|
-
await fetch(`${apiBase}/${encodeURIComponent(id)}`, {
|
|
350
|
+
const r = await fetch(`${apiBase}/${encodeURIComponent(id)}`, {
|
|
351
|
+
method: "DELETE",
|
|
352
|
+
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
353
|
+
});
|
|
354
|
+
if (!r.ok) {
|
|
355
|
+
throw new Error(`web-terminal-ui: session close failed (${String(r.status)})`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Persist the input-derived tab title server-side so it survives a page reload
|
|
359
|
+
// and shows on other devices. The engine stores it as the session's fallback
|
|
360
|
+
// title and uses it only when the program emits no OSC title (the vibecli case:
|
|
361
|
+
// kiro-cli emits no usable OSC title, so the latest user message is the label).
|
|
362
|
+
// Best-effort and fire-and-forget: a failure never disrupts the terminal — the
|
|
363
|
+
// locally-derived title still displays, it just is not persisted this time.
|
|
364
|
+
async function apiSetTitle(id: string, title: string): Promise<void> {
|
|
365
|
+
try {
|
|
366
|
+
await fetch(`${apiBase}/${encodeURIComponent(id)}/title`, {
|
|
367
|
+
method: "PUT",
|
|
368
|
+
headers: { "Content-Type": "application/json" },
|
|
369
|
+
body: JSON.stringify({ title }),
|
|
370
|
+
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
371
|
+
});
|
|
372
|
+
} catch {
|
|
373
|
+
/* best-effort persistence; ignore network/timeout errors */
|
|
374
|
+
}
|
|
202
375
|
}
|
|
203
376
|
|
|
204
377
|
return {
|
|
@@ -212,6 +385,71 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
212
385
|
const tablist = ctx.tablist();
|
|
213
386
|
const monitor = opts.activityMonitor ? ctx.use(opts.activityMonitor) : undefined;
|
|
214
387
|
|
|
388
|
+
// The keyboard buttons wired to the key grid — the mobile switcher's and
|
|
389
|
+
// the desktop strip's — built + wired by the ONE makeKbButton factory;
|
|
390
|
+
// closeKeyGrid and the sticky-Ctrl armed reflect update every one.
|
|
391
|
+
const kbButtons: HTMLElement[] = [];
|
|
392
|
+
// makeNewButton / makeKbButton are the shared control factories (goals 2 &
|
|
393
|
+
// 3): one "+" and one keyboard-button implementation, each built + wired
|
|
394
|
+
// once and reused for the desktop strip and the mobile switcher. The "+"
|
|
395
|
+
// spawns a terminal; the keyboard button toggles the key grid (via the
|
|
396
|
+
// keyboardToggle feature, read lazily so feature ordering does not matter)
|
|
397
|
+
// and reflects its open state on every keyboard button.
|
|
398
|
+
function makeNewButton(cls: string): HTMLElement {
|
|
399
|
+
const btn = fromHTML(newButtonHTML(cls));
|
|
400
|
+
// Keep the hidden terminal textarea focused: a bar button needs no focus
|
|
401
|
+
// of its own, and letting the tap shift focus makes iOS/iPadOS consume
|
|
402
|
+
// the FIRST tap to blur the input (so "+" only fired create on the second
|
|
403
|
+
// tap — the reported double-tap). preventDefault on pointerdown fires the
|
|
404
|
+
// action on the first tap; switchTo then re-focuses the input for the new
|
|
405
|
+
// tab on a physical-keyboard device.
|
|
406
|
+
btn.addEventListener("pointerdown", (e) => {
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
});
|
|
409
|
+
btn.addEventListener("click", () => {
|
|
410
|
+
void create();
|
|
411
|
+
});
|
|
412
|
+
return btn;
|
|
413
|
+
}
|
|
414
|
+
function makeKbButton(cls: string): HTMLElement {
|
|
415
|
+
const btn = fromHTML(kbButtonHTML(cls));
|
|
416
|
+
// First-tap focus retention (see makeNewButton): keep the terminal
|
|
417
|
+
// textarea focused so the toggle fires on the first tap on iOS/iPadOS.
|
|
418
|
+
btn.addEventListener("pointerdown", (e) => {
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
});
|
|
421
|
+
btn.addEventListener("click", () => {
|
|
422
|
+
const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
|
|
423
|
+
if (!kb) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
kb.toggle();
|
|
427
|
+
const open = kb.isOpen();
|
|
428
|
+
for (const b of kbButtons) {
|
|
429
|
+
b.setAttribute("aria-expanded", String(open));
|
|
430
|
+
b.classList.toggle("wt-active", open);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
kbButtons.push(btn);
|
|
434
|
+
return btn;
|
|
435
|
+
}
|
|
436
|
+
// makeSwitchButton builds the mobile switcher's dedicated open/close
|
|
437
|
+
// button (its notification dot is painted by paintSwitchDot). Like the
|
|
438
|
+
// other bar buttons it preventDefaults pointerdown for first-tap focus
|
|
439
|
+
// retention (keeps the terminal textarea focused so it fires on the first
|
|
440
|
+
// tap on iOS/iPadOS); its click toggles the list (toggleSwitcher opens when
|
|
441
|
+
// collapsed, closes when expanded).
|
|
442
|
+
function makeSwitchButton(): HTMLElement {
|
|
443
|
+
const btn = fromHTML(switchButtonHTML("wt-switcher-switch wt-btn"));
|
|
444
|
+
btn.addEventListener("pointerdown", (e) => {
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
});
|
|
447
|
+
btn.addEventListener("click", () => {
|
|
448
|
+
toggleSwitcher();
|
|
449
|
+
});
|
|
450
|
+
return btn;
|
|
451
|
+
}
|
|
452
|
+
|
|
215
453
|
// --- Desktop tab strip (top-bar region) ---
|
|
216
454
|
const slot = ctx.region("top-bar", "tabs");
|
|
217
455
|
const bar = document.createElement("div");
|
|
@@ -222,8 +460,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
222
460
|
// the slot (a sibling of the fixed .wt-tab-bar) flowed it out of the strip
|
|
223
461
|
// to the root, so it never appeared; inside the flex bar it renders at the
|
|
224
462
|
// end of the tabs (addTabChrome inserts each tab before it).
|
|
225
|
-
const newBtn =
|
|
463
|
+
const newBtn = makeNewButton("wt-tab-new");
|
|
226
464
|
bar.appendChild(newBtn);
|
|
465
|
+
// Desktop-strip keyboard button, built + wired by the SAME factory as the
|
|
466
|
+
// mobile switcher's. A SIBLING of the strip (appended to the region slot,
|
|
467
|
+
// not into .wt-tab-bar) so it never affects the strip's child order; CSS
|
|
468
|
+
// shows it only on a wide touchscreen (where the strip, not the switcher,
|
|
469
|
+
// is used) and hides it on a fine-pointer desktop. Un-hidden below only
|
|
470
|
+
// when a keyboardToggle feature is wired.
|
|
471
|
+
const deskKb = makeKbButton("wt-tab-kb wt-btn");
|
|
472
|
+
slot.appendChild(deskKb);
|
|
227
473
|
|
|
228
474
|
// Pull the terminal surface up off the fixed BOTTOM strip on desktop so
|
|
229
475
|
// the bar does not overlap the last rows. The surface is position:fixed
|
|
@@ -261,24 +507,55 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
261
507
|
// swipe: the content (dot + label) and the close (x). Moving both keeps the
|
|
262
508
|
// whole active-tab chip sliding as one, rather than the close staying put.
|
|
263
509
|
const swipeEls = [swInner, swClose];
|
|
264
|
-
|
|
265
|
-
|
|
510
|
+
// The mobile "+", keyboard, and switcher buttons: built + wired by the
|
|
511
|
+
// SAME shared factories as the desktop strip's, then appended to the bar
|
|
512
|
+
// row so the order stays current-wrap | keyboard | switch | "+". Unifying
|
|
513
|
+
// the controls means one implementation placed per layout rather than
|
|
514
|
+
// duplicated markup. The switch button sits BETWEEN the keyboard and "+":
|
|
515
|
+
// it toggles the list and carries the moved background-tab attention cue.
|
|
516
|
+
const swKb = makeKbButton("wt-switcher-kb wt-btn");
|
|
517
|
+
const swSwitch = makeSwitchButton();
|
|
518
|
+
const swSwitchDot = pick(swSwitch, ".wt-switcher-switch-dot");
|
|
519
|
+
const swNew = makeNewButton("wt-switcher-new wt-btn wt-switcher-new-btn");
|
|
520
|
+
swBar.append(swKb, swSwitch, swNew);
|
|
521
|
+
// Latest-wins notification state for the switch button's dot: overwritten
|
|
522
|
+
// by each qualifying background-tab event (applyStatus) and cleared when
|
|
523
|
+
// the list opens (expandSwitcher). "" = no pending cue; the dot is hidden.
|
|
524
|
+
let switchNotify: "" | "input" | "done" = "";
|
|
525
|
+
function paintSwitchDot(): void {
|
|
526
|
+
// Reuse the per-tab status-dot colours (single source, css/05-tabs.css
|
|
527
|
+
// .wt-status-dot[data-status="input"|"done"]) instead of re-declaring
|
|
528
|
+
// them: the dot has the .wt-status-dot class, so data-status colours it
|
|
529
|
+
// exactly like the tabs' own dots.
|
|
530
|
+
if (switchNotify === "") {
|
|
531
|
+
delete swSwitchDot.dataset["status"];
|
|
532
|
+
} else {
|
|
533
|
+
swSwitchDot.dataset["status"] = switchNotify;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
266
536
|
ctx.region("bottom-switcher", "switcher").appendChild(switcher);
|
|
267
|
-
// The keyboard
|
|
537
|
+
// The keyboard buttons open the key grid; show them only when a toolbar is
|
|
268
538
|
// wired to drive. Read the toolbar's API lazily at tap time (ctx.use), so
|
|
269
539
|
// feature ordering does not matter.
|
|
270
540
|
let offArmed: (() => void) | undefined;
|
|
271
541
|
if (opts.keyboardToggle) {
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
542
|
+
// Un-hide every keyboard button; the mobile one then shows in the
|
|
543
|
+
// switcher bar, the desktop one is CSS-gated to a wide touchscreen.
|
|
544
|
+
for (const b of kbButtons) {
|
|
545
|
+
b.hidden = false;
|
|
546
|
+
}
|
|
547
|
+
// Mirror sticky-Ctrl on every keyboard button: when a Ctrl press is
|
|
548
|
+
// armed, invert the button (like the armed Ctrl key) so the pending
|
|
549
|
+
// modifier is visible with the grid closed — the toolbar sets up before
|
|
550
|
+
// tabs, so its API is available now (see the preset ordering note). Also
|
|
551
|
+
// clears on the auto-disarm after a Ctrl byte and on a tab switch
|
|
552
|
+
// (onDetach disarms).
|
|
278
553
|
const kbApi = ctx.use(opts.keyboardToggle);
|
|
279
554
|
if (kbApi) {
|
|
280
555
|
const reflectArmed = (armed: boolean): void => {
|
|
281
|
-
|
|
556
|
+
for (const b of kbButtons) {
|
|
557
|
+
b.classList.toggle("wt-armed", armed);
|
|
558
|
+
}
|
|
282
559
|
};
|
|
283
560
|
reflectArmed(kbApi.isCtrlArmed());
|
|
284
561
|
offArmed = kbApi.onCtrlArmedChange(reflectArmed);
|
|
@@ -333,6 +610,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
333
610
|
ctx.region("overlay", "tab-menu").appendChild(tabMenu);
|
|
334
611
|
|
|
335
612
|
const tabList: Tab[] = [];
|
|
613
|
+
// Ids the user closed within CLOSE_TOMBSTONE_MS, so a stale server listing
|
|
614
|
+
// (the SSE re-open snapshot, or the poll's GET /api/sessions) that predates
|
|
615
|
+
// the server reaping the session does not re-adopt (flash back) the closed
|
|
616
|
+
// tab. TTL-cleared in adoptSession.
|
|
617
|
+
const recentlyClosed = new Map<string, number>();
|
|
618
|
+
const CLOSE_TOMBSTONE_MS = 15000;
|
|
619
|
+
function tombstone(id: string): void {
|
|
620
|
+
const now = Date.now();
|
|
621
|
+
// Sweep entries whose window already elapsed (adoptSession treats them as
|
|
622
|
+
// untombstoned anyway) so the map cannot grow without bound over a long
|
|
623
|
+
// session of opens/closes; then record this close.
|
|
624
|
+
for (const [k, t] of recentlyClosed) {
|
|
625
|
+
if (now - t >= CLOSE_TOMBSTONE_MS) {
|
|
626
|
+
recentlyClosed.delete(k);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
recentlyClosed.set(id, now);
|
|
630
|
+
}
|
|
336
631
|
// The expanded mobile list's row elements, keyed by tab id. Rows are
|
|
337
632
|
// reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
|
|
338
633
|
// same elements from their old slots to their new ones (the rotation).
|
|
@@ -355,11 +650,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
355
650
|
let creatingTab = false;
|
|
356
651
|
let collapseClearTimer: ReturnType<typeof setTimeout> | null = null;
|
|
357
652
|
let hintShown = false;
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
653
|
+
// Whether to focus the input on a tab switch. On a device with a physical
|
|
654
|
+
// keyboard this is what you want (switch, then type immediately); on a
|
|
655
|
+
// keyboard-less touchscreen it must NOT happen, or every switch pops the
|
|
656
|
+
// virtual keyboard. No web API reports a hardware keyboard directly, so we
|
|
657
|
+
// combine two proxies: (1) a fine pointer (a Magic Keyboard carries a
|
|
658
|
+
// trackpad, so an iPad with one matches, as does every desktop; a bare
|
|
659
|
+
// phone / keyboard-less tablet does not) — read live, since a keyboard can
|
|
660
|
+
// be attached/detached; and (2) sawHardwareKey, latched once we observe a
|
|
661
|
+
// keydown only a hardware keyboard emits (covers a trackpad-less keyboard
|
|
662
|
+
// folio). See looksLikeHardwareKey and the keydown observer below.
|
|
663
|
+
let sawHardwareKey = false;
|
|
664
|
+
const hasFinePointer = (): boolean =>
|
|
665
|
+
typeof window.matchMedia === "function" && window.matchMedia("(any-pointer: fine)").matches;
|
|
666
|
+
const physicalKeyboardLikely = (): boolean => sawHardwareKey || hasFinePointer();
|
|
363
667
|
// Motion opt-out (checked live: the OS setting can change). Gates the
|
|
364
668
|
// interactive swipe/rotation animations, mirroring the CSS .wt-animate gate.
|
|
365
669
|
const prefersReduce = (): boolean =>
|
|
@@ -374,7 +678,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
374
678
|
// "New tab". fallback=true marks that last case so relabelAll leaves
|
|
375
679
|
// untitled tabs as "New tab" with no numeric suffix.
|
|
376
680
|
function baseLabel(tab: Tab): { text: string; fallback: boolean } {
|
|
377
|
-
|
|
681
|
+
// preferInputTitle (agent shell with an unreliable OSC title): show the
|
|
682
|
+
// live submitted line, then the persisted client title on reload — the
|
|
683
|
+
// process OSC `title` is ignored. Default: OSC title first, then the live
|
|
684
|
+
// derived line, then the persisted client title (the latter two are the
|
|
685
|
+
// "latest user message" fallback when the program sets no OSC title).
|
|
686
|
+
const derived = tab.derived?.trim() ?? "";
|
|
687
|
+
const persisted = tab.clientTitle?.trim() ?? "";
|
|
688
|
+
const real = preferInputTitle
|
|
689
|
+
? derived || persisted
|
|
690
|
+
: tab.title.trim() || derived || persisted;
|
|
378
691
|
return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
|
|
379
692
|
}
|
|
380
693
|
|
|
@@ -421,12 +734,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
421
734
|
// tab's derived title on every non-empty submission. Best-effort: an odd
|
|
422
735
|
// editing sequence just yields no derived title (the tab then reads "New
|
|
423
736
|
// tab" until the next submission).
|
|
424
|
-
|
|
737
|
+
// A generous storage bound for the fallback (input-derived) title. The tab
|
|
738
|
+
// label truncates it visually with max-width + ellipsis in EVERY view — the
|
|
739
|
+
// desktop strip, the mobile active bar, and the mobile list — so this is
|
|
740
|
+
// only a guard against storing a huge paste, not a display cut. (It was a
|
|
741
|
+
// hard 60 that cut the label mid-word regardless of how much fit.)
|
|
742
|
+
const MAX_DERIVED = 512;
|
|
425
743
|
let lineBytes: number[] = [];
|
|
426
744
|
let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
|
|
745
|
+
let csiParams = ""; // accumulated CSI parameter bytes, to spot paste guards
|
|
746
|
+
let inPaste = false; // inside a bracketed paste (ESC[200~ … ESC[201~)
|
|
427
747
|
function resetInputLine(): void {
|
|
428
748
|
lineBytes = [];
|
|
429
749
|
escState = 0;
|
|
750
|
+
csiParams = "";
|
|
751
|
+
inPaste = false;
|
|
430
752
|
}
|
|
431
753
|
function deriveTitleFromInput(bytes: Uint8Array): void {
|
|
432
754
|
const t = tabList.find((x) => x.id === activeId);
|
|
@@ -440,7 +762,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
440
762
|
}
|
|
441
763
|
if (escState === 2) {
|
|
442
764
|
if (b >= 0x40 && b <= 0x7e) {
|
|
765
|
+
// CSI final byte. Recognize the bracketed-paste guards ESC[200~
|
|
766
|
+
// (start) and ESC[201~ (end) so embedded newlines in a pasted,
|
|
767
|
+
// often multi-line, message are not read as line submissions.
|
|
768
|
+
if (b === 0x7e) {
|
|
769
|
+
if (csiParams === "200") {
|
|
770
|
+
inPaste = true;
|
|
771
|
+
} else if (csiParams === "201") {
|
|
772
|
+
inPaste = false;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
csiParams = "";
|
|
443
776
|
escState = 0; // CSI final byte
|
|
777
|
+
} else if (b >= 0x30 && b <= 0x3f) {
|
|
778
|
+
csiParams += String.fromCharCode(b); // parameter byte (digits, ; ? etc.)
|
|
444
779
|
}
|
|
445
780
|
continue;
|
|
446
781
|
}
|
|
@@ -451,6 +786,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
451
786
|
if (b === 0x1b) {
|
|
452
787
|
escState = 1; // ESC: start of an escape sequence
|
|
453
788
|
} else if (b === 0x0d || b === 0x0a) {
|
|
789
|
+
if (inPaste) {
|
|
790
|
+
// A newline INSIDE a bracketed paste is pasted content, not a
|
|
791
|
+
// submit: fold it to a single space so the whole paste stays one
|
|
792
|
+
// logical line and the title reflects its START (a multi-line
|
|
793
|
+
// paste previously left only its LAST line as the title).
|
|
794
|
+
if (lineBytes.length > 0 && lineBytes[lineBytes.length - 1] !== 0x20) {
|
|
795
|
+
lineBytes.push(0x20);
|
|
796
|
+
}
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
454
799
|
const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
|
|
455
800
|
lineBytes = [];
|
|
456
801
|
// Every non-empty submitted line updates the fallback title (the
|
|
@@ -458,6 +803,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
458
803
|
// command path.
|
|
459
804
|
if (line) {
|
|
460
805
|
t.derived = line.slice(0, MAX_DERIVED);
|
|
806
|
+
// Persist it server-side (engine PUT .../title) so a reload and
|
|
807
|
+
// other devices recover this "latest message" label; the engine
|
|
808
|
+
// uses it only when the program emits no OSC title.
|
|
809
|
+
void apiSetTitle(t.id, t.derived);
|
|
461
810
|
syncChrome();
|
|
462
811
|
}
|
|
463
812
|
} else if (b === 0x7f || b === 0x08) {
|
|
@@ -500,16 +849,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
500
849
|
const active = idx >= 0 ? tabList[idx] : undefined;
|
|
501
850
|
swLabel.textContent = active ? active.display : "";
|
|
502
851
|
paintStatusDot(swDot, active?.dot.dataset["status"] ?? "idle", active?.reports ?? false);
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
)
|
|
506
|
-
if (bgInput) {
|
|
507
|
-
swCurrent.dataset["attention"] = "input";
|
|
508
|
-
swCurrent.setAttribute("aria-label", "Terminals; a background terminal needs input");
|
|
509
|
-
} else {
|
|
510
|
-
delete swCurrent.dataset["attention"];
|
|
511
|
-
swCurrent.removeAttribute("aria-label");
|
|
512
|
-
}
|
|
852
|
+
// The aggregate background-notification cue rides the dedicated switch
|
|
853
|
+
// button's dot (paintSwitchDot), not the active surface (it did not fit
|
|
854
|
+
// there).
|
|
513
855
|
}
|
|
514
856
|
|
|
515
857
|
// buildRow creates one expanded-list row for a tab and wires its handlers
|
|
@@ -757,8 +1099,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
757
1099
|
if (kb?.isOpen()) {
|
|
758
1100
|
kb.toggle();
|
|
759
1101
|
}
|
|
760
|
-
|
|
761
|
-
|
|
1102
|
+
for (const b of kbButtons) {
|
|
1103
|
+
b.setAttribute("aria-expanded", "false");
|
|
1104
|
+
b.classList.remove("wt-active");
|
|
1105
|
+
}
|
|
762
1106
|
}
|
|
763
1107
|
|
|
764
1108
|
// setExpandedState applies the resting expanded/collapsed state: the class
|
|
@@ -804,6 +1148,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
804
1148
|
if (expanded || tabList.length < 2) {
|
|
805
1149
|
return;
|
|
806
1150
|
}
|
|
1151
|
+
// Opening the list acknowledges any pending background-tab notification:
|
|
1152
|
+
// the user is now looking at the tabs, so clear the switch button's dot.
|
|
1153
|
+
switchNotify = "";
|
|
1154
|
+
paintSwitchDot();
|
|
807
1155
|
renderSwitcherList();
|
|
808
1156
|
setExpandedState(true);
|
|
809
1157
|
ctx.announce("Terminal list expanded");
|
|
@@ -851,6 +1199,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
851
1199
|
const tab: Tab = {
|
|
852
1200
|
id: info.id,
|
|
853
1201
|
title: info.title,
|
|
1202
|
+
clientTitle: info.clientTitle,
|
|
854
1203
|
display: "",
|
|
855
1204
|
createdAt: info.createdAt,
|
|
856
1205
|
store: new LineStore(),
|
|
@@ -968,6 +1317,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
968
1317
|
// rebuild viewport-first, so the last-known screen paints with no
|
|
969
1318
|
// round-trip. Then let the kernel reconnect the WS to it (resume delta).
|
|
970
1319
|
activeId = next.id;
|
|
1320
|
+
try {
|
|
1321
|
+
localStorage.setItem(ACTIVE_TAB_KEY, next.id);
|
|
1322
|
+
} catch {
|
|
1323
|
+
/* storage unavailable (private mode / disabled) — non-fatal */
|
|
1324
|
+
}
|
|
971
1325
|
resetInputLine(); // a partial line typed in the old tab does not carry over
|
|
972
1326
|
ctx.render.bind(next.store);
|
|
973
1327
|
ctx.notifySwitch({ id: next.id });
|
|
@@ -979,18 +1333,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
979
1333
|
syncChrome(); // reconciles the expanded list into the new order
|
|
980
1334
|
reelReconcile = false;
|
|
981
1335
|
playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
|
|
982
|
-
// Restore scroll memory best-effort after the async rebuild
|
|
983
|
-
//
|
|
1336
|
+
// Restore scroll memory best-effort after the async rebuild. The engine
|
|
1337
|
+
// scroll controller's follow flag is GLOBAL (one per kernel) and still
|
|
1338
|
+
// reflects the tab we just left, so binding a following tab right after
|
|
1339
|
+
// being scrolled up in another tab left the controller in "holding":
|
|
1340
|
+
// the renderer's post-flush stickToBottom() no-op'd and the cached
|
|
1341
|
+
// screen rendered above the viewport (a black gap until a touch scrolled
|
|
1342
|
+
// it and re-engaged follow — the "content pops down when I wiggle it"
|
|
1343
|
+
// symptom). Re-assert here: scrollToBottom() snaps down AND re-engages
|
|
1344
|
+
// follow (so the resume delta then pins correctly); a scrolled-up tab
|
|
1345
|
+
// restores its saved read position instead.
|
|
984
1346
|
const savedTop = next.scrollTop;
|
|
985
1347
|
const following = next.following;
|
|
986
1348
|
requestAnimationFrame(() => {
|
|
987
|
-
if (
|
|
1349
|
+
if (following) {
|
|
1350
|
+
ctx.scroll.scrollToBottom();
|
|
1351
|
+
} else {
|
|
988
1352
|
surface.scrollTop = savedTop;
|
|
989
1353
|
}
|
|
990
1354
|
});
|
|
991
1355
|
ctx.announce(`Switched to ${next.display}`);
|
|
992
|
-
if (
|
|
993
|
-
|
|
1356
|
+
if (physicalKeyboardLikely()) {
|
|
1357
|
+
// A physical keyboard is (likely) present, so focus the input on
|
|
1358
|
+
// switch — switch and type immediately (the iPad + Magic Keyboard
|
|
1359
|
+
// ask). On a keyboard-less touchscreen this is skipped, or every
|
|
1360
|
+
// switch would pop the virtual keyboard (#7).
|
|
1361
|
+
focusInput();
|
|
994
1362
|
}
|
|
995
1363
|
}
|
|
996
1364
|
|
|
@@ -1049,7 +1417,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1049
1417
|
// which have no live socket, still refresh their title from that sweep).
|
|
1050
1418
|
ctx.on("wire:title", ({ session, title }) => {
|
|
1051
1419
|
const t = tabList.find((x) => x.id === session);
|
|
1052
|
-
|
|
1420
|
+
// Ignore a blank title (an OSC 0/2 clear the process may emit when it
|
|
1421
|
+
// redraws its prompt after idling): keep the last good label until a real
|
|
1422
|
+
// replacement arrives, rather than reverting to "New tab". A non-blank
|
|
1423
|
+
// change updates the label at once.
|
|
1424
|
+
if (t && title.trim() !== "" && title !== t.title) {
|
|
1053
1425
|
t.title = title;
|
|
1054
1426
|
syncChrome();
|
|
1055
1427
|
}
|
|
@@ -1113,9 +1485,34 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1113
1485
|
if (tabList.find((t) => t.id === info.id)) {
|
|
1114
1486
|
return;
|
|
1115
1487
|
}
|
|
1488
|
+
const closedAt = recentlyClosed.get(info.id);
|
|
1489
|
+
if (closedAt !== undefined) {
|
|
1490
|
+
if (Date.now() - closedAt < CLOSE_TOMBSTONE_MS) {
|
|
1491
|
+
return; // just closed here; ignore a stale listing until the server reaps it
|
|
1492
|
+
}
|
|
1493
|
+
recentlyClosed.delete(info.id);
|
|
1494
|
+
}
|
|
1116
1495
|
tabList.push(addTabChrome(info));
|
|
1117
1496
|
}
|
|
1118
1497
|
|
|
1498
|
+
// Activate the first tab when nothing is active. The bootstrap normally
|
|
1499
|
+
// activates a tab, but if the initial apiList AND apiCreate both fail at
|
|
1500
|
+
// load its `if (startTab)` activation is skipped, leaving activeId null
|
|
1501
|
+
// and connectionInitiated false -- so the kernel never opens the terminal
|
|
1502
|
+
// WS and its wake handlers no-op. When the server recovers, the status
|
|
1503
|
+
// stream / poll adopt the existing sessions below; without this they would
|
|
1504
|
+
// render inert (blank, never connecting) until the user taps a tab. This
|
|
1505
|
+
// is the sibling of the '+'-retry recovery the bootstrap already handles.
|
|
1506
|
+
function ensureActive(): void {
|
|
1507
|
+
if (activeId !== null) {
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
const first = tabList[0];
|
|
1511
|
+
if (first) {
|
|
1512
|
+
switchTo(first.id);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1119
1516
|
async function create(): Promise<void> {
|
|
1120
1517
|
let info: SessionInfo;
|
|
1121
1518
|
try {
|
|
@@ -1174,6 +1571,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1174
1571
|
if (!tab) {
|
|
1175
1572
|
return;
|
|
1176
1573
|
}
|
|
1574
|
+
// Tombstone this id briefly so a stale status snapshot/poll that predates
|
|
1575
|
+
// the server reaping it cannot re-adopt the just-closed tab.
|
|
1576
|
+
tombstone(id);
|
|
1177
1577
|
tab.aria.remove();
|
|
1178
1578
|
// Remove immediately (no exit animation): a lingering element made the
|
|
1179
1579
|
// "+" teleport after a delay, and made a last-tab replacement appear in
|
|
@@ -1192,7 +1592,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1192
1592
|
}
|
|
1193
1593
|
syncChrome(); // reflect the drop immediately (count, position)
|
|
1194
1594
|
if (remote) {
|
|
1195
|
-
|
|
1595
|
+
try {
|
|
1596
|
+
await apiClose(id);
|
|
1597
|
+
} catch {
|
|
1598
|
+
ctx.toast("Couldn't close the terminal on the server");
|
|
1599
|
+
}
|
|
1196
1600
|
}
|
|
1197
1601
|
if (activeId === id) {
|
|
1198
1602
|
// Switch to a neighbor, or spawn a fresh session if this was the last.
|
|
@@ -1230,6 +1634,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1230
1634
|
if (idx >= 0) {
|
|
1231
1635
|
tabList.splice(idx, 1);
|
|
1232
1636
|
}
|
|
1637
|
+
// Tombstone briefly so a stale status snapshot/poll that predates the
|
|
1638
|
+
// server reaping these sessions cannot re-adopt (flash back) a just-
|
|
1639
|
+
// closed tab -- mirrors the single-close path in dropTab (h-f2).
|
|
1640
|
+
tombstone(t.id);
|
|
1233
1641
|
t.aria.remove();
|
|
1234
1642
|
t.el.remove();
|
|
1235
1643
|
ctx.dropSession(t.id);
|
|
@@ -1248,7 +1656,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1248
1656
|
syncChrome();
|
|
1249
1657
|
}
|
|
1250
1658
|
for (const t of victims) {
|
|
1251
|
-
|
|
1659
|
+
try {
|
|
1660
|
+
await apiClose(t.id);
|
|
1661
|
+
} catch {
|
|
1662
|
+
ctx.toast("Couldn't close a terminal on the server");
|
|
1663
|
+
}
|
|
1252
1664
|
}
|
|
1253
1665
|
if (tabList.length === 0) {
|
|
1254
1666
|
await create(); // there is always at least one terminal open
|
|
@@ -1350,8 +1762,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1350
1762
|
return;
|
|
1351
1763
|
}
|
|
1352
1764
|
hintShown = true;
|
|
1353
|
-
if (!window.matchMedia(
|
|
1354
|
-
return; //
|
|
1765
|
+
if (!window.matchMedia(SWITCHER_MEDIA).matches) {
|
|
1766
|
+
return; // only the mobile switcher layout has a swipe-to-switch bar
|
|
1355
1767
|
}
|
|
1356
1768
|
let seen = false;
|
|
1357
1769
|
try {
|
|
@@ -1377,6 +1789,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1377
1789
|
id: string,
|
|
1378
1790
|
status: string,
|
|
1379
1791
|
title: string | undefined,
|
|
1792
|
+
clientTitle: string | undefined,
|
|
1380
1793
|
reports: boolean,
|
|
1381
1794
|
): void {
|
|
1382
1795
|
const t = tabList.find((tab) => tab.id === id);
|
|
@@ -1385,12 +1798,34 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1385
1798
|
}
|
|
1386
1799
|
t.reports = reports;
|
|
1387
1800
|
paintStatusDot(t.dot, status, reports);
|
|
1801
|
+
// Latest-wins background-tab notification for the switch button's dot: a
|
|
1802
|
+
// background tab (not the active one) reaching "input" (needs you) or
|
|
1803
|
+
// "done" (turn finished) raises the cue in that colour; each qualifying
|
|
1804
|
+
// event overwrites the prior one, and expandSwitcher clears it when the
|
|
1805
|
+
// list opens. The active surface keeps its own needs-input cue (see
|
|
1806
|
+
// syncMobile); this is the moved + upgraded, glanceable version on the
|
|
1807
|
+
// dedicated button.
|
|
1808
|
+
if (id !== activeId && (status === "input" || status === "done")) {
|
|
1809
|
+
switchNotify = status;
|
|
1810
|
+
paintSwitchDot();
|
|
1811
|
+
}
|
|
1388
1812
|
// Record the raw server title; the displayed label (fallback + de-dup)
|
|
1389
1813
|
// is recomputed by relabelAll via syncChrome, which the callers run
|
|
1390
|
-
// right after applyStatus.
|
|
1391
|
-
|
|
1814
|
+
// right after applyStatus. Ignore a BLANK title: a status sweep (or the
|
|
1815
|
+
// process clearing its OSC 0/2 window title) reports an empty string,
|
|
1816
|
+
// and overwriting a good label with it dropped an idle tab back to "New
|
|
1817
|
+
// tab". Hold the last known title until a genuine (non-blank) one
|
|
1818
|
+
// arrives; the derived-from-input fallback is likewise sticky.
|
|
1819
|
+
if (title !== undefined && title.trim() !== "") {
|
|
1392
1820
|
t.title = title;
|
|
1393
1821
|
}
|
|
1822
|
+
// The persisted client title is authoritative from the server (set via
|
|
1823
|
+
// PUT .../title); apply it as-is, including "" for a fresh session, so the
|
|
1824
|
+
// preferInputTitle label recovers it on reload. No blank-guard: unlike the
|
|
1825
|
+
// OSC title it does not flicker (it changes only on an explicit push).
|
|
1826
|
+
if (clientTitle !== undefined) {
|
|
1827
|
+
t.clientTitle = clientTitle;
|
|
1828
|
+
}
|
|
1394
1829
|
}
|
|
1395
1830
|
|
|
1396
1831
|
// Live status: the activity monitor (SSE push) when present, else a poll of
|
|
@@ -1409,34 +1844,56 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1409
1844
|
adoptSession({
|
|
1410
1845
|
id: s.id,
|
|
1411
1846
|
title: s.title,
|
|
1847
|
+
clientTitle: undefined,
|
|
1412
1848
|
createdAt: s.createdAt,
|
|
1413
1849
|
status: s.status,
|
|
1414
1850
|
reportsActivity: s.reportsActivity ?? false,
|
|
1415
1851
|
});
|
|
1416
|
-
applyStatus(s.id, s.status, s.title, s.reportsActivity ?? false);
|
|
1852
|
+
applyStatus(s.id, s.status, s.title, undefined, s.reportsActivity ?? false);
|
|
1853
|
+
ensureActive();
|
|
1417
1854
|
syncChrome();
|
|
1418
1855
|
});
|
|
1419
1856
|
} else {
|
|
1420
1857
|
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
1858
|
+
// Guard against overlapping runs: a server slower than pollMs (but
|
|
1859
|
+
// within the 15s API timeout) would otherwise let setInterval start
|
|
1860
|
+
// a second pollOnce while the first is still awaiting, racing tabList
|
|
1861
|
+
// mutation. Skip the tick instead.
|
|
1862
|
+
let polling = false;
|
|
1421
1863
|
const pollOnce = async (): Promise<void> => {
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
list = await apiList();
|
|
1425
|
-
} catch {
|
|
1426
|
-
return; // transient; try again next tick
|
|
1427
|
-
}
|
|
1428
|
-
const seen = new Set(list.map((s) => s.id));
|
|
1429
|
-
for (const info of list) {
|
|
1430
|
-
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
1431
|
-
applyStatus(info.id, info.status, info.title, info.reportsActivity ?? false);
|
|
1864
|
+
if (polling) {
|
|
1865
|
+
return;
|
|
1432
1866
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1867
|
+
polling = true;
|
|
1868
|
+
try {
|
|
1869
|
+
let list: SessionInfo[];
|
|
1870
|
+
try {
|
|
1871
|
+
list = await apiList();
|
|
1872
|
+
} catch {
|
|
1873
|
+
return; // transient; try again next tick
|
|
1874
|
+
}
|
|
1875
|
+
const seen = new Set(list.map((s) => s.id));
|
|
1876
|
+
for (const info of list) {
|
|
1877
|
+
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
1878
|
+
applyStatus(
|
|
1879
|
+
info.id,
|
|
1880
|
+
info.status,
|
|
1881
|
+
info.title,
|
|
1882
|
+
info.clientTitle,
|
|
1883
|
+
info.reportsActivity ?? false,
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
// A tab the server no longer lists was reaped/closed elsewhere: drop it
|
|
1887
|
+
// locally (no DELETE — it is already gone).
|
|
1888
|
+
const gone = tabList.filter((t) => !seen.has(t.id)).map((t) => t.id);
|
|
1889
|
+
for (const id of gone) {
|
|
1890
|
+
await dropTab(id, false);
|
|
1891
|
+
}
|
|
1892
|
+
ensureActive();
|
|
1893
|
+
syncChrome();
|
|
1894
|
+
} finally {
|
|
1895
|
+
polling = false;
|
|
1438
1896
|
}
|
|
1439
|
-
syncChrome();
|
|
1440
1897
|
};
|
|
1441
1898
|
pollTimer = setInterval(() => {
|
|
1442
1899
|
void pollOnce();
|
|
@@ -1446,8 +1903,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1446
1903
|
// --- Event wiring ---
|
|
1447
1904
|
// Derive each tab's title from the first line the user submits into it.
|
|
1448
1905
|
const offInput = ctx.registerInputObserver(deriveTitleFromInput);
|
|
1449
|
-
|
|
1450
|
-
|
|
1906
|
+
// Observe (never consume) keydowns to detect a physical keyboard: a
|
|
1907
|
+
// hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
|
|
1908
|
+
// for a keyboard folio with no trackpad (no fine pointer to key off).
|
|
1909
|
+
const offHwKey = ctx.registerKeydown((ev) => {
|
|
1910
|
+
if (!sawHardwareKey && looksLikeHardwareKey(ev)) {
|
|
1911
|
+
sawHardwareKey = true;
|
|
1912
|
+
}
|
|
1913
|
+
return false;
|
|
1451
1914
|
});
|
|
1452
1915
|
// Live reorder while dragging a tab over the strip (dragend commits it).
|
|
1453
1916
|
bar.addEventListener("dragover", (e) => {
|
|
@@ -1460,23 +1923,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1460
1923
|
}
|
|
1461
1924
|
bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
|
|
1462
1925
|
});
|
|
1463
|
-
// Keyboard button: open/close the key grid above the bar. The toolbar API
|
|
1464
|
-
// is read lazily (ctx.use) so feature ordering does not matter; the button
|
|
1465
|
-
// is hidden unless a keyboardToggle was provided (set above).
|
|
1466
|
-
swKb.addEventListener("click", () => {
|
|
1467
|
-
const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
|
|
1468
|
-
if (!kb) {
|
|
1469
|
-
return;
|
|
1470
|
-
}
|
|
1471
|
-
kb.toggle();
|
|
1472
|
-
const open = kb.isOpen();
|
|
1473
|
-
swKb.setAttribute("aria-expanded", String(open));
|
|
1474
|
-
swKb.classList.toggle("wt-active", open);
|
|
1475
|
-
});
|
|
1476
|
-
// Mobile "+": spawn a terminal (mirrors the desktop strip's newBtn).
|
|
1477
|
-
swNew.addEventListener("click", () => {
|
|
1478
|
-
void create();
|
|
1479
|
-
});
|
|
1480
1926
|
// Active-row close (x): closes the current tab (mirrors a listed row's x).
|
|
1481
1927
|
// stopPropagation so it is not read as a tap/swipe on the row surface.
|
|
1482
1928
|
swClose.addEventListener("click", (e) => {
|
|
@@ -1520,6 +1966,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1520
1966
|
// the dismissing tap. Taps inside the switcher or the key grid are left to
|
|
1521
1967
|
// their own controls; when nothing is open the tap falls through untouched.
|
|
1522
1968
|
const onDocTapDismiss = (e: PointerEvent): void => {
|
|
1969
|
+
// A swipe gesture on the switcher owns the pointer; its own end logic
|
|
1970
|
+
// (endHorizontal/endVertical, via the window-level endOnUp) resolves the
|
|
1971
|
+
// outcome. Stand down here so this capture-phase handler's stopPropagation
|
|
1972
|
+
// cannot swallow that window-bubble pointerup -- which, when setPointerCapture
|
|
1973
|
+
// failed and the finger released outside the switcher, would strand gActive=true
|
|
1974
|
+
// and brick all future swipes until reload.
|
|
1975
|
+
if (gActive) {
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1523
1978
|
const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
|
|
1524
1979
|
const gridOpen = kb?.isOpen() ?? false;
|
|
1525
1980
|
if (!expanded && !gridOpen) {
|
|
@@ -1704,7 +2159,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1704
2159
|
switcher.classList.add("wt-switcher-expanded");
|
|
1705
2160
|
swList.style.transition = "none";
|
|
1706
2161
|
swList.style.overflowY = "hidden";
|
|
1707
|
-
|
|
2162
|
+
// Bound the interactive drag against the VISUAL viewport (the region above the soft
|
|
2163
|
+
// keyboard), matching the switcher's kb-inset bottom anchor, so the list can't grow
|
|
2164
|
+
// past the visible area with the keyboard open.
|
|
2165
|
+
const visH = window.visualViewport?.height ?? window.innerHeight;
|
|
2166
|
+
gTargetMax = Math.min(swList.scrollHeight, Math.round(visH * 0.5));
|
|
1708
2167
|
gStartMax = expanded ? gTargetMax : 0;
|
|
1709
2168
|
swList.style.maxHeight = `${String(gStartMax)}px`;
|
|
1710
2169
|
}
|
|
@@ -1901,7 +2360,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1901
2360
|
sessions = [];
|
|
1902
2361
|
}
|
|
1903
2362
|
if (sessions.length === 0) {
|
|
1904
|
-
|
|
2363
|
+
try {
|
|
2364
|
+
sessions = [await apiCreate()];
|
|
2365
|
+
} catch {
|
|
2366
|
+
// Do not let the initial spawn propagate out of setup(): the kernel
|
|
2367
|
+
// would tear down every feature ("terminal has no chrome") and, since
|
|
2368
|
+
// managesSessions skipped the startup connect, connectionInitiated
|
|
2369
|
+
// stays false so the wake handlers (visibilitychange/pageshow/online)
|
|
2370
|
+
// all no-op -- a permanently dead terminal with no recovery. Match the
|
|
2371
|
+
// runtime create() path: toast and leave the chrome up so "+" can retry.
|
|
2372
|
+
ctx.toast("Couldn't open a terminal");
|
|
2373
|
+
sessions = [];
|
|
2374
|
+
}
|
|
1905
2375
|
}
|
|
1906
2376
|
// Adopt (dedup) rather than blindly push. The status SSE pushes a snapshot
|
|
1907
2377
|
// of the existing sessions on open, and tabs subscribes (monitor.onStatus)
|
|
@@ -1914,12 +2384,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1914
2384
|
}
|
|
1915
2385
|
// From here on, tabs added at runtime (create / adopt) animate in.
|
|
1916
2386
|
started = true;
|
|
1917
|
-
// Activate the
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2387
|
+
// Activate the previously-active session if it still exists, else the
|
|
2388
|
+
// first (oldest). Session ids are stable server-side, so a page reload
|
|
2389
|
+
// reconnects to the tab the user left on instead of always the oldest.
|
|
2390
|
+
let startTab = tabList[0];
|
|
2391
|
+
try {
|
|
2392
|
+
const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
|
|
2393
|
+
if (savedId !== null && savedId !== "") {
|
|
2394
|
+
const saved = tabList.find((x) => x.id === savedId);
|
|
2395
|
+
if (saved) {
|
|
2396
|
+
startTab = saved;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
} catch {
|
|
2400
|
+
/* storage unavailable — fall back to the oldest tab */
|
|
2401
|
+
}
|
|
2402
|
+
if (startTab) {
|
|
2403
|
+
activeId = startTab.id;
|
|
2404
|
+
try {
|
|
2405
|
+
localStorage.setItem(ACTIVE_TAB_KEY, startTab.id);
|
|
2406
|
+
} catch {
|
|
2407
|
+
/* storage unavailable — non-fatal */
|
|
2408
|
+
}
|
|
2409
|
+
ctx.render.bind(startTab.store);
|
|
2410
|
+
ctx.notifySwitch({ id: startTab.id });
|
|
1923
2411
|
syncChrome();
|
|
1924
2412
|
focusInput();
|
|
1925
2413
|
}
|
|
@@ -1935,6 +2423,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1935
2423
|
teardown() {
|
|
1936
2424
|
offStatus?.();
|
|
1937
2425
|
offInput();
|
|
2426
|
+
offHwKey();
|
|
1938
2427
|
offArmed?.();
|
|
1939
2428
|
offMenuKey();
|
|
1940
2429
|
document.removeEventListener("click", onDocClickMenu);
|
|
@@ -1968,6 +2457,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1968
2457
|
rowEls.clear();
|
|
1969
2458
|
bar.remove();
|
|
1970
2459
|
newBtn.remove();
|
|
2460
|
+
deskKb.remove();
|
|
1971
2461
|
tabMenu.remove();
|
|
1972
2462
|
switcher.remove();
|
|
1973
2463
|
catchup.remove();
|