@cplieger/web-terminal-ui 3.5.0 → 4.0.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 +52 -23
- package/css/00-tokens.css +8 -2
- package/css/01-scope.css +56 -0
- package/css/{02-app.css → 02-terminal.css} +64 -239
- package/css/{03-primitives.css → 10-primitives.css} +21 -19
- package/css/20-connection-banner.css +42 -0
- package/css/21-scroll-to-bottom.css +65 -0
- package/css/22-context-menu.css +45 -0
- package/css/23-toolbar.css +181 -0
- package/css/{05-tabs.css → 30-tabs.css} +96 -57
- package/css/{06-mobile.css → 31-switcher.css} +66 -81
- package/css/{07-animations.css → 40-animations.css} +15 -15
- package/css/MANIFEST +19 -11
- package/css/MANIFEST.single +12 -0
- package/css/MANIFEST.tabbed +16 -0
- package/css/MANIFEST.touch +12 -0
- package/css/{01-reset.css → page.css} +18 -19
- package/package.json +18 -5
- package/scaffold/index.html +5 -0
- package/src/features/context-menu.ts +6 -33
- package/src/features/menu-position.ts +50 -0
- package/src/features/{tabs.ts → tabs/index.ts} +204 -655
- package/src/features/tabs/model.ts +334 -0
- package/src/features/tabs/strip.ts +101 -0
- package/src/features/tabs/switcher.ts +129 -0
- package/src/index.ts +1 -0
- package/src/kernel/a11y.ts +30 -5
- package/src/kernel/kernel.ts +149 -59
- package/src/kernel/types.ts +55 -14
- package/src/presets/agent-tabbed.ts +15 -0
- package/src/presets/single.ts +27 -0
- package/src/presets/tabbed.ts +42 -0
- package/src/presets/touch.ts +13 -0
- package/src/presets.ts +12 -76
- package/src/viewport.ts +28 -15
- package/css/04-feedback.css +0 -180
|
@@ -9,115 +9,57 @@
|
|
|
9
9
|
// background delta arrives after.
|
|
10
10
|
|
|
11
11
|
import { LineStore, modes } from "@cplieger/web-terminal-engine";
|
|
12
|
-
import type { TerminalContext, TerminalFeature
|
|
13
|
-
import type { ActivityMonitorApi } from "
|
|
14
|
-
import type { MobileToolbarApi } from "
|
|
15
|
-
import { fromHTML } from "
|
|
12
|
+
import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
|
|
13
|
+
import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
14
|
+
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
15
|
+
import { fromHTML } from "../dom.js";
|
|
16
|
+
import { placeMenuAt } from "../menu-position.js";
|
|
17
|
+
import type { SessionInfo, Tab } from "./model.js";
|
|
18
|
+
import {
|
|
19
|
+
ACTIVE_TAB_KEY,
|
|
20
|
+
STATUS_EXITED,
|
|
21
|
+
SWIPE_HINT_KEY,
|
|
22
|
+
baseLabel,
|
|
23
|
+
createInputTitleDeriver,
|
|
24
|
+
createSessionAPI,
|
|
25
|
+
createTombstones,
|
|
26
|
+
} from "./model.js";
|
|
27
|
+
import {
|
|
28
|
+
TAB_HTML,
|
|
29
|
+
kbButtonHTML,
|
|
30
|
+
newButtonHTML,
|
|
31
|
+
paintStatusDot,
|
|
32
|
+
pick,
|
|
33
|
+
switchButtonHTML,
|
|
34
|
+
} from "./strip.js";
|
|
35
|
+
import {
|
|
36
|
+
AXIS_LOCK_PX,
|
|
37
|
+
PREVIEW_DRAG_RATIO,
|
|
38
|
+
PREVIEW_PEEK_MAX,
|
|
39
|
+
SWIPE_DURATION,
|
|
40
|
+
SWIPE_MIN_PX,
|
|
41
|
+
SWIPE_VELOCITY,
|
|
42
|
+
SWITCHER_HTML,
|
|
43
|
+
SWITCHER_ROW_HTML,
|
|
44
|
+
VELOCITY_STALE_MS,
|
|
45
|
+
animateRowIn,
|
|
46
|
+
animateRowOut,
|
|
47
|
+
} from "./switcher.js";
|
|
16
48
|
|
|
17
49
|
const DEFAULT_API_BASE = "/api/sessions";
|
|
18
|
-
// Swipe recognition on the mobile switcher bar: a mostly-horizontal drag past
|
|
19
|
-
// this distance switches; a near-stationary release is a tap (opens overview).
|
|
20
|
-
const SWIPE_MIN_PX = 40;
|
|
21
|
-
// Movement (px) before an in-progress bar drag commits to a horizontal
|
|
22
|
-
// (tab-switch preview) or vertical (expand/collapse) axis.
|
|
23
|
-
const AXIS_LOCK_PX = 8;
|
|
24
|
-
// Flick-to-commit thresholds (from @use-gesture's drag defaults, MIT): a release
|
|
25
|
-
// counts as a flick when the gesture was quick, fast, and travelled enough, so a
|
|
26
|
-
// short fast swipe commits the switch/expand even below the halfway distance.
|
|
27
|
-
const SWIPE_VELOCITY = 0.5; // px/ms at release
|
|
28
|
-
const SWIPE_DURATION = 250; // ms, whole-gesture cap for a flick
|
|
29
|
-
// If the finger paused longer than this before lifting, the release velocity is
|
|
30
|
-
// stale (pointerup usually repeats the last pointermove position -> reads ~0),
|
|
31
|
-
// so ignore it and commit by distance instead (@use-gesture issue #332).
|
|
32
|
-
const VELOCITY_STALE_MS = 32;
|
|
33
|
-
// Live drag preview: while an open list is being swiped, it peeks in the swipe
|
|
34
|
-
// direction by this fraction of the finger's horizontal travel, capped at this
|
|
35
|
-
// many pixels — a hint of the coming rotation, not the full shift (the incoming
|
|
36
|
-
// row only appears on release, so a large move looked wrong). The release reel
|
|
37
|
-
// continues from wherever the peek left the rows.
|
|
38
|
-
const PREVIEW_DRAG_RATIO = 0.1;
|
|
39
|
-
const PREVIEW_PEEK_MAX = 10;
|
|
40
|
-
// One-time "swipe to switch" hint, remembered across loads.
|
|
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
50
|
// The mobile bottom-switcher (a single full-width active-tab chip + swipe) is
|
|
46
51
|
// used ONLY on a narrow coarse-pointer device (a phone). A wide touchscreen (an
|
|
47
52
|
// iPad) and every fine-pointer device (a desktop, or an iPad with a trackpad /
|
|
48
53
|
// Magic Keyboard) get the multi-tab top strip instead — the switcher's
|
|
49
54
|
// single-giant-tab layout wastes a big screen (an iPad was getting the phone
|
|
50
|
-
// UI).
|
|
51
|
-
// (.
|
|
52
|
-
//
|
|
53
|
-
// portrait width and below iPad portrait (768).
|
|
54
|
-
const SWITCHER_MEDIA = "(pointer: coarse) and (max-width: 600px)";
|
|
55
|
+
// UI). The narrow half of that fact is the kernel's .wt-narrow root class /
|
|
56
|
+
// ctx.layout().narrow (ONE breakpoint constant, kernel-owned, root-width
|
|
57
|
+
// driven); CSS pairs it with (pointer: coarse) where touch matters.
|
|
55
58
|
// Default cadence for the no-activityMonitor polling fallback.
|
|
56
59
|
const DEFAULT_POLL_MS = 4000;
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// produce output again, so session selection prefers live sessions everywhere.
|
|
61
|
-
const STATUS_EXITED = "exited";
|
|
62
|
-
// Tab context-menu viewport clamp (mirrors context-menu.ts): keep this margin
|
|
63
|
-
// from every viewport edge, and this gap above the pointer when the menu has to
|
|
64
|
-
// flip up (a right-click near the bottom edge) so it is neither clipped nor
|
|
65
|
-
// hidden under the cursor.
|
|
66
|
-
const TAB_MENU_EDGE = 8;
|
|
67
|
-
const TAB_MENU_GAP = 16;
|
|
68
|
-
|
|
69
|
-
/** One session's client-side wire shape (matches terminal.SessionInfo). */
|
|
70
|
-
interface SessionInfo {
|
|
71
|
-
id: string;
|
|
72
|
-
title: string;
|
|
73
|
-
/** The raw client-set title (the value pushed via PUT .../title), before the
|
|
74
|
-
* server's OSC fallback baked into `title`. A consumer that treats the
|
|
75
|
-
* program's OSC window title as unreliable (an agent shell) reads this. */
|
|
76
|
-
clientTitle?: string | undefined;
|
|
77
|
-
createdAt: string;
|
|
78
|
-
status: string;
|
|
79
|
-
/** Sticky: true once the session has emitted a genuine activity signal
|
|
80
|
-
* (OSC 9;4 progress, or a classified OSC 9 notification). Gates whether the
|
|
81
|
-
* per-tab activity dot is shown; see paintStatusDot / baseLabel. */
|
|
82
|
-
reportsActivity?: boolean;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
interface Tab {
|
|
86
|
-
id: string;
|
|
87
|
-
/** The raw server title (the OSC 0/2 window title the process set), possibly
|
|
88
|
-
* empty before the process sets one. The displayed label is derived from it
|
|
89
|
-
* with a numbered fallback and de-duplication (see relabelAll). */
|
|
90
|
-
title: string;
|
|
91
|
-
/** The computed, de-duplicated label actually shown in the chrome. */
|
|
92
|
-
display: string;
|
|
93
|
-
createdAt: string;
|
|
94
|
-
store: LineStore;
|
|
95
|
-
el: HTMLElement;
|
|
96
|
-
label: HTMLElement;
|
|
97
|
-
dot: HTMLElement;
|
|
98
|
-
aria: TabHandle;
|
|
99
|
-
scrollTop: number;
|
|
100
|
-
following: boolean;
|
|
101
|
-
/** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
|
|
102
|
-
* Its activity dot is shown only while true; a session that never reports
|
|
103
|
-
* activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
|
|
104
|
-
* reportsActivity via applyStatus. */
|
|
105
|
-
reports: boolean;
|
|
106
|
-
/** A fallback title derived from the LAST non-empty line the user submitted
|
|
107
|
-
* into this tab (updated on every Enter). Used only when the process sets no
|
|
108
|
-
* window title of its own: the OSC 0/2 title (`title` above) takes precedence
|
|
109
|
-
* when present and keeps updating, and this is what the tab reads as when it
|
|
110
|
-
* does not — a plain shell with no PROMPT_COMMAND title, or kiro-cli with its
|
|
111
|
-
* cwd-only title disabled. Undefined until the first non-empty submission; see
|
|
112
|
-
* baseLabel. */
|
|
113
|
-
derived?: string;
|
|
114
|
-
/** The persisted client title reported by the server (its `clientTitle` wire
|
|
115
|
-
* field): the last derived title pushed via PUT .../title, surviving reloads.
|
|
116
|
-
* In `preferInputTitle` mode this is the reload-recovery source (the live
|
|
117
|
-
* `derived` is lost on reload, and `title` is the unreliable OSC value there).
|
|
118
|
-
* In the default mode it is the lowest-priority fallback after title/derived. */
|
|
119
|
-
clientTitle?: string | undefined;
|
|
120
|
-
}
|
|
60
|
+
// Tab context-menu viewport clamping + the flip-above-the-pointer gap live in
|
|
61
|
+
// the shared point-anchored positioner (menu-position.ts), shared with the
|
|
62
|
+
// terminal context menu (formerly two hand-synced copies of the same math).
|
|
121
63
|
|
|
122
64
|
export interface TabsApi {
|
|
123
65
|
/** Spawn a fresh session and switch to it. */
|
|
@@ -158,130 +100,6 @@ export interface TabsOptions {
|
|
|
158
100
|
preferInputTitle?: boolean;
|
|
159
101
|
}
|
|
160
102
|
|
|
161
|
-
// The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
|
|
162
|
-
// exactly in their flex-centered buttons and stay symmetric regardless of the UI
|
|
163
|
-
// font's metrics. Each is defined ONCE here and shared by every chip site and
|
|
164
|
-
// control below (rather than duplicated across the desktop and mobile markup).
|
|
165
|
-
const CLOSE_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg>`;
|
|
166
|
-
const NEW_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`;
|
|
167
|
-
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>`;
|
|
168
|
-
// Two-overlapping-windows glyph for the mobile switcher's dedicated open/close
|
|
169
|
-
// button: the browser-style "tab switcher" icon, more recognisable than the
|
|
170
|
-
// prior swap-arrows (which read like a keyboard Tab key). A latest-wins
|
|
171
|
-
// background-tab notification dot rides on it (see switchButtonHTML). Same
|
|
172
|
-
// viewBox + stroke=currentcolor treatment as the others.
|
|
173
|
-
const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
|
|
174
|
-
|
|
175
|
-
// chipContent is the ONE builder for a tab chip's content — a status dot, a
|
|
176
|
-
// label, and a close (x) — shared by all three chip sites: the desktop strip
|
|
177
|
-
// (.wt-tab), the mobile active row (.wt-switcher-current), and each expanded
|
|
178
|
-
// mobile list row (.wt-switcher-row). Each site passes its OWN class set (never
|
|
179
|
-
// renamed) so every existing selector — and thus all CSS and all tests — still
|
|
180
|
-
// matches. The one structural difference is WHERE the close sits: the desktop
|
|
181
|
-
// chip nests it flat inside .wt-tab, while the two mobile chips place it as a
|
|
182
|
-
// sibling of the select/swipe button (a button can't nest in a button). So the
|
|
183
|
-
// builder returns two fragments — the dot+label pair and the close — that each
|
|
184
|
-
// site drops into its own structure.
|
|
185
|
-
function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
|
|
186
|
-
dotLabel: string;
|
|
187
|
-
close: string;
|
|
188
|
-
} {
|
|
189
|
-
return {
|
|
190
|
-
dotLabel:
|
|
191
|
-
`<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
|
|
192
|
-
`<span class="${v.label}"></span>`,
|
|
193
|
-
close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// The ONE "+" (new-terminal) and keyboard button factories, shared by the
|
|
198
|
-
// desktop strip and the mobile switcher — only the class set differs. These
|
|
199
|
-
// build the markup; element construction + event wiring live in makeNewButton /
|
|
200
|
-
// makeKbButton (in setup), which close over create() and the key-grid toggle.
|
|
201
|
-
function newButtonHTML(cls: string): string {
|
|
202
|
-
return `<button type="button" class="${cls}" aria-label="New terminal">${NEW_SVG}</button>`;
|
|
203
|
-
}
|
|
204
|
-
function kbButtonHTML(cls: string): string {
|
|
205
|
-
return `<button type="button" class="${cls}" aria-label="Keyboard keys" aria-expanded="false" hidden>${KB_SVG}</button>`;
|
|
206
|
-
}
|
|
207
|
-
// The mobile switcher's dedicated open/close button. It toggles the tab list and
|
|
208
|
-
// carries a latest-wins notification dot (a child span) — amber when a background
|
|
209
|
-
// terminal needs input, green when a background turn finished — cleared when the
|
|
210
|
-
// list opens. Only the mobile switcher builds it (element construction + event
|
|
211
|
-
// wiring live in makeSwitchButton in setup); the desktop strip already shows
|
|
212
|
-
// every tab's own status dot, so it needs no aggregate cue.
|
|
213
|
-
function switchButtonHTML(cls: string): string {
|
|
214
|
-
return (
|
|
215
|
-
`<button type="button" class="${cls}" aria-label="Open tab switcher">${SWITCH_SVG}` +
|
|
216
|
-
`<span class="wt-status-dot wt-switcher-switch-dot" aria-hidden="true"></span></button>`
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Desktop strip chip: dot + label + close all flat inside .wt-tab (the whole
|
|
221
|
-
// chip is the click/switch target; the close is a nested button). tabindex="-1"
|
|
222
|
-
// keeps the close out of the tab order.
|
|
223
|
-
const TAB_CHIP = chipContent({
|
|
224
|
-
dot: "wt-tab-dot",
|
|
225
|
-
label: "wt-tab-label",
|
|
226
|
-
close: "wt-tab-close",
|
|
227
|
-
closeAttr: ' tabindex="-1"',
|
|
228
|
-
});
|
|
229
|
-
const TAB_HTML = `
|
|
230
|
-
<div class="wt-tab">
|
|
231
|
-
${TAB_CHIP.dotLabel}
|
|
232
|
-
${TAB_CHIP.close}
|
|
233
|
-
</div>`;
|
|
234
|
-
|
|
235
|
-
// Mobile bottom bar. One element, two parts stacked in a column: the always-
|
|
236
|
-
// visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
|
|
237
|
-
// and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
|
|
238
|
-
// up and the list fills in beneath it (down to the safe area); swipe-down /
|
|
239
|
-
// tap collapses it back to the bottom. Selecting a listed tab swaps it into the
|
|
240
|
-
// active row. This replaces the old separate modal overview sheet ("one element"
|
|
241
|
-
// per the user): the bar itself lifts rather than opening a distinct surface.
|
|
242
|
-
// The bar is the FIRST child and the list the SECOND: the switcher is bottom-
|
|
243
|
-
// anchored, so a column with the list last grows the container upward, lifting
|
|
244
|
-
// the bar and revealing the list below it (DOM order = visual order top-to-bottom).
|
|
245
|
-
// - .wt-switcher-current-wrap: the active-tab row — a select/swipe surface
|
|
246
|
-
// (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
|
|
247
|
-
// right, mirroring the listed rows. No "n / m" counter: the list below is a
|
|
248
|
-
// rotating circular queue, so an absolute position number is meaningless.
|
|
249
|
-
// - The keyboard button (.wt-switcher-kb, opens the key grid above the bar,
|
|
250
|
-
// only wired + shown when a keyboardToggle feature is provided) and the
|
|
251
|
-
// accent "+" (.wt-switcher-new, spawns a terminal) are appended to the bar
|
|
252
|
-
// row from the shared factories in setup (see makeKbButton / makeNewButton).
|
|
253
|
-
const CURRENT_CHIP = chipContent({
|
|
254
|
-
dot: "wt-switcher-dot",
|
|
255
|
-
label: "wt-switcher-label",
|
|
256
|
-
close: "wt-switcher-current-close wt-btn",
|
|
257
|
-
});
|
|
258
|
-
const SWITCHER_HTML = `
|
|
259
|
-
<div class="wt-switcher" role="group" aria-label="Terminal tabs">
|
|
260
|
-
<div class="wt-switcher-bar">
|
|
261
|
-
<div class="wt-switcher-current-wrap">
|
|
262
|
-
<button type="button" class="wt-switcher-current" aria-haspopup="true" aria-expanded="false">
|
|
263
|
-
<span class="wt-switcher-current-inner">${CURRENT_CHIP.dotLabel}</span>
|
|
264
|
-
</button>
|
|
265
|
-
${CURRENT_CHIP.close}
|
|
266
|
-
</div>
|
|
267
|
-
</div>
|
|
268
|
-
<ul class="wt-switcher-list" role="list"></ul>
|
|
269
|
-
</div>`;
|
|
270
|
-
|
|
271
|
-
// One row per OTHER tab in the expanded list: a stretched select target (dot +
|
|
272
|
-
// label) with the close (x) laid inside it at the right (two buttons can't nest,
|
|
273
|
-
// so the x is a sibling overlapping the select's reserved right padding).
|
|
274
|
-
const ROW_CHIP = chipContent({
|
|
275
|
-
dot: "wt-switcher-row-dot",
|
|
276
|
-
label: "wt-switcher-row-label",
|
|
277
|
-
close: "wt-switcher-row-close wt-btn",
|
|
278
|
-
});
|
|
279
|
-
const SWITCHER_ROW_HTML = `
|
|
280
|
-
<li class="wt-switcher-row">
|
|
281
|
-
<button type="button" class="wt-switcher-row-select">${ROW_CHIP.dotLabel}</button>
|
|
282
|
-
${ROW_CHIP.close}
|
|
283
|
-
</li>`;
|
|
284
|
-
|
|
285
103
|
// looksLikeHardwareKey reports whether a keydown could only have come from a
|
|
286
104
|
// PHYSICAL keyboard on a touch device. There is no web API that directly says "a
|
|
287
105
|
// hardware keyboard is attached" (navigator.keyboard is layout/lock only and
|
|
@@ -315,80 +133,26 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
|
315
133
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
316
134
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
317
135
|
const preferInputTitle = opts.preferInputTitle ?? false;
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
throw new Error(`web-terminal-ui: session list failed (${String(r.status)})`);
|
|
331
|
-
}
|
|
332
|
-
const data: unknown = await r.json();
|
|
333
|
-
// A 200 with a non-array body -- a proxy error object, or a Go server
|
|
334
|
-
// marshaling a nil session slice as JSON `null` -- must not reach the
|
|
335
|
-
// bootstrap's `sessions.length` / `for...of` (or the poll's `list.map`)
|
|
336
|
-
// uncaught: it would throw out of setup(), trip the kernel's 'terminal
|
|
337
|
-
// has no chrome' teardown, and (managesSessions skipped the startup
|
|
338
|
-
// connect) leave connectionInitiated false -> a permanently dead
|
|
339
|
-
// terminal. Reject a non-array so the callers' existing catch paths
|
|
340
|
-
// recover (bootstrap -> [], poll -> skip the tick).
|
|
341
|
-
if (!Array.isArray(data)) {
|
|
342
|
-
throw new Error("web-terminal-ui: session list returned a non-array body");
|
|
343
|
-
}
|
|
344
|
-
return data as SessionInfo[];
|
|
345
|
-
}
|
|
346
|
-
async function apiCreate(): Promise<SessionInfo> {
|
|
347
|
-
const r = await fetch(apiBase, {
|
|
348
|
-
method: "POST",
|
|
349
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
350
|
-
});
|
|
351
|
-
if (!r.ok) {
|
|
352
|
-
throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
|
|
353
|
-
}
|
|
354
|
-
return (await r.json()) as SessionInfo;
|
|
355
|
-
}
|
|
356
|
-
async function apiClose(id: string): Promise<void> {
|
|
357
|
-
const r = await fetch(`${apiBase}/${encodeURIComponent(id)}`, {
|
|
358
|
-
method: "DELETE",
|
|
359
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
360
|
-
});
|
|
361
|
-
if (!r.ok) {
|
|
362
|
-
throw new Error(`web-terminal-ui: session close failed (${String(r.status)})`);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
// Persist the input-derived tab title server-side so it survives a page reload
|
|
366
|
-
// and shows on other devices. The engine stores it as the session's fallback
|
|
367
|
-
// title and uses it only when the program emits no OSC title (the web-terminal-kiro case:
|
|
368
|
-
// kiro-cli emits no usable OSC title, so the latest user message is the label).
|
|
369
|
-
// Best-effort and fire-and-forget: a failure never disrupts the terminal — the
|
|
370
|
-
// locally-derived title still displays, it just is not persisted this time.
|
|
371
|
-
async function apiSetTitle(id: string, title: string): Promise<void> {
|
|
372
|
-
try {
|
|
373
|
-
await fetch(`${apiBase}/${encodeURIComponent(id)}/title`, {
|
|
374
|
-
method: "PUT",
|
|
375
|
-
headers: { "Content-Type": "application/json" },
|
|
376
|
-
body: JSON.stringify({ title }),
|
|
377
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
378
|
-
});
|
|
379
|
-
} catch {
|
|
380
|
-
/* best-effort persistence; ignore network/timeout errors */
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
136
|
+
// The session REST client (model.ts): every call timeout-bounded, list
|
|
137
|
+
// shape-guarded, title persistence fire-and-forget.
|
|
138
|
+
const api = createSessionAPI(apiBase);
|
|
139
|
+
|
|
140
|
+
// tabs owns session selection. The static sessionOwner registration tells
|
|
141
|
+
// the kernel not to open a bare /ws at startup (which a SessionManager would
|
|
142
|
+
// 404 for lack of ?session=); the kernel instead awaits
|
|
143
|
+
// resolveInitialSession() once setup completes and performs the first switch
|
|
144
|
+
// itself. The registration must exist on the feature VALUE (read before
|
|
145
|
+
// setup), while the bootstrap needs setup-scoped state — so it delegates to
|
|
146
|
+
// a closure setup() wires. resolveImpl is nulled on teardown.
|
|
147
|
+
let resolveImpl: (() => Promise<SessionRef | null>) | null = null;
|
|
384
148
|
return {
|
|
385
149
|
name: "tabs",
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
150
|
+
sessionOwner: {
|
|
151
|
+
resolveInitialSession: () => (resolveImpl ? resolveImpl() : Promise.resolve(null)),
|
|
152
|
+
},
|
|
153
|
+
// Synchronous setup: the chrome mounts immediately; the async session
|
|
154
|
+
// bootstrap that used to live here is the kernel-driven resolver above.
|
|
155
|
+
setup(ctx: TerminalContext) {
|
|
392
156
|
const tablist = ctx.tablist();
|
|
393
157
|
const monitor = opts.activityMonitor ? ctx.use(opts.activityMonitor) : undefined;
|
|
394
158
|
|
|
@@ -477,23 +241,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
477
241
|
const newBtn = makeNewButton("wt-tab-new");
|
|
478
242
|
bar.appendChild(newBtn);
|
|
479
243
|
|
|
480
|
-
// Pull the terminal surface up off the
|
|
481
|
-
// the bar does not overlap the last rows. The surface is
|
|
482
|
-
// inset:0, so a bottom offset (gated to a fine pointer
|
|
483
|
-
// strip is hidden on
|
|
484
|
-
// own inset) clears it. A ResizeObserver
|
|
485
|
-
// real strip height rather than a
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
// and
|
|
244
|
+
// Pull the terminal surface up off the docked BOTTOM strip on desktop so
|
|
245
|
+
// the bar does not overlap the last rows. The surface is absolute
|
|
246
|
+
// inset:0, so a bottom offset (gated to a fine pointer / non-narrow root
|
|
247
|
+
// in CSS, since the strip is hidden on the narrow-coarse phone where the
|
|
248
|
+
// mobile switcher applies its own inset) clears it. A ResizeObserver
|
|
249
|
+
// keeps the offset in step with the real strip height rather than a
|
|
250
|
+
// hard-coded guess. The measured height is published on the terminal
|
|
251
|
+
// ROOT (not the surface): the scroll-to-bottom button sits in a sibling
|
|
252
|
+
// region, not inside .term, so a property set on .term would not inherit
|
|
253
|
+
// to it and it would fall back to the 44px guess and overlap the strip.
|
|
254
|
+
// Both .term and the button inherit it from .wt-root — and the host page
|
|
255
|
+
// never sees it.
|
|
490
256
|
const surface = ctx.surface();
|
|
491
257
|
surface.classList.add("wt-with-tabbar");
|
|
258
|
+
const varRoot = surface.parentElement ?? surface;
|
|
492
259
|
const barResize = new ResizeObserver(() => {
|
|
493
|
-
|
|
494
|
-
"--wt-tabbar-h",
|
|
495
|
-
`${String(bar.offsetHeight)}px`,
|
|
496
|
-
);
|
|
260
|
+
varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
|
|
497
261
|
});
|
|
498
262
|
barResize.observe(bar);
|
|
499
263
|
|
|
@@ -592,7 +356,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
592
356
|
root?.classList.add("wt-tabbed");
|
|
593
357
|
// Reserve the collapsed bar row's height so terminal content stops above it
|
|
594
358
|
// (mobile item 2): viewport.ts adds --wt-reserve-bottom to the surface's
|
|
595
|
-
// bottom inset
|
|
359
|
+
// bottom inset (it reads the var off the surface, which inherits it from
|
|
360
|
+
// the root). Measure the bar row (not the expandable list, which just
|
|
596
361
|
// overlays content). innerHeight - rect.top captures the row plus the
|
|
597
362
|
// safe-area beneath it; the RO fires with the keyboard closed, so the value
|
|
598
363
|
// excludes the keyboard lift (viewport.ts adds that separately). The
|
|
@@ -600,7 +365,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
600
365
|
const swReserve = new ResizeObserver(() => {
|
|
601
366
|
const rect = swBar.getBoundingClientRect();
|
|
602
367
|
const px = rect.height > 0 ? Math.max(0, Math.round(window.innerHeight - rect.top)) : 0;
|
|
603
|
-
|
|
368
|
+
varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
|
|
604
369
|
window.visualViewport?.dispatchEvent(new Event("resize"));
|
|
605
370
|
});
|
|
606
371
|
swReserve.observe(swBar);
|
|
@@ -635,24 +400,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
635
400
|
ctx.region("overlay", "tab-menu").appendChild(tabMenu);
|
|
636
401
|
|
|
637
402
|
const tabList: Tab[] = [];
|
|
638
|
-
//
|
|
639
|
-
// (the SSE re-open snapshot, or the poll's GET
|
|
640
|
-
// the server reaping the session does not
|
|
641
|
-
//
|
|
642
|
-
const
|
|
643
|
-
const CLOSE_TOMBSTONE_MS = 15000;
|
|
644
|
-
function tombstone(id: string): void {
|
|
645
|
-
const now = Date.now();
|
|
646
|
-
// Sweep entries whose window already elapsed (adoptSession treats them as
|
|
647
|
-
// untombstoned anyway) so the map cannot grow without bound over a long
|
|
648
|
-
// session of opens/closes; then record this close.
|
|
649
|
-
for (const [k, t] of recentlyClosed) {
|
|
650
|
-
if (now - t >= CLOSE_TOMBSTONE_MS) {
|
|
651
|
-
recentlyClosed.delete(k);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
recentlyClosed.set(id, now);
|
|
655
|
-
}
|
|
403
|
+
// Close tombstones (model.ts): ids the user closed recently, so a stale
|
|
404
|
+
// server listing (the SSE re-open snapshot, or the poll's GET
|
|
405
|
+
// /api/sessions) that predates the server reaping the session does not
|
|
406
|
+
// re-adopt (flash back) a closed tab.
|
|
407
|
+
const tombstones = createTombstones();
|
|
656
408
|
// The expanded mobile list's row elements, keyed by tab id. Rows are
|
|
657
409
|
// reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
|
|
658
410
|
// same elements from their old slots to their new ones (the rotation).
|
|
@@ -694,28 +446,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
694
446
|
const prefersReduce = (): boolean =>
|
|
695
447
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
696
448
|
|
|
697
|
-
// baseLabel is a tab's label before de-duplication. Preference order: the
|
|
698
|
-
// process window title (OSC 0/2, e.g. a shell's PROMPT_COMMAND title),
|
|
699
|
-
// which takes precedence whenever the program sets one and keeps updating
|
|
700
|
-
// as it changes (the status SSE re-pushes it); then a fallback derived from
|
|
701
|
-
// the last line the user submitted, for a program that sets no title (a
|
|
702
|
-
// bare shell, or kiro-cli with its cwd-only title disabled); then a plain
|
|
703
|
-
// "New tab". fallback=true marks that last case so relabelAll leaves
|
|
704
|
-
// untitled tabs as "New tab" with no numeric suffix.
|
|
705
|
-
function baseLabel(tab: Tab): { text: string; fallback: boolean } {
|
|
706
|
-
// preferInputTitle (agent shell with an unreliable OSC title): show the
|
|
707
|
-
// live submitted line, then the persisted client title on reload — the
|
|
708
|
-
// process OSC `title` is ignored. Default: OSC title first, then the live
|
|
709
|
-
// derived line, then the persisted client title (the latter two are the
|
|
710
|
-
// "latest user message" fallback when the program sets no OSC title).
|
|
711
|
-
const derived = tab.derived?.trim() ?? "";
|
|
712
|
-
const persisted = tab.clientTitle?.trim() ?? "";
|
|
713
|
-
const real = preferInputTitle
|
|
714
|
-
? derived || persisted
|
|
715
|
-
: tab.title.trim() || derived || persisted;
|
|
716
|
-
return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
|
|
717
|
-
}
|
|
718
|
-
|
|
719
449
|
// relabelAll recomputes every tab's display label with de-duplication:
|
|
720
450
|
// when two tabs resolve to the same base label (e.g. two shells with the
|
|
721
451
|
// same window title, or two tabs whose last submitted line was identical),
|
|
@@ -726,14 +456,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
726
456
|
// read "New tab" without a numeric suffix.
|
|
727
457
|
const counts = new Map<string, number>();
|
|
728
458
|
for (const t of tabList) {
|
|
729
|
-
const { text, fallback } = baseLabel(t);
|
|
459
|
+
const { text, fallback } = baseLabel(t, preferInputTitle);
|
|
730
460
|
if (!fallback) {
|
|
731
461
|
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
732
462
|
}
|
|
733
463
|
}
|
|
734
464
|
const seen = new Map<string, number>();
|
|
735
465
|
for (const t of tabList) {
|
|
736
|
-
const { text, fallback } = baseLabel(t);
|
|
466
|
+
const { text, fallback } = baseLabel(t, preferInputTitle);
|
|
737
467
|
let display = text;
|
|
738
468
|
if (!fallback && (counts.get(text) ?? 0) > 1) {
|
|
739
469
|
const k = (seen.get(text) ?? 0) + 1;
|
|
@@ -749,133 +479,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
749
479
|
}
|
|
750
480
|
|
|
751
481
|
// --- Fallback title derivation from the user's submitted lines ---
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
// session, so an input observer's bytes belong to activeId.
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
// A generous storage bound for the fallback (input-derived) title. The tab
|
|
763
|
-
// label truncates it visually with max-width + ellipsis in EVERY view — the
|
|
764
|
-
// desktop strip, the mobile active bar, and the mobile list — so this is
|
|
765
|
-
// only a guard against storing a huge paste, not a display cut. (It was a
|
|
766
|
-
// hard 60 that cut the label mid-word regardless of how much fit.)
|
|
767
|
-
const MAX_DERIVED = 512;
|
|
768
|
-
let lineBytes: number[] = [];
|
|
769
|
-
let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
|
|
770
|
-
let csiParams = ""; // accumulated CSI parameter bytes, to spot paste guards
|
|
771
|
-
let inPaste = false; // inside a bracketed paste (ESC[200~ … ESC[201~)
|
|
772
|
-
function resetInputLine(): void {
|
|
773
|
-
lineBytes = [];
|
|
774
|
-
escState = 0;
|
|
775
|
-
csiParams = "";
|
|
776
|
-
inPaste = false;
|
|
777
|
-
}
|
|
778
|
-
function deriveTitleFromInput(bytes: Uint8Array): void {
|
|
482
|
+
// The line-editor state machine lives in model.ts
|
|
483
|
+
// (createInputTitleDeriver); this wires its submissions to the active
|
|
484
|
+
// tab. Used only when the process sets no OSC window title of its own
|
|
485
|
+
// (baseLabel prefers tab.title). The kernel routes input to the active
|
|
486
|
+
// session, so an input observer's bytes belong to activeId. Every
|
|
487
|
+
// non-empty submitted line updates the fallback title (the last one
|
|
488
|
+
// wins) and is persisted server-side (engine PUT .../title) so a reload
|
|
489
|
+
// and other devices recover this "latest message" label; the engine uses
|
|
490
|
+
// it only when the program emits no OSC title.
|
|
491
|
+
const titleDeriver = createInputTitleDeriver((line) => {
|
|
779
492
|
const t = tabList.find((x) => x.id === activeId);
|
|
780
493
|
if (!t) {
|
|
781
494
|
return; // no active tab
|
|
782
495
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
}
|
|
788
|
-
if (escState === 1) {
|
|
789
|
-
escState = b === 0x5b ? 2 : b === 0x4f ? 3 : 0; // ESC [ = CSI, ESC O = SS3
|
|
790
|
-
continue;
|
|
791
|
-
}
|
|
792
|
-
if (escState === 2) {
|
|
793
|
-
if (b >= 0x40 && b <= 0x7e) {
|
|
794
|
-
// CSI final byte. Recognize the bracketed-paste guards ESC[200~
|
|
795
|
-
// (start) and ESC[201~ (end) so embedded newlines in a pasted,
|
|
796
|
-
// often multi-line, message are not read as line submissions.
|
|
797
|
-
if (b === 0x7e) {
|
|
798
|
-
if (csiParams === "200") {
|
|
799
|
-
inPaste = true;
|
|
800
|
-
} else if (csiParams === "201") {
|
|
801
|
-
inPaste = false;
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
csiParams = "";
|
|
805
|
-
escState = 0; // CSI final byte
|
|
806
|
-
} else if (b >= 0x30 && b <= 0x3f) {
|
|
807
|
-
csiParams += String.fromCharCode(b); // parameter byte (digits, ; ? etc.)
|
|
808
|
-
}
|
|
809
|
-
continue;
|
|
810
|
-
}
|
|
811
|
-
if (escState === 3) {
|
|
812
|
-
escState = 0; // SS3 final byte
|
|
813
|
-
continue;
|
|
814
|
-
}
|
|
815
|
-
if (b === 0x1b) {
|
|
816
|
-
escState = 1; // ESC: start of an escape sequence
|
|
817
|
-
} else if (b === 0x0d || b === 0x0a) {
|
|
818
|
-
// A newline is a line SUBMIT only when it terminates the input. It is
|
|
819
|
-
// folded to a single space (keeping the current line going) when it is
|
|
820
|
-
// instead a paste-internal break, so a multi-line message becomes one
|
|
821
|
-
// logical line whose title reflects its START rather than only its
|
|
822
|
-
// LAST line. Two cases fold:
|
|
823
|
-
// - inside a bracketed paste (ESC[200~ … ESC[201~), and
|
|
824
|
-
// - a newline FOLLOWED by more printable input in this SAME chunk:
|
|
825
|
-
// a human pressing Enter sends the newline as the end of its own
|
|
826
|
-
// input event, whereas a paste (even one sent WITHOUT bracketed-
|
|
827
|
-
// paste guards — e.g. an agent shell like kiro-cli that keeps a
|
|
828
|
-
// pasted multi-line message as one prompt) delivers
|
|
829
|
-
// text + newline + text together, so trailing content marks the
|
|
830
|
-
// newline as a soft break, not a submit. Without this, such a
|
|
831
|
-
// paste left only its last line as the title (the reported
|
|
832
|
-
// "the title cut off the start of my message").
|
|
833
|
-
let softBreak = inPaste;
|
|
834
|
-
if (!softBreak) {
|
|
835
|
-
for (let j = i + 1; j < bytes.length; j++) {
|
|
836
|
-
const nb = bytes[j];
|
|
837
|
-
if (nb !== undefined && nb >= 0x20) {
|
|
838
|
-
softBreak = true;
|
|
839
|
-
break;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
if (softBreak) {
|
|
844
|
-
if (lineBytes.length > 0 && lineBytes[lineBytes.length - 1] !== 0x20) {
|
|
845
|
-
lineBytes.push(0x20);
|
|
846
|
-
}
|
|
847
|
-
continue;
|
|
848
|
-
}
|
|
849
|
-
const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
|
|
850
|
-
lineBytes = [];
|
|
851
|
-
// Every non-empty submitted line updates the fallback title (the
|
|
852
|
-
// last one wins); a leading "/" counts, since it is a valid shell
|
|
853
|
-
// command path.
|
|
854
|
-
if (line) {
|
|
855
|
-
t.derived = line.slice(0, MAX_DERIVED);
|
|
856
|
-
// Persist it server-side (engine PUT .../title) so a reload and
|
|
857
|
-
// other devices recover this "latest message" label; the engine
|
|
858
|
-
// uses it only when the program emits no OSC title.
|
|
859
|
-
void apiSetTitle(t.id, t.derived);
|
|
860
|
-
syncChrome();
|
|
861
|
-
}
|
|
862
|
-
} else if (b === 0x7f || b === 0x08) {
|
|
863
|
-
// Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
|
|
864
|
-
while (lineBytes.length > 0) {
|
|
865
|
-
const last = lineBytes[lineBytes.length - 1];
|
|
866
|
-
if (last === undefined || (last & 0xc0) !== 0x80) {
|
|
867
|
-
break;
|
|
868
|
-
}
|
|
869
|
-
lineBytes.pop();
|
|
870
|
-
}
|
|
871
|
-
lineBytes.pop();
|
|
872
|
-
} else if (b === 0x03) {
|
|
873
|
-
lineBytes = []; // Ctrl-C: cancel the current line
|
|
874
|
-
} else if (b >= 0x20) {
|
|
875
|
-
lineBytes.push(b); // printable ASCII or a UTF-8 byte
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
496
|
+
t.derived = line;
|
|
497
|
+
void api.setTitle(t.id, t.derived);
|
|
498
|
+
syncChrome();
|
|
499
|
+
});
|
|
879
500
|
|
|
880
501
|
function focusInput(): void {
|
|
881
502
|
ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
|
|
@@ -928,51 +549,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
928
549
|
);
|
|
929
550
|
pick(row, ".wt-switcher-row-label").textContent = t.display;
|
|
930
551
|
}
|
|
931
|
-
// animateRowIn / animateRowOut give a listed tab an enter / leave motion:
|
|
932
|
-
// the row's own max-height grows from 0 (fading in) on add, and collapses
|
|
933
|
-
// to 0 (fading out, then removed) on close. The flex list's height follows
|
|
934
|
-
// the row, so adding/closing a tab animates the tray height rather than
|
|
935
|
-
// snapping. Inline-driven (cleared when done); the caller gates motion.
|
|
936
|
-
const ROW_ANIM_MS = 220;
|
|
937
|
-
const ROW_ANIM_EASE = "cubic-bezier(0.2, 0, 0, 1)";
|
|
938
|
-
function animateRowIn(row: HTMLElement): void {
|
|
939
|
-
const h = row.getBoundingClientRect().height;
|
|
940
|
-
if (h <= 0) {
|
|
941
|
-
return;
|
|
942
|
-
}
|
|
943
|
-
row.style.overflow = "hidden";
|
|
944
|
-
row.style.transition = "none";
|
|
945
|
-
row.style.maxHeight = "0";
|
|
946
|
-
row.style.opacity = "0";
|
|
947
|
-
requestAnimationFrame(() => {
|
|
948
|
-
row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
|
|
949
|
-
row.style.maxHeight = `${String(Math.ceil(h))}px`;
|
|
950
|
-
row.style.opacity = "1";
|
|
951
|
-
});
|
|
952
|
-
window.setTimeout(() => {
|
|
953
|
-
row.style.transition = "";
|
|
954
|
-
row.style.maxHeight = "";
|
|
955
|
-
row.style.opacity = "";
|
|
956
|
-
row.style.overflow = "";
|
|
957
|
-
}, ROW_ANIM_MS + 60);
|
|
958
|
-
}
|
|
959
|
-
function animateRowOut(row: HTMLElement): void {
|
|
960
|
-
const h = row.getBoundingClientRect().height;
|
|
961
|
-
row.style.overflow = "hidden";
|
|
962
|
-
row.style.pointerEvents = "none";
|
|
963
|
-
row.style.transition = "none";
|
|
964
|
-
row.style.maxHeight = `${String(Math.ceil(h))}px`;
|
|
965
|
-
row.style.opacity = "1";
|
|
966
|
-
requestAnimationFrame(() => {
|
|
967
|
-
row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
|
|
968
|
-
row.style.maxHeight = "0";
|
|
969
|
-
row.style.opacity = "0";
|
|
970
|
-
});
|
|
971
|
-
window.setTimeout(() => {
|
|
972
|
-
row.remove();
|
|
973
|
-
}, ROW_ANIM_MS + 60);
|
|
974
|
-
}
|
|
975
|
-
|
|
976
552
|
// renderSwitcherList reconciles the expanded list to a row per OTHER tab
|
|
977
553
|
// (the active tab lives in the bar row), REUSING existing row elements
|
|
978
554
|
// rather than rebuilding, so element identity is stable and the swipe
|
|
@@ -1309,7 +885,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1309
885
|
};
|
|
1310
886
|
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
1311
887
|
// once the tab is in tabList and syncChrome runs).
|
|
1312
|
-
tab.display = baseLabel(tab).text;
|
|
888
|
+
tab.display = baseLabel(tab, preferInputTitle).text;
|
|
1313
889
|
label.textContent = tab.display;
|
|
1314
890
|
aria.setLabel(tab.display);
|
|
1315
891
|
el.addEventListener("click", (e) => {
|
|
@@ -1422,7 +998,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1422
998
|
} catch {
|
|
1423
999
|
/* storage unavailable (private mode / disabled) — non-fatal */
|
|
1424
1000
|
}
|
|
1425
|
-
|
|
1001
|
+
titleDeriver.reset(); // a partial line typed in the old tab does not carry over
|
|
1426
1002
|
ctx.render.bind(next.store);
|
|
1427
1003
|
ctx.notifySwitch({ id: next.id });
|
|
1428
1004
|
armCatchup();
|
|
@@ -1585,12 +1161,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1585
1161
|
if (tabList.find((t) => t.id === info.id)) {
|
|
1586
1162
|
return;
|
|
1587
1163
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
if (Date.now() - closedAt < CLOSE_TOMBSTONE_MS) {
|
|
1591
|
-
return; // just closed here; ignore a stale listing until the server reaps it
|
|
1592
|
-
}
|
|
1593
|
-
recentlyClosed.delete(info.id);
|
|
1164
|
+
if (tombstones.active(info.id)) {
|
|
1165
|
+
return; // just closed here; ignore a stale listing until the server reaps it
|
|
1594
1166
|
}
|
|
1595
1167
|
tabList.push(addTabChrome(info));
|
|
1596
1168
|
}
|
|
@@ -1619,7 +1191,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1619
1191
|
async function create(): Promise<void> {
|
|
1620
1192
|
let info: SessionInfo;
|
|
1621
1193
|
try {
|
|
1622
|
-
info = await
|
|
1194
|
+
info = await api.create();
|
|
1623
1195
|
} catch {
|
|
1624
1196
|
// A create can still fail transiently (network, server error); tell
|
|
1625
1197
|
// the user rather than throwing.
|
|
@@ -1676,7 +1248,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1676
1248
|
}
|
|
1677
1249
|
// Tombstone this id briefly so a stale status snapshot/poll that predates
|
|
1678
1250
|
// the server reaping it cannot re-adopt the just-closed tab.
|
|
1679
|
-
|
|
1251
|
+
tombstones.add(id);
|
|
1680
1252
|
// A pending switch-button cue whose subject just closed is moot: clear
|
|
1681
1253
|
// it rather than leaving a dot no tab visit can ever resolve.
|
|
1682
1254
|
acknowledgeSwitchNotify(id);
|
|
@@ -1699,7 +1271,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1699
1271
|
syncChrome(); // reflect the drop immediately (count, position)
|
|
1700
1272
|
if (remote) {
|
|
1701
1273
|
try {
|
|
1702
|
-
await
|
|
1274
|
+
await api.close(id);
|
|
1703
1275
|
} catch {
|
|
1704
1276
|
ctx.toast("Couldn't close the terminal on the server");
|
|
1705
1277
|
}
|
|
@@ -1743,7 +1315,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1743
1315
|
// Tombstone briefly so a stale status snapshot/poll that predates the
|
|
1744
1316
|
// server reaping these sessions cannot re-adopt (flash back) a just-
|
|
1745
1317
|
// closed tab -- mirrors the single-close path in dropTab (h-f2).
|
|
1746
|
-
|
|
1318
|
+
tombstones.add(t.id);
|
|
1747
1319
|
acknowledgeSwitchNotify(t.id); // a cue for a closed tab is moot
|
|
1748
1320
|
t.aria.remove();
|
|
1749
1321
|
t.el.remove();
|
|
@@ -1764,7 +1336,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1764
1336
|
}
|
|
1765
1337
|
for (const t of victims) {
|
|
1766
1338
|
try {
|
|
1767
|
-
await
|
|
1339
|
+
await api.close(t.id);
|
|
1768
1340
|
} catch {
|
|
1769
1341
|
ctx.toast("Couldn't close a terminal on the server");
|
|
1770
1342
|
}
|
|
@@ -1836,31 +1408,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1836
1408
|
tabMenuItem("Close all", false, () => {
|
|
1837
1409
|
void closeAll();
|
|
1838
1410
|
});
|
|
1839
|
-
// Make visible (so it has measurable size), then
|
|
1840
|
-
// viewport
|
|
1841
|
-
// visual viewport
|
|
1411
|
+
// Make visible (so it has measurable size), then place it within the
|
|
1412
|
+
// visible viewport via the shared point-anchored positioner (clamp to
|
|
1413
|
+
// the visual viewport; flip above the pointer near the bottom edge).
|
|
1842
1414
|
tabMenu.classList.add("visible");
|
|
1843
|
-
|
|
1844
|
-
const viewLeft = vv ? vv.offsetLeft : 0;
|
|
1845
|
-
const viewTop = vv ? vv.offsetTop : 0;
|
|
1846
|
-
const viewWidth = vv ? vv.width : window.innerWidth;
|
|
1847
|
-
const viewHeight = vv ? vv.height : window.innerHeight;
|
|
1848
|
-
const menuW = tabMenu.offsetWidth;
|
|
1849
|
-
const menuH = tabMenu.offsetHeight;
|
|
1850
|
-
const left = Math.max(
|
|
1851
|
-
viewLeft + TAB_MENU_EDGE,
|
|
1852
|
-
Math.min(x, viewLeft + viewWidth - menuW - TAB_MENU_EDGE),
|
|
1853
|
-
);
|
|
1854
|
-
let top = y;
|
|
1855
|
-
if (y + menuH + TAB_MENU_EDGE > viewTop + viewHeight) {
|
|
1856
|
-
top = y - menuH - TAB_MENU_GAP;
|
|
1857
|
-
}
|
|
1858
|
-
top = Math.max(
|
|
1859
|
-
viewTop + TAB_MENU_EDGE,
|
|
1860
|
-
Math.min(top, viewTop + viewHeight - menuH - TAB_MENU_EDGE),
|
|
1861
|
-
);
|
|
1862
|
-
tabMenu.style.left = `${String(left)}px`;
|
|
1863
|
-
tabMenu.style.top = `${String(top)}px`;
|
|
1415
|
+
placeMenuAt(tabMenu, x, y);
|
|
1864
1416
|
}
|
|
1865
1417
|
|
|
1866
1418
|
// One-time "swipe to switch" hint on first multi-tab state, mobile only.
|
|
@@ -1869,7 +1421,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1869
1421
|
return;
|
|
1870
1422
|
}
|
|
1871
1423
|
hintShown = true;
|
|
1872
|
-
|
|
1424
|
+
const l = ctx.layout();
|
|
1425
|
+
if (!(l.narrow && l.coarse)) {
|
|
1873
1426
|
return; // only the mobile switcher layout has a swipe-to-switch bar
|
|
1874
1427
|
}
|
|
1875
1428
|
let seen = false;
|
|
@@ -1949,19 +1502,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1949
1502
|
return;
|
|
1950
1503
|
}
|
|
1951
1504
|
// Adopt a session created in another browser so all clients converge.
|
|
1952
|
-
//
|
|
1953
|
-
//
|
|
1954
|
-
//
|
|
1505
|
+
// The status record IS the session's wire shape (SessionStatus
|
|
1506
|
+
// extends SessionInfo), so it flows through whole — which also
|
|
1507
|
+
// carries the persisted clientTitle: the SSE snapshot usually beats
|
|
1508
|
+
// the initial GET /api/sessions (whose adoptSession then dedups and
|
|
1509
|
+
// never re-applies its fields), so dropping it here left every
|
|
1955
1510
|
// SSE-adopted tab without its persisted title — in preferInputTitle
|
|
1956
1511
|
// mode the label then fell back to "New tab" on reload.
|
|
1957
|
-
adoptSession(
|
|
1958
|
-
id: s.id,
|
|
1959
|
-
title: s.title,
|
|
1960
|
-
clientTitle: s.clientTitle,
|
|
1961
|
-
createdAt: s.createdAt,
|
|
1962
|
-
status: s.status,
|
|
1963
|
-
reportsActivity: s.reportsActivity ?? false,
|
|
1964
|
-
});
|
|
1512
|
+
adoptSession(s);
|
|
1965
1513
|
applyStatus(s.id, s.status, s.title, s.clientTitle, s.reportsActivity ?? false);
|
|
1966
1514
|
ensureActive();
|
|
1967
1515
|
syncChrome();
|
|
@@ -1981,7 +1529,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1981
1529
|
try {
|
|
1982
1530
|
let list: SessionInfo[];
|
|
1983
1531
|
try {
|
|
1984
|
-
list = await
|
|
1532
|
+
list = await api.list();
|
|
1985
1533
|
} catch {
|
|
1986
1534
|
return; // transient; try again next tick
|
|
1987
1535
|
}
|
|
@@ -2014,8 +1562,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2014
1562
|
}
|
|
2015
1563
|
|
|
2016
1564
|
// --- Event wiring ---
|
|
2017
|
-
// Derive each tab's title from the
|
|
2018
|
-
const offInput = ctx.registerInputObserver(
|
|
1565
|
+
// Derive each tab's title from the lines the user submits into it.
|
|
1566
|
+
const offInput = ctx.registerInputObserver((bytes) => {
|
|
1567
|
+
titleDeriver.observe(bytes);
|
|
1568
|
+
});
|
|
2019
1569
|
// Observe (never consume) keydowns to detect a physical keyboard: a
|
|
2020
1570
|
// hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
|
|
2021
1571
|
// for a keyboard folio with no trackpad (no fine pointer to key off).
|
|
@@ -2472,68 +2022,86 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2472
2022
|
toggleSwitcher();
|
|
2473
2023
|
});
|
|
2474
2024
|
|
|
2475
|
-
//
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
// corpse was the stuck-loading wedge. The exited ones are still adopted
|
|
2486
|
-
// below (switch to them to read their final screen; close them by hand).
|
|
2487
|
-
if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
|
|
2025
|
+
// The kernel-driven bootstrap (sessionOwner.resolveInitialSession): list
|
|
2026
|
+
// existing sessions or create the first one, adopt them, pick the start
|
|
2027
|
+
// tab, bind the renderer to its store — and RETURN its ref rather than
|
|
2028
|
+
// switching; the kernel performs the switch through the same path a tab
|
|
2029
|
+
// switch uses. A null return (nothing could be listed or spawned) keeps
|
|
2030
|
+
// the chrome up with the "+" retry alive, and the kernel — which now sees
|
|
2031
|
+
// the failure directly — dismisses the loading overlay over it.
|
|
2032
|
+
resolveImpl = async (): Promise<SessionRef | null> => {
|
|
2033
|
+
// Initial population: list existing sessions, or create the first one.
|
|
2034
|
+
let sessions: SessionInfo[];
|
|
2488
2035
|
try {
|
|
2489
|
-
sessions =
|
|
2036
|
+
sessions = await api.list();
|
|
2490
2037
|
} catch {
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2038
|
+
sessions = [];
|
|
2039
|
+
}
|
|
2040
|
+
// Spawn a fresh session unless a LIVE one is listed. An exited session
|
|
2041
|
+
// is viewable history, not a working terminal — booting a page whose
|
|
2042
|
+
// every session has died (the agent exited: a sign-in dead end, a
|
|
2043
|
+
// crash) onto a corpse was the stuck-loading wedge. The exited ones are
|
|
2044
|
+
// still adopted below (switch to them to read their final screen; close
|
|
2045
|
+
// them by hand).
|
|
2046
|
+
if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
|
|
2047
|
+
try {
|
|
2048
|
+
sessions = [...sessions, await api.create()];
|
|
2049
|
+
} catch {
|
|
2050
|
+
// Match the runtime create() path: toast and leave the chrome up so
|
|
2051
|
+
// "+" can retry. Any exited sessions stay adopted (frozen screen +
|
|
2052
|
+
// "Session ended" is still better than a blank page). A throw here
|
|
2053
|
+
// would also be survivable (the kernel treats a rejected resolver
|
|
2054
|
+
// as null), but the toast is the better UX.
|
|
2055
|
+
ctx.toast("Couldn't open a terminal");
|
|
2056
|
+
}
|
|
2500
2057
|
}
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2058
|
+
// Adopt (dedup) rather than blindly push. The status SSE pushes a
|
|
2059
|
+
// snapshot of the existing sessions on open, and tabs subscribes
|
|
2060
|
+
// (monitor.onStatus) BEFORE this list resolves, so a session may
|
|
2061
|
+
// already have a tab by the time the list lands. A straight push
|
|
2062
|
+
// doubled every already-adopted session (6 tabs from 3 across a fresh
|
|
2063
|
+
// load), and paintActive then lit both copies of the active id
|
|
2064
|
+
// ("2 active tabs" that move together).
|
|
2065
|
+
for (const info of sessions) {
|
|
2066
|
+
adoptSession(info);
|
|
2067
|
+
}
|
|
2068
|
+
// From here on, tabs added at runtime (create / adopt) animate in.
|
|
2069
|
+
started = true;
|
|
2070
|
+
// The SSE snapshot may have raced this bootstrap and already activated
|
|
2071
|
+
// a tab (ensureActive during the await above). The switch is then
|
|
2072
|
+
// already in flight — return null; the kernel sees connectionInitiated
|
|
2073
|
+
// and leaves the loading overlay to the normal ready path.
|
|
2074
|
+
if (activeId !== null) {
|
|
2075
|
+
return null;
|
|
2076
|
+
}
|
|
2077
|
+
// Activate the previously-active session if it still exists, else the
|
|
2078
|
+
// first (oldest). Session ids are stable server-side, so a page reload
|
|
2079
|
+
// reconnects to the tab the user left on instead of always the oldest.
|
|
2080
|
+
// Live sessions outrank exited ones: the saved id is honored only while
|
|
2081
|
+
// its session is still live (a reload used to restore straight onto the
|
|
2082
|
+
// corpse of a died-while-away session and wedge there), and the default
|
|
2083
|
+
// is the oldest LIVE tab. Only when nothing is live (the fresh-spawn
|
|
2084
|
+
// above failed too) does an exited tab start — a frozen final screen
|
|
2085
|
+
// with the "Session ended" banner beats a blank page.
|
|
2086
|
+
const liveIds = new Set(
|
|
2087
|
+
sessions.filter((s) => s.status !== STATUS_EXITED).map((s) => s.id),
|
|
2088
|
+
);
|
|
2089
|
+
const oldestLive = tabList.find((t) => liveIds.has(t.id));
|
|
2090
|
+
let startTab = oldestLive ?? tabList[0];
|
|
2091
|
+
try {
|
|
2092
|
+
const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
|
|
2093
|
+
if (savedId !== null && savedId !== "") {
|
|
2094
|
+
const saved = tabList.find((x) => x.id === savedId);
|
|
2095
|
+
if (saved && (liveIds.has(saved.id) || oldestLive === undefined)) {
|
|
2096
|
+
startTab = saved;
|
|
2097
|
+
}
|
|
2531
2098
|
}
|
|
2099
|
+
} catch {
|
|
2100
|
+
/* storage unavailable — fall back to the oldest live tab */
|
|
2101
|
+
}
|
|
2102
|
+
if (!startTab) {
|
|
2103
|
+
return null;
|
|
2532
2104
|
}
|
|
2533
|
-
} catch {
|
|
2534
|
-
/* storage unavailable — fall back to the oldest live tab */
|
|
2535
|
-
}
|
|
2536
|
-
if (startTab) {
|
|
2537
2105
|
activeId = startTab.id;
|
|
2538
2106
|
try {
|
|
2539
2107
|
localStorage.setItem(ACTIVE_TAB_KEY, startTab.id);
|
|
@@ -2541,10 +2109,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2541
2109
|
/* storage unavailable — non-fatal */
|
|
2542
2110
|
}
|
|
2543
2111
|
ctx.render.bind(startTab.store);
|
|
2544
|
-
ctx.notifySwitch({ id: startTab.id });
|
|
2545
2112
|
syncChrome();
|
|
2546
2113
|
focusInput();
|
|
2547
|
-
|
|
2114
|
+
return { id: startTab.id };
|
|
2115
|
+
};
|
|
2548
2116
|
|
|
2549
2117
|
return {
|
|
2550
2118
|
api: {
|
|
@@ -2555,6 +2123,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2555
2123
|
tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
|
|
2556
2124
|
},
|
|
2557
2125
|
teardown() {
|
|
2126
|
+
resolveImpl = null;
|
|
2558
2127
|
offStatus?.();
|
|
2559
2128
|
offInput();
|
|
2560
2129
|
offHwKey();
|
|
@@ -2570,9 +2139,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2570
2139
|
barResize.disconnect();
|
|
2571
2140
|
swReserve.disconnect();
|
|
2572
2141
|
surface.classList.remove("wt-with-tabbar");
|
|
2573
|
-
|
|
2142
|
+
varRoot.style.removeProperty("--wt-tabbar-h");
|
|
2574
2143
|
root?.classList.remove("wt-tabbed");
|
|
2575
|
-
|
|
2144
|
+
varRoot.style.removeProperty("--wt-reserve-bottom");
|
|
2576
2145
|
if (switchAnimTimer !== null) {
|
|
2577
2146
|
clearTimeout(switchAnimTimer);
|
|
2578
2147
|
}
|
|
@@ -2600,23 +2169,3 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2600
2169
|
},
|
|
2601
2170
|
};
|
|
2602
2171
|
}
|
|
2603
|
-
|
|
2604
|
-
// paintStatusDot applies a status dot's two orthogonal bits: data-status drives
|
|
2605
|
-
// its appearance (idle / working / done / input / exited via CSS), and the
|
|
2606
|
-
// .wt-reports class controls its visibility — the dot is hidden by default and
|
|
2607
|
-
// shown only once the session has reported activity (OSC 9;4 progress or a
|
|
2608
|
-
// classified OSC 9 notification), so a plain shell's tabs stay clean and
|
|
2609
|
-
// label-only while an agent's light up.
|
|
2610
|
-
function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
|
|
2611
|
-
el.dataset["status"] = status || "idle";
|
|
2612
|
-
el.classList.toggle("wt-reports", reports);
|
|
2613
|
-
}
|
|
2614
|
-
|
|
2615
|
-
// pick returns a required descendant element or throws (static chrome only).
|
|
2616
|
-
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
2617
|
-
const el = root.querySelector<HTMLElement>(selector);
|
|
2618
|
-
if (!el) {
|
|
2619
|
-
throw new Error(`web-terminal-ui: tabs chrome missing ${selector}`);
|
|
2620
|
-
}
|
|
2621
|
-
return el;
|
|
2622
|
-
}
|