xterm-input-panel 1.0.0 → 1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # xterm-input-panel
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Improve terminal interaction reliability, including InputPanel state persistence and ghostty virtual cursor behavior.
8
+
9
+ ## 1.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 7c7735b: Add OPSX compose workflow for change actions: actions now open a pop-area prompt editor with terminal target selection, copy/save-to-history controls, and send-to-terminal flow.
14
+
15
+ Improve terminal input safety/feedback by surfacing write readiness and sanitizing generated payloads before dispatch.
16
+
17
+ Enable InputPanel FAB usage on desktop while keeping touch-device keyboard suppression behavior.
18
+
19
+ Refine compose dialog/editor layout controls and add route/navigation support for `/opsx-compose`.
20
+
3
21
  ## 1.0.0
4
22
 
5
23
  ### Major Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xterm-input-panel",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "dependencies": {
@@ -10,7 +10,7 @@ import { iconSend } from './icons.js'
10
10
  export class InputMethodTab extends LitElement {
11
11
  static get properties() {
12
12
  return {
13
- _inputValue: { state: true },
13
+ value: { type: String, attribute: false },
14
14
  }
15
15
  }
16
16
 
@@ -84,15 +84,26 @@ export class InputMethodTab extends LitElement {
84
84
  }
85
85
  `
86
86
 
87
- declare _inputValue: string
87
+ declare value: string
88
88
 
89
89
  constructor() {
90
90
  super()
91
- this._inputValue = ''
91
+ this.value = ''
92
+ }
93
+
94
+ private _emitInputChange() {
95
+ this.dispatchEvent(
96
+ new CustomEvent('input-panel:input-change', {
97
+ detail: { value: this.value },
98
+ bubbles: true,
99
+ composed: true,
100
+ })
101
+ )
92
102
  }
93
103
 
94
104
  private _onInput(e: Event) {
95
- this._inputValue = (e.target as HTMLTextAreaElement).value
105
+ this.value = (e.target as HTMLTextAreaElement).value
106
+ this._emitInputChange()
96
107
  }
97
108
 
98
109
  private _onKeyDown(e: KeyboardEvent) {
@@ -104,7 +115,7 @@ export class InputMethodTab extends LitElement {
104
115
  }
105
116
 
106
117
  private _send() {
107
- const text = this._inputValue.trim()
118
+ const text = this.value.trim()
108
119
  if (!text) return
109
120
 
110
121
  // Dispatch to terminal
@@ -116,7 +127,8 @@ export class InputMethodTab extends LitElement {
116
127
  })
117
128
  )
118
129
 
119
- this._inputValue = ''
130
+ this.value = ''
131
+ this._emitInputChange()
120
132
  }
121
133
 
122
134
  render() {
@@ -124,7 +136,7 @@ export class InputMethodTab extends LitElement {
124
136
  <div class="input-area">
125
137
  <textarea
126
138
  placeholder="Type command and press Ctrl+Enter to send..."
127
- .value=${this._inputValue}
139
+ .value=${this.value}
128
140
  @input=${this._onInput}
129
141
  @keydown=${this._onKeyDown}
130
142
  ></textarea>
@@ -17,10 +17,11 @@ function resetAddonState() {
17
17
 
18
18
  // Remove any <input-panel> elements leaked into body or containers
19
19
  document.querySelectorAll('input-panel').forEach((el) => el.remove())
20
+ localStorage.removeItem('xtermInputPanelState')
20
21
  }
21
22
 
22
23
  /** Create a real xterm Terminal + InputPanelAddon, mount into container */
23
- function setupTerminal(container: HTMLElement) {
24
+ function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
24
25
  const terminal = new Terminal({
25
26
  cols: 80,
26
27
  rows: 10,
@@ -29,7 +30,7 @@ function setupTerminal(container: HTMLElement) {
29
30
  })
30
31
 
31
32
  const inputHandler = fn()
32
- const addon = new InputPanelAddon({ onInput: inputHandler })
33
+ const addon = new InputPanelAddon({ onInput: inputHandler, stateKey: opts?.stateKey })
33
34
  terminal.loadAddon(addon)
34
35
  terminal.open(container)
35
36
 
@@ -108,8 +109,8 @@ export const SingletonMigration: StoryObj = {
108
109
  resetAddonState()
109
110
  const containerA = canvasElement.querySelector('#term-a') as HTMLElement
110
111
  const containerB = canvasElement.querySelector('#term-b') as HTMLElement
111
- const { addon: addonA } = setupTerminal(containerA)
112
- const { addon: addonB } = setupTerminal(containerB)
112
+ const { addon: addonA } = setupTerminal(containerA, { stateKey: 'story-term-a' })
113
+ const { addon: addonB } = setupTerminal(containerB, { stateKey: 'story-term-b' })
113
114
 
114
115
  // Open A — panel should be inside container A
115
116
  addonA.open()
@@ -382,8 +383,8 @@ export const SharedMountTarget: StoryObj = {
382
383
  // Set shared mount target BEFORE creating terminals
383
384
  InputPanelAddon.mountTarget = wrapper
384
385
 
385
- const { addon: addonA } = setupTerminal(containerA)
386
- const { addon: addonB } = setupTerminal(containerB)
386
+ const { addon: addonA } = setupTerminal(containerA, { stateKey: 'switch-term-a' })
387
+ const { addon: addonB } = setupTerminal(containerB, { stateKey: 'switch-term-b' })
387
388
 
388
389
  // Open A — panel should be in the shared wrapper, NOT container A
389
390
  addonA.open()
@@ -411,3 +412,146 @@ export const SharedMountTarget: StoryObj = {
411
412
  InputPanelAddon.mountTarget = null
412
413
  },
413
414
  }
415
+
416
+ /**
417
+ * Persist panel runtime state between close/open cycles:
418
+ * - active tab (input mode)
419
+ * - input draft text in Input tab
420
+ */
421
+ export const PersistPanelSessionState: StoryObj = {
422
+ render: () => html`<div id="term-container" style="width:100%;height:100%;"></div>`,
423
+ play: async ({ canvasElement }) => {
424
+ resetAddonState()
425
+ const container = canvasElement.querySelector('#term-container') as HTMLElement
426
+ const { addon } = setupTerminal(container)
427
+
428
+ addon.open()
429
+ const panel = container.querySelector('input-panel') as HTMLElement & {
430
+ activeTab: string
431
+ updateComplete: Promise<void>
432
+ shadowRoot: ShadowRoot
433
+ }
434
+ await panel.updateComplete
435
+
436
+ const inputTab = panel.querySelector('input-method-tab') as HTMLElement & {
437
+ updateComplete: Promise<void>
438
+ shadowRoot: ShadowRoot
439
+ }
440
+ await inputTab.updateComplete
441
+ const textarea = inputTab.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
442
+ textarea.value = 'echo keep'
443
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
444
+ await inputTab.updateComplete
445
+
446
+ const keysBtn = panel.shadowRoot.querySelectorAll('.tab-btn')[1] as HTMLButtonElement
447
+ keysBtn.click()
448
+ await panel.updateComplete
449
+ expect(panel.activeTab).toBe('keys')
450
+
451
+ addon.close()
452
+ addon.open()
453
+
454
+ const reopened = container.querySelector('input-panel') as HTMLElement & {
455
+ activeTab: string
456
+ updateComplete: Promise<void>
457
+ }
458
+ await reopened.updateComplete
459
+ expect(reopened.activeTab).toBe('keys')
460
+
461
+ const reopenedInputTab = reopened.querySelector('input-method-tab') as HTMLElement & {
462
+ updateComplete: Promise<void>
463
+ shadowRoot: ShadowRoot
464
+ }
465
+ await reopenedInputTab.updateComplete
466
+ const reopenedTextarea = reopenedInputTab.shadowRoot.querySelector(
467
+ 'textarea'
468
+ ) as HTMLTextAreaElement
469
+ expect(reopenedTextarea.value).toBe('echo keep')
470
+
471
+ addon.close()
472
+ },
473
+ }
474
+
475
+ /**
476
+ * Persist session state across terminal-instance switching:
477
+ * A(input draft + keys) -> B -> A should restore A state.
478
+ */
479
+ export const PersistStateAcrossTerminalSwitch: StoryObj = {
480
+ render: () => html`
481
+ <div style="display:flex;gap:8px;height:100%;">
482
+ <div id="term-a" style="flex:1;"></div>
483
+ <div id="term-b" style="flex:1;"></div>
484
+ </div>
485
+ `,
486
+ play: async ({ canvasElement }) => {
487
+ type PanelEl = HTMLElement & {
488
+ activeTab: string
489
+ updateComplete: Promise<void>
490
+ }
491
+
492
+ resetAddonState()
493
+ const containerA = canvasElement.querySelector('#term-a') as HTMLElement
494
+ const containerB = canvasElement.querySelector('#term-b') as HTMLElement
495
+ const { addon: addonA } = setupTerminal(containerA, { stateKey: 'switch-term-a' })
496
+ const { addon: addonB } = setupTerminal(containerB, { stateKey: 'switch-term-b' })
497
+
498
+ addonA.open()
499
+ let panelA = containerA.querySelector('input-panel') as PanelEl
500
+ expect(panelA).not.toBeNull()
501
+ await panelA.updateComplete
502
+
503
+ const inputTabA = panelA.querySelector('input-method-tab') as HTMLElement & {
504
+ updateComplete: Promise<void>
505
+ shadowRoot: ShadowRoot
506
+ }
507
+ await inputTabA.updateComplete
508
+ const textareaA = inputTabA.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
509
+ textareaA.value = 'echo keep-on-a'
510
+ textareaA.dispatchEvent(new Event('input', { bubbles: true }))
511
+ await inputTabA.updateComplete
512
+
513
+ const keysBtnA = panelA.shadowRoot!.querySelectorAll('.tab-btn')[1] as HTMLButtonElement
514
+ keysBtnA.click()
515
+ await panelA.updateComplete
516
+ expect(panelA.activeTab).toBe('keys')
517
+ const persistedRaw = localStorage.getItem('xtermInputPanelState')
518
+ expect(persistedRaw).not.toBeNull()
519
+ const persisted = JSON.parse(persistedRaw ?? '{}') as {
520
+ sessions?: Record<string, { activeTab?: string; inputDraft?: string }>
521
+ }
522
+ expect(persisted.sessions?.['switch-term-a']?.activeTab).toBe('keys')
523
+ expect(persisted.sessions?.['switch-term-a']?.inputDraft).toBe('echo keep-on-a')
524
+
525
+ addonB.syncFocusLifecycle()
526
+ const panelB = containerB.querySelector('input-panel') as PanelEl
527
+ expect(panelB).not.toBeNull()
528
+ await panelB.updateComplete
529
+ expect(panelB.activeTab).toBe('keys')
530
+ const inputTabB = panelB.querySelector('input-method-tab') as HTMLElement & {
531
+ updateComplete: Promise<void>
532
+ shadowRoot: ShadowRoot
533
+ }
534
+ await inputTabB.updateComplete
535
+ const textareaB = inputTabB.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
536
+ expect(textareaB.value).toBe('')
537
+
538
+ addonA.syncFocusLifecycle()
539
+ panelA = containerA.querySelector('input-panel') as PanelEl
540
+ expect(panelA).not.toBeNull()
541
+ await panelA.updateComplete
542
+ expect(panelA.activeTab).toBe('keys')
543
+
544
+ const reopenedInputTabA = panelA.querySelector('input-method-tab') as HTMLElement & {
545
+ updateComplete: Promise<void>
546
+ shadowRoot: ShadowRoot
547
+ }
548
+ await reopenedInputTabA.updateComplete
549
+ const reopenedTextareaA = reopenedInputTabA.shadowRoot.querySelector(
550
+ 'textarea'
551
+ ) as HTMLTextAreaElement
552
+ expect(reopenedTextareaA.value).toBe('echo keep-on-a')
553
+
554
+ addonA.close()
555
+ addonB.close()
556
+ },
557
+ }
@@ -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. */
@@ -123,8 +203,6 @@ export class InputPanelAddon implements ITerminalAddon {
123
203
  return
124
204
  }
125
205
 
126
- if (!isTouchDevice()) return
127
-
128
206
  const btn = document.createElement('button')
129
207
  btn.type = 'button'
130
208
  btn.title = 'Open InputPanel'
@@ -303,6 +381,9 @@ export class InputPanelAddon implements ITerminalAddon {
303
381
  private _onSettingsChange: ((settings: InputPanelSettingsPayload) => Promise<void> | void) | null
304
382
  private _platform: HostPlatform
305
383
  private _defaultLayout: InputPanelLayout
384
+ private _panelSessionState: InputPanelSessionState
385
+ private _stateKey: string
386
+ private _hasOwnPersistedState: boolean
306
387
 
307
388
  constructor(opts?: {
308
389
  onInput?: (data: string) => void
@@ -314,6 +395,7 @@ export class InputPanelAddon implements ITerminalAddon {
314
395
  onSettingsChange?: (settings: InputPanelSettingsPayload) => Promise<void> | void
315
396
  platform?: HostPlatform
316
397
  defaultLayout?: InputPanelLayout
398
+ stateKey?: string
317
399
  }) {
318
400
  this._onInput = opts?.onInput ?? (() => {})
319
401
  this._onOpenCb = opts?.onOpen ?? null
@@ -324,6 +406,19 @@ export class InputPanelAddon implements ITerminalAddon {
324
406
  this._onSettingsChange = opts?.onSettingsChange ?? null
325
407
  this._platform = opts?.platform ?? 'common'
326
408
  this._defaultLayout = opts?.defaultLayout ?? 'floating'
409
+ this._stateKey = opts?.stateKey?.trim() ? opts.stateKey : 'default'
410
+ this._hasOwnPersistedState = false
411
+ this._panelSessionState = {
412
+ activeTab: 'input',
413
+ inputDraft: '',
414
+ }
415
+ const persistedState = loadPanelSessionState(this._stateKey)
416
+ if (persistedState) {
417
+ this._panelSessionState = persistedState
418
+ this._hasOwnPersistedState = true
419
+ } else {
420
+ this._panelSessionState.activeTab = InputPanelAddon._lastActiveTab || loadLastActiveTab()
421
+ }
327
422
  }
328
423
 
329
424
  get isOpen(): boolean {
@@ -350,12 +445,52 @@ export class InputPanelAddon implements ITerminalAddon {
350
445
  this._defaultLayout = layout
351
446
  }
352
447
 
448
+ private _persistPanelState(): void {
449
+ InputPanelAddon._lastActiveTab = this._panelSessionState.activeTab
450
+ const store = loadPanelStateStore()
451
+ const nextSessions = {
452
+ ...(store.sessions ?? {}),
453
+ [this._stateKey]: {
454
+ activeTab: this._panelSessionState.activeTab,
455
+ inputDraft: this._panelSessionState.inputDraft,
456
+ },
457
+ }
458
+ savePanelStateStore({
459
+ ...store,
460
+ lastActiveTab: this._panelSessionState.activeTab,
461
+ sessions: nextSessions,
462
+ })
463
+ }
464
+
353
465
  /**
354
466
  * Resolve the mount target for this addon instance.
355
467
  * Priority: static mountTarget > terminal container > document.body
356
468
  */
357
469
  private _getMountTarget(): HTMLElement {
358
- return InputPanelAddon._mountTarget ?? this._terminal?.element?.parentElement ?? document.body
470
+ return InputPanelAddon._mountTarget ?? this._getTerminalHostElement() ?? document.body
471
+ }
472
+
473
+ /**
474
+ * Resolve the visual host element used for overlay UI (cursor/panel).
475
+ *
476
+ * xterm renderer usually exposes `.xterm` as `terminal.element`, while
477
+ * ghostty may expose the mount container itself.
478
+ */
479
+ private _getTerminalHostElement(): HTMLElement | null {
480
+ const termElement = this._terminal?.element
481
+ if (!(termElement instanceof HTMLElement)) return null
482
+ if (termElement.classList.contains('xterm')) {
483
+ return termElement.parentElement instanceof HTMLElement ? termElement.parentElement : termElement
484
+ }
485
+ return termElement
486
+ }
487
+
488
+ private _detectTerminalEngine(): 'xterm' | 'non-xterm' {
489
+ const termElement = this._terminal?.element
490
+ if (termElement instanceof HTMLElement && termElement.classList.contains('xterm')) {
491
+ return 'xterm'
492
+ }
493
+ return 'non-xterm'
359
494
  }
360
495
 
361
496
  activate(terminal: Terminal): void {
@@ -393,11 +528,6 @@ export class InputPanelAddon implements ITerminalAddon {
393
528
 
394
529
  this._listenersAttached = true
395
530
 
396
- if (!isTouchDevice()) return
397
-
398
- // Permanently suppress native keyboard on touch devices
399
- textarea.setAttribute('inputmode', 'none')
400
-
401
531
  // Ensure native FAB exists in the correct mount target
402
532
  InputPanelAddon._ensureFab(this._getMountTarget())
403
533
 
@@ -410,10 +540,15 @@ export class InputPanelAddon implements ITerminalAddon {
410
540
  // textarea focus → open InputPanel (migration via singleton)
411
541
  const onFocus = () => {
412
542
  InputPanelAddon._lastFocused = this
413
- if (!this._isOpen) this.open()
543
+ if (isTouchDevice() && !this._isOpen) this.open()
414
544
  }
415
545
  textarea.addEventListener('focus', onFocus)
416
546
  this._persistentCleanups.push(() => textarea.removeEventListener('focus', onFocus))
547
+
548
+ // Permanently suppress native keyboard on touch devices only
549
+ if (isTouchDevice()) {
550
+ textarea.setAttribute('inputmode', 'none')
551
+ }
417
552
  }
418
553
 
419
554
  open(): void {
@@ -433,13 +568,21 @@ export class InputPanelAddon implements ITerminalAddon {
433
568
 
434
569
  this._suppressKeyboard()
435
570
 
571
+ if (!this._hasOwnPersistedState) {
572
+ const fallbackActiveTab = loadLastActiveTab()
573
+ this._panelSessionState.activeTab = fallbackActiveTab
574
+ InputPanelAddon._lastActiveTab = fallbackActiveTab
575
+ }
576
+
436
577
  // Build the element tree
437
- const panel = document.createElement('input-panel')
578
+ const panel = document.createElement('input-panel') as InputPanelElement
438
579
  panel.setAttribute('layout', this._defaultLayout)
439
580
  this._applyPanelThemeBindings(panel)
581
+ panel.activeTab = this._panelSessionState.activeTab
440
582
 
441
- const inputTab = document.createElement('input-method-tab')
583
+ const inputTab = document.createElement('input-method-tab') as InputMethodTabElement
442
584
  inputTab.setAttribute('slot', 'input')
585
+ inputTab.value = this._panelSessionState.inputDraft
443
586
  panel.appendChild(inputTab)
444
587
 
445
588
  const keysTab = document.createElement('virtual-keyboard-tab')
@@ -500,8 +643,21 @@ export class InputPanelAddon implements ITerminalAddon {
500
643
  }
501
644
  }
502
645
  })
646
+ this._on(inputTab, 'input-panel:input-change', (e) => {
647
+ const value = (e as CustomEvent).detail?.value
648
+ if (typeof value === 'string') {
649
+ this._panelSessionState.inputDraft = value
650
+ this._hasOwnPersistedState = true
651
+ this._persistPanelState()
652
+ }
653
+ })
503
654
  this._on(panel, 'input-panel:tab-change', (e) => {
504
655
  const tab = (e as CustomEvent).detail?.tab
656
+ if (isInputPanelTab(tab)) {
657
+ this._panelSessionState.activeTab = tab
658
+ this._hasOwnPersistedState = true
659
+ this._persistPanelState()
660
+ }
505
661
  if (tab === 'trackpad') this._showCursor()
506
662
  else this._hideCursor()
507
663
  })
@@ -592,7 +748,7 @@ export class InputPanelAddon implements ITerminalAddon {
592
748
  }
593
749
 
594
750
  private _applyPanelThemeBindings(panel: HTMLElement): void {
595
- const scope = this._terminal?.element?.parentElement ?? this._getMountTarget()
751
+ const scope = this._getTerminalHostElement() ?? this._getMountTarget()
596
752
  const style = getComputedStyle(scope)
597
753
 
598
754
  const background = this._readThemeVar(
@@ -654,8 +810,7 @@ export class InputPanelAddon implements ITerminalAddon {
654
810
  this._cursorEl?.remove()
655
811
  this._cursorEl = null
656
812
 
657
- // On touch devices, keep inputmode='none' permanently
658
- if (!this._listenersAttached) {
813
+ if (!isTouchDevice()) {
659
814
  this._restoreKeyboard()
660
815
  }
661
816
 
@@ -670,22 +825,48 @@ export class InputPanelAddon implements ITerminalAddon {
670
825
  this._isOpen ? this.close() : this.open()
671
826
  }
672
827
 
828
+ /**
829
+ * Sync addon singleton state when host marks this terminal as active.
830
+ *
831
+ * Lifecycle:
832
+ * 1) Always refresh `_lastFocused` so FAB targets the current terminal.
833
+ * 2) If another terminal owns an open panel, migrate panel ownership here.
834
+ * 3) If this panel is already open, keep terminal focus in sync.
835
+ */
836
+ syncFocusLifecycle(): void {
837
+ InputPanelAddon._lastFocused = this
838
+
839
+ if (InputPanelAddon._active && InputPanelAddon._active !== this) {
840
+ this.open()
841
+ return
842
+ }
843
+
844
+ if (this._isOpen) {
845
+ this._focusTerminal()
846
+ }
847
+ }
848
+
673
849
  // ── Terminal focus ──
674
850
 
675
851
  private _focusTerminal(): void {
676
852
  const textarea = this._terminal?.textarea
677
853
  if (!textarea) return
678
- textarea.setAttribute('inputmode', 'none')
854
+ if (isTouchDevice()) {
855
+ textarea.setAttribute('inputmode', 'none')
856
+ }
679
857
  textarea.focus()
680
858
  }
681
859
 
682
860
  // ── Cursor overlay ──
683
861
 
684
862
  private _createCursor(): void {
685
- const container = this._terminal?.element?.parentElement
863
+ const container = this._getTerminalHostElement()
686
864
  if (!container) return
687
865
 
688
866
  const el = document.createElement('div')
867
+ el.setAttribute('data-input-panel-cursor', 'virtual-mouse')
868
+ el.setAttribute('data-terminal-engine', this._detectTerminalEngine())
869
+ el.setAttribute('aria-hidden', 'true')
689
870
  el.style.cssText =
690
871
  'position:absolute;z-index:10;pointer-events:none;opacity:0;transition:opacity 0.15s;color:#fff;'
691
872
  const pointer = iconMousePointer2(20)
@@ -708,7 +889,7 @@ export class InputPanelAddon implements ITerminalAddon {
708
889
  private _showCursor(): void {
709
890
  if (!this._cursorEl) this._createCursor()
710
891
  if (this._cursorEl) {
711
- const container = this._terminal?.element?.parentElement
892
+ const container = this._getTerminalHostElement()
712
893
  if (container) {
713
894
  const rect = container.getBoundingClientRect()
714
895
  this._cursorPos = { x: rect.width / 2, y: rect.height / 2 }
@@ -723,7 +904,7 @@ export class InputPanelAddon implements ITerminalAddon {
723
904
  }
724
905
 
725
906
  private _moveCursor(dx: number, dy: number): void {
726
- const container = this._terminal?.element?.parentElement
907
+ const container = this._getTerminalHostElement()
727
908
  if (!container) return
728
909
  const rect = container.getBoundingClientRect()
729
910
  this._cursorPos.x = Math.max(0, Math.min(rect.width, this._cursorPos.x + dx * SENSITIVITY))
@@ -734,7 +915,7 @@ export class InputPanelAddon implements ITerminalAddon {
734
915
  // ── Mouse event dispatch ──
735
916
 
736
917
  private _getClientCoords(): { clientX: number; clientY: number } | null {
737
- const container = this._terminal?.element?.parentElement
918
+ const container = this._getTerminalHostElement()
738
919
  if (!container) return null
739
920
  const rect = container.getBoundingClientRect()
740
921
  return {
@@ -744,7 +925,7 @@ export class InputPanelAddon implements ITerminalAddon {
744
925
  }
745
926
 
746
927
  private _resolveTarget(clientX: number, clientY: number): Element {
747
- const container = this._terminal?.element?.parentElement
928
+ const container = this._getTerminalHostElement()
748
929
  if (!container) return document.body
749
930
  const el = document.elementFromPoint(clientX, clientY)
750
931
  if (el && container.contains(el)) return el
@@ -815,7 +996,7 @@ export class InputPanelAddon implements ITerminalAddon {
815
996
  // ── Edge scroll (during drag selection) ──
816
997
 
817
998
  private _updateEdgeScroll(): void {
818
- const container = this._terminal?.element?.parentElement
999
+ const container = this._getTerminalHostElement()
819
1000
  if (!container || !this._isDragging) {
820
1001
  this._stopEdgeScroll()
821
1002
  return
@@ -830,7 +1011,7 @@ export class InputPanelAddon implements ITerminalAddon {
830
1011
 
831
1012
  this._stopEdgeScroll()
832
1013
  this._edgeScrollTimer = setInterval(() => {
833
- const container = this._terminal?.element?.parentElement
1014
+ const container = this._getTerminalHostElement()
834
1015
  if (!container || !this._isDragging) {
835
1016
  this._stopEdgeScroll()
836
1017
  return
@@ -869,6 +1050,7 @@ export class InputPanelAddon implements ITerminalAddon {
869
1050
  // ── Keyboard suppression ──
870
1051
 
871
1052
  private _suppressKeyboard(): void {
1053
+ if (!isTouchDevice()) return
872
1054
  const textarea = this._terminal?.textarea
873
1055
  if (textarea) textarea.setAttribute('inputmode', 'none')
874
1056
  }
@@ -914,7 +1096,7 @@ export class InputPanelAddon implements ITerminalAddon {
914
1096
  button.style.border = '1px solid transparent'
915
1097
  button.style.borderRadius = '4px'
916
1098
  button.style.background = 'transparent'
917
- button.style.color = 'var(--muted-foreground, #888)'
1099
+ button.style.color = 'var(--foreground, #fff)'
918
1100
  button.style.fontFamily = 'inherit'
919
1101
  button.style.fontSize = '12px'
920
1102
  button.style.textAlign = 'left'