xterm-input-panel 1.1.0 → 1.2.1

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,12 +1,13 @@
1
1
  import type { ITerminalAddon, Terminal } from '@xterm/xterm'
2
2
  import { iconKeyboard, iconMousePointer2 } from './icons.js'
3
- import type { InputPanelLayout } from './input-panel.js'
3
+ import type { InputPanelLayout, InputPanelTab } from './input-panel.js'
4
4
  import type { HostPlatform } from './platform.js'
5
5
 
6
6
  const SENSITIVITY = 1.5
7
7
  const EDGE_SCROLL_ZONE = 30
8
8
  const EDGE_SCROLL_INTERVAL = 50
9
9
  const EDGE_SCROLL_OVERSHOOT = 15
10
+ const STATE_STORAGE_KEY = 'xtermInputPanelState'
10
11
 
11
12
  function isTouchDevice(): boolean {
12
13
  return 'ontouchstart' in window || navigator.maxTouchPoints > 0
@@ -25,6 +26,84 @@ export interface InputPanelSettingsPayload {
25
26
  historyLimit: number
26
27
  }
27
28
 
29
+ interface InputPanelSessionState {
30
+ activeTab: InputPanelTab
31
+ inputDraft: string
32
+ }
33
+
34
+ interface InputMethodTabElement extends HTMLElement {
35
+ value: string
36
+ }
37
+
38
+ interface InputPanelElement extends HTMLElement {
39
+ activeTab: InputPanelTab
40
+ }
41
+
42
+ function isInputPanelTab(value: unknown): value is InputPanelTab {
43
+ return (
44
+ value === 'input' ||
45
+ value === 'keys' ||
46
+ value === 'shortcuts' ||
47
+ value === 'trackpad' ||
48
+ value === 'settings'
49
+ )
50
+ }
51
+
52
+ interface InputPanelStateStore {
53
+ lastActiveTab?: InputPanelTab
54
+ sessions?: Record<string, Partial<InputPanelSessionState>>
55
+ }
56
+
57
+ function isRecord(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === 'object' && value !== null
59
+ }
60
+
61
+ function loadPanelStateStore(): InputPanelStateStore {
62
+ try {
63
+ const raw = localStorage.getItem(STATE_STORAGE_KEY)
64
+ if (!raw) return {}
65
+ const parsed = JSON.parse(raw)
66
+ return isRecord(parsed) ? (parsed as InputPanelStateStore) : {}
67
+ } catch {
68
+ return {}
69
+ }
70
+ }
71
+
72
+ function savePanelStateStore(store: InputPanelStateStore): void {
73
+ try {
74
+ localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(store))
75
+ } catch {
76
+ /* ignore */
77
+ }
78
+ }
79
+
80
+ function loadPanelSessionState(sessionKey: string): InputPanelSessionState | null {
81
+ const store = loadPanelStateStore()
82
+ const sessions = store.sessions
83
+ if (!sessions) return null
84
+ const rawState = sessions[sessionKey]
85
+ if (!isRecord(rawState)) return null
86
+
87
+ const state: InputPanelSessionState = {
88
+ activeTab: 'input',
89
+ inputDraft: '',
90
+ }
91
+
92
+ if (isInputPanelTab(rawState.activeTab)) {
93
+ state.activeTab = rawState.activeTab
94
+ }
95
+ if (typeof rawState.inputDraft === 'string') {
96
+ state.inputDraft = rawState.inputDraft
97
+ }
98
+
99
+ return state
100
+ }
101
+
102
+ function loadLastActiveTab(): InputPanelTab {
103
+ const store = loadPanelStateStore()
104
+ return isInputPanelTab(store.lastActiveTab) ? store.lastActiveTab : 'input'
105
+ }
106
+
28
107
  /**
29
108
  * xterm.js addon that provides full InputPanel integration.
30
109
  *
@@ -56,6 +135,7 @@ export interface InputPanelSettingsPayload {
56
135
  * ```
57
136
  */
58
137
  export class InputPanelAddon implements ITerminalAddon {
138
+ private static _lastActiveTab: InputPanelTab = 'input'
59
139
  // ── Singleton state ──
60
140
 
61
141
  /** The currently active (open) addon instance, or null. */
@@ -108,6 +188,7 @@ export class InputPanelAddon implements ITerminalAddon {
108
188
  // ── Native FAB (static singleton) ──
109
189
 
110
190
  private static _fabEl: HTMLButtonElement | null = null
191
+ private static _fabSubscriberCount = 0
111
192
 
112
193
  /**
113
194
  * Create the native FAB button and mount it into the given container.
@@ -273,7 +354,8 @@ export class InputPanelAddon implements ITerminalAddon {
273
354
 
274
355
  private static _setFabVisible(visible: boolean): void {
275
356
  if (InputPanelAddon._fabEl) {
276
- InputPanelAddon._fabEl.style.display = visible ? 'flex' : 'none'
357
+ InputPanelAddon._fabEl.style.display =
358
+ visible && InputPanelAddon._fabSubscriberCount > 0 ? 'flex' : 'none'
277
359
  }
278
360
  }
279
361
 
@@ -301,6 +383,11 @@ export class InputPanelAddon implements ITerminalAddon {
301
383
  private _onSettingsChange: ((settings: InputPanelSettingsPayload) => Promise<void> | void) | null
302
384
  private _platform: HostPlatform
303
385
  private _defaultLayout: InputPanelLayout
386
+ private _showFab: boolean
387
+ private _fabSubscribed: boolean
388
+ private _panelSessionState: InputPanelSessionState
389
+ private _stateKey: string
390
+ private _hasOwnPersistedState: boolean
304
391
 
305
392
  constructor(opts?: {
306
393
  onInput?: (data: string) => void
@@ -312,6 +399,8 @@ export class InputPanelAddon implements ITerminalAddon {
312
399
  onSettingsChange?: (settings: InputPanelSettingsPayload) => Promise<void> | void
313
400
  platform?: HostPlatform
314
401
  defaultLayout?: InputPanelLayout
402
+ showFab?: boolean
403
+ stateKey?: string
315
404
  }) {
316
405
  this._onInput = opts?.onInput ?? (() => {})
317
406
  this._onOpenCb = opts?.onOpen ?? null
@@ -322,6 +411,21 @@ export class InputPanelAddon implements ITerminalAddon {
322
411
  this._onSettingsChange = opts?.onSettingsChange ?? null
323
412
  this._platform = opts?.platform ?? 'common'
324
413
  this._defaultLayout = opts?.defaultLayout ?? 'floating'
414
+ this._showFab = opts?.showFab ?? true
415
+ this._fabSubscribed = false
416
+ this._stateKey = opts?.stateKey?.trim() ? opts.stateKey : 'default'
417
+ this._hasOwnPersistedState = false
418
+ this._panelSessionState = {
419
+ activeTab: 'input',
420
+ inputDraft: '',
421
+ }
422
+ const persistedState = loadPanelSessionState(this._stateKey)
423
+ if (persistedState) {
424
+ this._panelSessionState = persistedState
425
+ this._hasOwnPersistedState = true
426
+ } else {
427
+ this._panelSessionState.activeTab = InputPanelAddon._lastActiveTab || loadLastActiveTab()
428
+ }
325
429
  }
326
430
 
327
431
  get isOpen(): boolean {
@@ -348,12 +452,54 @@ export class InputPanelAddon implements ITerminalAddon {
348
452
  this._defaultLayout = layout
349
453
  }
350
454
 
455
+ private _persistPanelState(): void {
456
+ InputPanelAddon._lastActiveTab = this._panelSessionState.activeTab
457
+ const store = loadPanelStateStore()
458
+ const nextSessions = {
459
+ ...store.sessions,
460
+ [this._stateKey]: {
461
+ activeTab: this._panelSessionState.activeTab,
462
+ inputDraft: this._panelSessionState.inputDraft,
463
+ },
464
+ }
465
+ savePanelStateStore({
466
+ ...store,
467
+ lastActiveTab: this._panelSessionState.activeTab,
468
+ sessions: nextSessions,
469
+ })
470
+ }
471
+
351
472
  /**
352
473
  * Resolve the mount target for this addon instance.
353
474
  * Priority: static mountTarget > terminal container > document.body
354
475
  */
355
476
  private _getMountTarget(): HTMLElement {
356
- return InputPanelAddon._mountTarget ?? this._terminal?.element?.parentElement ?? document.body
477
+ return InputPanelAddon._mountTarget ?? this._getTerminalHostElement() ?? document.body
478
+ }
479
+
480
+ /**
481
+ * Resolve the visual host element used for overlay UI (cursor/panel).
482
+ *
483
+ * xterm renderer usually exposes `.xterm` as `terminal.element`, while
484
+ * ghostty may expose the mount container itself.
485
+ */
486
+ private _getTerminalHostElement(): HTMLElement | null {
487
+ const termElement = this._terminal?.element
488
+ if (!(termElement instanceof HTMLElement)) return null
489
+ if (termElement.classList.contains('xterm')) {
490
+ return termElement.parentElement instanceof HTMLElement
491
+ ? termElement.parentElement
492
+ : termElement
493
+ }
494
+ return termElement
495
+ }
496
+
497
+ private _detectTerminalEngine(): 'xterm' | 'non-xterm' {
498
+ const termElement = this._terminal?.element
499
+ if (termElement instanceof HTMLElement && termElement.classList.contains('xterm')) {
500
+ return 'xterm'
501
+ }
502
+ return 'non-xterm'
357
503
  }
358
504
 
359
505
  activate(terminal: Terminal): void {
@@ -366,6 +512,11 @@ export class InputPanelAddon implements ITerminalAddon {
366
512
  for (const fn of this._persistentCleanups) fn()
367
513
  this._persistentCleanups = []
368
514
  this._listenersAttached = false
515
+ if (this._fabSubscribed) {
516
+ InputPanelAddon._fabSubscriberCount = Math.max(0, InputPanelAddon._fabSubscriberCount - 1)
517
+ this._fabSubscribed = false
518
+ InputPanelAddon._setFabVisible(InputPanelAddon._active === null)
519
+ }
369
520
  InputPanelAddon._instances.delete(this)
370
521
  if (InputPanelAddon._lastFocused === this) {
371
522
  InputPanelAddon._lastFocused = null
@@ -392,7 +543,19 @@ export class InputPanelAddon implements ITerminalAddon {
392
543
  this._listenersAttached = true
393
544
 
394
545
  // Ensure native FAB exists in the correct mount target
395
- InputPanelAddon._ensureFab(this._getMountTarget())
546
+ if (this._showFab) {
547
+ InputPanelAddon._ensureFab(this._getMountTarget())
548
+ if (!this._fabSubscribed) {
549
+ InputPanelAddon._fabSubscriberCount += 1
550
+ this._fabSubscribed = true
551
+ }
552
+ InputPanelAddon._setFabVisible(true)
553
+ } else {
554
+ // Hide legacy/stale FAB when current runtime has no FAB subscribers.
555
+ if (InputPanelAddon._fabSubscriberCount === 0) {
556
+ InputPanelAddon._setFabVisible(false)
557
+ }
558
+ }
396
559
 
397
560
  // Default FAB target to the first terminal that attaches listeners
398
561
  if (!InputPanelAddon._lastFocused) {
@@ -415,7 +578,13 @@ export class InputPanelAddon implements ITerminalAddon {
415
578
  }
416
579
 
417
580
  open(): void {
418
- if (this._isOpen || !this._terminal) return
581
+ if (!this._terminal) return
582
+ if (this._isOpen) {
583
+ // Recover from host unmount/remount: panel DOM can be removed while addon
584
+ // still thinks it is open. In that case, close stale state and re-open.
585
+ if (this._panel?.isConnected) return
586
+ this.close()
587
+ }
419
588
 
420
589
  // Singleton: close any other active instance (migration)
421
590
  if (InputPanelAddon._active && InputPanelAddon._active !== this) {
@@ -427,17 +596,27 @@ export class InputPanelAddon implements ITerminalAddon {
427
596
  InputPanelAddon._lastFocused = this
428
597
 
429
598
  // Hide FAB while panel is open
430
- InputPanelAddon._setFabVisible(false)
599
+ if (this._showFab) {
600
+ InputPanelAddon._setFabVisible(false)
601
+ }
431
602
 
432
603
  this._suppressKeyboard()
433
604
 
605
+ if (!this._hasOwnPersistedState) {
606
+ const fallbackActiveTab = loadLastActiveTab()
607
+ this._panelSessionState.activeTab = fallbackActiveTab
608
+ InputPanelAddon._lastActiveTab = fallbackActiveTab
609
+ }
610
+
434
611
  // Build the element tree
435
- const panel = document.createElement('input-panel')
612
+ const panel = document.createElement('input-panel') as InputPanelElement
436
613
  panel.setAttribute('layout', this._defaultLayout)
437
614
  this._applyPanelThemeBindings(panel)
615
+ panel.activeTab = this._panelSessionState.activeTab
438
616
 
439
- const inputTab = document.createElement('input-method-tab')
617
+ const inputTab = document.createElement('input-method-tab') as InputMethodTabElement
440
618
  inputTab.setAttribute('slot', 'input')
619
+ inputTab.value = this._panelSessionState.inputDraft
441
620
  panel.appendChild(inputTab)
442
621
 
443
622
  const keysTab = document.createElement('virtual-keyboard-tab')
@@ -498,8 +677,21 @@ export class InputPanelAddon implements ITerminalAddon {
498
677
  }
499
678
  }
500
679
  })
680
+ this._on(inputTab, 'input-panel:input-change', (e) => {
681
+ const value = (e as CustomEvent).detail?.value
682
+ if (typeof value === 'string') {
683
+ this._panelSessionState.inputDraft = value
684
+ this._hasOwnPersistedState = true
685
+ this._persistPanelState()
686
+ }
687
+ })
501
688
  this._on(panel, 'input-panel:tab-change', (e) => {
502
689
  const tab = (e as CustomEvent).detail?.tab
690
+ if (isInputPanelTab(tab)) {
691
+ this._panelSessionState.activeTab = tab
692
+ this._hasOwnPersistedState = true
693
+ this._persistPanelState()
694
+ }
503
695
  if (tab === 'trackpad') this._showCursor()
504
696
  else this._hideCursor()
505
697
  })
@@ -590,7 +782,7 @@ export class InputPanelAddon implements ITerminalAddon {
590
782
  }
591
783
 
592
784
  private _applyPanelThemeBindings(panel: HTMLElement): void {
593
- const scope = this._terminal?.element?.parentElement ?? this._getMountTarget()
785
+ const scope = this._getTerminalHostElement() ?? this._getMountTarget()
594
786
  const style = getComputedStyle(scope)
595
787
 
596
788
  const background = this._readThemeVar(
@@ -657,14 +849,46 @@ export class InputPanelAddon implements ITerminalAddon {
657
849
  }
658
850
 
659
851
  // Show FAB again
660
- InputPanelAddon._setFabVisible(true)
852
+ if (this._showFab) {
853
+ InputPanelAddon._setFabVisible(true)
854
+ }
661
855
 
662
856
  this._onCloseCb?.()
663
857
  InputPanelAddon._onActiveChangeFn?.(null)
664
858
  }
665
859
 
666
860
  toggle(): void {
667
- this._isOpen ? this.close() : this.open()
861
+ if (this._isOpen) {
862
+ this.close()
863
+ return
864
+ }
865
+ this.open()
866
+ }
867
+
868
+ /**
869
+ * Sync addon singleton state when host marks this terminal as active.
870
+ *
871
+ * Lifecycle:
872
+ * 1) Always refresh `_lastFocused` so FAB targets the current terminal.
873
+ * 2) If another terminal owns an open panel, migrate panel ownership here.
874
+ * 3) If this panel is already open, keep terminal focus in sync.
875
+ */
876
+ syncFocusLifecycle(): void {
877
+ InputPanelAddon._lastFocused = this
878
+
879
+ if (this._isOpen && !this._panel?.isConnected) {
880
+ this.open()
881
+ return
882
+ }
883
+
884
+ if (InputPanelAddon._active && InputPanelAddon._active !== this) {
885
+ this.open()
886
+ return
887
+ }
888
+
889
+ if (this._isOpen) {
890
+ this._focusTerminal()
891
+ }
668
892
  }
669
893
 
670
894
  // ── Terminal focus ──
@@ -681,10 +905,13 @@ export class InputPanelAddon implements ITerminalAddon {
681
905
  // ── Cursor overlay ──
682
906
 
683
907
  private _createCursor(): void {
684
- const container = this._terminal?.element?.parentElement
908
+ const container = this._getTerminalHostElement()
685
909
  if (!container) return
686
910
 
687
911
  const el = document.createElement('div')
912
+ el.setAttribute('data-input-panel-cursor', 'virtual-mouse')
913
+ el.setAttribute('data-terminal-engine', this._detectTerminalEngine())
914
+ el.setAttribute('aria-hidden', 'true')
688
915
  el.style.cssText =
689
916
  'position:absolute;z-index:10;pointer-events:none;opacity:0;transition:opacity 0.15s;color:#fff;'
690
917
  const pointer = iconMousePointer2(20)
@@ -707,7 +934,7 @@ export class InputPanelAddon implements ITerminalAddon {
707
934
  private _showCursor(): void {
708
935
  if (!this._cursorEl) this._createCursor()
709
936
  if (this._cursorEl) {
710
- const container = this._terminal?.element?.parentElement
937
+ const container = this._getTerminalHostElement()
711
938
  if (container) {
712
939
  const rect = container.getBoundingClientRect()
713
940
  this._cursorPos = { x: rect.width / 2, y: rect.height / 2 }
@@ -722,7 +949,7 @@ export class InputPanelAddon implements ITerminalAddon {
722
949
  }
723
950
 
724
951
  private _moveCursor(dx: number, dy: number): void {
725
- const container = this._terminal?.element?.parentElement
952
+ const container = this._getTerminalHostElement()
726
953
  if (!container) return
727
954
  const rect = container.getBoundingClientRect()
728
955
  this._cursorPos.x = Math.max(0, Math.min(rect.width, this._cursorPos.x + dx * SENSITIVITY))
@@ -733,7 +960,7 @@ export class InputPanelAddon implements ITerminalAddon {
733
960
  // ── Mouse event dispatch ──
734
961
 
735
962
  private _getClientCoords(): { clientX: number; clientY: number } | null {
736
- const container = this._terminal?.element?.parentElement
963
+ const container = this._getTerminalHostElement()
737
964
  if (!container) return null
738
965
  const rect = container.getBoundingClientRect()
739
966
  return {
@@ -743,7 +970,7 @@ export class InputPanelAddon implements ITerminalAddon {
743
970
  }
744
971
 
745
972
  private _resolveTarget(clientX: number, clientY: number): Element {
746
- const container = this._terminal?.element?.parentElement
973
+ const container = this._getTerminalHostElement()
747
974
  if (!container) return document.body
748
975
  const el = document.elementFromPoint(clientX, clientY)
749
976
  if (el && container.contains(el)) return el
@@ -814,7 +1041,7 @@ export class InputPanelAddon implements ITerminalAddon {
814
1041
  // ── Edge scroll (during drag selection) ──
815
1042
 
816
1043
  private _updateEdgeScroll(): void {
817
- const container = this._terminal?.element?.parentElement
1044
+ const container = this._getTerminalHostElement()
818
1045
  if (!container || !this._isDragging) {
819
1046
  this._stopEdgeScroll()
820
1047
  return
@@ -829,7 +1056,7 @@ export class InputPanelAddon implements ITerminalAddon {
829
1056
 
830
1057
  this._stopEdgeScroll()
831
1058
  this._edgeScrollTimer = setInterval(() => {
832
- const container = this._terminal?.element?.parentElement
1059
+ const container = this._getTerminalHostElement()
833
1060
  if (!container || !this._isDragging) {
834
1061
  this._stopEdgeScroll()
835
1062
  return
@@ -914,7 +1141,7 @@ export class InputPanelAddon implements ITerminalAddon {
914
1141
  button.style.border = '1px solid transparent'
915
1142
  button.style.borderRadius = '4px'
916
1143
  button.style.background = 'transparent'
917
- button.style.color = 'var(--muted-foreground, #888)'
1144
+ button.style.color = 'var(--foreground, #fff)'
918
1145
  button.style.fontFamily = 'inherit'
919
1146
  button.style.fontSize = '12px'
920
1147
  button.style.textAlign = 'left'
@@ -1,7 +1,7 @@
1
1
  import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
2
2
  import { playwright } from '@vitest/browser-playwright'
3
- import { defineConfig } from 'vitest/config'
4
3
  import { resolve } from 'path'
4
+ import { defineConfig } from 'vitest/config'
5
5
 
6
6
  export default defineConfig({
7
7
  plugins: [