xterm-input-panel 1.2.4 → 1.2.5

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,11 @@
1
1
  # xterm-input-panel
2
2
 
3
+ ## 1.2.5
4
+
5
+ ### Patch Changes
6
+
7
+ - c265719: Add shared terminal keybindings for OS copy/paste behavior, preserve terminal selection when switching InputPanel tabs, and translate terminal touch gestures into mouse events for mobile terminal interaction.
8
+
3
9
  ## 1.2.4
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xterm-input-panel",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ export { blendHex, cssColorToHex, onThemeChange, resolvePixiTheme } from './pixi
11
11
  export type { PixiTheme } from './pixi-theme.js'
12
12
  export {
13
13
  InputPanelAddon,
14
+ type InputPanelCommand,
15
+ type InputPanelCommandOptions,
14
16
  type InputPanelHistoryItem,
15
17
  type InputPanelSettingsPayload,
16
18
  } from './xterm-addon.js'
@@ -21,6 +21,7 @@ function pointer(target: Element, type: string, x: number, y: number, id = 1) {
21
21
  pointerId: id,
22
22
  pointerType: 'mouse',
23
23
  bubbles: true,
24
+ composed: true,
24
25
  cancelable: true,
25
26
  })
26
27
  )
@@ -174,6 +175,42 @@ export const TabSwitching: StoryObj = {
174
175
  },
175
176
  }
176
177
 
178
+ /**
179
+ * Toolbar controls must not leak pointer events to the terminal host. Terminal
180
+ * renderers treat outside pointerdown as a selection boundary, so tab switching
181
+ * must stay inside the panel.
182
+ */
183
+ export const ToolbarEventBoundary: StoryObj = {
184
+ render: () => html`
185
+ <div data-terminal-boundary style="height: 100%;">
186
+ <input-panel layout="fixed" style="height: 100%;">
187
+ <input-method-tab slot="input"></input-method-tab>
188
+ <virtual-keyboard-tab slot="keys"></virtual-keyboard-tab>
189
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
190
+ <virtual-trackpad-tab slot="trackpad"></virtual-trackpad-tab>
191
+ </input-panel>
192
+ </div>
193
+ `,
194
+ play: async ({ canvasElement }) => {
195
+ const panel = await getLitElement(canvasElement, 'input-panel')
196
+ const boundary = canvasElement.querySelector('[data-terminal-boundary]') as HTMLElement
197
+ const shadow = panel.shadowRoot!
198
+ const tabButtons = shadow.querySelectorAll('.tab-btn')
199
+ const keysTab = tabButtons[1] as HTMLButtonElement
200
+ const boundaryPointerDown = fn()
201
+
202
+ boundary.addEventListener('pointerdown', boundaryPointerDown)
203
+ pointer(keysTab, 'pointerdown', 20, 20)
204
+ keysTab.click()
205
+ await panel.updateComplete
206
+
207
+ expect(boundaryPointerDown).not.toHaveBeenCalled()
208
+ expect(keysTab.hasAttribute('data-active')).toBe(true)
209
+
210
+ boundary.removeEventListener('pointerdown', boundaryPointerDown)
211
+ },
212
+ }
213
+
177
214
  /**
178
215
  * Verifies that the close button dispatches the `input-panel:close` event.
179
216
  */
@@ -568,6 +568,30 @@ export class InputPanel extends LitElement {
568
568
  this.requestUpdate()
569
569
  }
570
570
 
571
+ private _stopToolbarControlPointer(e: Event) {
572
+ e.stopPropagation()
573
+ e.preventDefault()
574
+ }
575
+
576
+ private _stopToolbarControlEvent(e: Event) {
577
+ e.stopPropagation()
578
+ }
579
+
580
+ private _switchTabFromToolbar(e: Event, tab: InputPanelTab) {
581
+ this._stopToolbarControlEvent(e)
582
+ this._switchTab(tab)
583
+ }
584
+
585
+ private _toggleLayoutFromToolbar(e: Event) {
586
+ this._stopToolbarControlEvent(e)
587
+ this._toggleLayout()
588
+ }
589
+
590
+ private _closeFromToolbar(e: Event) {
591
+ this._stopToolbarControlEvent(e)
592
+ this._close()
593
+ }
594
+
571
595
  private _toggleLayout() {
572
596
  this.layout = this.layout === 'fixed' ? 'floating' : 'fixed'
573
597
  if (this.layout === 'fixed') {
@@ -861,7 +885,9 @@ export class InputPanel extends LitElement {
861
885
  class="tab-btn"
862
886
  part="tab-btn"
863
887
  ?data-active=${this.activeTab === t.id}
864
- @click=${() => this._switchTab(t.id)}
888
+ @pointerdown=${(e: PointerEvent) => this._stopToolbarControlPointer(e)}
889
+ @mousedown=${(e: MouseEvent) => this._stopToolbarControlPointer(e)}
890
+ @click=${(e: Event) => this._switchTabFromToolbar(e, t.id)}
865
891
  >
866
892
  ${t.icon} ${this.activeTab === t.id ? t.label : ''}
867
893
  </button>
@@ -869,10 +895,23 @@ export class InputPanel extends LitElement {
869
895
  )}
870
896
  </div>
871
897
  <div class="action-group">
872
- <button class="icon-btn" @click=${this._toggleLayout} title="Toggle layout mode">
898
+ <button
899
+ class="icon-btn"
900
+ @pointerdown=${(e: PointerEvent) => this._stopToolbarControlPointer(e)}
901
+ @mousedown=${(e: MouseEvent) => this._stopToolbarControlPointer(e)}
902
+ @click=${(e: Event) => this._toggleLayoutFromToolbar(e)}
903
+ title="Toggle layout mode"
904
+ >
873
905
  ${this.layout === 'fixed' ? iconPin(14) : iconPinOff(14)}
874
906
  </button>
875
- <button class="icon-btn" part="close-btn" @click=${this._close} title="Close panel">
907
+ <button
908
+ class="icon-btn"
909
+ part="close-btn"
910
+ @pointerdown=${(e: PointerEvent) => this._stopToolbarControlPointer(e)}
911
+ @mousedown=${(e: MouseEvent) => this._stopToolbarControlPointer(e)}
912
+ @click=${(e: Event) => this._closeFromToolbar(e)}
913
+ title="Close panel"
914
+ >
876
915
  ${iconX(14)}
877
916
  </button>
878
917
  </div>
@@ -427,34 +427,14 @@ export class ShortcutTab extends LitElement {
427
427
  )
428
428
  }
429
429
 
430
- private async _handleCommand(command: 'copy' | 'paste' | 'select-all') {
431
- if (command === 'copy') {
432
- const selection = window.getSelection()?.toString() ?? ''
433
- if (!selection) return
434
- try {
435
- await navigator.clipboard.writeText(selection)
436
- } catch {
437
- document.execCommand('copy')
438
- }
439
- return
440
- }
441
-
442
- if (command === 'paste') {
443
- try {
444
- const text = await navigator.clipboard.readText()
445
- if (text) this._send(text)
446
- } catch {
447
- // ignore clipboard permission failures
448
- }
449
- return
450
- }
451
-
452
- const active = document.activeElement
453
- if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
454
- active.select()
455
- return
456
- }
457
- document.execCommand('selectAll')
430
+ private _handleCommand(command: 'copy' | 'paste' | 'select-all') {
431
+ this.dispatchEvent(
432
+ new CustomEvent('input-panel:command', {
433
+ detail: { command },
434
+ bubbles: true,
435
+ composed: true,
436
+ })
437
+ )
458
438
  }
459
439
 
460
440
  private _dpadData(
@@ -477,7 +457,7 @@ export class ShortcutTab extends LitElement {
477
457
  return dy < 0 ? '\x1b[A' : '\x1b[B'
478
458
  }
479
459
 
480
- private async _activateShortcut(
460
+ private _activateShortcut(
481
461
  item: ShortcutItem,
482
462
  event: FederatedPointerEvent,
483
463
  width: number,
@@ -502,7 +482,7 @@ export class ShortcutTab extends LitElement {
502
482
  return
503
483
  }
504
484
 
505
- await this._handleCommand(action.command)
485
+ this._handleCommand(action.command)
506
486
  }
507
487
 
508
488
  private _setActivePage(pageId: string) {
@@ -362,6 +362,79 @@ export const MacosCapsRow: StoryObj = {
362
362
  },
363
363
  }
364
364
 
365
+ /**
366
+ * macOS Command+C is an OS clipboard command, not terminal text input.
367
+ */
368
+ export const MacosCommandCopyCommand: StoryObj = {
369
+ render: () =>
370
+ html`<virtual-keyboard-tab platform="macos" style="height: 100%;"></virtual-keyboard-tab>`,
371
+ play: async ({ canvasElement }) => {
372
+ const el = await setup(canvasElement)
373
+ const commandKey = findKey(el, 'Command')
374
+ expect(commandKey).toBeDefined()
375
+
376
+ const commandHandler = fn()
377
+ const sendHandler = fn()
378
+ el.addEventListener('input-panel:command', commandHandler)
379
+ el.addEventListener('input-panel:send', sendHandler)
380
+
381
+ emitDown(commandKey!.container)
382
+ await new Promise((resolve) => setTimeout(resolve, 100))
383
+
384
+ const cKey = el._keys.find((k) => k.def.data === 'c')
385
+ expect(cKey).toBeDefined()
386
+ emitDown(cKey!.container)
387
+ await new Promise((resolve) => setTimeout(resolve, 50))
388
+ emitUp(cKey!.container)
389
+ await new Promise((resolve) => setTimeout(resolve, 50))
390
+
391
+ expect(sendHandler).toHaveBeenCalledTimes(0)
392
+ expect(commandHandler).toHaveBeenCalledTimes(1)
393
+ const detail = (commandHandler.mock.calls[0] as unknown[])[0] as CustomEvent
394
+ expect(detail.detail).toEqual({ command: 'copy', fallbackData: undefined })
395
+
396
+ el.removeEventListener('input-panel:command', commandHandler)
397
+ el.removeEventListener('input-panel:send', sendHandler)
398
+ },
399
+ }
400
+
401
+ /**
402
+ * Windows/Linux Ctrl+C uses the same command path, with terminal interrupt as
403
+ * fallback when no terminal selection can be copied.
404
+ */
405
+ export const CommonCtrlCopyCommandWithFallback: StoryObj = {
406
+ render: () =>
407
+ html`<virtual-keyboard-tab platform="common" style="height: 100%;"></virtual-keyboard-tab>`,
408
+ play: async ({ canvasElement }) => {
409
+ const el = await setup(canvasElement)
410
+ const ctrlKey = findKey(el, 'Ctrl')
411
+ expect(ctrlKey).toBeDefined()
412
+
413
+ const commandHandler = fn()
414
+ const sendHandler = fn()
415
+ el.addEventListener('input-panel:command', commandHandler)
416
+ el.addEventListener('input-panel:send', sendHandler)
417
+
418
+ emitDown(ctrlKey!.container)
419
+ await new Promise((resolve) => setTimeout(resolve, 100))
420
+
421
+ const cKey = el._keys.find((k) => k.def.data === 'c')
422
+ expect(cKey).toBeDefined()
423
+ emitDown(cKey!.container)
424
+ await new Promise((resolve) => setTimeout(resolve, 50))
425
+ emitUp(cKey!.container)
426
+ await new Promise((resolve) => setTimeout(resolve, 50))
427
+
428
+ expect(sendHandler).toHaveBeenCalledTimes(0)
429
+ expect(commandHandler).toHaveBeenCalledTimes(1)
430
+ const detail = (commandHandler.mock.calls[0] as unknown[])[0] as CustomEvent
431
+ expect(detail.detail).toEqual({ command: 'copy', fallbackData: '\x03' })
432
+
433
+ el.removeEventListener('input-panel:command', commandHandler)
434
+ el.removeEventListener('input-panel:send', sendHandler)
435
+ },
436
+ }
437
+
365
438
  /**
366
439
  * Swipe up on a key sends shifted value without pressing Shift key.
367
440
  */
@@ -9,8 +9,9 @@ import {
9
9
  TextStyle,
10
10
  } from 'pixi.js'
11
11
  import { onThemeChange, resolvePixiTheme, type PixiTheme } from './pixi-theme.js'
12
- import { detectHostPlatform, type PlatformMode } from './platform.js'
12
+ import { detectHostPlatform, type HostPlatform, type PlatformMode } from './platform.js'
13
13
  import { LAYOUTS, type KeyDef, type ModifierKey } from './virtual-keyboard-layouts.js'
14
+ import type { InputPanelCommand } from './xterm-addon.js'
14
15
 
15
16
  const KEY_PADDING = 3
16
17
  const KEY_RADIUS = 4
@@ -112,12 +113,14 @@ export class VirtualKeyboardTab extends LitElement {
112
113
  this._activePointerId = null
113
114
  }
114
115
 
116
+ private _hostPlatform(): HostPlatform {
117
+ return this.platform === 'windows' || this.platform === 'macos' || this.platform === 'common'
118
+ ? this.platform
119
+ : detectHostPlatform()
120
+ }
121
+
115
122
  private getRows(): KeyDef[][] {
116
- const hostPlatform =
117
- this.platform === 'windows' || this.platform === 'macos' || this.platform === 'common'
118
- ? this.platform
119
- : detectHostPlatform()
120
- return LAYOUTS[hostPlatform]
123
+ return LAYOUTS[this._hostPlatform()]
121
124
  }
122
125
 
123
126
  private async _initPixi() {
@@ -566,9 +569,9 @@ export class VirtualKeyboardTab extends LitElement {
566
569
  return def.data
567
570
  }
568
571
 
569
- private _sendKey(def: KeyDef, forceShift: boolean) {
572
+ private _resolveTerminalData(def: KeyDef, forceShift: boolean): string {
570
573
  let data = this._resolveOutputData(def, forceShift)
571
- if (!data) return
574
+ if (!data) return ''
572
575
 
573
576
  if (this._modifiers.ctrl && data.length === 1) {
574
577
  const code = data.toUpperCase().charCodeAt(0) - 64
@@ -581,6 +584,47 @@ export class VirtualKeyboardTab extends LitElement {
581
584
  data = `\x1b${data}`
582
585
  }
583
586
 
587
+ return data
588
+ }
589
+
590
+ private _resolvePrimaryCommand(def: KeyDef): InputPanelCommand | null {
591
+ if (this._modifiers.alt || this._modifiers.shift) return null
592
+
593
+ const platform = this._hostPlatform()
594
+ const primaryModifierActive =
595
+ platform === 'macos'
596
+ ? this._modifiers.meta && !this._modifiers.ctrl
597
+ : this._modifiers.ctrl && !this._modifiers.meta
598
+ if (!primaryModifierActive) return null
599
+
600
+ const key = def.data.toLowerCase()
601
+ if (key === 'c') return 'copy'
602
+ if (key === 'v') return 'paste'
603
+ if (key === 'a') return 'select-all'
604
+ return null
605
+ }
606
+
607
+ private _sendCommand(command: InputPanelCommand, fallbackData?: string) {
608
+ this.dispatchEvent(
609
+ new CustomEvent('input-panel:command', {
610
+ detail: { command, fallbackData },
611
+ bubbles: true,
612
+ composed: true,
613
+ })
614
+ )
615
+ }
616
+
617
+ private _sendKey(def: KeyDef, forceShift: boolean) {
618
+ const data = this._resolveTerminalData(def, forceShift)
619
+ if (!data) return
620
+
621
+ const command = this._resolvePrimaryCommand(def)
622
+ if (command) {
623
+ const fallbackData = this._hostPlatform() === 'macos' ? undefined : data
624
+ this._sendCommand(command, fallbackData)
625
+ return
626
+ }
627
+
584
628
  this.dispatchEvent(
585
629
  new CustomEvent('input-panel:send', {
586
630
  detail: { data },
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'
2
2
  import { Terminal } from '@xterm/xterm'
3
3
  import { html } from 'lit'
4
4
  import { expect, fn, waitFor } from 'storybook/test'
5
- import { InputPanelAddon } from './xterm-addon.js'
5
+ import { InputPanelAddon, resolveTerminalPointerTarget } from './xterm-addon.js'
6
6
 
7
7
  // Register all custom elements (critical — xterm-addon.ts does NOT import these)
8
8
  import './index.js'
@@ -32,7 +32,13 @@ function resetAddonState() {
32
32
  }
33
33
 
34
34
  /** Create a real xterm Terminal + InputPanelAddon, mount into container */
35
- function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
35
+ function setupTerminal(
36
+ container: HTMLElement,
37
+ opts?: {
38
+ stateKey?: string
39
+ onCommand?: (command: 'copy' | 'paste' | 'select-all') => boolean | Promise<boolean>
40
+ }
41
+ ) {
36
42
  const terminal = new Terminal({
37
43
  cols: 80,
38
44
  rows: 10,
@@ -41,7 +47,11 @@ function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
41
47
  })
42
48
 
43
49
  const inputHandler = fn()
44
- const addon = new InputPanelAddon({ onInput: inputHandler, stateKey: opts?.stateKey })
50
+ const addon = new InputPanelAddon({
51
+ onInput: inputHandler,
52
+ onCommand: opts?.onCommand,
53
+ stateKey: opts?.stateKey,
54
+ })
45
55
  terminal.loadAddon(addon)
46
56
  terminal.open(container)
47
57
  storyCleanups.add(() => {
@@ -278,6 +288,83 @@ export const InputForwarding: StoryObj = {
278
288
  },
279
289
  }
280
290
 
291
+ /**
292
+ * Command forwarding: input-panel:command events are delegated to the host.
293
+ * If the host allows the command, the addon writes fallback terminal data.
294
+ */
295
+ export const CommandForwardingWithFallback: StoryObj = {
296
+ render: () => html`<div id="term-container" style="width:100%;height:100%;"></div>`,
297
+ play: async ({ canvasElement }) => {
298
+ resetAddonState()
299
+ const container = canvasElement.querySelector('#term-container') as HTMLElement
300
+ const commandHandler = fn(() => false)
301
+ const { addon, inputHandler } = setupTerminal(container, { onCommand: commandHandler })
302
+
303
+ addon.open()
304
+ expect(addon.isOpen).toBe(true)
305
+
306
+ const panel = container.querySelector('input-panel')!
307
+ await (panel as any).updateComplete
308
+
309
+ panel.dispatchEvent(
310
+ new CustomEvent('input-panel:command', {
311
+ detail: { command: 'copy', fallbackData: '\x03' },
312
+ bubbles: true,
313
+ composed: true,
314
+ })
315
+ )
316
+
317
+ await waitFor(() => {
318
+ expect(commandHandler).toHaveBeenCalledWith('copy', { fallbackData: '\x03' })
319
+ expect(inputHandler).toHaveBeenCalledWith('\x03')
320
+ })
321
+
322
+ addon.close()
323
+ },
324
+ }
325
+
326
+ /**
327
+ * Mouse target resolution ignores the transparent touch overlay so the virtual
328
+ * trackpad can still deliver down/move/up events to the terminal screen.
329
+ */
330
+ export const PointerTargetIgnoresTouchOverlay: StoryObj = {
331
+ render: () => html`
332
+ <div id="term-container" style="width:100%;height:100%;position:relative;">
333
+ <div class="xterm-screen" id="screen" style="position:absolute;inset:0;"></div>
334
+ <div
335
+ class="terminal-touch-mouse-overlay"
336
+ id="overlay"
337
+ style="position:absolute;inset:0;z-index:20;pointer-events:auto;"
338
+ ></div>
339
+ </div>
340
+ `,
341
+ play: async ({ canvasElement }) => {
342
+ const container = canvasElement.querySelector('#term-container') as HTMLElement
343
+ const screen = canvasElement.querySelector('#screen') as HTMLElement
344
+ const overlay = canvasElement.querySelector('#overlay') as HTMLElement
345
+
346
+ const descriptor = Object.getOwnPropertyDescriptor(document, 'elementFromPoint')
347
+ let overlayEnabled = true
348
+ Object.defineProperty(document, 'elementFromPoint', {
349
+ configurable: true,
350
+ value: () => (overlayEnabled && overlay.style.pointerEvents !== 'none' ? overlay : screen),
351
+ })
352
+
353
+ try {
354
+ const target = resolveTerminalPointerTarget(container, 24, 32)
355
+ overlayEnabled = false
356
+ expect(target).toBe(screen)
357
+ expect(overlay.style.pointerEvents).toBe('auto')
358
+ } finally {
359
+ if (descriptor) {
360
+ Object.defineProperty(document, 'elementFromPoint', descriptor)
361
+ } else {
362
+ Reflect.deleteProperty(document, 'elementFromPoint')
363
+ }
364
+ }
365
+ },
366
+ }
367
+
281
368
  /**
282
369
  * Custom element registration: document.createElement('input-panel')
283
370
  * must produce a real InputPanel instance with shadow DOM, not a generic HTMLElement.
@@ -2,6 +2,7 @@ import type { ITerminalAddon, Terminal } from '@xterm/xterm'
2
2
  import { iconKeyboard, iconMousePointer2 } from './icons.js'
3
3
  import type { InputPanelLayout, InputPanelTab } from './input-panel.js'
4
4
  import type { HostPlatform } from './platform.js'
5
+ import type { ShortcutCommand } from './shortcut-pages.js'
5
6
  import { getSessionScopedStorageKey } from './storage-namespace.js'
6
7
 
7
8
  const SENSITIVITY = 1.5
@@ -13,6 +14,32 @@ function isTouchDevice(): boolean {
13
14
  return 'ontouchstart' in window || navigator.maxTouchPoints > 0
14
15
  }
15
16
 
17
+ function isTerminalTouchMouseOverlay(element: Element): element is HTMLElement {
18
+ return (
19
+ element instanceof HTMLElement && element.classList.contains('terminal-touch-mouse-overlay')
20
+ )
21
+ }
22
+
23
+ export function resolveTerminalPointerTarget(
24
+ container: HTMLElement,
25
+ clientX: number,
26
+ clientY: number
27
+ ): Element {
28
+ let element = document.elementFromPoint(clientX, clientY)
29
+ if (element && isTerminalTouchMouseOverlay(element)) {
30
+ const overlayElement = element
31
+ const previousPointerEvents = overlayElement.style.pointerEvents
32
+ overlayElement.style.pointerEvents = 'none'
33
+ element = document.elementFromPoint(clientX, clientY)
34
+ overlayElement.style.pointerEvents = previousPointerEvents
35
+ }
36
+
37
+ if (element && container.contains(element) && !isTerminalTouchMouseOverlay(element)) {
38
+ return element
39
+ }
40
+ return container.querySelector('.xterm-screen') ?? container
41
+ }
42
+
16
43
  export interface InputPanelHistoryItem {
17
44
  text: string
18
45
  time: number
@@ -26,6 +53,12 @@ export interface InputPanelSettingsPayload {
26
53
  historyLimit: number
27
54
  }
28
55
 
56
+ export type InputPanelCommand = ShortcutCommand
57
+
58
+ export interface InputPanelCommandOptions {
59
+ fallbackData?: string
60
+ }
61
+
29
62
  interface InputPanelSessionState {
30
63
  activeTab: InputPanelTab
31
64
  inputDraft: string
@@ -49,6 +82,10 @@ function isInputPanelTab(value: unknown): value is InputPanelTab {
49
82
  )
50
83
  }
51
84
 
85
+ function isInputPanelCommand(value: unknown): value is InputPanelCommand {
86
+ return value === 'copy' || value === 'paste' || value === 'select-all'
87
+ }
88
+
52
89
  interface InputPanelStateStore {
53
90
  lastActiveTab?: InputPanelTab
54
91
  sessions?: Record<string, Partial<InputPanelSessionState>>
@@ -373,6 +410,12 @@ export class InputPanelAddon implements ITerminalAddon {
373
410
  private _listenersAttached = false
374
411
 
375
412
  private _onInput: (data: string) => void
413
+ private _onCommand:
414
+ | ((
415
+ command: InputPanelCommand,
416
+ options: InputPanelCommandOptions
417
+ ) => boolean | Promise<boolean>)
418
+ | null
376
419
  private _onOpenCb: (() => void) | null
377
420
  private _onCloseCb: (() => void) | null
378
421
  private _getHistory: (() => Promise<readonly InputPanelHistoryItem[]>) | null
@@ -391,6 +434,10 @@ export class InputPanelAddon implements ITerminalAddon {
391
434
 
392
435
  constructor(opts?: {
393
436
  onInput?: (data: string) => void
437
+ onCommand?: (
438
+ command: InputPanelCommand,
439
+ options: InputPanelCommandOptions
440
+ ) => boolean | Promise<boolean>
394
441
  onOpen?: () => void
395
442
  onClose?: () => void
396
443
  getHistory?: () => Promise<readonly InputPanelHistoryItem[]>
@@ -403,6 +450,7 @@ export class InputPanelAddon implements ITerminalAddon {
403
450
  stateKey?: string
404
451
  }) {
405
452
  this._onInput = opts?.onInput ?? (() => {})
453
+ this._onCommand = opts?.onCommand ?? null
406
454
  this._onOpenCb = opts?.onOpen ?? null
407
455
  this._onCloseCb = opts?.onClose ?? null
408
456
  this._getHistory = opts?.getHistory ?? null
@@ -443,6 +491,17 @@ export class InputPanelAddon implements ITerminalAddon {
443
491
  this._onInput = fn
444
492
  }
445
493
 
494
+ set onCommand(
495
+ fn:
496
+ | ((
497
+ command: InputPanelCommand,
498
+ options: InputPanelCommandOptions
499
+ ) => boolean | Promise<boolean>)
500
+ | null
501
+ ) {
502
+ this._onCommand = fn
503
+ }
504
+
446
505
  setPlatform(platform: HostPlatform): void {
447
506
  this._platform = platform
448
507
  this._applyPlatformToPanel()
@@ -677,6 +736,25 @@ export class InputPanelAddon implements ITerminalAddon {
677
736
  }
678
737
  }
679
738
  })
739
+ this._on(panel, 'input-panel:command', (e) => {
740
+ const detail = (e as CustomEvent).detail
741
+ if (!isRecord(detail)) return
742
+ const command = detail.command
743
+ if (!isInputPanelCommand(command)) return
744
+ const fallbackData = typeof detail.fallbackData === 'string' ? detail.fallbackData : undefined
745
+
746
+ void Promise.resolve(this._onCommand?.(command, { fallbackData }) ?? false)
747
+ .then((handled) => {
748
+ if (!handled && fallbackData) {
749
+ this._onInput(fallbackData)
750
+ }
751
+ })
752
+ .catch(() => {
753
+ if (fallbackData) {
754
+ this._onInput(fallbackData)
755
+ }
756
+ })
757
+ })
680
758
  this._on(inputTab, 'input-panel:input-change', (e) => {
681
759
  const value = (e as CustomEvent).detail?.value
682
760
  if (typeof value === 'string') {
@@ -972,9 +1050,7 @@ export class InputPanelAddon implements ITerminalAddon {
972
1050
  private _resolveTarget(clientX: number, clientY: number): Element {
973
1051
  const container = this._getTerminalHostElement()
974
1052
  if (!container) return document.body
975
- const el = document.elementFromPoint(clientX, clientY)
976
- if (el && container.contains(el)) return el
977
- return container.querySelector('.xterm-screen') ?? container
1053
+ return resolveTerminalPointerTarget(container, clientX, clientY)
978
1054
  }
979
1055
 
980
1056
  private _dispatchMouse(