@aitty/browser 0.1.1 → 0.2.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.
@@ -1,22 +1,86 @@
1
- import { nextAltScreenState, parseCsiParams } from "./ansi-sequences.js";
1
+ import { createFocusReportingParser, nextAltScreenState, parseCsiParams } from "./ansi-sequences.js";
2
2
  import { createAnsiStyleTracker } from "./ansi-style-tracker.js";
3
3
  import { BrowserTerminalRenderer } from "./browser-terminal-renderer.js";
4
- import { installTerminalInputPolicies, isBrowserSafeShortcut, isCompositionKeyDown, isContainedTerminalNavigationKey } from "./terminal-input-policies.js";
4
+ import { areTerminalAppearancesEqual, areTerminalBehaviorsEqual, cloneTerminalConfig, hasTerminalLayoutAppearanceChange, mergeTerminalConfig, normalizeTerminalConfig, normalizeThemeName, normalizeThemeTarget } from "./terminal-config.js";
5
+ import { installShellControls } from "./shell-controls.js";
6
+ import { installTerminalInputPolicies, isBrowserSafeShortcut, isCompositionKeyDown, isContainedTerminalNavigationKey, resolveTerminalDocumentKeyData } from "./terminal-input-policies.js";
7
+ import { resolveScrollAnchorRestoreTop } from "./terminal-scroll-anchor.js";
8
+ import { isScrollAtBottom, resolveScrollFollowPolicy } from "./terminal-scroll-follow.js";
5
9
  import { createTerminalThemeProtocolParser, resolveTerminalThemeQueryResponse } from "./terminal-theme-protocol.js";
6
- import { WTerm } from "@wterm/dom";
7
- import { createPongControlFrame, createResizeControlFrame, normalizeTheme, parseAittyControlFrame } from "@aitty/protocol";
8
- //#region src/frontend/terminal-app.ts
10
+ import { resolveVirtualTranscriptWindow } from "./virtual-transcript-window.js";
11
+ import { WTerm, hasTerminalSelection } from "@aitty/wterm-dom";
12
+ import { createPongControlFrame, createPushSubscribeControlFrame, createPushUnsubscribeControlFrame, createResizeControlFrame, createTakeoverRequestControlFrame, createTakeoverResponseControlFrame, isAittyRoleControlFrame, isAittyTakeoverRequestControlFrame, isAittyTakeoverResultControlFrame, parseAittyControlFrame } from "@aitty/protocol";
13
+ //#region packages/browser/src/frontend/terminal-app.ts
9
14
  const SUPERSEDED_CLOSE_CODE = 4001;
10
15
  const DEFAULT_MAX_BYTES_PER_FRAME = 32 * 1024;
11
- const DEFAULT_TRANSCRIPT_SCROLLBACK_LIMIT = 1e3;
12
16
  const DEFAULT_RECONNECT_DELAY_MS = 250;
17
+ const DEFAULT_TERMINAL_OUTPUT_FRAME_INTERVAL_MS = 1e3 / 30;
18
+ const HIGH_PRESSURE_TERMINAL_OUTPUT_FRAME_INTERVAL_MS = 1e3 / 24;
19
+ const HIGH_PRESSURE_TERMINAL_OUTPUT_PENDING_BYTES = 96 * 1024;
20
+ const OUTPUT_PRESSURE_WINDOW_MS = 750;
21
+ const OUTPUT_PRESSURE_CHUNK_THRESHOLD = 12;
22
+ const OUTPUT_PRESSURE_BYTE_THRESHOLD = 128 * 1024;
23
+ const OUTPUT_PRESSURE_HOLD_MS = 1e3;
24
+ const DEFAULT_TERMINAL_RENDER_FONT_SIZE = 15;
13
25
  const SCREEN_REDRAW_ARCHIVE_SUPPRESS_MS = 500;
26
+ const DESKTOP_IME_TEXTAREA_INSET_PX = 8;
27
+ const DESKTOP_IME_CANDIDATE_SAFE_WIDTH_PX = 560;
28
+ const DESKTOP_IME_CANDIDATE_SAFE_HEIGHT_PX = 260;
29
+ const IOS_BROWSER_CHROME_KEYBOARD_GUARD_PX = 56;
30
+ const MOBILE_SCREEN_KEYBOARD_BASELINE_MIN_INSET_PX = 240;
31
+ const MOBILE_FOCUS_PROXY_MIN_WIDTH_PX = 280;
32
+ const MOBILE_FOCUS_PROXY_HEIGHT_PX = 44;
33
+ const MOBILE_FOCUS_PROXY_VIEWPORT_GAP_PX = 10;
34
+ const MOBILE_FOCUS_PROXY_OPACITY = "0.02";
35
+ const MOBILE_KEYBOARD_ALIGN_DELAYS_MS = [
36
+ 60,
37
+ 180,
38
+ 360,
39
+ 700,
40
+ 1100
41
+ ];
42
+ const MOBILE_COMPOSER_DIRECTION_LONG_PRESS_MS = 480;
43
+ const MOBILE_COMPOSER_DOCK_SIZE_PX = 58;
44
+ const MOBILE_COMPOSER_BUTTON_SIZE_PX = 54;
45
+ const MOBILE_COMPOSER_BUTTON_GAP_PX = 10;
46
+ const MOBILE_COMPOSER_EXPANDED_GAP_PX = 64;
47
+ const MOBILE_COMPOSER_MARGIN_PX = 10;
48
+ const MOBILE_COMPOSER_DIRECTION_OUTSET_PX = 62;
49
+ const MOBILE_COMPOSER_DIRECTION_BOTTOM_OUTSET_PX = 66;
50
+ const MOBILE_COMPOSER_TAP_SLOP_PX = 8;
51
+ const MOBILE_COMPOSER_CLICK_SUPPRESS_MS = 420;
52
+ const MOBILE_KEYBOARD_SCROLL_INTENT_MS = 1200;
53
+ const MOBILE_KEYBOARD_MANUAL_SCROLL_SUPPRESS_MS = 1800;
54
+ const TAKEOVER_NOTICE_TIMEOUT_MS = 6e4;
55
+ const OUTPUT_DRAIN_SETTLE_DELAY_MS = 80;
56
+ const LAYOUT_BOTTOM_FOLLOW_DELAYS_MS = [
57
+ 80,
58
+ 180,
59
+ 360,
60
+ 700,
61
+ 1100,
62
+ 1600
63
+ ];
64
+ const USER_INPUT_LAYOUT_STICK_MS = 12e4;
65
+ const KEYBOARD_VISIBLE_HEIGHT_RATIO = .85;
66
+ const VIEWPORT_BASELINE_WIDTH_RESET_PX = 24;
67
+ const LIVE_INPUT_KEYBOARD_MAX_GAP_PX = 112;
68
+ const TERMINAL_FOCUS_IN = "\x1B[I";
14
69
  const TERMINAL_THEME_PROTOCOL_VARIABLES = [
15
70
  "--theme-term-bg",
16
71
  "--theme-term-cursor",
17
72
  "--theme-term-fg",
73
+ "--theme-term-reverse-bg",
74
+ "--theme-term-reverse-fg",
18
75
  ...Array.from({ length: 16 }, (_, index) => `--theme-term-color-${index}`)
19
76
  ];
77
+ let nextTerminalInputId = 0;
78
+ const MOBILE_COMPOSER_PRIMARY_CONTROLS = Object.freeze([
79
+ "esc",
80
+ "tab",
81
+ "enter"
82
+ ]);
83
+ const browserViewportBaselines = /* @__PURE__ */ new WeakMap();
20
84
  /**
21
85
  * Batches raw PTY bytes into animation frames without splitting UTF-8 codepoints or
22
86
  * incomplete ANSI sequences. This is the only throttling layer in the browser path.
@@ -24,21 +88,60 @@ const TERMINAL_THEME_PROTOCOL_VARIABLES = [
24
88
  function createBufferedTerminalWriter(target, scheduler = {}, options = {}) {
25
89
  const requestFrame = scheduler.requestFrame ?? ((callback) => requestBrowserFrame(callback));
26
90
  const cancelFrame = scheduler.cancelFrame ?? ((handle) => cancelBrowserFrame(handle));
91
+ const now = scheduler.now ?? getMonotonicTime;
92
+ const scheduleTimeout = scheduler.setTimeout ?? ((callback, delayMs) => setTimeout(callback, delayMs));
93
+ const clearScheduledTimeout = scheduler.clearTimeout ?? ((handle) => clearTimeout(handle));
27
94
  const maxBytesPerFrame = Math.max(1, options.maxBytesPerFrame ?? DEFAULT_MAX_BYTES_PER_FRAME);
95
+ const minFrameIntervalMs = Math.max(0, options.minFrameIntervalMs ?? 0);
28
96
  let chunks = [];
29
97
  let frameHandle = null;
98
+ let frameTimer = null;
99
+ let headIndex = 0;
30
100
  let headOffset = 0;
31
- const getPendingBytes = () => {
32
- if (chunks.length === 0) return new Uint8Array(0);
33
- if (chunks.length === 1) return headOffset === 0 ? chunks[0] : chunks[0].subarray(headOffset);
34
- return concatChunks([chunks[0].subarray(headOffset), ...chunks.slice(1)]);
101
+ let lastFlushTimestamp = 0;
102
+ let pendingByteLength = 0;
103
+ const resetQueue = () => {
104
+ chunks = [];
105
+ headIndex = 0;
106
+ headOffset = 0;
107
+ pendingByteLength = 0;
108
+ };
109
+ const compactQueue = () => {
110
+ if (headIndex === 0) return;
111
+ if (headIndex < 64 && headIndex * 2 < chunks.length) return;
112
+ chunks = chunks.slice(headIndex);
113
+ headIndex = 0;
114
+ };
115
+ const peekPendingBytes = (maxByteCount) => {
116
+ const byteCount = Math.min(Math.max(0, maxByteCount), pendingByteLength);
117
+ if (byteCount === 0) return new Uint8Array(0);
118
+ const head = chunks[headIndex];
119
+ if (!head) {
120
+ resetQueue();
121
+ return new Uint8Array(0);
122
+ }
123
+ if (byteCount <= head.length - headOffset) return head.subarray(headOffset, headOffset + byteCount);
124
+ const payload = new Uint8Array(byteCount);
125
+ let copiedBytes = 0;
126
+ let chunkIndex = headIndex;
127
+ while (copiedBytes < byteCount && chunkIndex < chunks.length) {
128
+ const chunk = chunks[chunkIndex];
129
+ const chunkOffset = chunkIndex === headIndex ? headOffset : 0;
130
+ const chunkAvailableBytes = chunk.length - chunkOffset;
131
+ const copyBytes = Math.min(chunkAvailableBytes, byteCount - copiedBytes);
132
+ payload.set(chunk.subarray(chunkOffset, chunkOffset + copyBytes), copiedBytes);
133
+ copiedBytes += copyBytes;
134
+ chunkIndex += 1;
135
+ }
136
+ return payload;
35
137
  };
36
138
  const consumePendingBytes = (byteCount) => {
37
- let remainingBytes = byteCount;
38
- while (chunks.length > 0 && remainingBytes > 0) {
39
- const availableBytes = chunks[0].length - headOffset;
139
+ let remainingBytes = Math.min(Math.max(0, byteCount), pendingByteLength);
140
+ pendingByteLength -= remainingBytes;
141
+ while (headIndex < chunks.length && remainingBytes > 0) {
142
+ const availableBytes = chunks[headIndex].length - headOffset;
40
143
  if (availableBytes <= remainingBytes) {
41
- chunks.shift();
144
+ headIndex += 1;
42
145
  headOffset = 0;
43
146
  remainingBytes -= availableBytes;
44
147
  continue;
@@ -46,14 +149,31 @@ function createBufferedTerminalWriter(target, scheduler = {}, options = {}) {
46
149
  headOffset += remainingBytes;
47
150
  remainingBytes = 0;
48
151
  }
152
+ if (pendingByteLength === 0) {
153
+ resetQueue();
154
+ return;
155
+ }
156
+ compactQueue();
49
157
  };
50
158
  const isUtf8ContinuationByte = (value) => (value & 192) === 128;
51
159
  const isUtf8LeadByte = (value) => (value & 128) !== 0 && !isUtf8ContinuationByte(value);
160
+ const resolveUtf8SequenceLength = (value) => {
161
+ if ((value & 224) === 192) return 2;
162
+ if ((value & 240) === 224) return 3;
163
+ if ((value & 248) === 240) return 4;
164
+ return null;
165
+ };
52
166
  const resolveUtf8SafeBoundary = (bytes, preferredEnd) => {
53
167
  if (preferredEnd <= 0 || preferredEnd >= bytes.length) return preferredEnd;
54
168
  let index = preferredEnd;
55
169
  while (index > 0 && isUtf8ContinuationByte(bytes[index])) index -= 1;
56
- if (index < preferredEnd && isUtf8LeadByte(bytes[index])) return index;
170
+ if (index < preferredEnd && isUtf8LeadByte(bytes[index])) {
171
+ if (index === 0) {
172
+ const sequenceLength = resolveUtf8SequenceLength(bytes[index]);
173
+ if (sequenceLength && sequenceLength <= bytes.length) return sequenceLength;
174
+ }
175
+ return index;
176
+ }
57
177
  return preferredEnd;
58
178
  };
59
179
  const resolveAnsiSafeBoundary = (bytes, preferredEnd) => {
@@ -112,68 +232,209 @@ function createBufferedTerminalWriter(target, scheduler = {}, options = {}) {
112
232
  boundary = resolveAnsiSafeBoundary(bytes, boundary);
113
233
  return boundary > 0 ? boundary : preferredEnd;
114
234
  };
115
- const flush = () => {
235
+ const clearScheduledFrame = () => {
236
+ if (frameHandle !== null) {
237
+ cancelFrame(frameHandle);
238
+ frameHandle = null;
239
+ }
240
+ if (frameTimer !== null) {
241
+ clearScheduledTimeout(frameTimer);
242
+ frameTimer = null;
243
+ }
244
+ };
245
+ const resolveMinFrameIntervalMs = () => {
246
+ const resolved = options.resolveMinFrameIntervalMs?.({ pendingByteLength });
247
+ return Math.max(0, resolved ?? minFrameIntervalMs);
248
+ };
249
+ const scheduleFlush = () => {
250
+ if (frameHandle !== null || frameTimer !== null) return;
251
+ const frameIntervalMs = resolveMinFrameIntervalMs();
252
+ const delayMs = frameIntervalMs <= 0 ? 0 : Math.max(0, frameIntervalMs - (now() - lastFlushTimestamp));
253
+ const requestNextFrame = () => {
254
+ frameHandle = requestFrame((timestamp) => {
255
+ flush(timestamp);
256
+ });
257
+ };
258
+ if (delayMs > 1) {
259
+ frameTimer = scheduleTimeout(() => {
260
+ frameTimer = null;
261
+ requestNextFrame();
262
+ }, delayMs);
263
+ return;
264
+ }
265
+ requestNextFrame();
266
+ };
267
+ const flush = (timestamp = now()) => {
116
268
  frameHandle = null;
117
- if (chunks.length === 0) return;
118
- const pendingBytes = getPendingBytes();
269
+ if (pendingByteLength === 0) {
270
+ resetQueue();
271
+ return;
272
+ }
273
+ const preferredEnd = Math.min(maxBytesPerFrame, pendingByteLength);
274
+ const pendingBytes = peekPendingBytes(preferredEnd < pendingByteLength ? preferredEnd + 1 : preferredEnd);
119
275
  if (pendingBytes.length === 0) {
120
- chunks = [];
121
- headOffset = 0;
276
+ resetQueue();
122
277
  return;
123
278
  }
124
- const preferredEnd = Math.min(maxBytesPerFrame, pendingBytes.length);
125
279
  const safeEnd = resolveSafeSplitBoundary(pendingBytes, preferredEnd);
126
- const trailingAnsiCarryLength = preferredEnd === pendingBytes.length ? resolveTrailingAnsiCarryLength(pendingBytes) : 0;
280
+ const trailingAnsiCarryLength = preferredEnd === pendingByteLength ? resolveTrailingAnsiCarryLength(pendingBytes) : 0;
127
281
  const payloadByteCount = trailingAnsiCarryLength > 0 ? Math.max(0, safeEnd - trailingAnsiCarryLength) : safeEnd > 0 ? safeEnd : preferredEnd;
128
- if (payloadByteCount <= 0) {
129
- frameHandle = requestFrame(() => {
130
- flush();
131
- });
132
- return;
133
- }
282
+ if (payloadByteCount <= 0) return;
134
283
  const payload = pendingBytes.subarray(0, payloadByteCount);
135
284
  target.write(payload);
285
+ lastFlushTimestamp = timestamp;
136
286
  consumePendingBytes(payloadByteCount);
137
- if (chunks.length > 0) frameHandle = requestFrame(() => {
138
- flush();
287
+ options.onFlush?.({
288
+ pendingByteLength,
289
+ wroteByteLength: payloadByteCount
139
290
  });
291
+ if (pendingByteLength > 0) {
292
+ scheduleFlush();
293
+ return;
294
+ }
295
+ options.onDrain?.();
140
296
  };
141
297
  return {
142
298
  destroy() {
143
- if (frameHandle !== null) {
144
- cancelFrame(frameHandle);
145
- frameHandle = null;
146
- }
147
- chunks = [];
148
- headOffset = 0;
299
+ clearScheduledFrame();
300
+ resetQueue();
149
301
  },
150
302
  discardPending() {
151
- if (frameHandle !== null) {
152
- cancelFrame(frameHandle);
153
- frameHandle = null;
154
- }
155
- chunks = [];
156
- headOffset = 0;
303
+ clearScheduledFrame();
304
+ resetQueue();
157
305
  },
158
306
  enqueue(chunk) {
159
307
  if (chunk.length === 0) return;
160
308
  chunks.push(chunk);
161
- if (frameHandle === null) frameHandle = requestFrame(() => {
162
- flush();
163
- });
309
+ pendingByteLength += chunk.length;
310
+ scheduleFlush();
164
311
  }
165
312
  };
166
313
  }
314
+ function resolveBrowserViewport(windowObject) {
315
+ const visualViewport = windowObject.visualViewport;
316
+ const root = windowObject.document?.documentElement;
317
+ const body = windowObject.document?.body;
318
+ const layoutWidth = firstFinitePositiveNumber(windowObject.innerWidth, root?.clientWidth, body?.clientWidth) ?? 1;
319
+ const currentLayoutHeight = largestFinitePositiveNumber(windowObject.innerHeight, root?.clientHeight) ?? firstFinitePositiveNumber(body?.clientHeight) ?? 1;
320
+ const visualWidth = firstFinitePositiveNumber(visualViewport?.width);
321
+ const visualHeight = firstFinitePositiveNumber(visualViewport?.height);
322
+ const visualOffsetTop = normalizeViewportOffset(visualViewport?.offsetTop);
323
+ const height = visualHeight ?? currentLayoutHeight;
324
+ const layoutHeight = resolveStableViewportBaselineHeight(windowObject, layoutWidth, currentLayoutHeight, height);
325
+ const visualKeyboardVisible = layoutHeight > 0 && height < layoutHeight * KEYBOARD_VISIBLE_HEIGHT_RATIO;
326
+ const keyboardInsetBottom = visualKeyboardVisible ? Math.max(0, layoutHeight - height) : 0;
327
+ const keyboardVisible = visualKeyboardVisible && keyboardInsetBottom > 0;
328
+ const browserChromeInsetBottom = resolveBrowserChromeKeyboardGuard(windowObject, keyboardVisible);
329
+ return {
330
+ browserChromeInsetBottom,
331
+ height,
332
+ keyboardInsetBottom,
333
+ offsetTop: visualOffsetTop,
334
+ safeHeight: keyboardVisible ? layoutHeight : Math.max(1, height - browserChromeInsetBottom),
335
+ width: visualWidth ?? layoutWidth
336
+ };
337
+ }
338
+ function resolveStableViewportBaselineHeight(windowObject, width, layoutHeight, visibleHeight) {
339
+ const screenHeight = resolveMobileScreenViewportBaselineHeight(windowObject, width, visibleHeight);
340
+ const height = Math.max(1, layoutHeight, visibleHeight, screenHeight ?? 0);
341
+ const previous = browserViewportBaselines.get(windowObject);
342
+ if (!previous || Math.abs(previous.width - width) > VIEWPORT_BASELINE_WIDTH_RESET_PX) {
343
+ browserViewportBaselines.set(windowObject, {
344
+ height,
345
+ width
346
+ });
347
+ return height;
348
+ }
349
+ if (height > previous.height) {
350
+ const nextBaseline = {
351
+ height,
352
+ width
353
+ };
354
+ browserViewportBaselines.set(windowObject, nextBaseline);
355
+ return nextBaseline.height;
356
+ }
357
+ return previous.height;
358
+ }
359
+ function resolveMobileScreenViewportBaselineHeight(windowObject, width, visibleHeight) {
360
+ if (!isCoarseMobileViewport(windowObject) || !isAittyInputFocused(windowObject)) return null;
361
+ const screen = windowObject.screen;
362
+ const candidate = largestFinitePositiveNumber(screen?.availHeight, screen?.height);
363
+ if (!candidate || candidate < width) return null;
364
+ if (candidate - visibleHeight < MOBILE_SCREEN_KEYBOARD_BASELINE_MIN_INSET_PX) return null;
365
+ return candidate;
366
+ }
367
+ function isAittyInputFocused(windowObject) {
368
+ return (windowObject.document?.activeElement)?.hasAttribute?.("data-aitty-input") === true;
369
+ }
370
+ function resolveMobileFocusProxyPosition(windowObject) {
371
+ const viewport = resolveBrowserViewport(windowObject);
372
+ const anchor = resolveMobileLiveInputAnchorRect(windowObject);
373
+ const width = Math.max(44, viewport.width - 16);
374
+ const height = MOBILE_FOCUS_PROXY_HEIGHT_PX;
375
+ const viewportTop = viewport.offsetTop + MOBILE_FOCUS_PROXY_VIEWPORT_GAP_PX;
376
+ const viewportBottom = resolveMobileFocusProxyBottom(viewport, height);
377
+ if (anchor) return {
378
+ anchor: "mobile-focus-proxy",
379
+ height,
380
+ left: 8,
381
+ top: clamp(anchor.top + Math.max(0, anchor.height - height), viewportTop, viewportBottom - height),
382
+ width
383
+ };
384
+ const topAnchor = viewport.keyboardInsetBottom > 0 ? viewportBottom - height : viewport.offsetTop + Math.max(12, Math.min(96, viewport.height * .32));
385
+ return {
386
+ anchor: "mobile-focus-proxy",
387
+ height,
388
+ left: Math.max(8, Math.min(24, viewport.width - 2)),
389
+ top: clamp(topAnchor, viewportTop, viewportBottom - height),
390
+ width
391
+ };
392
+ }
393
+ function shouldEnableMobileFocusProxyPointerEvents(windowObject, state = {}) {
394
+ if (!isCoarseMobileViewport(windowObject)) return false;
395
+ if (state.keyboardOpen) return true;
396
+ return resolveMobileLiveInputAnchorRect(windowObject) !== null;
397
+ }
398
+ function isMobileLiveInputTapTarget(terminalRoot, windowObject, clientX, clientY) {
399
+ const anchorRect = resolveLiveInputSurfaceAnchor(terminalRoot)?.getBoundingClientRect?.();
400
+ if (!hasUsableClientRect(anchorRect)) return false;
401
+ const rect = anchorRect;
402
+ const viewport = resolveBrowserViewport(windowObject);
403
+ const proxyHeight = MOBILE_FOCUS_PROXY_HEIGHT_PX;
404
+ const viewportTop = viewport.offsetTop + MOBILE_FOCUS_PROXY_VIEWPORT_GAP_PX;
405
+ const viewportBottom = resolveMobileFocusProxyBottom(viewport, proxyHeight);
406
+ const hitTop = clamp(rect.top + Math.max(0, rect.height - proxyHeight), viewportTop, viewportBottom - proxyHeight);
407
+ const hitBottom = hitTop + proxyHeight;
408
+ return clientX >= 0 && clientX <= viewport.width && clientY >= hitTop && clientY <= hitBottom;
409
+ }
410
+ function resolveMobileFocusProxyBottom(viewport, proxyHeight) {
411
+ const visibleBottom = viewport.offsetTop + viewport.height - viewport.browserChromeInsetBottom - MOBILE_FOCUS_PROXY_VIEWPORT_GAP_PX;
412
+ return Math.max(viewport.offsetTop + MOBILE_FOCUS_PROXY_VIEWPORT_GAP_PX + proxyHeight, visibleBottom);
413
+ }
414
+ function resolveMobileKeyboardPromptAlignDelays() {
415
+ return [...MOBILE_KEYBOARD_ALIGN_DELAYS_MS];
416
+ }
417
+ function resolveTerminalOutputFrameIntervalMs({ pendingByteLength, sustainedPressure = false }) {
418
+ if (sustainedPressure || pendingByteLength >= HIGH_PRESSURE_TERMINAL_OUTPUT_PENDING_BYTES) return HIGH_PRESSURE_TERMINAL_OUTPUT_FRAME_INTERVAL_MS;
419
+ return DEFAULT_TERMINAL_OUTPUT_FRAME_INTERVAL_MS;
420
+ }
421
+ function resolveMobileComposerExpandedListHeight(controlCount = MOBILE_COMPOSER_PRIMARY_CONTROLS.length) {
422
+ return controlCount * MOBILE_COMPOSER_BUTTON_SIZE_PX + Math.max(0, controlCount - 1) * MOBILE_COMPOSER_BUTTON_GAP_PX;
423
+ }
167
424
  async function mountTerminalApp(doc = document, dependencies = {}) {
168
425
  const elements = dependencies.elements ?? {};
169
426
  const shell = elements.shell ?? getRequiredElement(doc, "[data-shell]");
170
427
  const status = hasElementOverride(elements, "status") ? elements.status ?? null : getOptionalElement(doc, "[data-terminal-status]");
171
428
  const loading = hasElementOverride(elements, "loading") ? elements.loading ?? null : getOptionalElement(doc, "[data-terminal-loading]");
172
429
  const terminalRoot = elements.terminalRoot ?? getRequiredElement(doc, "[data-terminal-root]");
430
+ const takeoverControl = shell.querySelector("[data-aitty-take-control]");
173
431
  terminalRoot.classList.add("aitty-terminal-root");
174
432
  const windowObject = doc.defaultView ?? window;
175
433
  const sessionUrl = resolveAittySessionUrl(windowObject, dependencies.src);
176
- const themeController = createTerminalThemeController(doc, shell, terminalRoot, dependencies.theme);
434
+ const initialConfig = normalizeTerminalConfig(dependencies.config);
435
+ const themeController = createTerminalThemeController(doc, shell, terminalRoot, initialConfig.appearance.theme, initialConfig.appearance.themeTarget);
436
+ let terminalConfig = initialConfig;
437
+ applyTerminalAppearance(shell, terminalRoot, terminalConfig.appearance);
177
438
  const resolvedRuntimeKind = (dependencies.resolveRuntimeKind ?? ((resolverDoc, resolverShell) => resolveShellRuntimeKind(resolverDoc, resolverShell, sessionUrl)))(doc, shell);
178
439
  const runtimeKind = typeof resolvedRuntimeKind === "string" ? resolvedRuntimeKind : await resolvedRuntimeKind;
179
440
  shell.dataset.runtime = runtimeKind;
@@ -183,33 +444,60 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
183
444
  const requestFrame = frameScheduler.requestFrame ?? ((callback) => windowObject.requestAnimationFrame(callback));
184
445
  const cancelFrame = frameScheduler.cancelFrame ?? ((handle) => windowObject.cancelAnimationFrame(handle));
185
446
  const reconnectDelayMs = Math.max(0, dependencies.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS);
186
- const getScrollSurface = () => resolveScrollSurface(doc, terminalRoot, dependencies.scroll);
447
+ const getScrollSurface = () => resolveScrollSurface(doc, shell, terminalRoot, dependencies.scroll);
187
448
  const encoder = new TextEncoder();
188
449
  const ansiStyleTracker = createAnsiStyleTracker();
450
+ const focusReportingParser = createFocusReportingParser();
189
451
  const themeProtocolParser = createTerminalThemeProtocolParser();
190
452
  let transport = null;
191
453
  let term = null;
192
454
  let transcriptArchive = null;
193
455
  let writer = null;
194
- let archiveWriter = null;
195
456
  let sessionState = "running";
196
457
  let destroyed = false;
197
458
  let helloReceived = false;
198
459
  let outputReady = false;
199
460
  let reconnecting = false;
200
461
  let termReady = false;
462
+ let terminalFocusReportingEnabled = false;
463
+ let terminalInputFocused = false;
464
+ let clientId = null;
465
+ let clientRole = null;
466
+ let takeoverPending = false;
467
+ let authoritativeTerminalSize = null;
468
+ let takeoverNotice = null;
201
469
  let snapScrollbackOnNextWrite = false;
202
470
  let followLiveOutput = false;
471
+ let pendingInitialBottomFollow = false;
203
472
  const pendingFrames = [];
204
473
  const pendingInputFrames = [];
205
474
  let reconnectHandle = null;
206
475
  let scrollbackSnapHandle = null;
207
476
  let scrollbackSnapPassesRemaining = 0;
477
+ let scrollProgrammaticGeneration = 0;
478
+ const programmaticScrollSurfaces = /* @__PURE__ */ new WeakMap();
479
+ let pendingLayoutBottomFollow = false;
208
480
  let disposeViewportSizer = null;
481
+ let disposeViewportDebugOverlay = null;
482
+ let mobileComposer = null;
209
483
  let disposeScrollMetricsObserver = null;
484
+ let disposeTerminalFocusInteractions = null;
210
485
  let lastResizeSignature = null;
211
486
  let lastScrollMetrics = null;
487
+ let keyboardVisible = false;
488
+ let shellViewportVariablesFrame = null;
489
+ let mobileKeyboardAlignFrame = null;
490
+ let outputDrainSettleTimer = null;
491
+ const mobileKeyboardAlignTimers = /* @__PURE__ */ new Set();
492
+ const layoutBottomFollowTimers = /* @__PURE__ */ new Set();
493
+ let mobileKeyboardAlignAllowedUntil = 0;
494
+ let mobileKeyboardAlignSuppressedUntil = 0;
495
+ let userInputLayoutStickUntil = 0;
212
496
  let suppressTermResizeCallback = false;
497
+ let outputPressureWindowStart = null;
498
+ let outputPressureWindowBytes = 0;
499
+ let outputPressureWindowChunks = 0;
500
+ let highOutputPressureUntil = 0;
213
501
  let statusSnapshot = {
214
502
  connection: normalizeConnectionState(shell.dataset.connection),
215
503
  loading: {
@@ -221,13 +509,193 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
221
509
  sessionState: normalizeSessionPresentationState(shell.dataset.sessionState)
222
510
  };
223
511
  const getStatusSnapshot = () => cloneTerminalStatusSnapshot(statusSnapshot);
512
+ const syncTerminalInputAnchor = () => {
513
+ term?.input?.setImeAnchor?.(resolveLiveInputSurfaceAnchor(terminalRoot));
514
+ };
515
+ const syncShellViewportVariables = () => {
516
+ const viewport = resolveBrowserViewport(windowObject);
517
+ const wasKeyboardVisible = keyboardVisible;
518
+ const visibleViewportHeight = Math.max(1, viewport.height - viewport.browserChromeInsetBottom);
519
+ keyboardVisible = viewport.keyboardInsetBottom > 0;
520
+ shell.style.setProperty("--shell-visual-viewport-height", `${viewport.safeHeight}px`);
521
+ shell.style.setProperty("--shell-visible-viewport-height", `${visibleViewportHeight}px`);
522
+ shell.style.setProperty("--shell-terminal-viewport-height", `${keyboardVisible ? visibleViewportHeight : viewport.safeHeight}px`);
523
+ shell.style.setProperty("--shell-visual-viewport-offset-top", `${viewport.offsetTop}px`);
524
+ shell.style.setProperty("--shell-keyboard-inset-bottom", `${viewport.keyboardInsetBottom}px`);
525
+ shell.style.setProperty("--shell-browser-chrome-inset-bottom", `${viewport.browserChromeInsetBottom}px`);
526
+ shell.style.setProperty("--shell-mobile-obstruction-inset-bottom", keyboardVisible ? "0px" : `${viewport.browserChromeInsetBottom}px`);
527
+ shell.dataset.keyboardOpen = String(keyboardVisible);
528
+ terminalRoot.style.setProperty("--shell-terminal-keyboard-padding-block-end", "0px");
529
+ syncTerminalInputAnchor();
530
+ if (!wasKeyboardVisible && keyboardVisible && terminalInputFocused) scheduleMobileKeyboardPromptAlign({ force: true });
531
+ };
532
+ const scheduleShellViewportVariableSync = () => {
533
+ if (shellViewportVariablesFrame !== null) return;
534
+ shellViewportVariablesFrame = requestFrame(() => {
535
+ shellViewportVariablesFrame = null;
536
+ syncShellViewportVariables();
537
+ });
538
+ };
224
539
  const publishScrollMetrics = () => {
225
- const onMetricsChange = dependencies.scroll?.onMetricsChange;
226
- if (!onMetricsChange) return;
227
540
  const nextMetrics = getScrollMetrics(getScrollSurface());
228
541
  if (lastScrollMetrics && areScrollMetricsEqual(lastScrollMetrics, nextMetrics)) return;
229
542
  lastScrollMetrics = nextMetrics;
230
- onMetricsChange({ ...nextMetrics });
543
+ dependencies.scroll?.onMetricsChange?.({ ...nextMetrics });
544
+ };
545
+ syncShellViewportVariables();
546
+ disposeViewportDebugOverlay = shouldInstallViewportDebugOverlay(windowObject, shell) ? installViewportDebugOverlay(shell, terminalRoot, getScrollSurface, windowObject) : null;
547
+ windowObject.addEventListener("resize", scheduleShellViewportVariableSync);
548
+ windowObject.visualViewport?.addEventListener("resize", scheduleShellViewportVariableSync);
549
+ windowObject.visualViewport?.addEventListener("scroll", scheduleShellViewportVariableSync);
550
+ const shouldKeepBottomFollow = () => pendingInitialBottomFollow || pendingLayoutBottomFollow || followLiveOutput;
551
+ const markProgrammaticScroll = (scrollSurface) => {
552
+ const generation = scrollProgrammaticGeneration += 1;
553
+ programmaticScrollSurfaces.set(scrollSurface, generation);
554
+ requestFrame(() => {
555
+ requestFrame(() => {
556
+ if (programmaticScrollSurfaces.get(scrollSurface) === generation) programmaticScrollSurfaces.delete(scrollSurface);
557
+ });
558
+ });
559
+ };
560
+ const isProgrammaticScroll = (scrollSurface) => programmaticScrollSurfaces.has(scrollSurface);
561
+ const setProgrammaticScrollTop = (scrollSurface, scrollTop) => {
562
+ const nextScrollTop = clamp(scrollTop, 0, getScrollbackMax(scrollSurface));
563
+ markProgrammaticScroll(scrollSurface);
564
+ setScrollSurfaceScrollTop(scrollSurface, nextScrollTop);
565
+ };
566
+ const stopBottomFollow = () => {
567
+ pendingInitialBottomFollow = false;
568
+ pendingLayoutBottomFollow = false;
569
+ snapScrollbackOnNextWrite = false;
570
+ followLiveOutput = false;
571
+ clearOutputDrainSettleTimer();
572
+ clearLayoutBottomFollowTimers();
573
+ cancelScrollbackSnap();
574
+ };
575
+ const syncAfterProgrammaticScroll = () => {
576
+ transcriptArchive?.refreshNow();
577
+ requestScrollUiUpdate(dependencies.scroll);
578
+ publishScrollMetrics();
579
+ };
580
+ const clearMobileKeyboardPromptAlign = () => {
581
+ if (mobileKeyboardAlignFrame !== null) {
582
+ cancelFrame(mobileKeyboardAlignFrame);
583
+ mobileKeyboardAlignFrame = null;
584
+ }
585
+ for (const timer of mobileKeyboardAlignTimers) clearTimeout(timer);
586
+ mobileKeyboardAlignTimers.clear();
587
+ };
588
+ const clearLayoutBottomFollowTimers = () => {
589
+ for (const timer of layoutBottomFollowTimers) clearTimeout(timer);
590
+ layoutBottomFollowTimers.clear();
591
+ };
592
+ const clearOutputDrainSettleTimer = () => {
593
+ if (outputDrainSettleTimer === null) return;
594
+ clearTimeout(outputDrainSettleTimer);
595
+ outputDrainSettleTimer = null;
596
+ };
597
+ const now = () => windowObject.performance?.now?.() ?? Date.now();
598
+ const noteOutputPressure = (byteLength) => {
599
+ const currentTime = now();
600
+ if (outputPressureWindowStart === null || currentTime - outputPressureWindowStart > OUTPUT_PRESSURE_WINDOW_MS) {
601
+ outputPressureWindowStart = currentTime;
602
+ outputPressureWindowBytes = 0;
603
+ outputPressureWindowChunks = 0;
604
+ }
605
+ outputPressureWindowBytes += Math.max(0, byteLength);
606
+ outputPressureWindowChunks += 1;
607
+ if (outputPressureWindowBytes >= OUTPUT_PRESSURE_BYTE_THRESHOLD || outputPressureWindowChunks >= OUTPUT_PRESSURE_CHUNK_THRESHOLD) highOutputPressureUntil = Math.max(highOutputPressureUntil, currentTime + OUTPUT_PRESSURE_HOLD_MS);
608
+ };
609
+ const hasHighOutputPressure = () => highOutputPressureUntil > now();
610
+ const grantMobileKeyboardPromptAlign = (durationMs = MOBILE_KEYBOARD_SCROLL_INTENT_MS) => {
611
+ mobileKeyboardAlignAllowedUntil = Math.max(mobileKeyboardAlignAllowedUntil, now() + durationMs);
612
+ };
613
+ const grantUserInputLayoutStick = (durationMs = USER_INPUT_LAYOUT_STICK_MS) => {
614
+ userInputLayoutStickUntil = Math.max(userInputLayoutStickUntil, now() + durationMs);
615
+ };
616
+ const isMobileKeyboardPromptAlignAllowed = (force = false) => force || mobileKeyboardAlignAllowedUntil > now();
617
+ const suppressMobileKeyboardPromptAlign = () => {
618
+ mobileKeyboardAlignSuppressedUntil = Math.max(mobileKeyboardAlignSuppressedUntil, now() + MOBILE_KEYBOARD_MANUAL_SCROLL_SUPPRESS_MS);
619
+ clearMobileKeyboardPromptAlign();
620
+ };
621
+ const noteMobileKeyboardManualScrollIntent = () => {
622
+ suppressMobileKeyboardPromptAlign();
623
+ userInputLayoutStickUntil = 0;
624
+ stopBottomFollow();
625
+ };
626
+ const isMobileKeyboardPromptAlignSuppressed = (force = false) => !force && mobileKeyboardAlignSuppressedUntil > now();
627
+ const hasRecentMobileKeyboardManualScrollIntent = () => mobileKeyboardAlignSuppressedUntil > now();
628
+ const hasRecentUserInputLayoutStick = () => userInputLayoutStickUntil > now();
629
+ const handleMobileKeyboardTouchScrollIntent = () => {
630
+ if (!terminalInputFocused || resolveBrowserViewport(windowObject).keyboardInsetBottom <= 0) return;
631
+ noteMobileKeyboardManualScrollIntent();
632
+ };
633
+ windowObject.addEventListener("touchmove", handleMobileKeyboardTouchScrollIntent, { passive: true });
634
+ const alignMobileKeyboardPrompt = (options = {}) => {
635
+ const viewport = resolveBrowserViewport(windowObject);
636
+ if (!terminalInputFocused || viewport.keyboardInsetBottom <= 0 || isMobileKeyboardPromptAlignSuppressed(options.force) || !isMobileKeyboardPromptAlignAllowed(options.force)) return;
637
+ syncShellViewportVariables();
638
+ syncTerminalInputAnchor();
639
+ const scrollSurface = getScrollSurface();
640
+ if (options.onlyIfNeeded && isLiveInputSurfaceSafelyAboveViewportBottom(shell, terminalRoot, windowObject)) return;
641
+ markProgrammaticScroll(scrollSurface);
642
+ alignLiveInputSurfaceToBottom(scrollSurface, shell, terminalRoot, windowObject);
643
+ markProgrammaticScroll(getScrollSurface());
644
+ alignLiveInputSurfaceIntoViewport(shell, terminalRoot, windowObject, scrollSurface);
645
+ syncAfterProgrammaticScroll();
646
+ };
647
+ const requestMobileKeyboardPromptAlign = (options = {}) => {
648
+ if (mobileKeyboardAlignFrame !== null || !terminalInputFocused || resolveBrowserViewport(windowObject).keyboardInsetBottom <= 0 || isMobileKeyboardPromptAlignSuppressed(options.force) || !isMobileKeyboardPromptAlignAllowed(options.force)) return;
649
+ mobileKeyboardAlignFrame = requestFrame(() => {
650
+ mobileKeyboardAlignFrame = null;
651
+ alignMobileKeyboardPrompt(options);
652
+ });
653
+ };
654
+ const scheduleMobileKeyboardPromptAlign = (options = {}) => {
655
+ grantMobileKeyboardPromptAlign();
656
+ clearMobileKeyboardPromptAlign();
657
+ requestMobileKeyboardPromptAlign(options);
658
+ for (const delay of MOBILE_KEYBOARD_ALIGN_DELAYS_MS) {
659
+ const timer = setTimeout(() => {
660
+ mobileKeyboardAlignTimers.delete(timer);
661
+ alignMobileKeyboardPrompt(options);
662
+ }, delay);
663
+ mobileKeyboardAlignTimers.add(timer);
664
+ }
665
+ };
666
+ const refreshScrollMetricsObserver = () => {
667
+ disposeScrollMetricsObserver?.();
668
+ disposeScrollMetricsObserver = installScrollMetricsObserver(getScrollSurface(), () => {
669
+ const scrollSurface = getScrollSurface();
670
+ const maxScrollTop = getScrollbackMax(scrollSurface);
671
+ const followDecision = resolveScrollFollowPolicy({
672
+ keepBottomFollow: shouldKeepBottomFollow(),
673
+ maxScrollTop,
674
+ previousScrollTop: lastScrollMetrics?.scrollTop ?? scrollSurface.scrollTop,
675
+ programmaticScroll: isProgrammaticScroll(scrollSurface),
676
+ scrollTop: scrollSurface.scrollTop
677
+ });
678
+ if (followDecision.action !== "idle") {
679
+ if (followDecision.action === "stop-follow") {
680
+ stopBottomFollow();
681
+ syncPreservedArchiveVisibility({
682
+ allowReveal: true,
683
+ maxScrollTop,
684
+ scrollTop: scrollSurface.scrollTop
685
+ });
686
+ requestScrollUiUpdate(dependencies.scroll);
687
+ publishScrollMetrics();
688
+ return;
689
+ }
690
+ setProgrammaticScrollTop(scrollSurface, maxScrollTop);
691
+ syncAfterProgrammaticScroll();
692
+ scheduleScrollbackSnapToBottom(followDecision.passes, { preferTranscriptFollow: followDecision.preferTranscriptFollow });
693
+ return;
694
+ }
695
+ transcriptArchive?.refresh();
696
+ publishScrollMetrics();
697
+ });
698
+ publishScrollMetrics();
231
699
  };
232
700
  const presentStatus = (patch) => {
233
701
  const nextStatus = {
@@ -251,13 +719,35 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
251
719
  if (changed) dependencies.onStatusChange?.(getStatusSnapshot());
252
720
  };
253
721
  dependencies.onStatusChange?.(getStatusSnapshot());
722
+ const resolveLiveStatusMessage = () => {
723
+ if (clientRole === "viewer") return takeoverPending ? "Viewer mode. Waiting for control..." : "Viewer mode. Read-only.";
724
+ return "Live shell connected";
725
+ };
726
+ const syncTakeoverControl = () => {
727
+ shell.dataset.clientRole = clientRole ?? "unknown";
728
+ shell.dataset.clientId = clientId ?? "";
729
+ shell.dataset.takeoverPending = String(takeoverPending);
730
+ terminalRoot.dataset.clientRole = clientRole ?? "unknown";
731
+ if (!takeoverControl) return;
732
+ takeoverControl.hidden = clientRole !== "viewer";
733
+ takeoverControl.disabled = clientRole !== "viewer" || takeoverPending || sessionState !== "running";
734
+ takeoverControl.textContent = takeoverPending ? "Request sent" : "Take control";
735
+ takeoverControl.setAttribute("aria-label", takeoverPending ? "Control request pending" : "Request terminal control");
736
+ };
254
737
  const syncTerminalPresentation = () => {
255
738
  if (!transcriptArchive) return;
256
- syncTerminalSurface(terminalRoot, transcriptArchive, term, ansiStyleTracker, { interactive: sessionState === "running" });
257
- syncTrailingTerminalBlankRows(terminalRoot);
739
+ syncTerminalSurface(terminalRoot, transcriptArchive, term, ansiStyleTracker, { interactive: sessionState === "running" && clientRole === "controller" });
740
+ syncTakeoverControl();
741
+ takeoverNotice?.syncPermissionPrompt(sessionState === "running" && clientRole === "controller");
742
+ takeoverNotice?.syncNotificationControl();
743
+ mobileComposer?.setEnabled(sessionState === "running" && clientRole === "controller");
258
744
  requestScrollUiUpdate(dependencies.scroll);
259
745
  publishScrollMetrics();
260
746
  };
747
+ const syncTerminalToAuthoritativeSize = (size) => {
748
+ authoritativeTerminalSize = size;
749
+ resizeTerminalTo(size.cols, size.rows);
750
+ };
261
751
  const markLiveSession = () => {
262
752
  reconnecting = false;
263
753
  presentStatus({
@@ -266,11 +756,44 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
266
756
  message: "Booting terminal...",
267
757
  visible: false
268
758
  },
269
- message: "Live shell on loopback",
759
+ message: resolveLiveStatusMessage(),
270
760
  output: "ready",
271
761
  sessionState: "live"
272
762
  });
273
763
  };
764
+ const requestTakeover = () => {
765
+ if (clientRole !== "viewer" || takeoverPending || sessionState !== "running") {
766
+ syncTakeoverControl();
767
+ return;
768
+ }
769
+ takeoverPending = true;
770
+ presentStatus({ message: "Requesting terminal control..." });
771
+ syncTakeoverControl();
772
+ transport?.sendControl?.(createTakeoverRequestControlFrame());
773
+ };
774
+ const handleTakeoverClick = (event) => {
775
+ event.preventDefault();
776
+ requestTakeover();
777
+ };
778
+ takeoverControl?.addEventListener("click", handleTakeoverClick);
779
+ takeoverNotice = installTakeoverNotice(shell, windowObject, {
780
+ getClientId() {
781
+ return clientId;
782
+ },
783
+ getClientRole() {
784
+ return clientRole;
785
+ },
786
+ onRespond(requestId, approved) {
787
+ if (clientRole !== "controller") return;
788
+ transport?.sendControl?.(createTakeoverResponseControlFrame({
789
+ approved,
790
+ requestId
791
+ }));
792
+ },
793
+ sendControl(data) {
794
+ transport?.sendControl?.(data);
795
+ }
796
+ });
274
797
  const markWaitingForOutput = () => {
275
798
  presentStatus({
276
799
  connection: "open",
@@ -278,7 +801,7 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
278
801
  message: "Booting terminal...",
279
802
  visible: !outputReady
280
803
  },
281
- message: outputReady ? "Live shell on loopback" : "Connected. Waiting for shell output...",
804
+ message: outputReady ? resolveLiveStatusMessage() : "Connected. Waiting for shell output...",
282
805
  sessionState: outputReady ? "live" : "starting"
283
806
  });
284
807
  };
@@ -305,26 +828,93 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
305
828
  const getTargetScrollTop = (scrollSurface, { preferTranscriptFollow = false } = {}) => {
306
829
  return getScrollbackMax(scrollSurface);
307
830
  };
831
+ const alignTerminalPrompt = () => {
832
+ const scrollSurface = getScrollSurface();
833
+ setProgrammaticScrollTop(scrollSurface, getTargetScrollTop(scrollSurface));
834
+ markProgrammaticScroll(scrollSurface);
835
+ alignLiveInputSurfaceToBottom(scrollSurface, shell, terminalRoot, windowObject);
836
+ markProgrammaticScroll(getScrollSurface());
837
+ alignLiveInputSurfaceIntoViewport(shell, terminalRoot, windowObject, scrollSurface);
838
+ syncAfterProgrammaticScroll();
839
+ syncPreservedArchiveVisibility();
840
+ };
841
+ const focusTerminalInput = ({ alignToPrompt = false } = {}) => {
842
+ syncTerminalInputAnchor();
843
+ if (alignToPrompt && resolveBrowserViewport(windowObject).keyboardInsetBottom <= 0) alignTerminalPrompt();
844
+ else if (alignToPrompt) scheduleMobileKeyboardPromptAlign({ force: true });
845
+ term?.focus?.();
846
+ };
847
+ const followBottomNow = (options = {}) => {
848
+ const scrollSurface = getScrollSurface();
849
+ setProgrammaticScrollTop(scrollSurface, getTargetScrollTop(scrollSurface, options));
850
+ if (options.preferTranscriptFollow) {
851
+ markProgrammaticScroll(scrollSurface);
852
+ alignLiveInputSurfaceToBottom(scrollSurface, shell, terminalRoot, windowObject);
853
+ markProgrammaticScroll(getScrollSurface());
854
+ alignLiveInputSurfaceIntoViewport(shell, terminalRoot, windowObject, scrollSurface);
855
+ }
856
+ if (options.sync !== false) syncAfterProgrammaticScroll();
857
+ };
308
858
  const scheduleScrollbackSnapToBottom = (passes = 1, options = {}) => {
309
859
  scrollbackSnapPassesRemaining = Math.max(scrollbackSnapPassesRemaining, passes);
310
860
  if (scrollbackSnapHandle !== null) return;
311
861
  const runSnap = () => {
312
862
  scrollbackSnapHandle = requestFrame(() => {
313
863
  scrollbackSnapHandle = null;
314
- const scrollSurface = getScrollSurface();
315
- scrollSurface.scrollTop = getTargetScrollTop(scrollSurface, options);
316
- requestScrollUiUpdate(dependencies.scroll);
317
- publishScrollMetrics();
864
+ followBottomNow(options);
318
865
  scrollbackSnapPassesRemaining = Math.max(0, scrollbackSnapPassesRemaining - 1);
319
866
  if (scrollbackSnapPassesRemaining > 0) runSnap();
320
867
  });
321
868
  };
322
869
  runSnap();
323
870
  };
324
- const syncLiveOutputFollowScroll = () => {
325
- if (!followLiveOutput) return;
871
+ const followBottomAfterLayoutChange = () => {
872
+ if (!pendingLayoutBottomFollow) return;
873
+ followBottomNow({ preferTranscriptFollow: true });
874
+ scheduleScrollbackSnapToBottom(12, { preferTranscriptFollow: true });
875
+ clearLayoutBottomFollowTimers();
876
+ for (const delay of LAYOUT_BOTTOM_FOLLOW_DELAYS_MS) {
877
+ const timer = setTimeout(() => {
878
+ layoutBottomFollowTimers.delete(timer);
879
+ if (!pendingLayoutBottomFollow) return;
880
+ followBottomNow({ preferTranscriptFollow: true });
881
+ if (layoutBottomFollowTimers.size === 0) requestFrame(() => {
882
+ pendingLayoutBottomFollow = false;
883
+ });
884
+ }, delay);
885
+ layoutBottomFollowTimers.add(timer);
886
+ }
887
+ };
888
+ const followBottomAfterOutputFlush = () => {
889
+ if (!shouldKeepBottomFollow()) return;
890
+ if (terminalInputFocused && resolveBrowserViewport(windowObject).keyboardInsetBottom > 0 && hasRecentMobileKeyboardManualScrollIntent()) {
891
+ stopBottomFollow();
892
+ return;
893
+ }
894
+ followBottomNow({ preferTranscriptFollow: true });
895
+ };
896
+ const settleBottomAfterOutputDrain = () => {
897
+ if (!shouldKeepBottomFollow()) return;
898
+ if (terminalInputFocused && resolveBrowserViewport(windowObject).keyboardInsetBottom > 0 && hasRecentMobileKeyboardManualScrollIntent()) {
899
+ stopBottomFollow();
900
+ return;
901
+ }
902
+ followBottomNow({ preferTranscriptFollow: true });
326
903
  scheduleScrollbackSnapToBottom(2, { preferTranscriptFollow: true });
327
904
  };
905
+ const scheduleBottomSettleAfterOutputDrain = () => {
906
+ if (!shouldKeepBottomFollow()) return;
907
+ clearOutputDrainSettleTimer();
908
+ outputDrainSettleTimer = setTimeout(() => {
909
+ outputDrainSettleTimer = null;
910
+ settleBottomAfterOutputDrain();
911
+ }, OUTPUT_DRAIN_SETTLE_DELAY_MS);
912
+ };
913
+ const isScrollSurfaceAtBottom = () => {
914
+ const scrollSurface = getScrollSurface();
915
+ return isAtScrollbackBottom(scrollSurface.scrollTop, getScrollbackMax(scrollSurface));
916
+ };
917
+ const isScrollSurfaceKnownAtBottom = () => lastScrollMetrics?.atBottom ?? isScrollSurfaceAtBottom();
328
918
  const syncPreservedArchiveVisibility = ({ allowReveal = false, scrollTop = getScrollSurface().scrollTop, maxScrollTop = getScrollbackMax(getScrollSurface()) } = {}) => {
329
919
  if (!transcriptArchive) return;
330
920
  if (isAtScrollbackBottom(scrollTop, maxScrollTop)) {
@@ -336,9 +926,8 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
336
926
  const disposeHotkeys = installScrollbackInteractions(terminalRoot, {
337
927
  getScrollSurface,
338
928
  setScrollTop(scrollSurface, scrollTop) {
339
- scrollSurface.scrollTop = scrollTop;
340
- requestScrollUiUpdate(dependencies.scroll);
341
- publishScrollMetrics();
929
+ setProgrammaticScrollTop(scrollSurface, scrollTop);
930
+ syncAfterProgrammaticScroll();
342
931
  },
343
932
  onInput({ event }) {
344
933
  const preserveVisiblePrompt = shouldPreserveVisiblePromptOnInput(event) && isLiveInputSurfaceVisible(shell, terminalRoot, windowObject);
@@ -348,29 +937,38 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
348
937
  cancelScrollbackSnap();
349
938
  if (!preserveVisiblePrompt) {
350
939
  const scrollSurface = getScrollSurface();
351
- scrollSurface.scrollTop = getTargetScrollTop(scrollSurface);
352
- requestScrollUiUpdate(dependencies.scroll);
353
- publishScrollMetrics();
940
+ setProgrammaticScrollTop(scrollSurface, getTargetScrollTop(scrollSurface));
941
+ syncAfterProgrammaticScroll();
354
942
  }
355
943
  syncPreservedArchiveVisibility();
356
944
  },
357
- onManualScroll({ scrollTop, maxScrollTop }) {
945
+ onManualScroll({ scrollTop, maxScrollTop, source }) {
946
+ noteMobileKeyboardManualScrollIntent();
358
947
  syncPreservedArchiveVisibility({
359
948
  allowReveal: true,
360
949
  scrollTop,
361
950
  maxScrollTop
362
951
  });
363
- snapScrollbackOnNextWrite = false;
364
- followLiveOutput = false;
365
- cancelScrollbackSnap();
952
+ stopBottomFollow();
953
+ if (source) shell.dataset.lastManualScrollSource = source;
366
954
  requestScrollUiUpdate(dependencies.scroll);
367
955
  publishScrollMetrics();
368
956
  }
369
957
  });
958
+ disposeTerminalFocusInteractions = installTerminalFocusInteractions(shell, terminalRoot, {
959
+ focusTerminal({ alignToPrompt = false } = {}) {
960
+ focusTerminalInput({ alignToPrompt });
961
+ },
962
+ resolveKeyData(event) {
963
+ return resolveTerminalDocumentKeyData(term, event);
964
+ },
965
+ sendData(data) {
966
+ sendUserInput(data);
967
+ }
968
+ });
370
969
  const resetTerminalBuffer = () => {
371
970
  if (!term) return;
372
971
  writer?.discardPending();
373
- archiveWriter?.discardPending();
374
972
  transcriptArchive?.preserveSnapshot([]);
375
973
  ansiStyleTracker.reset(term.cols, term.rows);
376
974
  if (term.bridge && typeof term.bridge.init === "function") {
@@ -388,42 +986,139 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
388
986
  if (!helloReceived || sessionState !== "running" || pendingInputFrames.length === 0) return;
389
987
  for (const chunk of pendingInputFrames.splice(0)) transport?.send(chunk);
390
988
  };
989
+ const sendTerminalFocusReport = (focused, { updateFocusState = true } = {}) => {
990
+ if (updateFocusState) terminalInputFocused = focused;
991
+ if (!terminalFocusReportingEnabled) return;
992
+ sendInputToPty(focused ? TERMINAL_FOCUS_IN : "\x1B[O");
993
+ };
994
+ const syncTerminalFocusReportingMode = (data) => {
995
+ const nextState = focusReportingParser.append(data);
996
+ if (nextState === null) return;
997
+ const changed = terminalFocusReportingEnabled !== nextState;
998
+ terminalFocusReportingEnabled = nextState;
999
+ if (changed && nextState && terminalInputFocused) sendInputToPty(TERMINAL_FOCUS_IN);
1000
+ };
1001
+ const notifyTerminalThemeChanged = () => {
1002
+ renderTerminalNow(term, { force: true });
1003
+ if (terminalInputFocused) sendTerminalFocusReport(true, { updateFocusState: false });
1004
+ };
1005
+ const applyTheme = (theme) => {
1006
+ const changed = themeController.setTheme(theme);
1007
+ if (changed) notifyTerminalThemeChanged();
1008
+ return changed;
1009
+ };
1010
+ const getConfigSnapshot = () => cloneTerminalConfig(terminalConfig);
1011
+ const updateTerminalConfig = (patch) => {
1012
+ const nextConfig = mergeTerminalConfig(terminalConfig, patch);
1013
+ const previousAppearance = terminalConfig.appearance;
1014
+ const nextAppearance = nextConfig.appearance;
1015
+ const appearanceChanged = !areTerminalAppearancesEqual(previousAppearance, nextAppearance);
1016
+ if (!appearanceChanged && areTerminalBehaviorsEqual(terminalConfig.behavior, nextConfig.behavior)) return getConfigSnapshot();
1017
+ const shouldRelayout = hasTerminalLayoutAppearanceChange(previousAppearance, nextAppearance);
1018
+ const scrollAnchor = shouldRelayout ? captureScrollSurfaceAnchor(getScrollSurface(), shell) : null;
1019
+ terminalConfig = nextConfig;
1020
+ if (appearanceChanged) {
1021
+ applyTerminalAppearance(shell, terminalRoot, nextAppearance, previousAppearance);
1022
+ if (previousAppearance.theme !== nextAppearance.theme) applyTheme(nextAppearance.theme);
1023
+ else renderTerminalNow(term, { force: true });
1024
+ }
1025
+ if (shouldRelayout) {
1026
+ resizeTerminalToViewport();
1027
+ renderTerminalNow(term, { force: true });
1028
+ syncTerminalPresentation();
1029
+ if (scrollAnchor) scheduleScrollAnchorRestore(scrollAnchor);
1030
+ }
1031
+ const snapshot = getConfigSnapshot();
1032
+ dependencies.onConfigChange?.(snapshot);
1033
+ return snapshot;
1034
+ };
391
1035
  const processVisiblePayload = (data) => {
1036
+ clearOutputDrainSettleTimer();
1037
+ noteOutputPressure(data.length);
392
1038
  const firstOutput = markOutputReady();
1039
+ const shouldFollowOutput = isScrollSurfaceKnownAtBottom() && !hasRecentMobileKeyboardManualScrollIntent();
1040
+ syncTerminalFocusReportingMode(data);
393
1041
  const themeUpdate = themeProtocolParser.append(data);
394
1042
  let themeChanged = false;
395
- if (themeUpdate) themeChanged = themeController.syncProtocolUpdate(themeUpdate);
1043
+ if (themeUpdate) {
1044
+ themeChanged = themeController.syncProtocolUpdate(themeUpdate);
1045
+ if (themeUpdate.theme && normalizeThemeName(themeUpdate.theme) !== terminalConfig.appearance.theme) {
1046
+ terminalConfig = mergeTerminalConfig(terminalConfig, { appearance: { theme: themeUpdate.theme } });
1047
+ dependencies.onConfigChange?.(getConfigSnapshot());
1048
+ }
1049
+ }
396
1050
  const themeQueryResponse = resolveTerminalThemeQueryResponse(data, {
397
1051
  root: terminalRoot,
398
1052
  theme: themeController.getTheme()
399
1053
  });
400
1054
  if (themeQueryResponse) sendInputToPty(themeQueryResponse);
401
- if (!termReady || !writer || !archiveWriter) {
1055
+ if (!termReady || !writer) {
402
1056
  pendingFrames.push(data);
403
- if (themeChanged) renderTerminalNow(term, { force: true });
1057
+ pendingInitialBottomFollow = pendingInitialBottomFollow || firstOutput;
1058
+ if (themeChanged) notifyTerminalThemeChanged();
404
1059
  return;
405
1060
  }
406
1061
  writer.enqueue(data);
407
- archiveWriter.enqueue(data);
408
- if (themeChanged) renderTerminalNow(term, { force: true });
409
- syncTerminalPresentation();
410
- if (followLiveOutput) {
1062
+ if (themeChanged) notifyTerminalThemeChanged();
1063
+ if (followLiveOutput || shouldFollowOutput) {
1064
+ const keyboardOpen = terminalInputFocused && resolveBrowserViewport(windowObject).keyboardInsetBottom > 0;
1065
+ if (keyboardOpen && hasRecentMobileKeyboardManualScrollIntent()) {
1066
+ stopBottomFollow();
1067
+ return;
1068
+ }
1069
+ if (keyboardOpen && !followLiveOutput) {
1070
+ snapScrollbackOnNextWrite = false;
1071
+ pendingInitialBottomFollow = pendingInitialBottomFollow || firstOutput;
1072
+ grantMobileKeyboardPromptAlign(280);
1073
+ requestMobileKeyboardPromptAlign({
1074
+ force: true,
1075
+ onlyIfNeeded: true
1076
+ });
1077
+ return;
1078
+ }
411
1079
  snapScrollbackOnNextWrite = false;
412
- syncLiveOutputFollowScroll();
1080
+ pendingInitialBottomFollow = pendingInitialBottomFollow || firstOutput || shouldFollowOutput;
413
1081
  } else if (snapScrollbackOnNextWrite || firstOutput) {
414
1082
  snapScrollbackOnNextWrite = false;
415
- scheduleScrollbackSnapToBottom(2);
1083
+ pendingInitialBottomFollow = pendingInitialBottomFollow || firstOutput;
416
1084
  }
417
1085
  };
418
1086
  const sendInputToPty = (data) => {
419
- if (sessionState !== "running") return;
1087
+ if (sessionState !== "running" || clientRole !== "controller") return;
420
1088
  if (!helloReceived) {
421
1089
  pendingInputFrames.push(data);
422
1090
  return;
423
1091
  }
424
1092
  transport?.send(data);
425
1093
  };
426
- const sendUserInput = (data) => {
1094
+ const sendUserInput = (data, options = {}) => {
1095
+ if (clientRole !== "controller") {
1096
+ syncTerminalPresentation();
1097
+ return;
1098
+ }
1099
+ if (options.focus === false) {
1100
+ sendInputToPty(data);
1101
+ return;
1102
+ }
1103
+ const shouldFollowLiveOutput = shouldFollowLiveOutputOnUserInputData(data);
1104
+ const keyboardOpen = resolveBrowserViewport(windowObject).keyboardInsetBottom > 0;
1105
+ grantUserInputLayoutStick();
1106
+ focusTerminalInput({ alignToPrompt: shouldFollowLiveOutput || !keyboardOpen });
1107
+ if (shouldFollowLiveOutput) {
1108
+ followLiveOutput = true;
1109
+ pendingInitialBottomFollow = true;
1110
+ snapScrollbackOnNextWrite = true;
1111
+ cancelScrollbackSnap();
1112
+ scheduleMobileKeyboardPromptAlign({ force: true });
1113
+ followBottomNow({ preferTranscriptFollow: true });
1114
+ scheduleScrollbackSnapToBottom(2, { preferTranscriptFollow: true });
1115
+ } else {
1116
+ grantMobileKeyboardPromptAlign(280);
1117
+ requestMobileKeyboardPromptAlign({
1118
+ force: true,
1119
+ onlyIfNeeded: true
1120
+ });
1121
+ }
427
1122
  sendInputToPty(data);
428
1123
  };
429
1124
  const emitResize = (cols, rows) => {
@@ -433,10 +1128,11 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
433
1128
  syncDimensions(terminalRoot, normalizedCols, normalizedRows);
434
1129
  transcriptArchive?.refresh();
435
1130
  syncTerminalPresentation();
1131
+ followBottomAfterLayoutChange();
436
1132
  const signature = `${normalizedCols}x${normalizedRows}`;
437
1133
  if (lastResizeSignature === signature) return;
438
1134
  lastResizeSignature = signature;
439
- if (sessionState !== "running") return;
1135
+ if (sessionState !== "running" || clientRole !== "controller") return;
440
1136
  transport?.sendControl?.(createResizeControlFrame(normalizedCols, normalizedRows));
441
1137
  };
442
1138
  const syncTermDimensionsFromBridge = (requestedCols = term?.cols ?? 80, requestedRows = term?.rows ?? 24) => {
@@ -450,7 +1146,7 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
450
1146
  if (term.renderer && typeof term.renderer.setup === "function" && (term.renderer.cols !== dimensions.cols || term.renderer.rows !== dimensions.rows)) term.renderer.setup(dimensions.cols, dimensions.rows);
451
1147
  return dimensions;
452
1148
  };
453
- const resizeTerminalTo = (cols, rows) => {
1149
+ const resizeTerminalTo = (cols, rows, options = {}) => {
454
1150
  if (!term) return;
455
1151
  const requestedCols = normalizeResizeDimension(cols, term.cols);
456
1152
  const requestedRows = normalizeResizeDimension(rows, term.rows);
@@ -464,16 +1160,55 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
464
1160
  }
465
1161
  const actual = syncTermDimensionsFromBridge(requestedCols, requestedRows);
466
1162
  applyTerminalElementHeight(terminalRoot, actual.rows);
467
- emitResize(actual.cols, actual.rows);
1163
+ if (options.sendControl === true) {
1164
+ emitResize(actual.cols, actual.rows);
1165
+ return;
1166
+ }
1167
+ ansiStyleTracker.resize(actual.cols, actual.rows);
1168
+ syncDimensions(terminalRoot, actual.cols, actual.rows);
1169
+ transcriptArchive?.refresh();
1170
+ syncTerminalPresentation();
1171
+ followBottomAfterLayoutChange();
1172
+ };
1173
+ const resizeTerminalToViewport = () => {
1174
+ if (!term) return;
1175
+ syncShellViewportVariables();
1176
+ if (clientRole !== "controller" && authoritativeTerminalSize) {
1177
+ syncTerminalToAuthoritativeSize(authoritativeTerminalSize);
1178
+ return;
1179
+ }
1180
+ const target = resolveViewportSize(term, terminalRoot, windowObject);
1181
+ resizeTerminalTo(target.cols, target.rows, { sendControl: clientRole === "controller" });
1182
+ };
1183
+ const handleViewportResize = (cols, rows) => {
1184
+ if (clientRole !== "controller") return;
1185
+ resizeTerminalTo(cols, rows, { sendControl: true });
1186
+ };
1187
+ const scheduleScrollAnchorRestore = (anchor, passes = 10) => {
1188
+ let remainingPasses = Math.max(1, passes);
1189
+ const restore = () => {
1190
+ requestFrame(() => {
1191
+ markProgrammaticScroll(getScrollSurface());
1192
+ restoreScrollSurfaceAnchor(getScrollSurface(), shell, anchor);
1193
+ syncAfterProgrammaticScroll();
1194
+ remainingPasses -= 1;
1195
+ if (remainingPasses > 0) restore();
1196
+ });
1197
+ };
1198
+ restore();
468
1199
  };
469
1200
  const handleTermResize = (cols, rows) => {
470
1201
  if (suppressTermResizeCallback) return;
471
1202
  const actual = syncTermDimensionsFromBridge(cols, rows);
472
- emitResize(actual.cols, actual.rows);
1203
+ if (clientRole === "controller") emitResize(actual.cols, actual.rows);
473
1204
  };
474
1205
  const showReconnectState = (statusText) => {
475
1206
  reconnecting = true;
476
1207
  helloReceived = false;
1208
+ clientRole = null;
1209
+ clientId = null;
1210
+ takeoverPending = false;
1211
+ syncTakeoverControl();
477
1212
  presentStatus({
478
1213
  connection: "reconnecting",
479
1214
  loading: {
@@ -522,22 +1257,31 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
522
1257
  getCols() {
523
1258
  return term?.cols ?? 80;
524
1259
  },
1260
+ getScrollSurface,
525
1261
  getVisibleRows() {
526
- return term?.rows ?? 24;
1262
+ return (term?.renderer?.getRenderedScrollbackCount?.() ?? 0) + (term?.rows ?? 24);
527
1263
  },
528
- scrollbackLimit: dependencies.transcriptArchiveOptions?.scrollbackLimit
1264
+ scrollbackLimit: terminalConfig.behavior.scrollbackLimit
529
1265
  });
530
- archiveWriter = createBufferedTerminalWriter({ write(data) {
531
- transcriptArchive?.append(data);
532
- } }, frameScheduler);
533
1266
  writer = createBufferedTerminalWriter({ write(data) {
534
1267
  ansiStyleTracker.append(data);
535
- term?.write?.(data);
1268
+ transcriptArchive?.append(data);
1269
+ term?.write?.(data, { scheduleRender: false });
536
1270
  ansiStyleTracker.syncFromBridge(term?.bridge);
537
1271
  const renderHints = ansiStyleTracker.consumeRenderHints();
538
1272
  renderTerminalNow(term, { force: renderHints.clearScreen || renderHints.styleChanged });
539
1273
  syncTerminalPresentation();
540
- } }, frameScheduler);
1274
+ } }, frameScheduler, {
1275
+ minFrameIntervalMs: DEFAULT_TERMINAL_OUTPUT_FRAME_INTERVAL_MS,
1276
+ resolveMinFrameIntervalMs({ pendingByteLength }) {
1277
+ return resolveTerminalOutputFrameIntervalMs({
1278
+ pendingByteLength,
1279
+ sustainedPressure: hasHighOutputPressure()
1280
+ });
1281
+ },
1282
+ onDrain: scheduleBottomSettleAfterOutputDrain,
1283
+ onFlush: followBottomAfterOutputFlush
1284
+ });
541
1285
  transport = (dependencies.createTransport ?? ((handlers) => createBrowserTransport(handlers)))({
542
1286
  onBinary(data) {
543
1287
  if (!helloReceived) helloReceived = true;
@@ -576,7 +1320,12 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
576
1320
  if (!message) return;
577
1321
  if (message.type === "hello") {
578
1322
  helloReceived = true;
1323
+ pendingInputFrames.length = 0;
579
1324
  const replay = decodeHelloReplay(message.replay);
1325
+ syncTerminalToAuthoritativeSize({
1326
+ cols: message.cols,
1327
+ rows: message.rows
1328
+ });
580
1329
  if (reconnecting && replay.length > 0) resetTerminalBuffer();
581
1330
  if (replay.length > 0) processVisiblePayload(replay);
582
1331
  cancelReconnect();
@@ -592,16 +1341,58 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
592
1341
  transport?.sendControl?.(createPongControlFrame());
593
1342
  return;
594
1343
  }
1344
+ if (message.type === "resize") {
1345
+ const cols = normalizeResizeDimension(message.cols, term?.cols ?? 80);
1346
+ const rows = normalizeResizeDimension(message.rows, term?.rows ?? 24);
1347
+ if (clientRole !== "controller") syncTerminalToAuthoritativeSize({
1348
+ cols,
1349
+ rows
1350
+ });
1351
+ else authoritativeTerminalSize = {
1352
+ cols,
1353
+ rows
1354
+ };
1355
+ return;
1356
+ }
1357
+ if (isAittyRoleControlFrame(message)) {
1358
+ clientId = message.clientId;
1359
+ persistAittyClientId(windowObject, sessionUrl, clientId);
1360
+ clientRole = message.role;
1361
+ takeoverNotice?.syncClient(clientId, clientRole);
1362
+ takeoverPending = false;
1363
+ pendingInputFrames.length = 0;
1364
+ syncTakeoverControl();
1365
+ if (clientRole === "controller") {
1366
+ authoritativeTerminalSize = null;
1367
+ lastResizeSignature = null;
1368
+ resizeTerminalToViewport();
1369
+ } else if (authoritativeTerminalSize) syncTerminalToAuthoritativeSize(authoritativeTerminalSize);
1370
+ else resizeTerminalToViewport();
1371
+ markWaitingForOutput();
1372
+ syncTerminalPresentation();
1373
+ return;
1374
+ }
1375
+ if (isAittyTakeoverRequestControlFrame(message)) {
1376
+ if (clientRole !== "controller" || !message.requestId) return;
1377
+ takeoverNotice?.show({
1378
+ requestId: message.requestId,
1379
+ requesterId: message.requesterId
1380
+ });
1381
+ return;
1382
+ }
1383
+ if (isAittyTakeoverResultControlFrame(message)) {
1384
+ takeoverPending = false;
1385
+ if (!message.approved) presentStatus({ message: resolveTakeoverResultMessage(message.reason) });
1386
+ syncTerminalPresentation();
1387
+ return;
1388
+ }
595
1389
  if (message.type === "theme") {
596
- let themeChanged = false;
597
1390
  if (message.theme === null) {
598
- themeChanged = themeController.setTheme(void 0);
599
- if (themeChanged) renderTerminalNow(term, { force: true });
1391
+ updateTerminalConfig({ appearance: { theme: void 0 } });
600
1392
  return;
601
1393
  }
602
1394
  const theme = normalizeThemeName(message.theme);
603
- if (theme) themeChanged = themeController.setTheme(theme);
604
- if (themeChanged) renderTerminalNow(term, { force: true });
1395
+ if (theme) updateTerminalConfig({ appearance: { theme } });
605
1396
  return;
606
1397
  }
607
1398
  if (message.type !== "exit") return;
@@ -650,29 +1441,85 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
650
1441
  presentStatus({ connection: "open" });
651
1442
  }
652
1443
  });
1444
+ const disposeShellControls = dependencies.shellControls ? installShellControls({
1445
+ captureScrollAnchor() {
1446
+ return captureScrollSurfaceAnchor(getScrollSurface(), shell);
1447
+ },
1448
+ doc,
1449
+ focusTerminal() {
1450
+ focusTerminalInput();
1451
+ },
1452
+ getConfig: getConfigSnapshot,
1453
+ onInterrupt() {
1454
+ sendUserInput("");
1455
+ },
1456
+ onScrollSurfaceChange() {
1457
+ syncShellViewportVariables();
1458
+ syncTerminalInputAnchor();
1459
+ lastScrollMetrics = null;
1460
+ syncAfterProgrammaticScroll();
1461
+ refreshScrollMetricsObserver();
1462
+ },
1463
+ onStickToBottomChange(stickToBottom) {
1464
+ pendingLayoutBottomFollow = stickToBottom || hasRecentUserInputLayoutStick();
1465
+ if (pendingLayoutBottomFollow) followBottomAfterLayoutChange();
1466
+ },
1467
+ resetConfig: initialConfig,
1468
+ restoreScrollAnchor(anchor) {
1469
+ restoreScrollSurfaceAnchor(getScrollSurface(), shell, anchor);
1470
+ syncTerminalInputAnchor();
1471
+ },
1472
+ shell,
1473
+ updateConfig: updateTerminalConfig,
1474
+ windowObject
1475
+ }) : () => {};
653
1476
  await term.init();
654
- replaceTerminalInputHandler(term);
1477
+ replaceTerminalInputHandler(term, {
1478
+ getScrollSurface,
1479
+ onBlur() {
1480
+ sendTerminalFocusReport(false);
1481
+ },
1482
+ onFocus() {
1483
+ sendTerminalFocusReport(true);
1484
+ syncTerminalInputAnchor();
1485
+ scheduleMobileKeyboardPromptAlign({ force: true });
1486
+ },
1487
+ windowObject
1488
+ });
1489
+ mobileComposer = shouldInstallMobileComposer(shell, windowObject) ? installMobileComposer(shell, terminalRoot, {
1490
+ focusTerminal() {
1491
+ focusTerminalInput({ alignToPrompt: true });
1492
+ },
1493
+ sendData(data) {
1494
+ sendUserInput(data, { focus: false });
1495
+ },
1496
+ windowObject
1497
+ }) : null;
655
1498
  const disposeInputPolicies = installTerminalInputPolicies(term, {
656
1499
  captureDocumentCtrlN: false,
657
1500
  onData(data) {
658
1501
  sendUserInput(data);
659
1502
  },
660
- resolveKey: dependencies.input?.resolveKey
1503
+ onPasteShortcut(event) {
1504
+ return pasteClipboardImageFromShortcut(windowObject, sessionUrl, event);
1505
+ },
1506
+ pasteShortcut: resolveBrowserPasteShortcut(windowObject),
1507
+ resolveKey: terminalConfig.behavior.input?.resolveKey
661
1508
  });
662
- installBrowserRenderer(term, ansiStyleTracker);
1509
+ installBrowserRenderer(term, { onScrollbackRowsDropped(rows) {
1510
+ transcriptArchive?.appendPreservedLines(rows);
1511
+ } });
663
1512
  termReady = true;
664
1513
  const initialSize = syncTermDimensionsFromBridge(term.cols, term.rows);
665
1514
  ansiStyleTracker.reset(initialSize.cols, initialSize.rows);
666
- disposeViewportSizer = installTerminalViewportSizer(term, terminalRoot, windowObject, resizeTerminalTo);
1515
+ disposeViewportSizer = installTerminalViewportSizer(term, terminalRoot, windowObject, handleViewportResize, () => clientRole === "controller" && resolveBrowserViewport(windowObject).keyboardInsetBottom <= 0);
667
1516
  syncTermDimensionsFromBridge(term.cols, term.rows);
668
- emitResize(term.cols, term.rows);
669
1517
  syncTerminalPresentation();
670
- term.focus();
671
- disposeScrollMetricsObserver = installScrollMetricsObserver(getScrollSurface(), publishScrollMetrics);
672
- publishScrollMetrics();
1518
+ if (!shouldAllowImeViewportAvoidance(windowObject)) term.focus();
1519
+ refreshScrollMetricsObserver();
673
1520
  windowObject.addEventListener("offline", handleBrowserOffline);
674
1521
  windowObject.addEventListener("online", handleBrowserOnline);
675
- transport.connect(toWebSocketUrl(sessionUrl));
1522
+ transport.connect(toWebSocketUrl(sessionUrl, resolveStoredAittyClientId(windowObject, sessionUrl)));
676
1523
  for (const frame of pendingFrames.splice(0)) writer.enqueue(frame);
677
1524
  return {
678
1525
  destroy() {
@@ -683,24 +1530,47 @@ async function mountTerminalApp(doc = document, dependencies = {}) {
683
1530
  cancelReconnect();
684
1531
  disposeViewportSizer?.();
685
1532
  disposeViewportSizer = null;
1533
+ disposeViewportDebugOverlay?.();
1534
+ disposeViewportDebugOverlay = null;
1535
+ mobileComposer?.destroy();
1536
+ mobileComposer = null;
1537
+ clearMobileKeyboardPromptAlign();
1538
+ clearOutputDrainSettleTimer();
1539
+ clearLayoutBottomFollowTimers();
1540
+ if (mobileKeyboardAlignFrame !== null) {
1541
+ cancelFrame(mobileKeyboardAlignFrame);
1542
+ mobileKeyboardAlignFrame = null;
1543
+ }
1544
+ if (shellViewportVariablesFrame !== null) {
1545
+ cancelFrame(shellViewportVariablesFrame);
1546
+ shellViewportVariablesFrame = null;
1547
+ }
686
1548
  disposeScrollMetricsObserver?.();
687
1549
  disposeScrollMetricsObserver = null;
688
- archiveWriter?.destroy();
1550
+ disposeTerminalFocusInteractions?.();
1551
+ disposeTerminalFocusInteractions = null;
689
1552
  transcriptArchive?.destroy();
690
1553
  writer?.destroy();
1554
+ disposeShellControls();
691
1555
  disposeHotkeys();
692
1556
  disposeInputPolicies();
693
1557
  themeController.destroy();
1558
+ takeoverControl?.removeEventListener("click", handleTakeoverClick);
1559
+ takeoverNotice?.destroy();
1560
+ windowObject.removeEventListener("resize", scheduleShellViewportVariableSync);
1561
+ windowObject.removeEventListener("touchmove", handleMobileKeyboardTouchScrollIntent);
1562
+ windowObject.visualViewport?.removeEventListener("resize", scheduleShellViewportVariableSync);
1563
+ windowObject.visualViewport?.removeEventListener("scroll", scheduleShellViewportVariableSync);
694
1564
  windowObject.removeEventListener("offline", handleBrowserOffline);
695
1565
  windowObject.removeEventListener("online", handleBrowserOnline);
696
1566
  transport?.close();
697
1567
  term?.destroy();
698
1568
  },
1569
+ getConfig: getConfigSnapshot,
699
1570
  getStatus: getStatusSnapshot,
700
- getTheme: themeController.getTheme,
701
- setTheme: themeController.setTheme,
702
1571
  term,
703
- transport
1572
+ transport,
1573
+ updateConfig: updateTerminalConfig
704
1574
  };
705
1575
  }
706
1576
  function mountAitty(containerOrOptions = {}, maybeOptions = {}) {
@@ -727,7 +1597,13 @@ function defineAittyTerminalElement(registry = globalThis.customElements) {
727
1597
  function createAittyTerminalElementConstructor() {
728
1598
  return class AittyTerminalCustomElement extends HTMLElement {
729
1599
  static get observedAttributes() {
730
- return ["src", "theme"];
1600
+ return [
1601
+ "font-size",
1602
+ "line-height",
1603
+ "src",
1604
+ "theme",
1605
+ "theme-target"
1606
+ ];
731
1607
  }
732
1608
  #mounted = null;
733
1609
  #mountVersion = 0;
@@ -739,8 +1615,8 @@ function createAittyTerminalElementConstructor() {
739
1615
  }
740
1616
  attributeChangedCallback(name, oldValue, nextValue) {
741
1617
  if (oldValue === nextValue || !this.isConnected) return;
742
- if (name === "theme" && this.#mounted) {
743
- this.#mounted.setTheme(normalizeThemeName(nextValue ?? void 0));
1618
+ if (name !== "src" && this.#mounted) {
1619
+ this.#mounted.updateConfig(this.#readConfig());
744
1620
  return;
745
1621
  }
746
1622
  this.#mount();
@@ -763,8 +1639,15 @@ function createAittyTerminalElementConstructor() {
763
1639
  detail: status
764
1640
  }));
765
1641
  },
766
- src: this.getAttribute("src") ?? void 0,
767
- theme: normalizeThemeName(this.getAttribute("theme") ?? void 0)
1642
+ onConfigChange: (config) => {
1643
+ this.dispatchEvent(new CustomEvent("aitty:configchange", {
1644
+ bubbles: true,
1645
+ composed: true,
1646
+ detail: config
1647
+ }));
1648
+ },
1649
+ config: this.#readConfig(),
1650
+ src: this.getAttribute("src") ?? void 0
768
1651
  });
769
1652
  if (!this.isConnected || this.#mountVersion !== mountVersion) {
770
1653
  mounted.destroy();
@@ -784,6 +1667,14 @@ function createAittyTerminalElementConstructor() {
784
1667
  }));
785
1668
  }
786
1669
  }
1670
+ #readConfig() {
1671
+ return { appearance: {
1672
+ fontSize: parseOptionalNumber(this.getAttribute("font-size") ?? void 0),
1673
+ lineHeight: parseOptionalNumber(this.getAttribute("line-height") ?? void 0),
1674
+ theme: normalizeThemeName(this.getAttribute("theme") ?? void 0),
1675
+ themeTarget: normalizeThemeTarget(this.getAttribute("theme-target") ?? void 0)
1676
+ } };
1677
+ }
787
1678
  };
788
1679
  }
789
1680
  async function mountAittyInContainer(container, options) {
@@ -879,139 +1770,1038 @@ function createAittyEmbedView(doc, mount) {
879
1770
  terminalRoot,
880
1771
  viewport
881
1772
  };
882
- }
883
- function replaceTerminalInputHandler(term) {
884
- const previousInput = term.input;
885
- if (typeof previousInput?.destroy !== "function") return;
886
- previousInput.destroy();
887
- const textarea = term.element.ownerDocument.createElement("textarea");
888
- configureTerminalTextarea(textarea);
889
- term.element.appendChild(textarea);
890
- syncTerminalTextareaPosition(term, textarea);
891
- const syncInputPosition = () => {
892
- syncTerminalTextareaPosition(term, textarea);
1773
+ }
1774
+ function replaceTerminalInputHandler(term, options = {}) {
1775
+ const previousInput = term.input;
1776
+ if (typeof previousInput?.destroy !== "function") return;
1777
+ previousInput.destroy();
1778
+ const textarea = term.element.ownerDocument.createElement("textarea");
1779
+ const inputOwnerId = `aitty-input-${nextTerminalInputId += 1}`;
1780
+ configureTerminalTextarea(textarea);
1781
+ term.element.dataset.aittyInputOwner = inputOwnerId;
1782
+ textarea.dataset.aittyInputOwner = inputOwnerId;
1783
+ appendTerminalTextarea(term.element, textarea);
1784
+ const windowObject = options.windowObject ?? textarea.ownerDocument.defaultView ?? window;
1785
+ syncTerminalTextareaPosition(term, textarea, { allowViewportAvoidance: shouldAllowImeViewportAvoidance(windowObject) });
1786
+ let imeScrollLock = null;
1787
+ let imeRestoreFrame = null;
1788
+ let imeReleaseTimer = null;
1789
+ const syncInputPosition = () => {
1790
+ syncTerminalTextareaPosition(term, textarea, { allowViewportAvoidance: shouldAllowImeViewportAvoidance(windowObject) });
1791
+ };
1792
+ const clearImeReleaseTimer = () => {
1793
+ if (imeReleaseTimer === null) return;
1794
+ clearTimeout(imeReleaseTimer);
1795
+ imeReleaseTimer = null;
1796
+ };
1797
+ const restoreDesktopImeScroll = () => {
1798
+ const lock = imeScrollLock;
1799
+ if (!lock || shouldAllowImeViewportAvoidance(windowObject)) return;
1800
+ restoreImeScrollLock(lock);
1801
+ };
1802
+ const scheduleDesktopImeScrollRestore = () => {
1803
+ if (!imeScrollLock || shouldAllowImeViewportAvoidance(windowObject)) return;
1804
+ if (imeRestoreFrame !== null) windowObject.cancelAnimationFrame(imeRestoreFrame);
1805
+ imeRestoreFrame = windowObject.requestAnimationFrame(() => {
1806
+ imeRestoreFrame = null;
1807
+ restoreDesktopImeScroll();
1808
+ windowObject.setTimeout(restoreDesktopImeScroll, 0);
1809
+ });
1810
+ };
1811
+ const captureDesktopImeScroll = () => {
1812
+ clearImeReleaseTimer();
1813
+ if (shouldAllowImeViewportAvoidance(windowObject)) {
1814
+ imeScrollLock = null;
1815
+ return;
1816
+ }
1817
+ imeScrollLock = captureImeScrollLock(windowObject, options.getScrollSurface?.());
1818
+ };
1819
+ const releaseDesktopImeScroll = () => {
1820
+ clearImeReleaseTimer();
1821
+ scheduleDesktopImeScrollRestore();
1822
+ imeReleaseTimer = setTimeout(() => {
1823
+ restoreDesktopImeScroll();
1824
+ imeScrollLock = null;
1825
+ imeReleaseTimer = null;
1826
+ }, 120);
1827
+ };
1828
+ const syncInputPositionPreservingDesktopImeScroll = () => {
1829
+ syncInputPosition();
1830
+ scheduleDesktopImeScrollRestore();
1831
+ };
1832
+ const handleCompositionStart = () => {
1833
+ captureDesktopImeScroll();
1834
+ syncInputPositionPreservingDesktopImeScroll();
1835
+ };
1836
+ const handleCompositionUpdate = () => {
1837
+ syncInputPositionPreservingDesktopImeScroll();
1838
+ };
1839
+ const handleCompositionEnd = () => {
1840
+ syncInputPositionPreservingDesktopImeScroll();
1841
+ releaseDesktopImeScroll();
1842
+ };
1843
+ const onFocus = () => {
1844
+ syncInputPosition();
1845
+ term.element.classList.add("focused");
1846
+ options.onFocus?.();
1847
+ };
1848
+ const onBlur = () => {
1849
+ clearImeReleaseTimer();
1850
+ imeScrollLock = null;
1851
+ term.element.classList.remove("focused");
1852
+ options.onBlur?.();
1853
+ };
1854
+ textarea.addEventListener("focus", onFocus);
1855
+ textarea.addEventListener("blur", onBlur);
1856
+ textarea.addEventListener("beforeinput", syncInputPositionPreservingDesktopImeScroll, true);
1857
+ textarea.addEventListener("compositionstart", handleCompositionStart, true);
1858
+ textarea.addEventListener("compositionupdate", handleCompositionUpdate, true);
1859
+ textarea.addEventListener("compositionend", handleCompositionEnd, true);
1860
+ textarea.addEventListener("input", syncInputPositionPreservingDesktopImeScroll, true);
1861
+ textarea.addEventListener("keydown", syncInputPositionPreservingDesktopImeScroll, true);
1862
+ term.input = {
1863
+ textarea,
1864
+ focus() {
1865
+ syncInputPosition();
1866
+ textarea.focus({ preventScroll: true });
1867
+ },
1868
+ setImeAnchor(anchor) {
1869
+ syncTerminalTextareaPosition(term, textarea, {
1870
+ allowViewportAvoidance: shouldAllowImeViewportAvoidance(windowObject),
1871
+ anchor
1872
+ });
1873
+ },
1874
+ destroy() {
1875
+ clearImeReleaseTimer();
1876
+ if (imeRestoreFrame !== null) {
1877
+ windowObject.cancelAnimationFrame(imeRestoreFrame);
1878
+ imeRestoreFrame = null;
1879
+ }
1880
+ textarea.removeEventListener("focus", onFocus);
1881
+ textarea.removeEventListener("blur", onBlur);
1882
+ textarea.removeEventListener("beforeinput", syncInputPositionPreservingDesktopImeScroll, true);
1883
+ textarea.removeEventListener("compositionstart", handleCompositionStart, true);
1884
+ textarea.removeEventListener("compositionupdate", handleCompositionUpdate, true);
1885
+ textarea.removeEventListener("compositionend", handleCompositionEnd, true);
1886
+ textarea.removeEventListener("input", syncInputPositionPreservingDesktopImeScroll, true);
1887
+ textarea.removeEventListener("keydown", syncInputPositionPreservingDesktopImeScroll, true);
1888
+ term.element.classList.remove("focused");
1889
+ delete term.element.dataset.aittyInputOwner;
1890
+ textarea.remove();
1891
+ }
1892
+ };
1893
+ }
1894
+ function appendTerminalTextarea(terminalRoot, textarea) {
1895
+ const body = terminalRoot.ownerDocument.body;
1896
+ if (body) {
1897
+ body.appendChild(textarea);
1898
+ return;
1899
+ }
1900
+ terminalRoot.appendChild(textarea);
1901
+ }
1902
+ function shouldInstallMobileComposer(shell, windowObject) {
1903
+ return shell.hasAttribute("data-aitty-mobile-composer") && isCoarseMobileViewport(windowObject);
1904
+ }
1905
+ function installMobileComposer(shell, terminalRoot, options) {
1906
+ const doc = shell.ownerDocument;
1907
+ const dock = doc.createElement("div");
1908
+ const toggleButton = doc.createElement("button");
1909
+ const controlList = doc.createElement("div");
1910
+ const directionPad = doc.createElement("div");
1911
+ let dockExpanded = false;
1912
+ let dockDragState = null;
1913
+ let dockPositionFrame = null;
1914
+ let pendingDockPosition = null;
1915
+ let directionModeActive = false;
1916
+ let toggleLongPressState = null;
1917
+ let suppressDockClickUntil = 0;
1918
+ let terminalTapFocusState = null;
1919
+ let composerEnabled = true;
1920
+ dock.className = "aitty-mobile-control-dock";
1921
+ dock.dataset.aittyMobileControlDock = "";
1922
+ dock.style.setProperty("--aitty-mobile-dock-x", "calc(100vw - 78px - env(safe-area-inset-right))");
1923
+ dock.style.setProperty("--aitty-mobile-dock-y", "calc(var(--shell-visual-viewport-offset-top, 0px) + var(--shell-visible-viewport-height, 100dvh) - 96px - env(safe-area-inset-bottom))");
1924
+ dock.setAttribute("aria-label", "Mobile terminal controls");
1925
+ toggleButton.className = "aitty-mobile-control-toggle";
1926
+ toggleButton.type = "button";
1927
+ toggleButton.setAttribute("aria-label", "Show terminal controls");
1928
+ toggleButton.setAttribute("aria-expanded", "false");
1929
+ toggleButton.innerHTML = "<span aria-hidden=\"true\"></span><span aria-hidden=\"true\"></span><span aria-hidden=\"true\"></span>";
1930
+ controlList.className = "aitty-mobile-control-list";
1931
+ for (const control of MOBILE_COMPOSER_PRIMARY_CONTROLS) {
1932
+ const button = createMobileComposerButton(doc, control, resolveMobileComposerControlLabel(control));
1933
+ if (control === "enter") button.classList.add("aitty-mobile-composer-enter");
1934
+ controlList.append(button);
1935
+ }
1936
+ directionPad.className = "aitty-mobile-direction-pad";
1937
+ directionPad.setAttribute("aria-label", "Arrow keys");
1938
+ directionPad.append(createMobileDirectionButton(doc, "arrow-up", "Up", "↑"), createMobileDirectionButton(doc, "arrow-right", "Right", "→"), createMobileDirectionButton(doc, "arrow-down", "Down", "↓"), createMobileDirectionButton(doc, "arrow-left", "Left", "←"));
1939
+ dock.append(controlList, directionPad, toggleButton);
1940
+ shell.append(dock);
1941
+ const applyDockPosition = (left, top) => {
1942
+ dock.style.setProperty("--aitty-mobile-dock-x", `${Math.round(left)}px`);
1943
+ dock.style.setProperty("--aitty-mobile-dock-y", `${Math.round(top)}px`);
1944
+ };
1945
+ const queueDockPosition = (left, top) => {
1946
+ pendingDockPosition = {
1947
+ left,
1948
+ top
1949
+ };
1950
+ if (dockPositionFrame !== null) return;
1951
+ dockPositionFrame = options.windowObject.requestAnimationFrame(() => {
1952
+ dockPositionFrame = null;
1953
+ if (!pendingDockPosition) return;
1954
+ const nextPosition = pendingDockPosition;
1955
+ pendingDockPosition = null;
1956
+ applyDockPosition(nextPosition.left, nextPosition.top);
1957
+ });
1958
+ };
1959
+ const flushDockPosition = () => {
1960
+ if (!pendingDockPosition) return;
1961
+ const nextPosition = pendingDockPosition;
1962
+ pendingDockPosition = null;
1963
+ if (dockPositionFrame !== null) {
1964
+ options.windowObject.cancelAnimationFrame(dockPositionFrame);
1965
+ dockPositionFrame = null;
1966
+ }
1967
+ applyDockPosition(nextPosition.left, nextPosition.top);
1968
+ };
1969
+ const syncDockExpandedState = () => {
1970
+ dock.hidden = !composerEnabled;
1971
+ dock.dataset.expanded = dockExpanded ? "true" : "false";
1972
+ dock.dataset.directionMode = directionModeActive ? "true" : "false";
1973
+ dock.dataset.enabled = composerEnabled ? "true" : "false";
1974
+ toggleButton.disabled = !composerEnabled;
1975
+ for (const button of dock.querySelectorAll("button")) button.disabled = !composerEnabled;
1976
+ toggleButton.setAttribute("aria-expanded", String(dockExpanded || directionModeActive));
1977
+ toggleButton.setAttribute("aria-label", directionModeActive ? "Hide arrow controls" : dockExpanded ? "Hide terminal controls" : "Show terminal controls");
1978
+ };
1979
+ const setDockExpanded = (expanded) => {
1980
+ if (!composerEnabled) return;
1981
+ dockExpanded = expanded;
1982
+ if (expanded) directionModeActive = false;
1983
+ syncDockExpandedState();
1984
+ keepActiveDockInsideViewport();
1985
+ scheduleDockViewportClamp();
1986
+ };
1987
+ const resolveVisibleDockBounds = () => {
1988
+ const viewport = resolveBrowserViewport(options.windowObject);
1989
+ const visibleHeight = Math.max(1, viewport.height - viewport.browserChromeInsetBottom);
1990
+ return {
1991
+ bottom: viewport.offsetTop + visibleHeight - MOBILE_COMPOSER_MARGIN_PX,
1992
+ left: MOBILE_COMPOSER_MARGIN_PX,
1993
+ right: viewport.width - MOBILE_COMPOSER_MARGIN_PX,
1994
+ top: viewport.offsetTop + MOBILE_COMPOSER_MARGIN_PX
1995
+ };
1996
+ };
1997
+ const resolveCurrentDockPosition = () => {
1998
+ flushDockPosition();
1999
+ const rect = dock.getBoundingClientRect();
2000
+ return {
2001
+ left: rect.left,
2002
+ top: rect.top
2003
+ };
2004
+ };
2005
+ const clampDockPosition = ({ dockHeight = MOBILE_COMPOSER_DOCK_SIZE_PX, dockWidth = MOBILE_COMPOSER_DOCK_SIZE_PX, minLeftOffset = 0, minTopOffset = 0, bottomOffset = 0, left, rightOffset = 0, top }) => {
2006
+ const bounds = resolveVisibleDockBounds();
2007
+ const minLeft = bounds.left + minLeftOffset;
2008
+ const minTop = bounds.top + minTopOffset;
2009
+ const maxLeft = Math.max(minLeft, bounds.right - dockWidth - rightOffset);
2010
+ const maxTop = Math.max(minTop, bounds.bottom - dockHeight - bottomOffset);
2011
+ return {
2012
+ left: clamp(left, minLeft, maxLeft),
2013
+ top: clamp(top, minTop, maxTop)
2014
+ };
2015
+ };
2016
+ const keepCollapsedDockInsideViewport = () => {
2017
+ const current = resolveCurrentDockPosition();
2018
+ const next = clampDockPosition(current);
2019
+ if (Math.abs(next.left - current.left) >= 1 || Math.abs(next.top - current.top) >= 1) applyDockPosition(next.left, next.top);
2020
+ };
2021
+ const keepExpandedControlsInsideViewport = () => {
2022
+ const current = resolveCurrentDockPosition();
2023
+ const listHeight = resolveMobileComposerExpandedListHeight();
2024
+ const next = clampDockPosition({
2025
+ ...current,
2026
+ minTopOffset: listHeight + MOBILE_COMPOSER_EXPANDED_GAP_PX
2027
+ });
2028
+ if (Math.abs(next.left - current.left) >= 1 || Math.abs(next.top - current.top) >= 1) applyDockPosition(next.left, next.top);
2029
+ };
2030
+ const keepDirectionPadInsideViewport = () => {
2031
+ const current = resolveCurrentDockPosition();
2032
+ const next = clampDockPosition({
2033
+ ...current,
2034
+ bottomOffset: MOBILE_COMPOSER_DIRECTION_BOTTOM_OUTSET_PX,
2035
+ minLeftOffset: MOBILE_COMPOSER_DIRECTION_OUTSET_PX,
2036
+ minTopOffset: MOBILE_COMPOSER_DIRECTION_OUTSET_PX,
2037
+ rightOffset: MOBILE_COMPOSER_DIRECTION_OUTSET_PX
2038
+ });
2039
+ if (Math.abs(next.left - current.left) >= 1 || Math.abs(next.top - current.top) >= 1) applyDockPosition(next.left, next.top);
2040
+ };
2041
+ const keepActiveDockInsideViewport = () => {
2042
+ if (directionModeActive) {
2043
+ keepDirectionPadInsideViewport();
2044
+ return;
2045
+ }
2046
+ if (dockExpanded) {
2047
+ keepExpandedControlsInsideViewport();
2048
+ return;
2049
+ }
2050
+ keepCollapsedDockInsideViewport();
2051
+ };
2052
+ const scheduleDockViewportClamp = () => {
2053
+ if (dockPositionFrame !== null) return;
2054
+ dockPositionFrame = options.windowObject.requestAnimationFrame(() => {
2055
+ dockPositionFrame = null;
2056
+ keepActiveDockInsideViewport();
2057
+ });
2058
+ };
2059
+ const setDirectionModeActive = (active) => {
2060
+ if (!composerEnabled) return;
2061
+ directionModeActive = active;
2062
+ if (active) dockExpanded = false;
2063
+ syncDockExpandedState();
2064
+ keepActiveDockInsideViewport();
2065
+ };
2066
+ const open = () => {
2067
+ if (!composerEnabled || terminalRoot.dataset.sessionInteractive === "false") return;
2068
+ options.focusTerminal();
2069
+ };
2070
+ syncDockExpandedState();
2071
+ const sendControl = (control) => {
2072
+ if (!composerEnabled) return;
2073
+ const hadTerminalFocus = isElementInside(doc.activeElement, terminalRoot);
2074
+ options.sendData(resolveMobileComposerControlSequence(control));
2075
+ if (!hadTerminalFocus && doc.activeElement instanceof HTMLElement && isElementInside(doc.activeElement, terminalRoot)) doc.activeElement.blur();
2076
+ };
2077
+ const activateControl = (control) => {
2078
+ if (isMobileComposerDirectionControl(control)) setDirectionModeActive(true);
2079
+ else setDockExpanded(false);
2080
+ sendControl(control);
2081
+ };
2082
+ const activateToggle = () => {
2083
+ if (!composerEnabled) return;
2084
+ if (directionModeActive) {
2085
+ setDirectionModeActive(false);
2086
+ return;
2087
+ }
2088
+ setDockExpanded(!dockExpanded);
2089
+ };
2090
+ const resolvePointerAction = (target) => {
2091
+ if (!(target instanceof Element)) return { kind: "none" };
2092
+ if (target.closest(".aitty-mobile-control-toggle") === toggleButton) return { kind: "toggle" };
2093
+ const controlButton = target.closest("[data-aitty-mobile-composer-control]");
2094
+ if (!(controlButton instanceof HTMLButtonElement)) return { kind: "none" };
2095
+ const control = controlButton.dataset.aittyMobileComposerControl;
2096
+ return control ? {
2097
+ kind: "control",
2098
+ control
2099
+ } : { kind: "none" };
2100
+ };
2101
+ const clearToggleLongPress = () => {
2102
+ if (!toggleLongPressState) return;
2103
+ options.windowObject.clearTimeout(toggleLongPressState.timer);
2104
+ toggleLongPressState = null;
2105
+ };
2106
+ const startToggleLongPress = (event) => {
2107
+ if (directionModeActive || !(event.target instanceof Element) || !event.target.closest(".aitty-mobile-control-toggle")) return;
2108
+ clearToggleLongPress();
2109
+ const pointerId = event.pointerId;
2110
+ const timer = options.windowObject.setTimeout(() => {
2111
+ if (!toggleLongPressState || toggleLongPressState.pointerId !== pointerId) return;
2112
+ toggleLongPressState.triggered = true;
2113
+ setDirectionModeActive(true);
2114
+ }, MOBILE_COMPOSER_DIRECTION_LONG_PRESS_MS);
2115
+ toggleLongPressState = {
2116
+ pointerId,
2117
+ startX: event.clientX,
2118
+ startY: event.clientY,
2119
+ timer,
2120
+ triggered: false
2121
+ };
2122
+ };
2123
+ const cancelToggleLongPressOnMove = (event) => {
2124
+ if (!toggleLongPressState || toggleLongPressState.pointerId !== event.pointerId) return;
2125
+ if (Math.hypot(event.clientX - toggleLongPressState.startX, event.clientY - toggleLongPressState.startY) >= MOBILE_COMPOSER_TAP_SLOP_PX) clearToggleLongPress();
2126
+ };
2127
+ const onShellPointerDown = (event) => {
2128
+ if (event.defaultPrevented || !isCoarseMobileViewport(options.windowObject)) return;
2129
+ const target = event.target;
2130
+ if (target instanceof Element && target.closest("[data-shell-control], .aitty-mobile-control-dock")) return;
2131
+ if (!isElementInside(target, terminalRoot) && !isElementInside(target, shell)) {
2132
+ terminalTapFocusState = null;
2133
+ return;
2134
+ }
2135
+ if (hasTerminalSelection(terminalRoot) || !isMobileLiveInputTapTarget(terminalRoot, options.windowObject, event.clientX, event.clientY)) {
2136
+ terminalTapFocusState = null;
2137
+ return;
2138
+ }
2139
+ terminalTapFocusState = {
2140
+ pointerId: event.pointerId,
2141
+ startTime: options.windowObject.performance.now(),
2142
+ startX: event.clientX,
2143
+ startY: event.clientY
2144
+ };
2145
+ };
2146
+ const onShellPointerMove = (event) => {
2147
+ if (!terminalTapFocusState || terminalTapFocusState.pointerId !== event.pointerId) return;
2148
+ if (Math.hypot(event.clientX - terminalTapFocusState.startX, event.clientY - terminalTapFocusState.startY) >= 8) terminalTapFocusState = null;
2149
+ };
2150
+ const onShellPointerUp = (event) => {
2151
+ if (!terminalTapFocusState || terminalTapFocusState.pointerId !== event.pointerId) return;
2152
+ const tapState = terminalTapFocusState;
2153
+ terminalTapFocusState = null;
2154
+ if (Math.hypot(event.clientX - tapState.startX, event.clientY - tapState.startY) >= 8) return;
2155
+ const pressedForMs = options.windowObject.performance.now() - tapState.startTime;
2156
+ const isLiveInputTarget = isMobileLiveInputTapTarget(terminalRoot, options.windowObject, event.clientX, event.clientY);
2157
+ if (hasTerminalSelection(terminalRoot) || !isLiveInputTarget) return;
2158
+ if (pressedForMs >= MOBILE_COMPOSER_DIRECTION_LONG_PRESS_MS) return;
2159
+ options.focusTerminal();
2160
+ };
2161
+ const onShellPointerCancel = (event) => {
2162
+ if (terminalTapFocusState?.pointerId === event.pointerId) terminalTapFocusState = null;
2163
+ };
2164
+ const onControlClick = (event) => {
2165
+ event.preventDefault();
2166
+ if (suppressDockClickUntil > options.windowObject.performance.now()) return;
2167
+ const target = event.target;
2168
+ if (!(target instanceof HTMLButtonElement)) return;
2169
+ if (dockDragState?.moved) return;
2170
+ const control = target.dataset.aittyMobileComposerControl;
2171
+ if (!control) return;
2172
+ activateControl(control);
2173
+ };
2174
+ const onToggleClick = (event) => {
2175
+ event.preventDefault();
2176
+ if (suppressDockClickUntil > options.windowObject.performance.now()) return;
2177
+ if (dockDragState?.moved) return;
2178
+ activateToggle();
2179
+ };
2180
+ const onDockPointerDown = (event) => {
2181
+ if (!composerEnabled || event.button > 0 || !isCoarseMobileViewport(options.windowObject)) return;
2182
+ event.preventDefault();
2183
+ flushDockPosition();
2184
+ const rect = dock.getBoundingClientRect();
2185
+ dockDragState = {
2186
+ action: resolvePointerAction(event.target),
2187
+ dockHeight: rect.height || MOBILE_COMPOSER_DOCK_SIZE_PX,
2188
+ dockWidth: rect.width || MOBILE_COMPOSER_DOCK_SIZE_PX,
2189
+ moved: false,
2190
+ originLeft: rect.left,
2191
+ originTop: rect.top,
2192
+ pointerId: event.pointerId,
2193
+ startX: event.clientX,
2194
+ startY: event.clientY
2195
+ };
2196
+ startToggleLongPress(event);
2197
+ try {
2198
+ dock.setPointerCapture?.(event.pointerId);
2199
+ } catch {}
2200
+ dock.dataset.dragging = "true";
2201
+ };
2202
+ const onDockPointerMove = (event) => {
2203
+ if (!dockDragState || dockDragState.pointerId !== event.pointerId) return;
2204
+ const deltaX = event.clientX - dockDragState.startX;
2205
+ const deltaY = event.clientY - dockDragState.startY;
2206
+ cancelToggleLongPressOnMove(event);
2207
+ if (!dockDragState.moved && Math.hypot(deltaX, deltaY) < MOBILE_COMPOSER_TAP_SLOP_PX) return;
2208
+ dockDragState.moved = true;
2209
+ const bounds = resolveVisibleDockBounds();
2210
+ const maxLeft = Math.max(bounds.left, bounds.right - dockDragState.dockWidth);
2211
+ const maxTop = Math.max(bounds.top, bounds.bottom - dockDragState.dockHeight);
2212
+ queueDockPosition(clamp(dockDragState.originLeft + deltaX, bounds.left, maxLeft), clamp(dockDragState.originTop + deltaY, bounds.top, maxTop));
2213
+ event.preventDefault();
2214
+ };
2215
+ const onDockPointerEnd = (event) => {
2216
+ if (!dockDragState || dockDragState.pointerId !== event.pointerId) return;
2217
+ const cancelled = event.type === "pointercancel";
2218
+ try {
2219
+ dock.releasePointerCapture?.(event.pointerId);
2220
+ } catch {}
2221
+ if (toggleLongPressState?.pointerId === event.pointerId && toggleLongPressState.triggered) {
2222
+ suppressDockClickUntil = options.windowObject.performance.now() + MOBILE_COMPOSER_CLICK_SUPPRESS_MS;
2223
+ options.windowObject.setTimeout(() => {
2224
+ clearToggleLongPress();
2225
+ }, 0);
2226
+ } else clearToggleLongPress();
2227
+ if (cancelled) suppressDockClickUntil = options.windowObject.performance.now() + MOBILE_COMPOSER_CLICK_SUPPRESS_MS;
2228
+ else if (dockDragState.moved) {
2229
+ flushDockPosition();
2230
+ keepActiveDockInsideViewport();
2231
+ suppressDockClickUntil = options.windowObject.performance.now() + MOBILE_COMPOSER_CLICK_SUPPRESS_MS;
2232
+ event.preventDefault();
2233
+ } else if (suppressDockClickUntil <= options.windowObject.performance.now()) {
2234
+ if (dockDragState.action.kind === "toggle") {
2235
+ activateToggle();
2236
+ suppressDockClickUntil = options.windowObject.performance.now() + MOBILE_COMPOSER_CLICK_SUPPRESS_MS;
2237
+ event.preventDefault();
2238
+ } else if (dockDragState.action.kind === "control") {
2239
+ activateControl(dockDragState.action.control);
2240
+ suppressDockClickUntil = options.windowObject.performance.now() + MOBILE_COMPOSER_CLICK_SUPPRESS_MS;
2241
+ event.preventDefault();
2242
+ }
2243
+ }
2244
+ delete dock.dataset.dragging;
2245
+ options.windowObject.setTimeout(() => {
2246
+ dockDragState = null;
2247
+ }, 0);
2248
+ };
2249
+ shell.addEventListener("pointerdown", onShellPointerDown, true);
2250
+ shell.addEventListener("pointermove", onShellPointerMove, true);
2251
+ shell.addEventListener("pointerup", onShellPointerUp, true);
2252
+ shell.addEventListener("pointercancel", onShellPointerCancel, true);
2253
+ toggleButton.addEventListener("click", onToggleClick);
2254
+ controlList.addEventListener("click", onControlClick);
2255
+ directionPad.addEventListener("click", onControlClick);
2256
+ dock.addEventListener("pointerdown", onDockPointerDown);
2257
+ dock.addEventListener("pointermove", onDockPointerMove);
2258
+ dock.addEventListener("pointerup", onDockPointerEnd);
2259
+ dock.addEventListener("pointercancel", onDockPointerEnd);
2260
+ options.windowObject.visualViewport?.addEventListener("resize", scheduleDockViewportClamp);
2261
+ options.windowObject.visualViewport?.addEventListener("scroll", scheduleDockViewportClamp);
2262
+ return {
2263
+ destroy() {
2264
+ shell.removeEventListener("pointerdown", onShellPointerDown, true);
2265
+ shell.removeEventListener("pointermove", onShellPointerMove, true);
2266
+ shell.removeEventListener("pointerup", onShellPointerUp, true);
2267
+ shell.removeEventListener("pointercancel", onShellPointerCancel, true);
2268
+ toggleButton.removeEventListener("click", onToggleClick);
2269
+ controlList.removeEventListener("click", onControlClick);
2270
+ directionPad.removeEventListener("click", onControlClick);
2271
+ dock.removeEventListener("pointerdown", onDockPointerDown);
2272
+ dock.removeEventListener("pointermove", onDockPointerMove);
2273
+ dock.removeEventListener("pointerup", onDockPointerEnd);
2274
+ dock.removeEventListener("pointercancel", onDockPointerEnd);
2275
+ options.windowObject.visualViewport?.removeEventListener("resize", scheduleDockViewportClamp);
2276
+ options.windowObject.visualViewport?.removeEventListener("scroll", scheduleDockViewportClamp);
2277
+ clearToggleLongPress();
2278
+ if (dockPositionFrame !== null) {
2279
+ options.windowObject.cancelAnimationFrame(dockPositionFrame);
2280
+ dockPositionFrame = null;
2281
+ }
2282
+ dock.remove();
2283
+ },
2284
+ focus() {
2285
+ open();
2286
+ },
2287
+ setEnabled(enabled) {
2288
+ if (composerEnabled === enabled) return;
2289
+ composerEnabled = enabled;
2290
+ if (!enabled) {
2291
+ dockExpanded = false;
2292
+ directionModeActive = false;
2293
+ dockDragState = null;
2294
+ terminalTapFocusState = null;
2295
+ clearToggleLongPress();
2296
+ delete dock.dataset.dragging;
2297
+ }
2298
+ syncDockExpandedState();
2299
+ }
2300
+ };
2301
+ }
2302
+ function installTakeoverNotice(shell, windowObject, options) {
2303
+ const doc = shell.ownerDocument;
2304
+ const notice = resolveTakeoverApprovalElement(shell);
2305
+ const message = notice.querySelector("[data-aitty-takeover-message]");
2306
+ const allowButton = notice.querySelector("[data-aitty-takeover-allow]");
2307
+ const denyButton = notice.querySelector("[data-aitty-takeover-deny]");
2308
+ const enableNotificationsButton = resolveNotificationEnableButton(shell);
2309
+ const pushPublicKey = resolvePushPublicKey(doc);
2310
+ let permissionPromptEnabled = false;
2311
+ let activeRequest = null;
2312
+ let timeoutHandle = null;
2313
+ let systemNotification = null;
2314
+ let activePushEndpoint = null;
2315
+ let pushRegistrationInFlight = false;
2316
+ let lastAutoRegisteredRole = null;
2317
+ const clearTimer = () => {
2318
+ if (timeoutHandle !== null) {
2319
+ windowObject.clearTimeout(timeoutHandle);
2320
+ timeoutHandle = null;
2321
+ }
2322
+ };
2323
+ const closeSystemNotification = () => {
2324
+ systemNotification?.close?.();
2325
+ systemNotification = null;
2326
+ };
2327
+ const hide = () => {
2328
+ clearTimer();
2329
+ closeSystemNotification();
2330
+ activeRequest = null;
2331
+ notice.hidden = true;
2332
+ };
2333
+ const respond = (approved) => {
2334
+ const request = activeRequest;
2335
+ if (!request) {
2336
+ hide();
2337
+ return;
2338
+ }
2339
+ options.onRespond(request.requestId, approved);
2340
+ hide();
2341
+ };
2342
+ const focusWindow = () => {
2343
+ windowObject.focus?.();
2344
+ notice.scrollIntoView?.({
2345
+ block: "nearest",
2346
+ inline: "nearest"
2347
+ });
2348
+ };
2349
+ const showSystemNotification = (request) => {
2350
+ const NotificationConstructor = windowObject.Notification;
2351
+ if (typeof NotificationConstructor !== "function" || NotificationConstructor.permission !== "granted" || doc.visibilityState !== "hidden") return;
2352
+ try {
2353
+ closeSystemNotification();
2354
+ systemNotification = new NotificationConstructor("Terminal control requested", {
2355
+ body: `${formatTakeoverRequester(request)} wants to control this terminal. Click to review.`,
2356
+ tag: "aitty-takeover-request"
2357
+ });
2358
+ systemNotification.onclick = () => {
2359
+ focusWindow();
2360
+ closeSystemNotification();
2361
+ };
2362
+ } catch {
2363
+ closeSystemNotification();
2364
+ }
2365
+ };
2366
+ const hasNotificationPermission = () => {
2367
+ const NotificationConstructor = windowObject.Notification;
2368
+ return typeof NotificationConstructor === "function" && NotificationConstructor.permission === "granted";
2369
+ };
2370
+ const requestNotificationPermission = async () => {
2371
+ const NotificationConstructor = windowObject.Notification;
2372
+ if (typeof NotificationConstructor !== "function" || NotificationConstructor.permission !== "default") return;
2373
+ await NotificationConstructor.requestPermission?.();
2374
+ };
2375
+ const registerPushNotifications = async () => {
2376
+ if (pushRegistrationInFlight || activePushEndpoint || options.getClientRole() !== "controller") return;
2377
+ pushRegistrationInFlight = true;
2378
+ try {
2379
+ const subscription = await subscribeBrowserPush(windowObject, pushPublicKey);
2380
+ if (!subscription) return;
2381
+ const payload = normalizePushSubscription(subscription.toJSON());
2382
+ if (!payload) return;
2383
+ activePushEndpoint = payload.endpoint;
2384
+ options.sendControl(createPushSubscribeControlFrame({
2385
+ subscription: payload,
2386
+ url: windowObject.location?.href
2387
+ }));
2388
+ } finally {
2389
+ pushRegistrationInFlight = false;
2390
+ }
2391
+ };
2392
+ const syncNotificationPermissionControl = () => {
2393
+ if (!enableNotificationsButton) return;
2394
+ const NotificationConstructor = windowObject.Notification;
2395
+ const canRequestPageNotification = typeof NotificationConstructor === "function" && NotificationConstructor.permission === "default";
2396
+ const canRequestPush = Boolean(pushPublicKey) && canUseBrowserPush(windowObject) && activePushEndpoint === null && options.getClientRole() === "controller";
2397
+ enableNotificationsButton.hidden = !(permissionPromptEnabled && options.getClientRole() === "controller" && (canRequestPageNotification || canRequestPush));
2398
+ enableNotificationsButton.disabled = pushRegistrationInFlight;
2399
+ enableNotificationsButton.textContent = pushRegistrationInFlight ? "Enabling alerts..." : "Enable alerts";
2400
+ enableNotificationsButton.setAttribute("aria-label", "Enable takeover alerts for this controller");
2401
+ };
2402
+ const syncNotificationState = () => {
2403
+ if (options.getClientRole() === "controller" && hasNotificationPermission()) registerPushNotifications().catch(() => void 0).finally(() => syncNotificationPermissionControl());
2404
+ syncNotificationPermissionControl();
2405
+ };
2406
+ const onAllow = (event) => {
2407
+ event.preventDefault();
2408
+ respond(true);
2409
+ };
2410
+ const onDeny = (event) => {
2411
+ event.preventDefault();
2412
+ respond(false);
893
2413
  };
894
- const onFocus = () => {
895
- syncInputPosition();
896
- term.element.classList.add("focused");
2414
+ const onVisibilityChange = () => {
2415
+ const request = activeRequest;
2416
+ if (request && doc.visibilityState === "hidden") showSystemNotification(request);
897
2417
  };
898
- const onBlur = () => {
899
- term.element.classList.remove("focused");
2418
+ const onEnableNotifications = (event) => {
2419
+ event.preventDefault();
2420
+ requestNotificationPermission().then(() => registerPushNotifications()).catch(() => void 0).finally(() => syncNotificationPermissionControl());
2421
+ syncNotificationPermissionControl();
900
2422
  };
901
- textarea.addEventListener("focus", onFocus);
902
- textarea.addEventListener("blur", onBlur);
903
- textarea.addEventListener("beforeinput", syncInputPosition, true);
904
- textarea.addEventListener("compositionstart", syncInputPosition, true);
905
- textarea.addEventListener("compositionupdate", syncInputPosition, true);
906
- textarea.addEventListener("compositionend", syncInputPosition, true);
907
- textarea.addEventListener("input", syncInputPosition, true);
908
- textarea.addEventListener("keydown", syncInputPosition, true);
909
- term.input = {
910
- textarea,
911
- focus() {
912
- syncInputPosition();
913
- textarea.focus({ preventScroll: true });
914
- },
2423
+ allowButton?.addEventListener("click", onAllow);
2424
+ denyButton?.addEventListener("click", onDeny);
2425
+ enableNotificationsButton?.addEventListener("click", onEnableNotifications);
2426
+ doc.addEventListener("visibilitychange", onVisibilityChange);
2427
+ return {
915
2428
  destroy() {
916
- textarea.removeEventListener("focus", onFocus);
917
- textarea.removeEventListener("blur", onBlur);
918
- textarea.removeEventListener("beforeinput", syncInputPosition, true);
919
- textarea.removeEventListener("compositionstart", syncInputPosition, true);
920
- textarea.removeEventListener("compositionupdate", syncInputPosition, true);
921
- textarea.removeEventListener("compositionend", syncInputPosition, true);
922
- textarea.removeEventListener("input", syncInputPosition, true);
923
- textarea.removeEventListener("keydown", syncInputPosition, true);
924
- term.element.classList.remove("focused");
925
- textarea.remove();
2429
+ hide();
2430
+ if (activePushEndpoint) {
2431
+ options.sendControl(createPushUnsubscribeControlFrame({ endpoint: activePushEndpoint }));
2432
+ activePushEndpoint = null;
2433
+ }
2434
+ allowButton?.removeEventListener("click", onAllow);
2435
+ denyButton?.removeEventListener("click", onDeny);
2436
+ enableNotificationsButton?.removeEventListener("click", onEnableNotifications);
2437
+ doc.removeEventListener("visibilitychange", onVisibilityChange);
2438
+ },
2439
+ show(request) {
2440
+ activeRequest = request;
2441
+ notice.hidden = false;
2442
+ if (message) message.textContent = `${formatTakeoverRequester(request)} wants to control this terminal. Approve only if you intend to hand off input.`;
2443
+ syncNotificationPermissionControl();
2444
+ clearTimer();
2445
+ timeoutHandle = windowObject.setTimeout(() => {
2446
+ respond(false);
2447
+ }, TAKEOVER_NOTICE_TIMEOUT_MS);
2448
+ showSystemNotification(request);
2449
+ },
2450
+ syncClient() {
2451
+ const role = options.getClientRole();
2452
+ if (role !== lastAutoRegisteredRole) {
2453
+ lastAutoRegisteredRole = role;
2454
+ syncNotificationState();
2455
+ return;
2456
+ }
2457
+ syncNotificationPermissionControl();
2458
+ },
2459
+ syncNotificationControl() {
2460
+ syncNotificationPermissionControl();
2461
+ },
2462
+ syncPermissionPrompt(enabled) {
2463
+ const wasEnabled = permissionPromptEnabled;
2464
+ const role = options.getClientRole();
2465
+ if (role !== lastAutoRegisteredRole) lastAutoRegisteredRole = role;
2466
+ permissionPromptEnabled = enabled;
2467
+ if (!wasEnabled && enabled && hasNotificationPermission()) {
2468
+ syncNotificationState();
2469
+ return;
2470
+ }
2471
+ syncNotificationPermissionControl();
2472
+ }
2473
+ };
2474
+ }
2475
+ function resolveNotificationEnableButton(shell) {
2476
+ const existing = shell.querySelector("[data-aitty-notification-enable]");
2477
+ if (existing) return existing;
2478
+ const statusActions = shell.querySelector(".shell-status-actions");
2479
+ if (!statusActions) return null;
2480
+ const button = shell.ownerDocument.createElement("button");
2481
+ button.className = "notification-enable-button";
2482
+ button.dataset.aittyNotificationEnable = "";
2483
+ button.hidden = true;
2484
+ button.type = "button";
2485
+ button.textContent = "Enable alerts";
2486
+ statusActions.append(button);
2487
+ return button;
2488
+ }
2489
+ function resolvePushPublicKey(doc) {
2490
+ const key = doc.querySelector("meta[name=\"aitty-vapid-public-key\"]")?.content.trim();
2491
+ return key ? key : null;
2492
+ }
2493
+ function canUseBrowserPush(windowObject) {
2494
+ const navigatorLike = windowObject.navigator;
2495
+ return Boolean(windowObject.isSecureContext && navigatorLike.serviceWorker && "PushManager" in windowObject && typeof navigatorLike.serviceWorker.register === "function");
2496
+ }
2497
+ async function subscribeBrowserPush(windowObject, publicKey) {
2498
+ if (!publicKey || !canUseBrowserPush(windowObject)) return null;
2499
+ const registration = await windowObject.navigator.serviceWorker.register("/assets/aitty-sw.js", { scope: "/" });
2500
+ const existing = await registration.pushManager.getSubscription();
2501
+ if (existing) return existing;
2502
+ return registration.pushManager.subscribe({
2503
+ applicationServerKey: urlBase64ToArrayBuffer(publicKey),
2504
+ userVisibleOnly: true
2505
+ });
2506
+ }
2507
+ function normalizePushSubscription(subscription) {
2508
+ if (typeof subscription.endpoint !== "string" || !subscription.endpoint || !subscription.keys || typeof subscription.keys.auth !== "string" || !subscription.keys.auth || typeof subscription.keys.p256dh !== "string" || !subscription.keys.p256dh) return null;
2509
+ return {
2510
+ endpoint: subscription.endpoint,
2511
+ expirationTime: typeof subscription.expirationTime === "number" ? subscription.expirationTime : null,
2512
+ keys: {
2513
+ auth: subscription.keys.auth,
2514
+ p256dh: subscription.keys.p256dh
926
2515
  }
927
2516
  };
928
2517
  }
2518
+ function urlBase64ToArrayBuffer(value) {
2519
+ const base64 = `${value}${"=".repeat((4 - value.length % 4) % 4)}`.replace(/-/g, "+").replace(/_/g, "/");
2520
+ const raw = atob(base64);
2521
+ const output = new Uint8Array(raw.length);
2522
+ for (let index = 0; index < raw.length; index += 1) output[index] = raw.charCodeAt(index);
2523
+ return output.buffer;
2524
+ }
2525
+ function resolveTakeoverApprovalElement(shell) {
2526
+ const existing = shell.querySelector("[data-aitty-takeover-approval]");
2527
+ if (existing) return existing;
2528
+ const notice = shell.ownerDocument.createElement("section");
2529
+ notice.className = "takeover-approval";
2530
+ notice.dataset.aittyTakeoverApproval = "";
2531
+ notice.hidden = true;
2532
+ notice.setAttribute("aria-live", "polite");
2533
+ notice.innerHTML = [
2534
+ "<strong>Terminal control requested</strong>",
2535
+ "<p data-aitty-takeover-message>Another browser wants to control this terminal.</p>",
2536
+ "<div class=\"takeover-approval-actions\">",
2537
+ "<button type=\"button\" data-aitty-takeover-deny>Deny</button>",
2538
+ "<button type=\"button\" data-aitty-takeover-allow>Allow</button>",
2539
+ "</div>"
2540
+ ].join("");
2541
+ shell.append(notice);
2542
+ return notice;
2543
+ }
2544
+ function formatTakeoverRequester(request) {
2545
+ return request.requesterId ? `Browser ${request.requesterId}` : "Another browser";
2546
+ }
2547
+ function resolveTakeoverResultMessage(reason) {
2548
+ switch (reason) {
2549
+ case "controller-disconnected": return "Control request expired because the controller disconnected.";
2550
+ case "controller-suspended": return "Controller is reconnecting. Try again shortly.";
2551
+ case "pending": return "Control request already pending.";
2552
+ case "rejected": return "Control request rejected.";
2553
+ default: return "Control request failed.";
2554
+ }
2555
+ }
2556
+ function createMobileComposerButton(doc, control, label) {
2557
+ const button = doc.createElement("button");
2558
+ button.className = "aitty-mobile-composer-button";
2559
+ button.dataset.aittyMobileComposerControl = control;
2560
+ button.type = "button";
2561
+ button.textContent = label;
2562
+ return button;
2563
+ }
2564
+ function resolveMobileComposerControlLabel(control) {
2565
+ if (control === "enter") return "Enter";
2566
+ if (control === "esc") return "Esc";
2567
+ if (control === "tab") return "Tab";
2568
+ return control.replace("arrow-", "");
2569
+ }
2570
+ function createMobileDirectionButton(doc, control, label, glyph) {
2571
+ const button = createMobileComposerButton(doc, control, glyph);
2572
+ button.classList.add("aitty-mobile-direction-button", `aitty-mobile-direction-${control.replace("arrow-", "")}`);
2573
+ button.setAttribute("aria-label", `${label} arrow`);
2574
+ return button;
2575
+ }
2576
+ function isCoarseMobileViewport(windowObject) {
2577
+ return (firstFinitePositiveNumber(windowObject.visualViewport?.width, windowObject.innerWidth, windowObject.document?.documentElement?.clientWidth) ?? Number.POSITIVE_INFINITY) <= 768 && windowObject.matchMedia?.("(pointer: coarse)").matches === true;
2578
+ }
929
2579
  function configureTerminalTextarea(textarea) {
930
2580
  textarea.setAttribute("autocapitalize", "off");
931
2581
  textarea.setAttribute("autocomplete", "off");
932
2582
  textarea.setAttribute("autocorrect", "off");
933
2583
  textarea.setAttribute("spellcheck", "false");
934
2584
  textarea.setAttribute("enterkeyhint", "send");
2585
+ textarea.setAttribute("inputmode", "text");
935
2586
  textarea.setAttribute("tabindex", "0");
936
2587
  textarea.setAttribute("aria-hidden", "true");
2588
+ textarea.dataset.aittyInput = "";
937
2589
  const style = textarea.style;
938
2590
  style.position = "fixed";
939
2591
  style.left = "0";
940
2592
  style.top = "0";
941
- style.width = "1px";
942
- style.height = "1px";
943
- style.opacity = "0";
2593
+ style.zIndex = "1400";
2594
+ style.width = "2px";
2595
+ style.height = "2px";
2596
+ style.opacity = "0.01";
944
2597
  style.overflow = "hidden";
945
2598
  style.border = "0";
946
2599
  style.padding = "0";
947
2600
  style.margin = "0";
948
2601
  style.outline = "none";
949
2602
  style.resize = "none";
2603
+ style.boxSizing = "border-box";
2604
+ style.fontSize = "16px";
2605
+ style.lineHeight = "16px";
950
2606
  style.pointerEvents = "none";
2607
+ style.touchAction = "manipulation";
2608
+ style.userSelect = "text";
2609
+ style.setProperty("-webkit-touch-callout", "default");
2610
+ style.setProperty("-webkit-user-select", "text");
951
2611
  style.caretColor = "transparent";
952
2612
  style.color = "transparent";
953
2613
  style.background = "transparent";
2614
+ style.colorScheme = "inherit";
2615
+ suppressNativeTextareaChrome(textarea);
2616
+ }
2617
+ function suppressNativeTextareaChrome(textarea) {
2618
+ const style = textarea.style;
2619
+ style.borderRadius = "0";
2620
+ style.boxShadow = "none";
2621
+ style.textDecoration = "none";
2622
+ style.setProperty("appearance", "none");
2623
+ style.setProperty("-webkit-appearance", "none");
2624
+ style.setProperty("-webkit-text-fill-color", "transparent");
2625
+ style.setProperty("outline-color", "transparent");
2626
+ style.setProperty("text-decoration", "none");
2627
+ style.setProperty("text-decoration-color", "transparent");
2628
+ style.setProperty("text-decoration-line", "none");
2629
+ }
2630
+ function resolveMobileLiveInputAnchorRect(windowObject) {
2631
+ const doc = windowObject.document;
2632
+ if (typeof doc?.querySelector !== "function") return null;
2633
+ const owner = doc.querySelector("[data-terminal-root][data-aitty-input-owner]");
2634
+ if (!isTerminalRootLike(owner)) return null;
2635
+ const rect = resolveLiveInputSurfaceAnchor(owner)?.getBoundingClientRect?.();
2636
+ return rect && hasUsableClientRect(rect) ? rect : null;
954
2637
  }
955
- function syncTerminalTextareaPosition(term, textarea) {
2638
+ function isTerminalRootLike(value) {
2639
+ return typeof value === "object" && value !== null && "querySelectorAll" in value && typeof value.querySelectorAll === "function";
2640
+ }
2641
+ function syncTerminalTextareaPosition(term, textarea, options = {}) {
2642
+ const windowObject = textarea.ownerDocument.defaultView;
956
2643
  const viewport = resolveTextareaViewport(textarea);
957
- const anchorRect = (resolveLiveInputSurfaceAnchor(term.element) ?? term.element).getBoundingClientRect?.();
2644
+ const anchorRect = (options.anchor ?? resolveLiveInputSurfaceAnchor(term.element) ?? term.element).getBoundingClientRect?.();
958
2645
  const fallbackRect = term.element.getBoundingClientRect?.();
959
2646
  const rect = hasUsableClientRect(anchorRect) ? anchorRect : fallbackRect;
960
- if (!hasUsableClientRect(rect)) {
961
- textarea.style.left = "0";
962
- textarea.style.top = "0";
2647
+ if (options.allowViewportAvoidance !== true) {
2648
+ textarea.dataset.aittyImeAnchor = "safe-cursor";
2649
+ textarea.style.width = "2px";
2650
+ textarea.style.height = "2px";
2651
+ textarea.style.opacity = "0.01";
2652
+ textarea.style.padding = "0";
2653
+ textarea.style.fontSize = "16px";
2654
+ textarea.style.lineHeight = "16px";
2655
+ textarea.style.pointerEvents = "none";
2656
+ textarea.style.touchAction = "manipulation";
2657
+ textarea.style.caretColor = "transparent";
2658
+ textarea.style.color = "transparent";
2659
+ textarea.style.background = "transparent";
2660
+ suppressNativeTextareaChrome(textarea);
2661
+ if (!hasUsableClientRect(rect)) {
2662
+ textarea.style.left = `${Math.min(DESKTOP_IME_TEXTAREA_INSET_PX, Math.max(0, viewport.width - 1))}px`;
2663
+ textarea.style.top = `${Math.min(DESKTOP_IME_TEXTAREA_INSET_PX, Math.max(0, viewport.height - 1))}px`;
2664
+ return;
2665
+ }
2666
+ const safeMaxLeft = Math.max(DESKTOP_IME_TEXTAREA_INSET_PX, viewport.width - DESKTOP_IME_CANDIDATE_SAFE_WIDTH_PX);
2667
+ const safeMaxTop = Math.max(DESKTOP_IME_TEXTAREA_INSET_PX, viewport.height - DESKTOP_IME_CANDIDATE_SAFE_HEIGHT_PX);
2668
+ const left = clamp(rect.left, DESKTOP_IME_TEXTAREA_INSET_PX, safeMaxLeft);
2669
+ const top = clamp(rect.top, DESKTOP_IME_TEXTAREA_INSET_PX, safeMaxTop);
2670
+ textarea.style.left = `${left}px`;
2671
+ textarea.style.top = `${top}px`;
963
2672
  return;
964
2673
  }
965
- const left = clamp(rect.left, 0, Math.max(0, viewport.width - 1));
966
- const top = clamp(rect.top, 0, Math.max(0, viewport.height - 1));
2674
+ textarea.dataset.aittyImeAnchor = "mobile-focus-proxy";
2675
+ const proxyPosition = windowObject ? resolveMobileFocusProxyPosition(windowObject) : {
2676
+ anchor: "mobile-focus-proxy",
2677
+ height: MOBILE_FOCUS_PROXY_HEIGHT_PX,
2678
+ left: 0,
2679
+ top: 0,
2680
+ width: MOBILE_FOCUS_PROXY_MIN_WIDTH_PX
2681
+ };
2682
+ const left = clamp(proxyPosition.left, 0, Math.max(0, viewport.width - proxyPosition.width));
2683
+ const top = clamp(proxyPosition.top, 0, Math.max(0, viewport.height - proxyPosition.height));
967
2684
  textarea.style.left = `${left}px`;
968
2685
  textarea.style.top = `${top}px`;
2686
+ textarea.style.width = `${proxyPosition.width}px`;
2687
+ textarea.style.height = `${proxyPosition.height}px`;
2688
+ textarea.style.opacity = MOBILE_FOCUS_PROXY_OPACITY;
2689
+ textarea.style.padding = "0";
2690
+ textarea.style.fontSize = "16px";
2691
+ textarea.style.lineHeight = "16px";
2692
+ const mobileViewport = isCoarseMobileViewport(windowObject ?? window);
2693
+ const keyboardOpen = windowObject ? resolveBrowserViewport(windowObject).keyboardInsetBottom > 0 : false;
2694
+ const textareaFocused = textarea.ownerDocument.activeElement === textarea;
2695
+ const proxyPointerEvents = windowObject && shouldEnableMobileFocusProxyPointerEvents(windowObject, {
2696
+ keyboardOpen,
2697
+ textareaFocused
2698
+ }) && !hasTerminalSelection(term.element);
2699
+ textarea.style.pointerEvents = mobileViewport && proxyPointerEvents ? "auto" : "none";
2700
+ textarea.style.touchAction = "manipulation";
2701
+ textarea.style.caretColor = "transparent";
2702
+ textarea.style.color = "transparent";
2703
+ textarea.style.background = "transparent";
2704
+ suppressNativeTextareaChrome(textarea);
2705
+ }
2706
+ function captureImeScrollLock(windowObject, surface) {
2707
+ const doc = windowObject.document;
2708
+ const targets = /* @__PURE__ */ new Set();
2709
+ if (surface instanceof HTMLElement) targets.add(surface);
2710
+ if (doc?.scrollingElement) targets.add(doc.scrollingElement);
2711
+ if (doc?.documentElement) targets.add(doc.documentElement);
2712
+ if (doc?.body) targets.add(doc.body);
2713
+ return {
2714
+ scrollTargets: Array.from(targets).map((element) => ({
2715
+ element,
2716
+ scrollLeft: element.scrollLeft,
2717
+ scrollTop: element.scrollTop
2718
+ })),
2719
+ windowObject,
2720
+ windowScrollX: normalizeScrollMetric(windowObject.scrollX ?? windowObject.pageXOffset ?? 0),
2721
+ windowScrollY: normalizeScrollMetric(windowObject.scrollY ?? windowObject.pageYOffset ?? 0)
2722
+ };
2723
+ }
2724
+ function restoreImeScrollLock(lock) {
2725
+ for (const target of lock.scrollTargets) {
2726
+ target.element.scrollLeft = target.scrollLeft;
2727
+ target.element.scrollTop = target.scrollTop;
2728
+ }
2729
+ try {
2730
+ lock.windowObject.scrollTo(lock.windowScrollX, lock.windowScrollY);
2731
+ } catch {}
969
2732
  }
970
2733
  function resolveTextareaViewport(textarea) {
971
2734
  const view = textarea.ownerDocument.defaultView;
972
- const root = textarea.ownerDocument.documentElement;
973
- const body = textarea.ownerDocument.body;
974
- const width = firstFinitePositiveNumber(view?.innerWidth, root?.clientWidth, body?.clientWidth) ?? 1;
2735
+ if (view) {
2736
+ const viewport = resolveBrowserViewport(view);
2737
+ return {
2738
+ height: viewport.safeHeight,
2739
+ width: viewport.width
2740
+ };
2741
+ }
975
2742
  return {
976
- height: firstFinitePositiveNumber(view?.innerHeight, root?.clientHeight, body?.clientHeight) ?? 1,
977
- width
2743
+ height: 1,
2744
+ width: 1
978
2745
  };
979
2746
  }
980
- function installBrowserRenderer(term, styleTracker) {
2747
+ function shouldAllowImeViewportAvoidance(windowObject) {
2748
+ const root = windowObject.document?.documentElement;
2749
+ const layoutWidth = firstFinitePositiveNumber(windowObject.innerWidth, root?.clientWidth) ?? 1;
2750
+ const layoutHeight = firstFinitePositiveNumber(windowObject.innerHeight, root?.clientHeight) ?? 1;
2751
+ const visualHeight = windowObject.visualViewport?.height;
2752
+ const viewportShrunkForKeyboard = typeof visualHeight === "number" && Number.isFinite(visualHeight) && visualHeight > 0 && visualHeight < layoutHeight * .85;
2753
+ const primaryPointerIsCoarse = windowObject.matchMedia?.("(pointer: coarse)").matches === true;
2754
+ return viewportShrunkForKeyboard || primaryPointerIsCoarse && layoutWidth <= 1024;
2755
+ }
2756
+ function installBrowserRenderer(term, options = {}) {
981
2757
  const container = term?._container;
982
2758
  if (!(container instanceof HTMLElement)) return;
983
- const renderer = new BrowserTerminalRenderer(container, styleTracker);
2759
+ const renderer = new BrowserTerminalRenderer(container, options);
984
2760
  term.renderer = renderer;
985
2761
  renderer.setup(term.cols, term.rows);
986
2762
  if (term.bridge) renderer.render(term.bridge);
987
2763
  }
988
2764
  function renderTerminalNow(term, options = {}) {
2765
+ if (typeof term?.renderNow === "function") {
2766
+ term.renderNow(options);
2767
+ return;
2768
+ }
989
2769
  const bridge = term?.bridge;
990
2770
  const renderer = term?.renderer;
991
2771
  if (!bridge || !renderer || typeof renderer.render !== "function") return;
992
2772
  renderer.render(bridge, options);
993
2773
  }
994
- function installTerminalViewportSizer(term, terminalRoot, windowObject, onResize) {
995
- const target = resolveViewportSize(term, terminalRoot, windowObject);
996
- applyTerminalElementHeight(terminalRoot, target.rows);
997
- onResize(target.cols, target.rows);
2774
+ function installTerminalViewportSizer(term, terminalRoot, windowObject, onResize, shouldResizeToViewport = () => true, onViewportChange = () => {}) {
2775
+ const resizeToViewport = () => {
2776
+ if (!shouldResizeToViewport()) {
2777
+ onViewportChange();
2778
+ return;
2779
+ }
2780
+ const target = resolveViewportSize(term, terminalRoot, windowObject);
2781
+ applyTerminalElementHeight(terminalRoot, target.rows);
2782
+ onResize(target.cols, target.rows);
2783
+ };
2784
+ resizeToViewport();
998
2785
  let frameHandle = null;
999
2786
  const scheduleResize = () => {
1000
2787
  if (frameHandle !== null) return;
1001
2788
  frameHandle = windowObject.requestAnimationFrame(() => {
1002
2789
  frameHandle = null;
1003
- const nextTarget = resolveViewportSize(term, terminalRoot, windowObject);
1004
- applyTerminalElementHeight(terminalRoot, nextTarget.rows);
1005
- onResize(nextTarget.cols, nextTarget.rows);
2790
+ if (resolveBrowserViewport(windowObject).keyboardInsetBottom > 0) return;
2791
+ resizeToViewport();
1006
2792
  });
1007
2793
  };
1008
2794
  windowObject.addEventListener("resize", scheduleResize);
2795
+ windowObject.visualViewport?.addEventListener("resize", scheduleResize);
2796
+ windowObject.visualViewport?.addEventListener("scroll", scheduleResize);
1009
2797
  return () => {
1010
2798
  if (frameHandle !== null) {
1011
2799
  windowObject.cancelAnimationFrame(frameHandle);
1012
2800
  frameHandle = null;
1013
2801
  }
1014
2802
  windowObject.removeEventListener("resize", scheduleResize);
2803
+ windowObject.visualViewport?.removeEventListener("resize", scheduleResize);
2804
+ windowObject.visualViewport?.removeEventListener("scroll", scheduleResize);
1015
2805
  };
1016
2806
  }
1017
2807
  function resolveViewportSize(term, terminalRoot, windowObject) {
@@ -1027,28 +2817,50 @@ function resolveViewportSize(term, terminalRoot, windowObject) {
1027
2817
  };
1028
2818
  }
1029
2819
  function measureTerminalCell(term, terminalRoot) {
2820
+ const rowHeight = syncTerminalRowHeight(terminalRoot);
1030
2821
  const measured = typeof term?._measureCharSize === "function" ? term._measureCharSize() : null;
1031
- if (measured && Number.isFinite(measured.charWidth) && measured.charWidth > 0 && Number.isFinite(measured.rowHeight) && measured.rowHeight > 0) {
1032
- terminalRoot.style.setProperty("--term-row-height", `${measured.rowHeight}px`);
1033
- return measured;
2822
+ if (measured && Number.isFinite(measured.charWidth) && measured.charWidth > 0 && rowHeight > 0) {
2823
+ terminalRoot.style.setProperty("--term-cell-width", `${measured.charWidth}px`);
2824
+ return {
2825
+ charWidth: measured.charWidth,
2826
+ rowHeight
2827
+ };
1034
2828
  }
1035
- const computedStyle = getComputedStyle(terminalRoot);
1036
- const rowHeight = parseCssPixelValue(computedStyle.getPropertyValue("--term-row-height")) || parseCssPixelValue(computedStyle.lineHeight) || 17;
1037
- const fontSize = parseCssPixelValue(computedStyle.fontSize) || 15;
2829
+ const fontSize = parseCssPixelValue(getComputedStyle(terminalRoot).fontSize) || 15;
2830
+ const charWidth = Math.max(1, fontSize * .6);
2831
+ terminalRoot.style.setProperty("--term-cell-width", `${charWidth}px`);
1038
2832
  return {
1039
- charWidth: Math.max(1, fontSize * .6),
1040
- rowHeight: Math.max(1, rowHeight)
2833
+ charWidth,
2834
+ rowHeight
1041
2835
  };
1042
2836
  }
2837
+ function syncTerminalRowHeight(terminalRoot) {
2838
+ const computedStyle = getComputedStyle(terminalRoot);
2839
+ const fontSize = parseCssPixelValue(computedStyle.fontSize) || DEFAULT_TERMINAL_RENDER_FONT_SIZE;
2840
+ const configuredLineHeight = computedStyle.lineHeight;
2841
+ const rowHeight = configuredLineHeight === "normal" ? Math.ceil(fontSize * 1.2) : Math.ceil(parseCssPixelValue(configuredLineHeight) || fontSize * 1.2);
2842
+ const normalizedRowHeight = Math.max(1, rowHeight);
2843
+ terminalRoot.style.setProperty("--term-row-height", `${normalizedRowHeight}px`);
2844
+ return normalizedRowHeight;
2845
+ }
1043
2846
  function resolveAvailableTerminalViewport(terminalRoot, windowObject) {
1044
2847
  const rect = terminalRoot.getBoundingClientRect?.();
1045
- const width = Number.isFinite(rect?.width) && rect.width > 0 ? rect.width : 0;
1046
- if (width <= 0 || !Number.isFinite(rect?.top)) return null;
1047
- const viewportHeight = Math.max(1, windowObject.innerHeight || terminalRoot.ownerDocument?.documentElement?.clientHeight || 24);
1048
- const top = Math.max(0, rect.top);
1049
- const paddingBottom = parseCssPixelValue(getComputedStyle(terminalRoot).paddingBottom);
1050
- const height = viewportHeight - top - paddingBottom;
1051
- if (!Number.isFinite(height) || height <= 0) return null;
2848
+ const borderBoxWidth = Number.isFinite(rect?.width) && rect.width > 0 ? rect.width : 0;
2849
+ const scrollViewport = terminalRoot.closest("[data-aitty-scroll-viewport]");
2850
+ const scrollViewportRect = scrollViewport instanceof HTMLElement ? scrollViewport.getBoundingClientRect?.() : null;
2851
+ const shell = terminalRoot.closest("[data-shell]");
2852
+ const scrollViewportHeight = scrollViewportRect && hasUsableClientRect(scrollViewportRect) && Number.isFinite(scrollViewportRect.height) && scrollViewportRect.height > 0 ? scrollViewportRect.height : 0;
2853
+ const isDocumentViewport = !scrollViewportHeight || shell?.dataset.fullscreen === "true" || shell?.dataset.viewportFullscreen === "true";
2854
+ if (borderBoxWidth <= 0 || !Number.isFinite(rect?.top)) return null;
2855
+ const viewportHeight = isDocumentViewport ? Math.max(1, resolveBrowserViewport(windowObject).safeHeight) : scrollViewportHeight;
2856
+ const computedStyle = getComputedStyle(terminalRoot);
2857
+ const paddingLeft = parseCssPixelValue(computedStyle.paddingLeft);
2858
+ const paddingRight = parseCssPixelValue(computedStyle.paddingRight);
2859
+ const paddingTop = parseCssPixelValue(computedStyle.paddingTop);
2860
+ const paddingBottom = parseCssPixelValue(computedStyle.paddingBottom);
2861
+ const width = borderBoxWidth - paddingLeft - paddingRight;
2862
+ const height = viewportHeight - (isDocumentViewport ? Math.max(0, rect.top + paddingTop) : paddingTop) - paddingBottom;
2863
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
1052
2864
  return {
1053
2865
  height,
1054
2866
  width
@@ -1061,6 +2873,27 @@ function parseCssPixelValue(value) {
1061
2873
  const parsed = Number.parseFloat(value);
1062
2874
  return Number.isFinite(parsed) ? parsed : 0;
1063
2875
  }
2876
+ function applyTerminalAppearance(shell, terminalRoot, appearance, previousAppearance) {
2877
+ terminalRoot.style.setProperty("--theme-term-font-size", `${appearance.fontSize}px`);
2878
+ terminalRoot.dataset.fontSize = String(appearance.fontSize);
2879
+ shell.dataset.fontSize = String(appearance.fontSize);
2880
+ if (appearance.lineHeight === void 0) terminalRoot.style.removeProperty("--theme-term-line-height");
2881
+ else terminalRoot.style.setProperty("--theme-term-line-height", String(appearance.lineHeight));
2882
+ for (const name of Object.keys(previousAppearance?.cssVars ?? {})) if (!(name in appearance.cssVars)) {
2883
+ terminalRoot.style.removeProperty(name);
2884
+ shell.style.removeProperty(name);
2885
+ }
2886
+ for (const [name, value] of Object.entries(appearance.cssVars)) {
2887
+ terminalRoot.style.setProperty(name, value);
2888
+ shell.style.setProperty(name, value);
2889
+ }
2890
+ syncTerminalRowHeight(terminalRoot);
2891
+ }
2892
+ function parseOptionalNumber(value) {
2893
+ if (value === void 0 || value.trim().length === 0) return;
2894
+ const parsed = Number(value);
2895
+ return Number.isFinite(parsed) ? parsed : void 0;
2896
+ }
1064
2897
  function normalizeResizeDimension(value, fallback) {
1065
2898
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : Math.max(1, fallback);
1066
2899
  }
@@ -1086,7 +2919,6 @@ function syncTerminalSurface(element, transcriptArchive, term, styleTracker, opt
1086
2919
  element.dataset.altScreen = String(resolvedAltScreenState);
1087
2920
  element.dataset.renderMode = resolvedAltScreenState ? "screen" : "transcript";
1088
2921
  transcriptArchive.setScreenModeActive?.(resolvedAltScreenState);
1089
- syncScreenModeScrollbackRows(element, resolvedAltScreenState);
1090
2922
  if (!bridge) {
1091
2923
  delete element.dataset.cursorVisible;
1092
2924
  delete element.dataset.cursorRow;
@@ -1105,7 +2937,9 @@ function syncTerminalSurface(element, transcriptArchive, term, styleTracker, opt
1105
2937
  element.dataset.cursorCol = String(cursor.col + 1);
1106
2938
  }
1107
2939
  function syncTerminalInteractivity(element, term, interactive) {
1108
- element.dataset.sessionInteractive = String(interactive);
2940
+ const nextState = String(interactive);
2941
+ if (element.dataset.sessionInteractive === nextState) return;
2942
+ element.dataset.sessionInteractive = nextState;
1109
2943
  element.setAttribute("aria-disabled", String(!interactive));
1110
2944
  if (interactive) element.removeAttribute("tabindex");
1111
2945
  else element.setAttribute("tabindex", "-1");
@@ -1119,16 +2953,6 @@ function syncTerminalInteractivity(element, term, interactive) {
1119
2953
  textarea.setAttribute("aria-hidden", String(!interactive));
1120
2954
  if (!interactive) textarea.blur();
1121
2955
  }
1122
- element.querySelectorAll(".term-cursor").forEach((cursor) => {
1123
- cursor.toggleAttribute("hidden", !interactive);
1124
- cursor.setAttribute("aria-hidden", String(!interactive));
1125
- });
1126
- }
1127
- function syncScreenModeScrollbackRows(element, screenModeActive) {
1128
- element.querySelectorAll(".term-scrollback-row").forEach((row) => {
1129
- row.toggleAttribute("hidden", screenModeActive);
1130
- row.setAttribute("aria-hidden", String(screenModeActive));
1131
- });
1132
2956
  }
1133
2957
  function containsScreenRedrawControl(text) {
1134
2958
  const escape = String.fromCharCode(27);
@@ -1158,14 +2982,21 @@ function createTranscriptArchive(element, scheduler = {}, options = {}) {
1158
2982
  const requestFrame = scheduler.requestFrame ?? ((callback) => requestBrowserFrame(callback, view));
1159
2983
  const cancelFrame = scheduler.cancelFrame ?? ((handle) => cancelBrowserFrame(handle, view));
1160
2984
  const getCols = options.getCols ?? (() => 80);
1161
- const scrollbackLimit = Math.max(0, options.scrollbackLimit ?? DEFAULT_TRANSCRIPT_SCROLLBACK_LIMIT);
2985
+ const scrollbackLimit = normalizeTranscriptScrollbackLimit(options.scrollbackLimit);
1162
2986
  const getVisibleRows = options.getVisibleRows ?? (() => 24);
1163
2987
  let decoder = new TextDecoder();
1164
- const archive = doc.createElement("pre");
2988
+ const archive = doc.createElement("div");
2989
+ const topSpacer = doc.createElement("div");
2990
+ const visibleArchive = doc.createElement("pre");
2991
+ const bottomSpacer = doc.createElement("div");
1165
2992
  const archiveText = doc.createTextNode("");
1166
2993
  archive.setAttribute("data-terminal-transcript-archive", "");
1167
2994
  archive.className = "term-transcript-archive";
1168
- archive.append(archiveText);
2995
+ topSpacer.className = "term-transcript-spacer term-transcript-spacer-top";
2996
+ visibleArchive.className = "term-transcript-visible";
2997
+ bottomSpacer.className = "term-transcript-spacer term-transcript-spacer-bottom";
2998
+ visibleArchive.append(archiveText);
2999
+ archive.append(topSpacer, visibleArchive, bottomSpacer);
1169
3000
  element.insertBefore(archive, element.firstChild);
1170
3001
  let preservedLines = [];
1171
3002
  const finalizedLines = [];
@@ -1180,9 +3011,14 @@ function createTranscriptArchive(element, scheduler = {}, options = {}) {
1180
3011
  let screenModeActive = false;
1181
3012
  let concealedPreservedArchive = false;
1182
3013
  let screenRedrawArchiveSuppressedUntil = 0;
3014
+ let reconciledDroppedTranscriptIndex = 0;
3015
+ const getScrollSurface = options.getScrollSurface ?? (() => element);
3016
+ const getRowHeight = () => {
3017
+ const computedStyle = getComputedStyle(element);
3018
+ return parseCssPixelValue(computedStyle.getPropertyValue("--term-row-height")) || parseCssPixelValue(computedStyle.lineHeight) || 17;
3019
+ };
1183
3020
  const setPreservedLines = (lines) => {
1184
- const normalizedLines = normalizePreservedTranscriptLines(lines);
1185
- preservedLines = scrollbackLimit > 0 ? normalizedLines.slice(-scrollbackLimit) : [];
3021
+ preservedLines = trimTranscriptLines(normalizePreservedTranscriptLines(lines), scrollbackLimit);
1186
3022
  };
1187
3023
  const syncArchiveVisibility = () => {
1188
3024
  const hasArchivedContent = preservedLines.length > 0 || archiveText.data.length > 0;
@@ -1210,12 +3046,19 @@ function createTranscriptArchive(element, scheduler = {}, options = {}) {
1210
3046
  const syncArchive = () => {
1211
3047
  reconcilePreservedLines();
1212
3048
  const liveLineCount = finalizedLines.length + 1;
1213
- const visibleCapacity = Math.max(1, scrollbackLimit + Math.max(1, getVisibleRows()));
3049
+ const visibleCapacity = scrollbackLimit === null ? Math.max(1, getVisibleRows()) : Math.max(1, scrollbackLimit + Math.max(1, getVisibleRows()));
1214
3050
  const hiddenLineCount = Math.max(0, liveLineCount - visibleCapacity);
1215
- const archivedStart = Math.max(0, hiddenLineCount - Math.min(hiddenLineCount, scrollbackLimit));
3051
+ const archivedStart = Math.max(0, hiddenLineCount - (scrollbackLimit === null ? hiddenLineCount : Math.min(hiddenLineCount, scrollbackLimit)));
1216
3052
  const liveArchiveWindow = finalizedLines.slice(archivedStart, hiddenLineCount);
1217
3053
  const archiveWindow = preservedLines.length > 0 ? [...preservedLines, ...liveArchiveWindow] : liveArchiveWindow;
1218
- archiveText.data = archiveWindow.length > 0 ? `${archiveWindow.join("\n")}\n` : "";
3054
+ renderVirtualTranscriptArchive(archiveWindow, {
3055
+ archiveText,
3056
+ bottomSpacer,
3057
+ rowHeight: getRowHeight(),
3058
+ scrollSurface: getScrollSurface(),
3059
+ topSpacer,
3060
+ visibleArchive
3061
+ });
1219
3062
  archivedLineCount = archiveWindow.length;
1220
3063
  syncTranscriptMetadata(element, preservedLines.length + liveLineCount, archivedLineCount);
1221
3064
  syncArchiveVisibility();
@@ -1223,6 +3066,7 @@ function createTranscriptArchive(element, scheduler = {}, options = {}) {
1223
3066
  const resetLiveTranscript = () => {
1224
3067
  finalizedLines.length = 0;
1225
3068
  currentLine = "";
3069
+ reconciledDroppedTranscriptIndex = 0;
1226
3070
  parserState = "text";
1227
3071
  csiParams = "";
1228
3072
  csiPrivate = "";
@@ -1352,6 +3196,23 @@ function createTranscriptArchive(element, scheduler = {}, options = {}) {
1352
3196
  };
1353
3197
  syncArchive();
1354
3198
  return {
3199
+ appendPreservedLines(lines) {
3200
+ const normalizedLines = normalizePreservedTranscriptLines(lines);
3201
+ if (normalizedLines.length === 0) return;
3202
+ const liveLines = normalizePreservedTranscriptLines([...finalizedLines, currentLine]);
3203
+ const missingLines = [];
3204
+ for (const line of normalizedLines) {
3205
+ if (liveLines[reconciledDroppedTranscriptIndex] === line) {
3206
+ reconciledDroppedTranscriptIndex += 1;
3207
+ continue;
3208
+ }
3209
+ missingLines.push(line);
3210
+ }
3211
+ if (missingLines.length === 0) return;
3212
+ setPreservedLines([...preservedLines, ...missingLines]);
3213
+ concealedPreservedArchive = preservedLines.length > 0;
3214
+ scheduleSync();
3215
+ },
1355
3216
  append(chunk) {
1356
3217
  if (screenModeActive) return;
1357
3218
  const text = decoder.decode(chunk, { stream: true });
@@ -1393,6 +3254,13 @@ function createTranscriptArchive(element, scheduler = {}, options = {}) {
1393
3254
  refresh() {
1394
3255
  scheduleSync();
1395
3256
  },
3257
+ refreshNow() {
3258
+ if (frameHandle !== null) {
3259
+ cancelFrame(frameHandle);
3260
+ frameHandle = null;
3261
+ }
3262
+ syncArchive();
3263
+ },
1396
3264
  revealPreservedArchive() {
1397
3265
  if (!concealedPreservedArchive) return;
1398
3266
  concealedPreservedArchive = false;
@@ -1402,6 +3270,47 @@ function createTranscriptArchive(element, scheduler = {}, options = {}) {
1402
3270
  setScreenModeActive
1403
3271
  };
1404
3272
  }
3273
+ function renderVirtualTranscriptArchive(lines, context) {
3274
+ if (lines.length === 0) {
3275
+ context.archiveText.data = "";
3276
+ context.topSpacer.style.height = "";
3277
+ context.bottomSpacer.style.height = "";
3278
+ context.visibleArchive.dataset.virtualStart = "0";
3279
+ context.visibleArchive.dataset.virtualEnd = "0";
3280
+ return;
3281
+ }
3282
+ const rowHeight = Math.max(1, context.rowHeight);
3283
+ const windowState = resolveVirtualTranscriptWindow({
3284
+ archiveTop: resolveElementScrollOffset(context.visibleArchive.parentElement, context.scrollSurface),
3285
+ rowHeight,
3286
+ scrollTop: context.scrollSurface.scrollTop,
3287
+ thresholdRows: 600,
3288
+ totalRows: lines.length,
3289
+ viewportHeight: context.scrollSurface.clientHeight || rowHeight,
3290
+ overscanRows: 80
3291
+ });
3292
+ if (!windowState.virtualized) {
3293
+ context.archiveText.data = `${lines.join("\n")}\n`;
3294
+ context.topSpacer.style.height = "";
3295
+ context.bottomSpacer.style.height = "";
3296
+ context.visibleArchive.dataset.virtualStart = "0";
3297
+ context.visibleArchive.dataset.virtualEnd = String(lines.length);
3298
+ return;
3299
+ }
3300
+ const visibleLines = lines.slice(windowState.startRow, windowState.endRow);
3301
+ context.archiveText.data = `${visibleLines.join("\n")}\n`;
3302
+ context.topSpacer.style.height = `${windowState.topSpacerHeight}px`;
3303
+ context.bottomSpacer.style.height = `${windowState.bottomSpacerHeight}px`;
3304
+ context.visibleArchive.dataset.virtualStart = String(windowState.startRow);
3305
+ context.visibleArchive.dataset.virtualEnd = String(windowState.endRow);
3306
+ }
3307
+ function resolveElementScrollOffset(element, scrollSurface) {
3308
+ if (!(element instanceof HTMLElement)) return 0;
3309
+ const elementRect = element.getBoundingClientRect?.();
3310
+ const surfaceRect = scrollSurface.getBoundingClientRect?.();
3311
+ if (elementRect && surfaceRect) return Math.max(0, elementRect.top - surfaceRect.top + scrollSurface.scrollTop);
3312
+ return Math.max(0, element.offsetTop ?? 0);
3313
+ }
1405
3314
  function createBrowserTransport(handlers) {
1406
3315
  const pendingMessages = [];
1407
3316
  const encoder = new TextEncoder();
@@ -1423,7 +3332,7 @@ function createBrowserTransport(handlers) {
1423
3332
  return;
1424
3333
  }
1425
3334
  const payload = typeof message.data === "string" ? encoder.encode(message.data) : message.data;
1426
- socket.send(copyToArrayBuffer(payload));
3335
+ socket.send(toWebSocketPayload(payload));
1427
3336
  };
1428
3337
  return {
1429
3338
  close() {
@@ -1507,8 +3416,9 @@ function installScrollbackInteractions(element, callbacks = {}) {
1507
3416
  const nextScrollTop = clamp(scrollSurface.scrollTop + event.deltaY, 0, maxScrollTop);
1508
3417
  if (nextScrollTop === scrollSurface.scrollTop) return;
1509
3418
  onManualScroll({
3419
+ maxScrollTop,
1510
3420
  scrollTop: nextScrollTop,
1511
- maxScrollTop
3421
+ source: "wheel"
1512
3422
  });
1513
3423
  event.preventDefault();
1514
3424
  event.stopPropagation();
@@ -1523,7 +3433,8 @@ function installScrollbackInteractions(element, callbacks = {}) {
1523
3433
  const nextScrollTop = event.key === "PageUp" ? scrollSurface.scrollTop - Math.max(scrollSurface.clientHeight, 1) : maxScrollTop;
1524
3434
  onManualScroll({
1525
3435
  scrollTop: clamp(nextScrollTop, 0, maxScrollTop),
1526
- maxScrollTop
3436
+ maxScrollTop,
3437
+ source: "keyboard"
1527
3438
  });
1528
3439
  setScrollTop(scrollSurface, clamp(nextScrollTop, 0, getScrollbackMax(scrollSurface)));
1529
3440
  return;
@@ -1540,24 +3451,57 @@ function installScrollbackInteractions(element, callbacks = {}) {
1540
3451
  element.removeEventListener("keydown", onKeyDown, true);
1541
3452
  };
1542
3453
  }
1543
- function hasVisibleTerminalPaint(element) {
1544
- const style = getComputedStyle(element);
1545
- return hasVisibleCssPaint(style.backgroundColor) || hasVisibleCssBoxShadow(style.boxShadow);
1546
- }
1547
- function hasVisibleCssBoxShadow(value) {
1548
- return typeof value === "string" && value.trim().length > 0 && value !== "none";
1549
- }
1550
- function hasVisibleCssPaint(value) {
1551
- if (typeof value !== "string") return false;
1552
- const normalizedValue = value.trim().replace(/\s+/g, "").toLowerCase();
1553
- return normalizedValue.length > 0 && normalizedValue !== "transparent" && !/rgba\((?:[^,]+,){3}0(?:\.0+)?\)/.test(normalizedValue);
3454
+ function installTerminalFocusInteractions(shell, terminalRoot, callbacks) {
3455
+ const doc = shell.ownerDocument;
3456
+ const focusTerminal = ({ alignToPrompt = false } = {}) => {
3457
+ if (terminalRoot.dataset.sessionInteractive === "false") return;
3458
+ callbacks.focusTerminal({ alignToPrompt });
3459
+ };
3460
+ const onPointerDown = (event) => {
3461
+ if (shouldIgnoreTerminalFocusPointer(event, shell, terminalRoot)) return;
3462
+ const view = shell.ownerDocument.defaultView;
3463
+ if (view && shouldAllowImeViewportAvoidance(view)) return;
3464
+ focusTerminal();
3465
+ };
3466
+ const onKeyDownCapture = (event) => {
3467
+ if (!isShellFullscreenLike(shell) || event.defaultPrevented || isEditableEventTarget(event.target) || isCopyShortcutWithShellSelection(shell, event)) return;
3468
+ if (isElementInside(event.target, terminalRoot)) return;
3469
+ const data = callbacks.resolveKeyData(event);
3470
+ if (data === null) return;
3471
+ event.preventDefault();
3472
+ event.stopPropagation();
3473
+ focusTerminal({ alignToPrompt: true });
3474
+ callbacks.sendData(data);
3475
+ };
3476
+ shell.addEventListener("pointerdown", onPointerDown, true);
3477
+ doc.addEventListener("keydown", onKeyDownCapture, true);
3478
+ return () => {
3479
+ shell.removeEventListener("pointerdown", onPointerDown, true);
3480
+ doc.removeEventListener("keydown", onKeyDownCapture, true);
3481
+ };
1554
3482
  }
1555
3483
  function getScrollbackMax(element) {
1556
3484
  return Math.max(0, element.scrollHeight - element.clientHeight);
1557
3485
  }
1558
- function resolveScrollSurface(doc, fallbackElement, scrollOptions) {
3486
+ function setScrollSurfaceScrollTop(element, scrollTop) {
3487
+ const doc = element.ownerDocument;
3488
+ const view = doc.defaultView;
3489
+ if (!(element === doc.scrollingElement || element === doc.documentElement || element === doc.body) || !view) {
3490
+ element.scrollTop = scrollTop;
3491
+ return;
3492
+ }
3493
+ element.scrollTop = scrollTop;
3494
+ try {
3495
+ view.scrollTo(view.scrollX ?? view.pageXOffset ?? 0, scrollTop);
3496
+ } catch {
3497
+ element.scrollTop = scrollTop;
3498
+ }
3499
+ }
3500
+ function resolveScrollSurface(doc, shell, fallbackElement, scrollOptions) {
1559
3501
  const configuredViewport = scrollOptions?.getViewport?.() ?? scrollOptions?.viewport;
1560
3502
  if (configuredViewport instanceof HTMLElement) return configuredViewport;
3503
+ const shellViewport = shell.querySelector("[data-aitty-scroll-viewport]");
3504
+ if (shellViewport instanceof HTMLElement) return shellViewport;
1561
3505
  const scrollingElement = doc.scrollingElement;
1562
3506
  if (scrollingElement instanceof HTMLElement) return scrollingElement;
1563
3507
  if (doc.documentElement instanceof HTMLElement) return doc.documentElement;
@@ -1586,6 +3530,91 @@ function getScrollMetrics(element) {
1586
3530
  thumbSizeRatio
1587
3531
  };
1588
3532
  }
3533
+ function captureScrollSurfaceAnchor(element, shell) {
3534
+ const maxScrollTop = getScrollbackMax(element);
3535
+ const scrollTop = normalizeScrollMetric(element.scrollTop);
3536
+ const rowAnchor = captureVisibleTerminalRowAnchor(element, shell);
3537
+ const inputBottomOffset = captureLiveInputBottomOffset(element, shell);
3538
+ const stickToBottom = isAtScrollbackBottom(scrollTop, maxScrollTop) || isLiveInputSurfaceAtBottom(shell, element);
3539
+ return {
3540
+ inputBottomOffset,
3541
+ maxScrollTop,
3542
+ ratio: maxScrollTop <= 0 ? 0 : clamp(scrollTop / maxScrollTop, 0, 1),
3543
+ row: rowAnchor ?? void 0,
3544
+ scrollTop,
3545
+ stickToBottom
3546
+ };
3547
+ }
3548
+ function restoreScrollSurfaceAnchor(element, shell, anchor) {
3549
+ const maxScrollTop = getScrollbackMax(element);
3550
+ setScrollSurfaceScrollTop(element, resolveScrollAnchorRestoreTop({
3551
+ anchoredScrollTop: anchor.row ? resolveScrollTopForRowAnchor(element, shell, anchor.row) : null,
3552
+ inputAnchoredScrollTop: anchor.inputBottomOffset === null ? null : resolveScrollTopForLiveInputBottomOffset(element, shell, anchor.inputBottomOffset),
3553
+ maxScrollTop,
3554
+ ratio: anchor.ratio,
3555
+ scrollTop: anchor.scrollTop,
3556
+ stickToBottom: anchor.stickToBottom
3557
+ }));
3558
+ }
3559
+ function captureLiveInputBottomOffset(element, shell) {
3560
+ const anchor = resolveLiveInputSurfaceAnchor(shell);
3561
+ const scrollRect = element.getBoundingClientRect?.() ?? null;
3562
+ const anchorRect = anchor?.getBoundingClientRect?.() ?? null;
3563
+ if (!anchor || scrollRect === null || anchorRect === null || !hasUsableClientRect(scrollRect) || !hasUsableClientRect(anchorRect)) return null;
3564
+ return scrollRect.bottom - anchorRect.bottom;
3565
+ }
3566
+ function resolveScrollTopForLiveInputBottomOffset(element, shell, inputBottomOffset) {
3567
+ const anchor = resolveLiveInputSurfaceAnchor(shell);
3568
+ const scrollRect = element.getBoundingClientRect?.() ?? null;
3569
+ const anchorRect = anchor?.getBoundingClientRect?.() ?? null;
3570
+ if (!anchor || scrollRect === null || anchorRect === null || !hasUsableClientRect(scrollRect) || !hasUsableClientRect(anchorRect)) return null;
3571
+ return element.scrollTop + anchorRect.bottom - (scrollRect.bottom - inputBottomOffset);
3572
+ }
3573
+ function captureVisibleTerminalRowAnchor(element, shell) {
3574
+ const scrollRect = element.getBoundingClientRect();
3575
+ if (!hasUsableClientRect(scrollRect)) return null;
3576
+ const topBoundary = Math.max(scrollRect.top, getViewportTopBoundary(shell));
3577
+ const rows = Array.from(shell.querySelectorAll(".term-grid .term-row"));
3578
+ const seenTexts = /* @__PURE__ */ new Map();
3579
+ let firstVisibleRow = null;
3580
+ for (const row of rows) {
3581
+ if (!(row instanceof HTMLElement)) continue;
3582
+ const text = row.textContent?.trimEnd() ?? "";
3583
+ const occurrenceIndex = seenTexts.get(text) ?? 0;
3584
+ seenTexts.set(text, occurrenceIndex + 1);
3585
+ const rect = row.getBoundingClientRect();
3586
+ if (!hasUsableClientRect(rect) || rect.bottom < topBoundary) continue;
3587
+ const rowAnchor = {
3588
+ offsetTop: rect.top - topBoundary,
3589
+ occurrenceIndex,
3590
+ row,
3591
+ text
3592
+ };
3593
+ if (text.length > 0) return rowAnchor;
3594
+ firstVisibleRow = firstVisibleRow ?? rowAnchor;
3595
+ }
3596
+ return firstVisibleRow;
3597
+ }
3598
+ function resolveScrollTopForRowAnchor(element, shell, anchor) {
3599
+ const row = anchor.row.isConnected ? anchor.row : resolveAnchoredTerminalRow(shell, anchor);
3600
+ if (!element.isConnected || !row) return null;
3601
+ const scrollRect = element.getBoundingClientRect();
3602
+ const rowRect = row.getBoundingClientRect();
3603
+ if (!hasUsableClientRect(scrollRect) || !hasUsableClientRect(rowRect)) return null;
3604
+ const topBoundary = Math.max(scrollRect.top, getViewportTopBoundary(shell));
3605
+ return element.scrollTop + rowRect.top - topBoundary - anchor.offsetTop;
3606
+ }
3607
+ function resolveAnchoredTerminalRow(shell, anchor) {
3608
+ if (anchor.text.length === 0) return null;
3609
+ let occurrenceIndex = 0;
3610
+ for (const row of shell.querySelectorAll(".term-grid .term-row")) {
3611
+ if (!(row instanceof HTMLElement)) continue;
3612
+ if ((row.textContent?.trimEnd() ?? "") !== anchor.text) continue;
3613
+ if (occurrenceIndex === anchor.occurrenceIndex) return row;
3614
+ occurrenceIndex += 1;
3615
+ }
3616
+ return null;
3617
+ }
1589
3618
  function normalizeScrollMetric(value) {
1590
3619
  return Number.isFinite(value) ? Math.max(0, value) : 0;
1591
3620
  }
@@ -1599,7 +3628,7 @@ function installScrollMetricsObserver(element, onScroll) {
1599
3628
  };
1600
3629
  }
1601
3630
  function isAtScrollbackBottom(scrollTop, maxScrollTop) {
1602
- return maxScrollTop <= 0 || scrollTop >= maxScrollTop - 1;
3631
+ return isScrollAtBottom(scrollTop, maxScrollTop);
1603
3632
  }
1604
3633
  function clamp(value, min, max) {
1605
3634
  return Math.min(max, Math.max(min, value));
@@ -1607,6 +3636,37 @@ function clamp(value, min, max) {
1607
3636
  function isScrollbackShortcut(event) {
1608
3637
  return event.shiftKey && (event.key === "PageUp" || event.key === "PageDown");
1609
3638
  }
3639
+ function isShellFullscreenLike(shell) {
3640
+ return shell.dataset.fullscreen === "true" || shell.dataset.viewportFullscreen === "true";
3641
+ }
3642
+ function shouldIgnoreTerminalFocusPointer(event, shell, terminalRoot) {
3643
+ if (event.button !== 0 || event.defaultPrevented) return true;
3644
+ const target = event.target;
3645
+ if (!isElementInside(target, shell) || isInteractiveEventTarget(target)) return true;
3646
+ const selection = shell.ownerDocument.getSelection?.();
3647
+ if (selection && !selection.isCollapsed && isElementInside(target, terminalRoot)) return true;
3648
+ return false;
3649
+ }
3650
+ function isElementInside(target, element) {
3651
+ return target instanceof Node && element.contains(target);
3652
+ }
3653
+ function isInteractiveEventTarget(target) {
3654
+ if (!(target instanceof Element)) return false;
3655
+ return target.closest("button, a, input, textarea, select, option, [contenteditable=\"\"], [contenteditable=\"true\"], [data-shell-control]") !== null;
3656
+ }
3657
+ function isEditableEventTarget(target) {
3658
+ if (!(target instanceof Element)) return false;
3659
+ return target.closest("input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"]") !== null;
3660
+ }
3661
+ function isCopyShortcutWithShellSelection(shell, event) {
3662
+ if (event.altKey || event.key.toLowerCase() !== "c" || !(event.metaKey || event.ctrlKey)) return false;
3663
+ const selection = shell.ownerDocument.getSelection?.();
3664
+ if (!selection || selection.isCollapsed || !selection.toString()) return false;
3665
+ return isNodeInside(selection.anchorNode, shell) && isNodeInside(selection.focusNode, shell);
3666
+ }
3667
+ function isNodeInside(node, element) {
3668
+ return node !== null && element.contains(node);
3669
+ }
1610
3670
  function shouldPreserveVisiblePromptOnInput(event) {
1611
3671
  if (!(event instanceof KeyboardEvent)) return false;
1612
3672
  if (event.altKey || event.ctrlKey || event.metaKey) return false;
@@ -1616,15 +3676,201 @@ function shouldFollowLiveOutputOnInput(event) {
1616
3676
  if (!(event instanceof KeyboardEvent)) return false;
1617
3677
  return !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && event.key === "Enter";
1618
3678
  }
3679
+ function shouldFollowLiveOutputOnUserInputData(data) {
3680
+ if (typeof data === "string") return data.includes("\r") || data.includes("\n");
3681
+ return data.includes(13) || data.includes(10);
3682
+ }
3683
+ function resolveMobileComposerControlSequence(control) {
3684
+ if (control === "enter") return "\r";
3685
+ if (control === "esc") return "\x1B";
3686
+ if (control === "tab") return " ";
3687
+ if (control === "arrow-up") return "\x1B[A";
3688
+ if (control === "arrow-down") return "\x1B[B";
3689
+ if (control === "arrow-right") return "\x1B[C";
3690
+ return "\x1B[D";
3691
+ }
3692
+ function isMobileComposerDirectionControl(control) {
3693
+ return control === "arrow-down" || control === "arrow-left" || control === "arrow-right" || control === "arrow-up";
3694
+ }
3695
+ function isLiveInputSurfaceAtBottom(shell, scrollSurface) {
3696
+ const terminalRoot = shell.querySelector("[data-terminal-root]");
3697
+ if (!(terminalRoot instanceof HTMLElement)) return false;
3698
+ const anchor = resolveLiveInputSurfaceAnchor(terminalRoot);
3699
+ if (!(anchor instanceof HTMLElement)) return false;
3700
+ const rect = anchor.getBoundingClientRect();
3701
+ if (!hasUsableClientRect(rect)) return false;
3702
+ const bottomBoundary = getScrollSurfaceBottomBoundary(scrollSurface, shell.ownerDocument?.defaultView ?? window);
3703
+ return rect.bottom >= bottomBoundary - 96;
3704
+ }
3705
+ function alignLiveInputSurfaceToBottom(scrollSurface, shell, terminalRoot, windowObject) {
3706
+ const anchor = resolveLiveInputSurfaceAnchor(terminalRoot);
3707
+ if (!(anchor instanceof HTMLElement)) return;
3708
+ const rect = anchor.getBoundingClientRect();
3709
+ if (!hasUsableClientRect(rect)) return;
3710
+ const topBoundary = getViewportTopBoundary(shell);
3711
+ const bottomBoundary = getScrollSurfaceBottomBoundary(scrollSurface, windowObject);
3712
+ const targetBottom = Math.max(topBoundary + 1, bottomBoundary - 24);
3713
+ if (rect.bottom <= targetBottom && rect.top >= topBoundary) return;
3714
+ setScrollSurfaceScrollTop(scrollSurface, clamp(scrollSurface.scrollTop + rect.bottom - targetBottom, 0, getScrollbackMax(scrollSurface)));
3715
+ }
3716
+ function alignLiveInputSurfaceIntoViewport(shell, terminalRoot, windowObject, scrollSurface) {
3717
+ if (!shouldAlignDocumentViewportForShell(shell, scrollSurface)) return;
3718
+ const anchor = resolveLiveInputSurfaceAnchor(terminalRoot);
3719
+ if (!(anchor instanceof HTMLElement)) return;
3720
+ const rect = anchor.getBoundingClientRect();
3721
+ if (!hasUsableClientRect(rect)) return;
3722
+ const topBoundary = getViewportTopBoundary(shell);
3723
+ const bottomBoundary = getVisibleViewportBottomBoundary(windowObject);
3724
+ const targetBottom = Math.max(topBoundary + 1, bottomBoundary - 24);
3725
+ const inputGap = targetBottom - rect.bottom;
3726
+ const inputTooFarAboveKeyboard = resolveBrowserViewport(windowObject).keyboardInsetBottom > 0 && inputGap > LIVE_INPUT_KEYBOARD_MAX_GAP_PX;
3727
+ if (rect.bottom <= targetBottom && rect.top >= topBoundary && !inputTooFarAboveKeyboard) return;
3728
+ const currentScrollY = normalizeScrollMetric(windowObject.scrollY ?? windowObject.pageYOffset ?? 0);
3729
+ const maxScrollY = getDocumentScrollMax(windowObject);
3730
+ const nextScrollY = clamp(currentScrollY + rect.bottom - targetBottom, 0, maxScrollY);
3731
+ if (Math.abs(nextScrollY - currentScrollY) < 1) return;
3732
+ try {
3733
+ windowObject.scrollTo(windowObject.scrollX ?? windowObject.pageXOffset ?? 0, nextScrollY);
3734
+ } catch {
3735
+ const scrollingElement = windowObject.document?.scrollingElement;
3736
+ if (scrollingElement instanceof HTMLElement) scrollingElement.scrollTop = nextScrollY;
3737
+ }
3738
+ }
3739
+ function shouldAlignDocumentViewportForShell(shell, scrollSurface) {
3740
+ const doc = shell.ownerDocument;
3741
+ const isDocumentScrollSurface = scrollSurface === doc.scrollingElement || scrollSurface === doc.documentElement || scrollSurface === doc.body;
3742
+ if (shell.dataset.viewportFullscreen === "true") return false;
3743
+ return isDocumentScrollSurface || shell.dataset.keyboardOpen === "true" || shell.dataset.fullscreen === "true" || shell.dataset.viewportFullscreen === "true";
3744
+ }
3745
+ function getDocumentScrollMax(windowObject) {
3746
+ const doc = windowObject.document;
3747
+ const scrollingElement = doc?.scrollingElement;
3748
+ if (scrollingElement instanceof HTMLElement) return getScrollbackMax(scrollingElement);
3749
+ const documentElement = doc?.documentElement;
3750
+ const body = doc?.body;
3751
+ const scrollHeight = Math.max(normalizeScrollMetric(documentElement?.scrollHeight ?? 0), normalizeScrollMetric(body?.scrollHeight ?? 0));
3752
+ const clientHeight = Math.max(normalizeScrollMetric(documentElement?.clientHeight ?? 0), normalizeScrollMetric(body?.clientHeight ?? 0), normalizeScrollMetric(windowObject.innerHeight ?? 0));
3753
+ return Math.max(0, scrollHeight - clientHeight);
3754
+ }
3755
+ function getScrollSurfaceBottomBoundary(scrollSurface, windowObject) {
3756
+ const scrollRect = scrollSurface.getBoundingClientRect();
3757
+ const doc = scrollSurface.ownerDocument;
3758
+ const isDocumentScrollSurface = scrollSurface === doc.scrollingElement || scrollSurface === doc.documentElement || scrollSurface === doc.body;
3759
+ const viewportBottom = getVisibleViewportBottomBoundary(windowObject);
3760
+ if (!isDocumentScrollSurface && hasUsableClientRect(scrollRect) && scrollRect.height > 0) return Math.min(scrollRect.bottom, viewportBottom);
3761
+ return viewportBottom;
3762
+ }
1619
3763
  function isLiveInputSurfaceVisible(shell, terminalRoot, windowObject) {
1620
3764
  const anchor = resolveLiveInputSurfaceAnchor(terminalRoot);
1621
3765
  if (!(anchor instanceof HTMLElement)) return false;
1622
3766
  const rect = anchor.getBoundingClientRect();
1623
3767
  if (!hasUsableClientRect(rect)) return false;
1624
3768
  const topBoundary = getViewportTopBoundary(shell);
1625
- const bottomBoundary = getViewportBottomBoundary(windowObject);
3769
+ const bottomBoundary = getVisibleViewportBottomBoundary(windowObject);
1626
3770
  return rect.bottom > topBoundary + 1 && rect.top < bottomBoundary - 1;
1627
3771
  }
3772
+ function isLiveInputSurfaceSafelyAboveViewportBottom(shell, terminalRoot, windowObject) {
3773
+ const anchor = resolveLiveInputSurfaceAnchor(terminalRoot);
3774
+ if (!(anchor instanceof HTMLElement)) return false;
3775
+ const rect = anchor.getBoundingClientRect();
3776
+ if (!hasUsableClientRect(rect)) return false;
3777
+ const topBoundary = getViewportTopBoundary(shell);
3778
+ const bottomBoundary = getVisibleViewportBottomBoundary(windowObject);
3779
+ const inputGap = bottomBoundary - rect.bottom;
3780
+ const keyboardOpen = resolveBrowserViewport(windowObject).keyboardInsetBottom > 0;
3781
+ return rect.top >= topBoundary && rect.bottom <= bottomBoundary - 24 && (!keyboardOpen || inputGap <= LIVE_INPUT_KEYBOARD_MAX_GAP_PX);
3782
+ }
3783
+ function shouldInstallViewportDebugOverlay(windowObject, shell) {
3784
+ if (shell.dataset.aittyViewportDebug === "true") return true;
3785
+ try {
3786
+ return new URLSearchParams(windowObject.location?.search ?? "").has("aittyViewportDebug");
3787
+ } catch {
3788
+ return false;
3789
+ }
3790
+ }
3791
+ function installViewportDebugOverlay(shell, terminalRoot, getScrollSurface, windowObject) {
3792
+ const doc = shell.ownerDocument;
3793
+ const overlay = doc.createElement("pre");
3794
+ let frameHandle = null;
3795
+ let observedScrollSurface = null;
3796
+ overlay.className = "aitty-viewport-debug";
3797
+ overlay.setAttribute("aria-hidden", "true");
3798
+ doc.body?.append(overlay);
3799
+ const render = () => {
3800
+ frameHandle = null;
3801
+ const viewport = resolveBrowserViewport(windowObject);
3802
+ const layoutHeight = largestFinitePositiveNumber(windowObject.innerHeight, windowObject.document?.documentElement?.clientHeight) ?? firstFinitePositiveNumber(windowObject.document?.body?.clientHeight) ?? 0;
3803
+ const visualViewport = windowObject.visualViewport;
3804
+ const scrollSurface = getScrollSurface();
3805
+ if (scrollSurface !== observedScrollSurface) {
3806
+ observedScrollSurface?.removeEventListener("scroll", schedule);
3807
+ observedScrollSurface = scrollSurface;
3808
+ observedScrollSurface.addEventListener("scroll", schedule);
3809
+ }
3810
+ const scrollRect = scrollSurface.getBoundingClientRect?.();
3811
+ const anchorRect = resolveLiveInputSurfaceAnchor(terminalRoot)?.getBoundingClientRect?.() ?? null;
3812
+ const anchorBottom = anchorRect && hasUsableClientRect(anchorRect) ? anchorRect.bottom : NaN;
3813
+ const safeViewportBottom = getViewportBottomBoundary(windowObject);
3814
+ const viewportBottom = getVisibleViewportBottomBoundary(windowObject);
3815
+ const inputGap = Number.isFinite(anchorBottom) ? viewportBottom - anchorBottom : NaN;
3816
+ const activeElement = windowObject.document?.activeElement;
3817
+ const activeElementLabel = activeElement instanceof HTMLElement ? `${activeElement.tagName.toLowerCase()}${activeElement.hasAttribute("data-aitty-input") ? "[aitty-input]" : ""}` : "none";
3818
+ overlay.textContent = [
3819
+ "aitty viewport debug",
3820
+ `ua: ${navigator.userAgent}`,
3821
+ `ios: ${String(isLikelyIosBrowser(windowObject))}`,
3822
+ `standalone: ${String(isStandaloneDisplayMode(windowObject))}`,
3823
+ `fullscreen: ${shell.dataset.fullscreen === "true" ? "system" : "false"}`,
3824
+ `viewportFullscreen: ${shell.dataset.viewportFullscreen === "true"}`,
3825
+ `active: ${activeElementLabel}`,
3826
+ `inner: ${formatNumber(windowObject.innerWidth)} x ${formatNumber(windowObject.innerHeight)}`,
3827
+ `docEl: ${formatNumber(windowObject.document?.documentElement?.clientWidth)} x ${formatNumber(windowObject.document?.documentElement?.clientHeight)}`,
3828
+ `layoutH: ${formatNumber(layoutHeight)}`,
3829
+ `vv: ${formatNumber(visualViewport?.width)} x ${formatNumber(visualViewport?.height)} @ top ${formatNumber(visualViewport?.offsetTop)}`,
3830
+ `safeH: ${formatNumber(viewport.safeHeight)}`,
3831
+ `kbd: ${formatNumber(viewport.keyboardInsetBottom)}`,
3832
+ `chrome: ${formatNumber(viewport.browserChromeInsetBottom)}`,
3833
+ `bottom: ${formatNumber(viewportBottom)}`,
3834
+ `safeBottom: ${formatNumber(safeViewportBottom)}`,
3835
+ `scroll: top ${formatNumber(scrollSurface.scrollTop)} / max ${formatNumber(getScrollbackMax(scrollSurface))}`,
3836
+ `scrollRect: ${formatNumber(scrollRect?.top)}..${formatNumber(scrollRect?.bottom)} h ${formatNumber(scrollRect?.height)}`,
3837
+ `inputBottom: ${formatNumber(anchorBottom)}`,
3838
+ `inputGap: ${formatNumber(inputGap)}`
3839
+ ].join("\n");
3840
+ };
3841
+ const schedule = () => {
3842
+ if (frameHandle !== null) return;
3843
+ frameHandle = windowObject.requestAnimationFrame(render);
3844
+ };
3845
+ const observer = new MutationObserver(schedule);
3846
+ observer.observe(terminalRoot, {
3847
+ attributes: true,
3848
+ childList: true,
3849
+ subtree: true
3850
+ });
3851
+ render();
3852
+ windowObject.addEventListener("resize", schedule);
3853
+ windowObject.addEventListener("scroll", schedule, true);
3854
+ windowObject.visualViewport?.addEventListener("resize", schedule);
3855
+ windowObject.visualViewport?.addEventListener("scroll", schedule);
3856
+ return () => {
3857
+ if (frameHandle !== null) {
3858
+ windowObject.cancelAnimationFrame(frameHandle);
3859
+ frameHandle = null;
3860
+ }
3861
+ observer.disconnect();
3862
+ windowObject.removeEventListener("resize", schedule);
3863
+ windowObject.removeEventListener("scroll", schedule, true);
3864
+ windowObject.visualViewport?.removeEventListener("resize", schedule);
3865
+ windowObject.visualViewport?.removeEventListener("scroll", schedule);
3866
+ observedScrollSurface?.removeEventListener("scroll", schedule);
3867
+ observedScrollSurface = null;
3868
+ overlay.remove();
3869
+ };
3870
+ }
3871
+ function formatNumber(value) {
3872
+ return typeof value === "number" && Number.isFinite(value) ? String(Math.round(value)) : "n/a";
3873
+ }
1628
3874
  function resolveLiveInputSurfaceAnchor(terminalRoot) {
1629
3875
  const visibleCursors = Array.from(terminalRoot.querySelectorAll(".term-grid .term-cursor")).filter((cursor) => cursor instanceof HTMLElement && !cursor.hidden && cursor.getAttribute("aria-hidden") !== "true");
1630
3876
  if (visibleCursors.length > 0) return visibleCursors[visibleCursors.length - 1];
@@ -1649,18 +3895,38 @@ function getViewportTopBoundary(shell) {
1649
3895
  return 0;
1650
3896
  }
1651
3897
  function getViewportBottomBoundary(windowObject) {
1652
- const candidates = [
1653
- windowObject?.innerHeight,
1654
- windowObject?.document?.documentElement?.clientHeight,
1655
- windowObject?.document?.body?.clientHeight
1656
- ];
1657
- for (const candidate of candidates) if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) return candidate;
1658
- return Number.POSITIVE_INFINITY;
3898
+ const viewport = resolveBrowserViewport(windowObject);
3899
+ return viewport.offsetTop + viewport.safeHeight;
3900
+ }
3901
+ function getVisibleViewportBottomBoundary(windowObject) {
3902
+ const viewport = resolveBrowserViewport(windowObject);
3903
+ return viewport.offsetTop + viewport.height - viewport.browserChromeInsetBottom;
1659
3904
  }
1660
3905
  function firstFinitePositiveNumber(...candidates) {
1661
3906
  for (const candidate of candidates) if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) return candidate;
1662
3907
  return null;
1663
3908
  }
3909
+ function largestFinitePositiveNumber(...candidates) {
3910
+ const values = candidates.filter((candidate) => typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0);
3911
+ return values.length > 0 ? Math.max(...values) : null;
3912
+ }
3913
+ function resolveBrowserChromeKeyboardGuard(windowObject, keyboardVisible) {
3914
+ if (isStandaloneDisplayMode(windowObject) || !isLikelyIosBrowser(windowObject)) return 0;
3915
+ return IOS_BROWSER_CHROME_KEYBOARD_GUARD_PX;
3916
+ }
3917
+ function isStandaloneDisplayMode(windowObject) {
3918
+ return windowObject.navigator?.standalone === true || windowObject.matchMedia?.("(display-mode: standalone)").matches === true || windowObject.matchMedia?.("(display-mode: fullscreen)").matches === true;
3919
+ }
3920
+ function isLikelyIosBrowser(windowObject) {
3921
+ const navigatorLike = windowObject.navigator;
3922
+ const platform = navigatorLike?.platform ?? "";
3923
+ const userAgent = navigatorLike?.userAgent ?? "";
3924
+ const maxTouchPoints = navigatorLike?.maxTouchPoints ?? 0;
3925
+ return /iP(?:hone|ad|od)/.test(platform) || /iP(?:hone|ad|od)/.test(userAgent) || platform === "MacIntel" && maxTouchPoints > 1;
3926
+ }
3927
+ function normalizeViewportOffset(value) {
3928
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
3929
+ }
1664
3930
  function hasUsableClientRect(rect) {
1665
3931
  if (!rect) return false;
1666
3932
  return Number.isFinite(rect.top) && Number.isFinite(rect.bottom) && Number.isFinite(rect.left) && Number.isFinite(rect.right) && !(rect.top === 0 && rect.bottom === 0 && rect.left === 0 && rect.right === 0);
@@ -1721,11 +3987,12 @@ function getOptionalElement(doc, selector) {
1721
3987
  function hasElementOverride(elements, key) {
1722
3988
  return Object.prototype.hasOwnProperty.call(elements, key);
1723
3989
  }
1724
- function createTerminalThemeController(doc, shell, terminalRoot, theme) {
3990
+ function createTerminalThemeController(doc, shell, terminalRoot, theme, target) {
1725
3991
  const explicitTheme = normalizeThemeName(theme);
1726
3992
  let observingTheme = false;
3993
+ const usesDocumentTheme = target === "document";
1727
3994
  const clearProtocolOverrides = () => {
1728
- clearTerminalThemeProtocolOverrides(doc.documentElement);
3995
+ if (usesDocumentTheme) clearTerminalThemeProtocolOverrides(doc.documentElement);
1729
3996
  clearTerminalThemeProtocolOverrides(shell);
1730
3997
  clearTerminalThemeProtocolOverrides(terminalRoot);
1731
3998
  };
@@ -1752,17 +4019,26 @@ function createTerminalThemeController(doc, shell, terminalRoot, theme) {
1752
4019
  const syncTheme = (nextTheme) => {
1753
4020
  const configuredTheme = normalizeThemeName(nextTheme);
1754
4021
  if (!configuredTheme) {
1755
- if (doc.documentElement.dataset.theme !== void 0) delete doc.documentElement.dataset.theme;
1756
- doc.documentElement.style.removeProperty("color-scheme");
4022
+ if (usesDocumentTheme && doc.documentElement.dataset.theme !== void 0) delete doc.documentElement.dataset.theme;
4023
+ if (usesDocumentTheme) doc.documentElement.style.removeProperty("color-scheme");
4024
+ shell.style.removeProperty("color-scheme");
1757
4025
  delete shell.dataset.theme;
1758
4026
  delete terminalRoot.dataset.theme;
1759
4027
  return;
1760
4028
  }
1761
- if (configuredTheme === "light" || configuredTheme === "dark") doc.documentElement.style.setProperty("color-scheme", configuredTheme);
4029
+ const colorScheme = configuredTheme === "light" || configuredTheme === "dark" ? configuredTheme : void 0;
4030
+ if (usesDocumentTheme) if (colorScheme) doc.documentElement.style.setProperty("color-scheme", colorScheme);
1762
4031
  else doc.documentElement.style.removeProperty("color-scheme");
1763
- if (doc.documentElement.dataset.theme !== configuredTheme) doc.documentElement.dataset.theme = configuredTheme;
4032
+ if (colorScheme) shell.style.setProperty("color-scheme", colorScheme);
4033
+ else shell.style.removeProperty("color-scheme");
4034
+ if (usesDocumentTheme && doc.documentElement.dataset.theme !== configuredTheme) doc.documentElement.dataset.theme = configuredTheme;
1764
4035
  if (shell.dataset.theme !== configuredTheme) shell.dataset.theme = configuredTheme;
1765
4036
  if (terminalRoot.dataset.theme !== configuredTheme) terminalRoot.dataset.theme = configuredTheme;
4037
+ const textarea = resolveTerminalTextarea(terminalRoot);
4038
+ if (textarea) {
4039
+ const themeValue = configuredTheme === "light" || configuredTheme === "dark" ? configuredTheme : "inherit";
4040
+ textarea.style.colorScheme = themeValue;
4041
+ }
1766
4042
  };
1767
4043
  const syncInternalTheme = (nextTheme) => {
1768
4044
  pauseThemeObservation();
@@ -1780,7 +4056,7 @@ function createTerminalThemeController(doc, shell, terminalRoot, theme) {
1780
4056
  mutationObserver?.disconnect();
1781
4057
  },
1782
4058
  getTheme() {
1783
- return doc.documentElement.dataset.theme;
4059
+ return shell.dataset.theme ?? doc.documentElement.dataset.theme;
1784
4060
  },
1785
4061
  syncProtocolUpdate(update) {
1786
4062
  let themeChanged = false;
@@ -1789,7 +4065,7 @@ function createTerminalThemeController(doc, shell, terminalRoot, theme) {
1789
4065
  syncInternalTheme(update.theme);
1790
4066
  themeChanged = true;
1791
4067
  }
1792
- applyTerminalThemeProtocolUpdate(doc.documentElement, update);
4068
+ if (usesDocumentTheme) applyTerminalThemeProtocolUpdate(doc.documentElement, update);
1793
4069
  applyTerminalThemeProtocolUpdate(shell, update);
1794
4070
  applyTerminalThemeProtocolUpdate(terminalRoot, update);
1795
4071
  return themeChanged || hasTerminalThemeProtocolUpdate(update);
@@ -1803,6 +4079,17 @@ function createTerminalThemeController(doc, shell, terminalRoot, theme) {
1803
4079
  }
1804
4080
  };
1805
4081
  }
4082
+ function resolveTerminalTextarea(terminalRoot) {
4083
+ const inputOwner = terminalRoot.dataset.aittyInputOwner;
4084
+ if (inputOwner) {
4085
+ const ownedTextarea = Array.from(terminalRoot.ownerDocument.querySelectorAll("textarea[data-aitty-input]")).find((textarea) => {
4086
+ return textarea instanceof HTMLTextAreaElement && textarea.dataset.aittyInputOwner === inputOwner;
4087
+ });
4088
+ if (ownedTextarea) return ownedTextarea;
4089
+ }
4090
+ const localTextarea = terminalRoot.querySelector("textarea[data-aitty-input]");
4091
+ return localTextarea instanceof HTMLTextAreaElement ? localTextarea : null;
4092
+ }
1806
4093
  function clearTerminalThemeProtocolOverrides(element) {
1807
4094
  for (const variableName of TERMINAL_THEME_PROTOCOL_VARIABLES) element.style.removeProperty(variableName);
1808
4095
  }
@@ -1816,9 +4103,6 @@ function applyTerminalThemeProtocolUpdate(element, update) {
1816
4103
  function hasTerminalThemeProtocolUpdate(update) {
1817
4104
  return Object.keys(update.colors).length > 0 || update.palette.length > 0 || Boolean(update.theme);
1818
4105
  }
1819
- function normalizeThemeName(theme) {
1820
- return normalizeTheme(theme);
1821
- }
1822
4106
  function normalizeConnectionState(value) {
1823
4107
  switch (value) {
1824
4108
  case "open":
@@ -1852,12 +4136,55 @@ function cloneTerminalStatusSnapshot(status) {
1852
4136
  function areTerminalStatusSnapshotsEqual(left, right) {
1853
4137
  return left.connection === right.connection && left.message === right.message && left.output === right.output && left.sessionState === right.sessionState && left.loading.message === right.loading.message && left.loading.visible === right.loading.visible;
1854
4138
  }
1855
- function toWebSocketUrl(sessionUrl) {
4139
+ function toWebSocketUrl(sessionUrl, clientId) {
1856
4140
  const url = new URL(sessionUrl.href);
1857
4141
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1858
4142
  url.pathname = "/ws";
4143
+ if (clientId) url.searchParams.set("cid", clientId);
1859
4144
  return url.toString();
1860
4145
  }
4146
+ function resolveStoredAittyClientId(windowObject, sessionUrl) {
4147
+ const key = resolveAittyClientIdStorageKey(sessionUrl);
4148
+ const stored = normalizeStoredAittyClientId(readSessionStorageItem(windowObject, key));
4149
+ if (stored) return stored;
4150
+ const generated = createAittyBrowserClientId(windowObject);
4151
+ writeSessionStorageItem(windowObject, key, generated);
4152
+ return generated;
4153
+ }
4154
+ function persistAittyClientId(windowObject, sessionUrl, clientId) {
4155
+ const normalized = normalizeStoredAittyClientId(clientId);
4156
+ if (!normalized) return;
4157
+ writeSessionStorageItem(windowObject, resolveAittyClientIdStorageKey(sessionUrl), normalized);
4158
+ }
4159
+ function resolveAittyClientIdStorageKey(sessionUrl) {
4160
+ const token = sessionUrl.searchParams.get("t") ?? "";
4161
+ return `aitty:client-id:${sessionUrl.origin}${sessionUrl.pathname}:${token.slice(0, 12)}`;
4162
+ }
4163
+ function createAittyBrowserClientId(windowObject) {
4164
+ const crypto = windowObject.crypto;
4165
+ if (crypto && typeof crypto.randomUUID === "function") return `b-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
4166
+ const values = new Uint8Array(12);
4167
+ if (crypto && typeof crypto.getRandomValues === "function") crypto.getRandomValues(values);
4168
+ else for (let index = 0; index < values.length; index += 1) values[index] = Math.floor(Math.random() * 256);
4169
+ return `b-${Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("")}`;
4170
+ }
4171
+ function normalizeStoredAittyClientId(value) {
4172
+ if (typeof value !== "string") return null;
4173
+ const normalized = value.trim();
4174
+ return /^[A-Za-z0-9_-]{1,80}$/.test(normalized) ? normalized : null;
4175
+ }
4176
+ function readSessionStorageItem(windowObject, key) {
4177
+ try {
4178
+ return windowObject.sessionStorage?.getItem(key) ?? null;
4179
+ } catch {
4180
+ return null;
4181
+ }
4182
+ }
4183
+ function writeSessionStorageItem(windowObject, key, value) {
4184
+ try {
4185
+ windowObject.sessionStorage?.setItem(key, value);
4186
+ } catch {}
4187
+ }
1861
4188
  function resolveAittySessionUrl(windowObject, src) {
1862
4189
  if (src instanceof URL) return new URL(src.href);
1863
4190
  if (typeof src === "string" && src.trim()) return new URL(src, windowObject.location.href);
@@ -1884,26 +4211,81 @@ async function fetchRuntimeKindFromSession(sessionUrl, fetchRuntimeKind) {
1884
4211
  return "pty";
1885
4212
  }
1886
4213
  }
4214
+ async function uploadClipboardImage(file, sessionUrl, fetchRuntime) {
4215
+ if (!fetchRuntime || !sessionUrl || !file.type.startsWith("image/")) return null;
4216
+ const uploadUrl = new URL("/clipboard/image", sessionUrl);
4217
+ const token = sessionUrl.searchParams.get("t");
4218
+ uploadUrl.hash = "";
4219
+ if (token) uploadUrl.searchParams.set("t", token);
4220
+ const response = await fetchRuntime(uploadUrl.toString(), {
4221
+ body: file,
4222
+ headers: { "Content-Type": file.type },
4223
+ method: "POST"
4224
+ });
4225
+ if (!response.ok) return null;
4226
+ const body = await response.json();
4227
+ if (!body || typeof body !== "object" || !("path" in body)) return null;
4228
+ const { path } = body;
4229
+ return typeof path === "string" && path.length > 0 ? path : null;
4230
+ }
4231
+ async function pasteClipboardImageFromShortcut(windowObject, sessionUrl, event) {
4232
+ const image = readClipboardImageFromPasteEvent(event) ?? await readClipboardImage(windowObject);
4233
+ if (!image) return {
4234
+ allowTerminalFallback: isLoopbackBrowserHost(windowObject.location?.hostname),
4235
+ data: null
4236
+ };
4237
+ const path = await uploadClipboardImage(image, sessionUrl, windowObject.fetch?.bind(windowObject));
4238
+ if (path) return `${path} `;
4239
+ return {
4240
+ allowTerminalFallback: isLoopbackBrowserHost(windowObject.location?.hostname),
4241
+ data: null
4242
+ };
4243
+ }
4244
+ function isLoopbackBrowserHost(hostname) {
4245
+ const normalizedHostname = (hostname ?? "").toLowerCase();
4246
+ return normalizedHostname === "localhost" || normalizedHostname === "127.0.0.1" || normalizedHostname === "::1" || normalizedHostname.endsWith(".localhost");
4247
+ }
4248
+ function resolveBrowserPasteShortcut(windowObject) {
4249
+ if (windowObject.isSecureContext || isLoopbackBrowserHost(windowObject.location?.hostname)) return "ctrl-v";
4250
+ return "cmd-v";
4251
+ }
4252
+ function readClipboardImageFromPasteEvent(event) {
4253
+ const clipboardData = event?.clipboardData;
4254
+ if (!clipboardData) return null;
4255
+ for (const item of Array.from(clipboardData.items)) {
4256
+ if (item.kind !== "file" || !item.type.startsWith("image/")) continue;
4257
+ const file = item.getAsFile();
4258
+ if (file) return file;
4259
+ }
4260
+ for (const file of Array.from(clipboardData.files)) if (file.type.startsWith("image/")) return file;
4261
+ return null;
4262
+ }
4263
+ async function readClipboardImage(windowObject) {
4264
+ const clipboard = windowObject.navigator?.clipboard;
4265
+ if (!clipboard || typeof clipboard.read !== "function") return null;
4266
+ try {
4267
+ const items = await clipboard.read();
4268
+ for (const item of items) {
4269
+ const imageType = item.types.find((type) => type.startsWith("image/"));
4270
+ if (!imageType) continue;
4271
+ return item.getType(imageType);
4272
+ }
4273
+ } catch {
4274
+ return null;
4275
+ }
4276
+ return null;
4277
+ }
1887
4278
  function syncDimensions(element, cols, rows) {
1888
4279
  element.dataset.cols = String(cols);
1889
4280
  element.dataset.rows = String(rows);
4281
+ element.style.setProperty("--term-cols", String(cols));
4282
+ element.style.setProperty("--term-rows", String(rows));
1890
4283
  }
1891
4284
  function syncTranscriptMetadata(element, transcriptLineCount, archivedLineCount) {
1892
4285
  element.dataset.transcriptLines = String(transcriptLineCount);
1893
4286
  element.dataset.archivedLines = String(archivedLineCount);
1894
4287
  element.classList.toggle("has-scrollback", archivedLineCount > 0 || element.querySelector(".term-scrollback-row") !== null);
1895
4288
  }
1896
- function syncTrailingTerminalBlankRows(element) {
1897
- const rows = Array.from(element.querySelectorAll(".term-grid .term-row"));
1898
- let trailingBlank = true;
1899
- for (let index = rows.length - 1; index >= 0; index -= 1) {
1900
- const row = rows[index];
1901
- if (!(row instanceof HTMLElement)) continue;
1902
- const isBlank = (row.textContent ?? "").trim().length === 0 && !hasVisibleTerminalPaint(row);
1903
- row.classList.toggle("term-row--trailing-blank", trailingBlank && isBlank);
1904
- if (!isBlank) trailingBlank = false;
1905
- }
1906
- }
1907
4289
  function trimReconciledPreservedTranscriptLines(preservedLines, liveLines) {
1908
4290
  if (preservedLines.length === 0 || liveLines.length === 0) return preservedLines;
1909
4291
  const normalizedLiveLines = normalizePreservedTranscriptLines(liveLines);
@@ -1913,30 +4295,30 @@ function trimReconciledPreservedTranscriptLines(preservedLines, liveLines) {
1913
4295
  }
1914
4296
  return preservedLines;
1915
4297
  }
4298
+ function normalizeTranscriptScrollbackLimit(value) {
4299
+ if (value === void 0) return null;
4300
+ if (!Number.isFinite(value)) return null;
4301
+ return Math.max(0, Math.floor(value));
4302
+ }
4303
+ function trimTranscriptLines(lines, limit) {
4304
+ if (limit === null) return lines;
4305
+ return limit > 0 ? lines.slice(-limit) : [];
4306
+ }
1916
4307
  function normalizePreservedTranscriptLines(lines) {
1917
4308
  return lines.map((line) => line.trimEnd()).filter((line, index, array) => line.length > 0 || index > 0 && index < array.length - 1);
1918
4309
  }
1919
4310
  function looksLikePromptLine(text) {
1920
4311
  return /^\s*(?:[$>#]|[^\s]+[@:][^\s]+[$#])\s?/.test(text);
1921
4312
  }
1922
- function copyToArrayBuffer(bytes) {
4313
+ function toWebSocketPayload(bytes) {
4314
+ if (bytes.buffer instanceof ArrayBuffer) return bytes;
1923
4315
  const copy = new Uint8Array(bytes.byteLength);
1924
4316
  copy.set(bytes);
1925
- return copy.buffer;
1926
- }
1927
- function concatChunks(chunks) {
1928
- const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
1929
- const payload = new Uint8Array(totalLength);
1930
- let offset = 0;
1931
- for (const chunk of chunks) {
1932
- payload.set(chunk, offset);
1933
- offset += chunk.length;
1934
- }
1935
- return payload;
4317
+ return copy;
1936
4318
  }
1937
4319
  if (typeof document !== "undefined") {
1938
4320
  defineAittyTerminalElement();
1939
- if (shouldAutoMount(document)) mountTerminalApp().catch((error) => {
4321
+ if (shouldAutoMount(document)) mountTerminalApp(document, resolveAutoMountOptions(document)).catch((error) => {
1940
4322
  const shell = document.querySelector("[data-shell]");
1941
4323
  const status = document.querySelector("[data-terminal-status]");
1942
4324
  const loading = document.querySelector("[data-terminal-loading]");
@@ -1951,7 +4333,19 @@ if (typeof document !== "undefined") {
1951
4333
  }
1952
4334
  function shouldAutoMount(doc) {
1953
4335
  const shell = doc.querySelector("[data-shell]");
1954
- return shell instanceof HTMLElement && !shell.hasAttribute("data-aitty-manual");
4336
+ return shell instanceof HTMLElement && shell.hasAttribute("data-aitty-autostart") && !shell.hasAttribute("data-aitty-manual");
4337
+ }
4338
+ function resolveAutoMountOptions(doc) {
4339
+ const shell = doc.querySelector("[data-shell]");
4340
+ if (!(shell instanceof HTMLElement)) return {};
4341
+ return {
4342
+ config: { appearance: {
4343
+ fontSize: parseOptionalNumber(shell.dataset.aittyFontSize),
4344
+ lineHeight: parseOptionalNumber(shell.dataset.aittyLineHeight),
4345
+ themeTarget: shell.hasAttribute("data-aitty-document-theme") ? "document" : "container"
4346
+ } },
4347
+ shellControls: shell.hasAttribute("data-aitty-shell-controls")
4348
+ };
1955
4349
  }
1956
4350
  //#endregion
1957
- export { createBufferedTerminalWriter, defineAittyTerminalElement, mountAitty, mountTerminalApp };
4351
+ export { MOBILE_COMPOSER_PRIMARY_CONTROLS, createBufferedTerminalWriter, defineAittyTerminalElement, isMobileLiveInputTapTarget, mountAitty, mountTerminalApp, resolveBrowserViewport, resolveMobileComposerControlSequence, resolveMobileComposerExpandedListHeight, resolveMobileFocusProxyPosition, resolveMobileKeyboardPromptAlignDelays, resolveTerminalOutputFrameIntervalMs, shouldEnableMobileFocusProxyPointerEvents, shouldFollowLiveOutputOnUserInputData };