xterm-input-panel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,947 @@
1
+ import type { ITerminalAddon, Terminal } from '@xterm/xterm'
2
+ import { iconKeyboard, iconMousePointer2 } from './icons.js'
3
+ import type { InputPanelLayout } from './input-panel.js'
4
+ import type { HostPlatform } from './platform.js'
5
+
6
+ const SENSITIVITY = 1.5
7
+ const EDGE_SCROLL_ZONE = 30
8
+ const EDGE_SCROLL_INTERVAL = 50
9
+ const EDGE_SCROLL_OVERSHOOT = 15
10
+
11
+ function isTouchDevice(): boolean {
12
+ return 'ontouchstart' in window || navigator.maxTouchPoints > 0
13
+ }
14
+
15
+ export interface InputPanelHistoryItem {
16
+ text: string
17
+ time: number
18
+ }
19
+
20
+ export interface InputPanelSettingsPayload {
21
+ fixedHeight: number
22
+ floatingWidth: number
23
+ floatingHeight: number
24
+ vibrationIntensity: number
25
+ historyLimit: number
26
+ }
27
+
28
+ /**
29
+ * xterm.js addon that provides full InputPanel integration.
30
+ *
31
+ * **DOM Mounting**: All addon UI (panel, FAB, cursor) mounts into the
32
+ * terminal's own container (`terminal.element.parentElement`) by default.
33
+ * For multi-terminal layouts, set `InputPanelAddon.mountTarget` to a shared
34
+ * ancestor so the singleton panel and FAB live in one place.
35
+ *
36
+ * **Singleton**: Only one `<input-panel>` element exists at a time across all
37
+ * terminal instances. When a different terminal receives focus, the panel
38
+ * migrates automatically (previous addon closes, new one opens).
39
+ *
40
+ * **Native FAB**: On touch devices, a draggable floating action button is
41
+ * created automatically inside the mount target. No React needed.
42
+ *
43
+ * Usage:
44
+ * ```ts
45
+ * import { InputPanelAddon } from 'xterm-input-panel'
46
+ *
47
+ * // Optional: shared container for multi-terminal layouts
48
+ * InputPanelAddon.mountTarget = document.getElementById('app')
49
+ *
50
+ * const addon = new InputPanelAddon({
51
+ * onInput: (data) => pty.write(data),
52
+ * })
53
+ * terminal.loadAddon(addon)
54
+ * // After terminal.open(container):
55
+ * addon.attachListeners()
56
+ * ```
57
+ */
58
+ export class InputPanelAddon implements ITerminalAddon {
59
+ // ── Singleton state ──
60
+
61
+ /** The currently active (open) addon instance, or null. */
62
+ private static _active: InputPanelAddon | null = null
63
+
64
+ /** The most recently focused terminal's addon (FAB opens this). */
65
+ private static _lastFocused: InputPanelAddon | null = null
66
+
67
+ /** All alive addon instances (for FAB fallback when _lastFocused is null). */
68
+ private static _instances = new Set<InputPanelAddon>()
69
+
70
+ /** Global callback for singleton state changes. */
71
+ private static _onActiveChangeFn: ((addon: InputPanelAddon | null) => void) | null = null
72
+
73
+ /**
74
+ * Shared mount target for multi-terminal scenarios.
75
+ * When set, both `<input-panel>` and FAB mount here instead of each
76
+ * terminal's individual container. Set this to a common ancestor element
77
+ * (e.g. the app shell or terminal panel wrapper).
78
+ */
79
+ private static _mountTarget: HTMLElement | null = null
80
+
81
+ /** Get the currently active instance (the one with the open panel). */
82
+ static get activeInstance(): InputPanelAddon | null {
83
+ return InputPanelAddon._active
84
+ }
85
+
86
+ /** Subscribe to singleton state changes (open/close/migration). */
87
+ static set onActiveChange(fn: ((addon: InputPanelAddon | null) => void) | null) {
88
+ InputPanelAddon._onActiveChangeFn = fn
89
+ }
90
+
91
+ /**
92
+ * Set a shared mount target for all InputPanelAddon instances.
93
+ * The `<input-panel>` element and FAB will be appended here.
94
+ * If null (default), each addon mounts into its own terminal container.
95
+ */
96
+ static set mountTarget(el: HTMLElement | null) {
97
+ InputPanelAddon._mountTarget = el
98
+ // Migrate existing FAB to the new target
99
+ if (InputPanelAddon._fabEl && el) {
100
+ el.appendChild(InputPanelAddon._fabEl)
101
+ }
102
+ }
103
+
104
+ static get mountTarget(): HTMLElement | null {
105
+ return InputPanelAddon._mountTarget
106
+ }
107
+
108
+ // ── Native FAB (static singleton) ──
109
+
110
+ private static _fabEl: HTMLButtonElement | null = null
111
+
112
+ /**
113
+ * Create the native FAB button and mount it into the given container.
114
+ * The FAB is a static singleton — created once, then moved between
115
+ * containers as terminals gain/lose focus.
116
+ */
117
+ private static _ensureFab(mountTarget: HTMLElement): void {
118
+ if (InputPanelAddon._fabEl) {
119
+ // FAB already exists — just ensure it's in the right container
120
+ if (InputPanelAddon._fabEl.parentElement !== mountTarget) {
121
+ mountTarget.appendChild(InputPanelAddon._fabEl)
122
+ }
123
+ return
124
+ }
125
+
126
+ if (!isTouchDevice()) return
127
+
128
+ const btn = document.createElement('button')
129
+ btn.type = 'button'
130
+ btn.title = 'Open InputPanel'
131
+ btn.replaceChildren(iconKeyboard(24))
132
+
133
+ Object.assign(btn.style, {
134
+ position: 'fixed',
135
+ zIndex: '50',
136
+ width: '56px',
137
+ height: '56px',
138
+ borderRadius: '50%',
139
+ border: '2px solid currentColor',
140
+ display: 'flex',
141
+ alignItems: 'center',
142
+ justifyContent: 'center',
143
+ touchAction: 'none',
144
+ userSelect: 'none',
145
+ webkitUserSelect: 'none',
146
+ webkitTouchCallout: 'none',
147
+ cursor: 'pointer',
148
+ boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
149
+ background: 'var(--primary, #e04a2f)',
150
+ color: 'var(--primary-foreground, #fff)',
151
+ padding: '0',
152
+ margin: '0',
153
+ outline: 'none',
154
+ })
155
+
156
+ // Load saved position
157
+ let posX = window.innerWidth - 72
158
+ let posY = window.innerHeight - 140
159
+ try {
160
+ const saved = localStorage.getItem('input-panel-fab-pos')
161
+ if (saved) {
162
+ const p = JSON.parse(saved)
163
+ posX = p.x ?? posX
164
+ posY = p.y ?? posY
165
+ }
166
+ } catch {
167
+ /* ignore */
168
+ }
169
+ posX = Math.max(0, Math.min(window.innerWidth - 56, posX))
170
+ posY = Math.max(0, Math.min(window.innerHeight - 56, posY))
171
+ btn.style.left = `${posX}px`
172
+ btn.style.top = `${posY}px`
173
+
174
+ // Drag state
175
+ let dragging = false
176
+ let wasDragged = false
177
+ let startX = 0
178
+ let startY = 0
179
+ let origX = 0
180
+ let origY = 0
181
+
182
+ const doOpen = () => {
183
+ const preferred = InputPanelAddon._lastFocused
184
+ const target =
185
+ (preferred && InputPanelAddon._instances.has(preferred) ? preferred : null) ??
186
+ [...InputPanelAddon._instances].find((instance) => instance._terminal != null) ??
187
+ null
188
+
189
+ if (target) {
190
+ InputPanelAddon._lastFocused = target
191
+ target.open()
192
+ }
193
+ }
194
+
195
+ btn.addEventListener('pointerdown', (e) => {
196
+ dragging = true
197
+ wasDragged = false
198
+ startX = e.clientX
199
+ startY = e.clientY
200
+ origX = parseInt(btn.style.left) || 0
201
+ origY = parseInt(btn.style.top) || 0
202
+ btn.setPointerCapture(e.pointerId)
203
+ e.preventDefault()
204
+ })
205
+
206
+ btn.addEventListener('pointermove', (e) => {
207
+ if (!dragging) return
208
+ const dx = e.clientX - startX
209
+ const dy = e.clientY - startY
210
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) wasDragged = true
211
+ const newX = Math.max(0, Math.min(window.innerWidth - 56, origX + dx))
212
+ const newY = Math.max(0, Math.min(window.innerHeight - 56, origY + dy))
213
+ btn.style.left = `${newX}px`
214
+ btn.style.top = `${newY}px`
215
+ })
216
+
217
+ btn.addEventListener('pointerup', () => {
218
+ if (!dragging) return
219
+ dragging = false
220
+ try {
221
+ localStorage.setItem(
222
+ 'input-panel-fab-pos',
223
+ JSON.stringify({
224
+ x: parseInt(btn.style.left),
225
+ y: parseInt(btn.style.top),
226
+ })
227
+ )
228
+ } catch {
229
+ /* ignore */
230
+ }
231
+ if (!wasDragged) doOpen()
232
+ })
233
+
234
+ btn.addEventListener('pointercancel', () => {
235
+ dragging = false
236
+ })
237
+
238
+ // Click fallback for environments where pointer events are unreliable
239
+ let pointerDownFired = false
240
+ btn.addEventListener(
241
+ 'pointerdown',
242
+ () => {
243
+ pointerDownFired = true
244
+ },
245
+ { capture: true }
246
+ )
247
+ btn.addEventListener('click', () => {
248
+ if (pointerDownFired) {
249
+ pointerDownFired = false
250
+ return
251
+ }
252
+ doOpen()
253
+ })
254
+
255
+ btn.addEventListener('contextmenu', (e) => {
256
+ e.preventDefault()
257
+ e.stopPropagation()
258
+ })
259
+
260
+ btn.addEventListener('dragstart', (e) => {
261
+ e.preventDefault()
262
+ })
263
+
264
+ // Keep in bounds on resize
265
+ window.addEventListener('resize', () => {
266
+ const x = Math.min(parseInt(btn.style.left) || 0, window.innerWidth - 56)
267
+ const y = Math.min(parseInt(btn.style.top) || 0, window.innerHeight - 56)
268
+ btn.style.left = `${Math.max(0, x)}px`
269
+ btn.style.top = `${Math.max(0, y)}px`
270
+ })
271
+
272
+ mountTarget.appendChild(btn)
273
+ InputPanelAddon._fabEl = btn
274
+ }
275
+
276
+ private static _setFabVisible(visible: boolean): void {
277
+ if (InputPanelAddon._fabEl) {
278
+ InputPanelAddon._fabEl.style.display = visible ? 'flex' : 'none'
279
+ }
280
+ }
281
+
282
+ // ── Instance state ──
283
+
284
+ private _terminal: Terminal | null = null
285
+ private _panel: HTMLElement | null = null
286
+ private _cursorEl: HTMLElement | null = null
287
+ private _cursorPos = { x: 0, y: 0 }
288
+ private _isDragging = false
289
+ private _isOpen = false
290
+ private _edgeScrollTimer: ReturnType<typeof setInterval> | null = null
291
+ private _cleanups: Array<() => void> = []
292
+ private _persistentCleanups: Array<() => void> = []
293
+ private _listenersAttached = false
294
+
295
+ private _onInput: (data: string) => void
296
+ private _onOpenCb: (() => void) | null
297
+ private _onCloseCb: (() => void) | null
298
+ private _getHistory: (() => Promise<readonly InputPanelHistoryItem[]>) | null
299
+ private _addHistory: ((text: string) => Promise<void> | void) | null
300
+ private _subscribeHistory:
301
+ | ((listener: (items: readonly InputPanelHistoryItem[]) => void) => () => void)
302
+ | null
303
+ private _onSettingsChange: ((settings: InputPanelSettingsPayload) => Promise<void> | void) | null
304
+ private _platform: HostPlatform
305
+ private _defaultLayout: InputPanelLayout
306
+
307
+ constructor(opts?: {
308
+ onInput?: (data: string) => void
309
+ onOpen?: () => void
310
+ onClose?: () => void
311
+ getHistory?: () => Promise<readonly InputPanelHistoryItem[]>
312
+ addHistory?: (text: string) => Promise<void> | void
313
+ subscribeHistory?: (listener: (items: readonly InputPanelHistoryItem[]) => void) => () => void
314
+ onSettingsChange?: (settings: InputPanelSettingsPayload) => Promise<void> | void
315
+ platform?: HostPlatform
316
+ defaultLayout?: InputPanelLayout
317
+ }) {
318
+ this._onInput = opts?.onInput ?? (() => {})
319
+ this._onOpenCb = opts?.onOpen ?? null
320
+ this._onCloseCb = opts?.onClose ?? null
321
+ this._getHistory = opts?.getHistory ?? null
322
+ this._addHistory = opts?.addHistory ?? null
323
+ this._subscribeHistory = opts?.subscribeHistory ?? null
324
+ this._onSettingsChange = opts?.onSettingsChange ?? null
325
+ this._platform = opts?.platform ?? 'common'
326
+ this._defaultLayout = opts?.defaultLayout ?? 'floating'
327
+ }
328
+
329
+ get isOpen(): boolean {
330
+ return this._isOpen
331
+ }
332
+
333
+ /** Allow changing callbacks after construction. */
334
+ set onOpen(fn: (() => void) | null) {
335
+ this._onOpenCb = fn
336
+ }
337
+ set onClose(fn: (() => void) | null) {
338
+ this._onCloseCb = fn
339
+ }
340
+ set onInput(fn: (data: string) => void) {
341
+ this._onInput = fn
342
+ }
343
+
344
+ setPlatform(platform: HostPlatform): void {
345
+ this._platform = platform
346
+ this._applyPlatformToPanel()
347
+ }
348
+
349
+ setDefaultLayout(layout: InputPanelLayout): void {
350
+ this._defaultLayout = layout
351
+ }
352
+
353
+ /**
354
+ * Resolve the mount target for this addon instance.
355
+ * Priority: static mountTarget > terminal container > document.body
356
+ */
357
+ private _getMountTarget(): HTMLElement {
358
+ return InputPanelAddon._mountTarget ?? this._terminal?.element?.parentElement ?? document.body
359
+ }
360
+
361
+ activate(terminal: Terminal): void {
362
+ this._terminal = terminal
363
+ InputPanelAddon._instances.add(this)
364
+ }
365
+
366
+ dispose(): void {
367
+ this.close()
368
+ for (const fn of this._persistentCleanups) fn()
369
+ this._persistentCleanups = []
370
+ this._listenersAttached = false
371
+ InputPanelAddon._instances.delete(this)
372
+ if (InputPanelAddon._lastFocused === this) {
373
+ InputPanelAddon._lastFocused = null
374
+ }
375
+ this._terminal = null
376
+ }
377
+
378
+ // ── Public API ──
379
+
380
+ /**
381
+ * Set up persistent listeners for auto-open behavior.
382
+ * Must be called after terminal.open(container) so that DOM elements exist.
383
+ *
384
+ * On touch devices:
385
+ * - Permanently sets inputmode='none' to suppress native keyboard
386
+ * - Creates the native FAB inside the mount target
387
+ * - textarea focus → opens InputPanel (migrates from other terminal if needed)
388
+ */
389
+ attachListeners(): void {
390
+ if (this._listenersAttached || !this._terminal) return
391
+ const textarea = this._terminal.textarea
392
+ if (!textarea) return
393
+
394
+ this._listenersAttached = true
395
+
396
+ if (!isTouchDevice()) return
397
+
398
+ // Permanently suppress native keyboard on touch devices
399
+ textarea.setAttribute('inputmode', 'none')
400
+
401
+ // Ensure native FAB exists in the correct mount target
402
+ InputPanelAddon._ensureFab(this._getMountTarget())
403
+
404
+ // Default FAB target to the first terminal that attaches listeners
405
+ if (!InputPanelAddon._lastFocused) {
406
+ InputPanelAddon._lastFocused = this
407
+ }
408
+
409
+ // Track last focused terminal for FAB
410
+ // textarea focus → open InputPanel (migration via singleton)
411
+ const onFocus = () => {
412
+ InputPanelAddon._lastFocused = this
413
+ if (!this._isOpen) this.open()
414
+ }
415
+ textarea.addEventListener('focus', onFocus)
416
+ this._persistentCleanups.push(() => textarea.removeEventListener('focus', onFocus))
417
+ }
418
+
419
+ open(): void {
420
+ if (this._isOpen || !this._terminal) return
421
+
422
+ // Singleton: close any other active instance (migration)
423
+ if (InputPanelAddon._active && InputPanelAddon._active !== this) {
424
+ InputPanelAddon._active.close()
425
+ }
426
+
427
+ this._isOpen = true
428
+ InputPanelAddon._active = this
429
+ InputPanelAddon._lastFocused = this
430
+
431
+ // Hide FAB while panel is open
432
+ InputPanelAddon._setFabVisible(false)
433
+
434
+ this._suppressKeyboard()
435
+
436
+ // Build the element tree
437
+ const panel = document.createElement('input-panel')
438
+ panel.setAttribute('layout', this._defaultLayout)
439
+ this._applyPanelThemeBindings(panel)
440
+
441
+ const inputTab = document.createElement('input-method-tab')
442
+ inputTab.setAttribute('slot', 'input')
443
+ panel.appendChild(inputTab)
444
+
445
+ const keysTab = document.createElement('virtual-keyboard-tab')
446
+ keysTab.setAttribute('slot', 'keys')
447
+ keysTab.setAttribute('floating', '')
448
+ keysTab.setAttribute('platform', this._platform)
449
+ panel.appendChild(keysTab)
450
+
451
+ const shortcutsTab = document.createElement('shortcut-tab')
452
+ shortcutsTab.setAttribute('slot', 'shortcuts')
453
+ shortcutsTab.setAttribute('platform', this._platform)
454
+ panel.appendChild(shortcutsTab)
455
+
456
+ const trackpadTab = document.createElement('virtual-trackpad-tab')
457
+ trackpadTab.setAttribute('slot', 'trackpad')
458
+ trackpadTab.setAttribute('floating', '')
459
+ panel.appendChild(trackpadTab)
460
+
461
+ this._panel = panel
462
+
463
+ const renderHistory = (items: readonly InputPanelHistoryItem[]) => {
464
+ this._renderHistory(inputTab, items)
465
+ }
466
+
467
+ if (this._getHistory) {
468
+ void this._getHistory()
469
+ .then((items) => renderHistory(items))
470
+ .catch(() => {})
471
+ }
472
+
473
+ if (this._subscribeHistory) {
474
+ try {
475
+ const unsubscribe = this._subscribeHistory((items) => {
476
+ renderHistory(items)
477
+ })
478
+ this._cleanups.push(unsubscribe)
479
+ } catch {
480
+ // ignore history subscription failures
481
+ }
482
+ }
483
+
484
+ // Wire panel events
485
+ this._on(panel, 'input-panel:close', () => this.close())
486
+ this._on(panel, 'input-panel:send', (e) => {
487
+ const data = (e as CustomEvent).detail?.data
488
+ if (data) this._onInput(data)
489
+
490
+ const source = e.composedPath()[0]
491
+ if (source === inputTab && this._addHistory && data) {
492
+ const normalized = this._normalizeHistoryText(data)
493
+ if (normalized) {
494
+ Promise.resolve(this._addHistory(normalized))
495
+ .then(() => this._getHistory?.())
496
+ .then((items) => {
497
+ if (items) renderHistory(items)
498
+ })
499
+ .catch(() => {})
500
+ }
501
+ }
502
+ })
503
+ this._on(panel, 'input-panel:tab-change', (e) => {
504
+ const tab = (e as CustomEvent).detail?.tab
505
+ if (tab === 'trackpad') this._showCursor()
506
+ else this._hideCursor()
507
+ })
508
+ this._on(panel, 'input-panel:settings-change', (e) => {
509
+ if (!this._onSettingsChange) return
510
+ const detail = (e as CustomEvent).detail
511
+ if (typeof detail !== 'object' || detail == null) return
512
+ const payload = detail as Partial<InputPanelSettingsPayload>
513
+ if (
514
+ typeof payload.fixedHeight !== 'number' ||
515
+ typeof payload.floatingWidth !== 'number' ||
516
+ typeof payload.floatingHeight !== 'number' ||
517
+ typeof payload.vibrationIntensity !== 'number' ||
518
+ typeof payload.historyLimit !== 'number'
519
+ ) {
520
+ return
521
+ }
522
+
523
+ void this._onSettingsChange({
524
+ fixedHeight: payload.fixedHeight,
525
+ floatingWidth: payload.floatingWidth,
526
+ floatingHeight: payload.floatingHeight,
527
+ vibrationIntensity: payload.vibrationIntensity,
528
+ historyLimit: payload.historyLimit,
529
+ })
530
+ })
531
+
532
+ // Wire trackpad gesture events
533
+ this._on(panel, 'trackpad:move', (e) => {
534
+ const { dx, dy } = (e as CustomEvent).detail
535
+ this._moveCursor(dx, dy)
536
+ })
537
+ this._on(panel, 'trackpad:tap', () => this._dispatchClick(1))
538
+ this._on(panel, 'trackpad:double-tap', () => this._dispatchDblClick())
539
+ this._on(panel, 'trackpad:long-press', () => this._dispatchRightClick())
540
+ this._on(panel, 'trackpad:two-finger-tap', () => this._dispatchRightClick())
541
+ this._on(panel, 'trackpad:drag-start', () => {
542
+ this._isDragging = true
543
+ this._dispatchMouse('mousedown', { detail: 1 })
544
+ })
545
+ this._on(panel, 'trackpad:drag-move', (e) => {
546
+ const { dx, dy } = (e as CustomEvent).detail
547
+ this._moveCursor(dx, dy)
548
+ this._dispatchMouse('mousemove', { buttons: 1 })
549
+ this._updateEdgeScroll()
550
+ })
551
+ this._on(panel, 'trackpad:drag-end', () => {
552
+ this._isDragging = false
553
+ this._stopEdgeScroll()
554
+ this._dispatchMouse('mouseup', { detail: 1 })
555
+ })
556
+ this._on(panel, 'trackpad:scroll', (e) => {
557
+ const { deltaY } = (e as CustomEvent).detail
558
+ this._dispatchWheel(deltaY)
559
+ })
560
+
561
+ // Mount panel into the terminal's container (or shared mount target)
562
+ this._getMountTarget().appendChild(panel)
563
+
564
+ // Create cursor overlay inside terminal container
565
+ this._createCursor()
566
+
567
+ // Focus terminal textarea to show blinking cursor
568
+ this._focusTerminal()
569
+
570
+ this._onOpenCb?.()
571
+ InputPanelAddon._onActiveChangeFn?.(this)
572
+ }
573
+
574
+ private _applyPlatformToPanel(): void {
575
+ if (!this._panel) return
576
+ const keysTab = this._panel.querySelector('virtual-keyboard-tab')
577
+ const shortcutsTab = this._panel.querySelector('shortcut-tab')
578
+ keysTab?.setAttribute('platform', this._platform)
579
+ shortcutsTab?.setAttribute('platform', this._platform)
580
+ }
581
+
582
+ private _readThemeVar(
583
+ style: CSSStyleDeclaration,
584
+ names: readonly string[],
585
+ fallback: string
586
+ ): string {
587
+ for (const name of names) {
588
+ const value = style.getPropertyValue(name).trim()
589
+ if (value) return value
590
+ }
591
+ return fallback
592
+ }
593
+
594
+ private _applyPanelThemeBindings(panel: HTMLElement): void {
595
+ const scope = this._terminal?.element?.parentElement ?? this._getMountTarget()
596
+ const style = getComputedStyle(scope)
597
+
598
+ const background = this._readThemeVar(
599
+ style,
600
+ ['--input-panel-background', '--terminal', '--background'],
601
+ '#1a1a1a'
602
+ )
603
+ const foreground = this._readThemeVar(
604
+ style,
605
+ ['--input-panel-foreground', '--terminal-foreground', '--foreground'],
606
+ '#ffffff'
607
+ )
608
+ const primary = this._readThemeVar(style, ['--input-panel-primary', '--primary'], '#e04a2f')
609
+ const primaryForeground = this._readThemeVar(
610
+ style,
611
+ ['--input-panel-primary-foreground', '--primary-foreground'],
612
+ '#ffffff'
613
+ )
614
+ const border = this._readThemeVar(
615
+ style,
616
+ ['--input-panel-border', '--border'],
617
+ `color-mix(in srgb, ${foreground} 24%, transparent)`
618
+ )
619
+ const muted = this._readThemeVar(
620
+ style,
621
+ ['--input-panel-muted', '--muted'],
622
+ `color-mix(in srgb, ${background} 86%, ${foreground} 14%)`
623
+ )
624
+ const mutedForeground = this._readThemeVar(
625
+ style,
626
+ ['--input-panel-muted-foreground', '--muted-foreground'],
627
+ `color-mix(in srgb, ${foreground} 62%, transparent)`
628
+ )
629
+
630
+ panel.style.setProperty('--input-panel-background', background)
631
+ panel.style.setProperty('--input-panel-foreground', foreground)
632
+ panel.style.setProperty('--input-panel-primary', primary)
633
+ panel.style.setProperty('--input-panel-primary-foreground', primaryForeground)
634
+ panel.style.setProperty('--input-panel-border', border)
635
+ panel.style.setProperty('--input-panel-muted', muted)
636
+ panel.style.setProperty('--input-panel-muted-foreground', mutedForeground)
637
+ }
638
+
639
+ close(): void {
640
+ if (!this._isOpen) return
641
+ this._isOpen = false
642
+
643
+ if (InputPanelAddon._active === this) {
644
+ InputPanelAddon._active = null
645
+ }
646
+
647
+ this._stopEdgeScroll()
648
+ for (const fn of this._cleanups) fn()
649
+ this._cleanups = []
650
+
651
+ this._panel?.remove()
652
+ this._panel = null
653
+
654
+ this._cursorEl?.remove()
655
+ this._cursorEl = null
656
+
657
+ // On touch devices, keep inputmode='none' permanently
658
+ if (!this._listenersAttached) {
659
+ this._restoreKeyboard()
660
+ }
661
+
662
+ // Show FAB again
663
+ InputPanelAddon._setFabVisible(true)
664
+
665
+ this._onCloseCb?.()
666
+ InputPanelAddon._onActiveChangeFn?.(null)
667
+ }
668
+
669
+ toggle(): void {
670
+ this._isOpen ? this.close() : this.open()
671
+ }
672
+
673
+ // ── Terminal focus ──
674
+
675
+ private _focusTerminal(): void {
676
+ const textarea = this._terminal?.textarea
677
+ if (!textarea) return
678
+ textarea.setAttribute('inputmode', 'none')
679
+ textarea.focus()
680
+ }
681
+
682
+ // ── Cursor overlay ──
683
+
684
+ private _createCursor(): void {
685
+ const container = this._terminal?.element?.parentElement
686
+ if (!container) return
687
+
688
+ const el = document.createElement('div')
689
+ el.style.cssText =
690
+ 'position:absolute;z-index:10;pointer-events:none;opacity:0;transition:opacity 0.15s;color:#fff;'
691
+ const pointer = iconMousePointer2(20)
692
+ pointer.style.filter = 'drop-shadow(0 0 1px rgba(0,0,0,0.9))'
693
+ el.replaceChildren(pointer)
694
+ container.appendChild(el)
695
+ this._cursorEl = el
696
+
697
+ const rect = container.getBoundingClientRect()
698
+ this._cursorPos = { x: rect.width / 2, y: rect.height / 2 }
699
+ this._positionCursor()
700
+ }
701
+
702
+ private _positionCursor(): void {
703
+ if (!this._cursorEl) return
704
+ this._cursorEl.style.left = `${this._cursorPos.x - 4}px`
705
+ this._cursorEl.style.top = `${this._cursorPos.y - 2}px`
706
+ }
707
+
708
+ private _showCursor(): void {
709
+ if (!this._cursorEl) this._createCursor()
710
+ if (this._cursorEl) {
711
+ const container = this._terminal?.element?.parentElement
712
+ if (container) {
713
+ const rect = container.getBoundingClientRect()
714
+ this._cursorPos = { x: rect.width / 2, y: rect.height / 2 }
715
+ this._positionCursor()
716
+ }
717
+ this._cursorEl.style.opacity = '1'
718
+ }
719
+ }
720
+
721
+ private _hideCursor(): void {
722
+ if (this._cursorEl) this._cursorEl.style.opacity = '0.3'
723
+ }
724
+
725
+ private _moveCursor(dx: number, dy: number): void {
726
+ const container = this._terminal?.element?.parentElement
727
+ if (!container) return
728
+ const rect = container.getBoundingClientRect()
729
+ this._cursorPos.x = Math.max(0, Math.min(rect.width, this._cursorPos.x + dx * SENSITIVITY))
730
+ this._cursorPos.y = Math.max(0, Math.min(rect.height, this._cursorPos.y + dy * SENSITIVITY))
731
+ this._positionCursor()
732
+ }
733
+
734
+ // ── Mouse event dispatch ──
735
+
736
+ private _getClientCoords(): { clientX: number; clientY: number } | null {
737
+ const container = this._terminal?.element?.parentElement
738
+ if (!container) return null
739
+ const rect = container.getBoundingClientRect()
740
+ return {
741
+ clientX: rect.left + this._cursorPos.x,
742
+ clientY: rect.top + this._cursorPos.y,
743
+ }
744
+ }
745
+
746
+ private _resolveTarget(clientX: number, clientY: number): Element {
747
+ const container = this._terminal?.element?.parentElement
748
+ if (!container) return document.body
749
+ const el = document.elementFromPoint(clientX, clientY)
750
+ if (el && container.contains(el)) return el
751
+ return container.querySelector('.xterm-screen') ?? container
752
+ }
753
+
754
+ private _dispatchMouse(
755
+ type: string,
756
+ opts: { button?: number; detail?: number; buttons?: number } = {}
757
+ ): void {
758
+ const coords = this._getClientCoords()
759
+ if (!coords) return
760
+ const button = opts.button ?? 0
761
+ const detail = opts.detail ?? 1
762
+ const buttons =
763
+ opts.buttons ?? (type === 'mousedown' ? (button === 0 ? 1 : button === 2 ? 2 : 4) : 0)
764
+ const target = this._resolveTarget(coords.clientX, coords.clientY)
765
+ target.dispatchEvent(
766
+ new MouseEvent(type, {
767
+ bubbles: true,
768
+ cancelable: true,
769
+ view: window,
770
+ detail,
771
+ clientX: coords.clientX,
772
+ clientY: coords.clientY,
773
+ button,
774
+ buttons,
775
+ })
776
+ )
777
+ }
778
+
779
+ private _dispatchClick(detail: number): void {
780
+ this._dispatchMouse('mousedown', { detail })
781
+ this._dispatchMouse('mouseup', { detail })
782
+ this._dispatchMouse('click', { detail })
783
+ }
784
+
785
+ private _dispatchDblClick(): void {
786
+ this._dispatchMouse('mousedown', { detail: 2 })
787
+ this._dispatchMouse('mouseup', { detail: 2 })
788
+ this._dispatchMouse('click', { detail: 2 })
789
+ this._dispatchMouse('dblclick', { detail: 2 })
790
+ }
791
+
792
+ private _dispatchRightClick(): void {
793
+ this._dispatchMouse('mousedown', { button: 2 })
794
+ this._dispatchMouse('mouseup', { button: 2 })
795
+ this._dispatchMouse('contextmenu', { button: 2 })
796
+ }
797
+
798
+ private _dispatchWheel(deltaY: number): void {
799
+ const coords = this._getClientCoords()
800
+ if (!coords) return
801
+ const target = this._resolveTarget(coords.clientX, coords.clientY)
802
+ target.dispatchEvent(
803
+ new WheelEvent('wheel', {
804
+ bubbles: true,
805
+ cancelable: true,
806
+ view: window,
807
+ clientX: coords.clientX,
808
+ clientY: coords.clientY,
809
+ deltaY,
810
+ deltaMode: WheelEvent.DOM_DELTA_PIXEL,
811
+ })
812
+ )
813
+ }
814
+
815
+ // ── Edge scroll (during drag selection) ──
816
+
817
+ private _updateEdgeScroll(): void {
818
+ const container = this._terminal?.element?.parentElement
819
+ if (!container || !this._isDragging) {
820
+ this._stopEdgeScroll()
821
+ return
822
+ }
823
+ const rect = container.getBoundingClientRect()
824
+ const nearTop = this._cursorPos.y < EDGE_SCROLL_ZONE
825
+ const nearBottom = this._cursorPos.y > rect.height - EDGE_SCROLL_ZONE
826
+ if (!nearTop && !nearBottom) {
827
+ this._stopEdgeScroll()
828
+ return
829
+ }
830
+
831
+ this._stopEdgeScroll()
832
+ this._edgeScrollTimer = setInterval(() => {
833
+ const container = this._terminal?.element?.parentElement
834
+ if (!container || !this._isDragging) {
835
+ this._stopEdgeScroll()
836
+ return
837
+ }
838
+ const rect = container.getBoundingClientRect()
839
+ const clientX = rect.left + this._cursorPos.x
840
+ const clientY =
841
+ this._cursorPos.y < EDGE_SCROLL_ZONE
842
+ ? rect.top - EDGE_SCROLL_OVERSHOOT
843
+ : rect.bottom + EDGE_SCROLL_OVERSHOOT
844
+ const target = this._resolveTarget(
845
+ rect.left + this._cursorPos.x,
846
+ rect.top + this._cursorPos.y
847
+ )
848
+ target.dispatchEvent(
849
+ new MouseEvent('mousemove', {
850
+ bubbles: true,
851
+ cancelable: true,
852
+ view: window,
853
+ clientX,
854
+ clientY,
855
+ button: 0,
856
+ buttons: 1,
857
+ })
858
+ )
859
+ }, EDGE_SCROLL_INTERVAL)
860
+ }
861
+
862
+ private _stopEdgeScroll(): void {
863
+ if (this._edgeScrollTimer) {
864
+ clearInterval(this._edgeScrollTimer)
865
+ this._edgeScrollTimer = null
866
+ }
867
+ }
868
+
869
+ // ── Keyboard suppression ──
870
+
871
+ private _suppressKeyboard(): void {
872
+ const textarea = this._terminal?.textarea
873
+ if (textarea) textarea.setAttribute('inputmode', 'none')
874
+ }
875
+
876
+ private _restoreKeyboard(): void {
877
+ const textarea = this._terminal?.textarea
878
+ if (textarea) textarea.removeAttribute('inputmode')
879
+ }
880
+
881
+ // ── Event helper ──
882
+
883
+ private _on(target: EventTarget, event: string, handler: EventListener): void {
884
+ target.addEventListener(event, handler)
885
+ this._cleanups.push(() => target.removeEventListener(event, handler))
886
+ }
887
+
888
+ private _normalizeHistoryText(raw: string): string | null {
889
+ const text = raw.replace(/[\r\n]+$/u, '').trim()
890
+ return text ? text : null
891
+ }
892
+
893
+ private _renderHistory(inputTab: HTMLElement, items: readonly InputPanelHistoryItem[]): void {
894
+ inputTab.querySelectorAll('[data-input-history-root="true"]').forEach((node) => node.remove())
895
+ if (items.length === 0) return
896
+
897
+ const root = document.createElement('div')
898
+ root.dataset.inputHistoryRoot = 'true'
899
+ root.setAttribute('slot', 'history')
900
+ root.style.display = 'flex'
901
+ root.style.flexDirection = 'column'
902
+ root.style.gap = '4px'
903
+ root.style.padding = '4px 0'
904
+
905
+ for (const item of items) {
906
+ const button = document.createElement('button')
907
+ button.type = 'button'
908
+ button.title = item.text
909
+ button.style.display = 'flex'
910
+ button.style.alignItems = 'center'
911
+ button.style.gap = '8px'
912
+ button.style.width = '100%'
913
+ button.style.padding = '4px 6px'
914
+ button.style.border = '1px solid transparent'
915
+ button.style.borderRadius = '4px'
916
+ button.style.background = 'transparent'
917
+ button.style.color = 'var(--muted-foreground, #888)'
918
+ button.style.fontFamily = 'inherit'
919
+ button.style.fontSize = '12px'
920
+ button.style.textAlign = 'left'
921
+
922
+ const time = document.createElement('span')
923
+ time.textContent = new Date(item.time).toLocaleTimeString([], {
924
+ hour: '2-digit',
925
+ minute: '2-digit',
926
+ })
927
+ time.style.opacity = '0.65'
928
+ time.style.fontSize = '10px'
929
+ time.style.flexShrink = '0'
930
+
931
+ const content = document.createElement('span')
932
+ content.textContent = item.text
933
+ content.style.overflow = 'hidden'
934
+ content.style.textOverflow = 'ellipsis'
935
+ content.style.whiteSpace = 'nowrap'
936
+
937
+ button.appendChild(time)
938
+ button.appendChild(content)
939
+ button.addEventListener('click', () => {
940
+ this._onInput(`${item.text}\n`)
941
+ })
942
+ root.appendChild(button)
943
+ }
944
+
945
+ inputTab.appendChild(root)
946
+ }
947
+ }