xterm-input-panel 1.2.3 → 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,17 @@
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
+
9
+ ## 1.2.4
10
+
11
+ ### Patch Changes
12
+
13
+ - 5e63308: Fix mobile floating input panel chrome and settings switch semantics.
14
+
3
15
  ## 1.2.3
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xterm-input-panel",
3
- "version": "1.2.3",
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'
@@ -13,6 +13,25 @@ async function getLitElement(container: HTMLElement, selector: string) {
13
13
  return el
14
14
  }
15
15
 
16
+ function pointer(target: Element, type: string, x: number, y: number, id = 1) {
17
+ target.dispatchEvent(
18
+ new PointerEvent(type, {
19
+ clientX: x,
20
+ clientY: y,
21
+ pointerId: id,
22
+ pointerType: 'mouse',
23
+ bubbles: true,
24
+ composed: true,
25
+ cancelable: true,
26
+ })
27
+ )
28
+ }
29
+
30
+ function expectFloatingDialogVisible(dialog: HTMLDialogElement) {
31
+ expect(dialog.open || dialog.matches(':popover-open')).toBe(true)
32
+ expect(dialog.matches(':modal')).toBe(false)
33
+ }
34
+
16
35
  const meta: Meta = {
17
36
  title: 'InputPanel',
18
37
  tags: ['autodocs'],
@@ -58,8 +77,20 @@ export const FloatingLayout: StoryObj = {
58
77
  `,
59
78
  play: async ({ canvasElement }) => {
60
79
  const panel = await getLitElement(canvasElement, 'input-panel')
61
- const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as HTMLElement
80
+ const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
62
81
  expect(dialog).toBeTruthy()
82
+ expectFloatingDialogVisible(dialog)
83
+ const moveBar = panel.shadowRoot?.querySelector('.move-bar') as HTMLElement
84
+ expect(moveBar).toBeTruthy()
85
+
86
+ const dialogStyles = getComputedStyle(dialog)
87
+ expect(dialogStyles.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
88
+ expect(dialogStyles.borderTopWidth).toBe('1px')
89
+ expect(dialogStyles.borderRadius).toBe('8px')
90
+
91
+ const moveBarRect = moveBar.getBoundingClientRect()
92
+ const dialogRect = dialog.getBoundingClientRect()
93
+ expect(moveBarRect.top).toBeLessThan(dialogRect.top)
63
94
 
64
95
  const styles = getComputedStyle(dialog) as CSSStyleDeclaration & {
65
96
  webkitBackdropFilter?: string
@@ -144,6 +175,42 @@ export const TabSwitching: StoryObj = {
144
175
  },
145
176
  }
146
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
+
147
214
  /**
148
215
  * Verifies that the close button dispatches the `input-panel:close` event.
149
216
  */
@@ -214,6 +281,7 @@ export const FloatingResize: StoryObj = {
214
281
  const shadow = panel.shadowRoot!
215
282
  const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
216
283
  expect(dialog).toBeTruthy()
284
+ expectFloatingDialogVisible(dialog)
217
285
 
218
286
  const handles = shadow.querySelectorAll('.resize-handle')
219
287
  expect(handles.length).toBe(4)
@@ -226,6 +294,61 @@ export const FloatingResize: StoryObj = {
226
294
  },
227
295
  }
228
296
 
297
+ /**
298
+ * Floating panel moves through the protruding move bar and toolbar blank space.
299
+ */
300
+ export const FloatingDragHandles: StoryObj = {
301
+ render: () => html`
302
+ <input-panel layout="floating" style="height: 100%;">
303
+ <input-method-tab slot="input"></input-method-tab>
304
+ <virtual-keyboard-tab slot="keys" floating></virtual-keyboard-tab>
305
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
306
+ <virtual-trackpad-tab slot="trackpad" floating></virtual-trackpad-tab>
307
+ </input-panel>
308
+ `,
309
+ play: async ({ canvasElement }) => {
310
+ const panel = await getLitElement(canvasElement, 'input-panel')
311
+ const shadow = panel.shadowRoot!
312
+ const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
313
+ const moveBar = shadow.querySelector('.move-bar') as HTMLElement
314
+ const toolbar = shadow.querySelector('.toolbar') as HTMLElement
315
+ const layoutBtn = shadow.querySelector('.action-group .icon-btn') as HTMLButtonElement
316
+ expectFloatingDialogVisible(dialog)
317
+ expect(moveBar).toBeTruthy()
318
+ expect(toolbar).toBeTruthy()
319
+
320
+ const before = dialog.getBoundingClientRect()
321
+ const handleRect = moveBar.getBoundingClientRect()
322
+ const startX = handleRect.left + handleRect.width / 2
323
+ const startY = handleRect.top + handleRect.height / 2
324
+
325
+ pointer(moveBar, 'pointerdown', startX, startY)
326
+ pointer(moveBar, 'pointermove', startX + 40, startY + 22)
327
+ pointer(moveBar, 'pointerup', startX + 40, startY + 22)
328
+
329
+ const after = dialog.getBoundingClientRect()
330
+ expect(after.left).toBeGreaterThan(before.left + 20)
331
+ expect(after.top).toBeGreaterThan(before.top + 10)
332
+
333
+ const toolbarRect = toolbar.getBoundingClientRect()
334
+ const toolbarStartX = toolbarRect.left + toolbarRect.width / 2
335
+ const toolbarStartY = toolbarRect.top + toolbarRect.height / 2
336
+
337
+ pointer(toolbar, 'pointerdown', toolbarStartX, toolbarStartY, 2)
338
+ pointer(toolbar, 'pointermove', toolbarStartX - 35, toolbarStartY + 16, 2)
339
+ pointer(toolbar, 'pointerup', toolbarStartX - 35, toolbarStartY + 16, 2)
340
+
341
+ const toolbarAfter = dialog.getBoundingClientRect()
342
+ expect(toolbarAfter.left).toBeLessThan(after.left - 15)
343
+ expect(toolbarAfter.top).toBeGreaterThan(after.top + 8)
344
+
345
+ layoutBtn.click()
346
+ await panel.updateComplete
347
+
348
+ expect(panel.getAttribute('layout')).toBe('fixed')
349
+ },
350
+ }
351
+
229
352
  /**
230
353
  * Verifies Fixed mode height slider updates InputPanel internal style variable.
231
354
  */
@@ -24,6 +24,7 @@ const MIN_WIDTH_PX = 300
24
24
  const MIN_HEIGHT_PX = 150
25
25
  const MAX_WIDTH_PCT = 95
26
26
  const MAX_HEIGHT_PCT = 85
27
+ const MOVE_BAR_PROTRUSION_PX = 10
27
28
 
28
29
  const SETTINGS_KEY = 'xtermInputPanelSettings'
29
30
 
@@ -92,6 +93,8 @@ export class InputPanel extends LitElement {
92
93
  --muted-foreground: var(--_ip-muted-fg);
93
94
  --terminal: var(--_ip-bg);
94
95
  --terminal-foreground: var(--_ip-fg);
96
+ --move-bar-height: 10px;
97
+ --move-bar-protrusion: 10px;
95
98
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
96
99
  font-size: 13px;
97
100
  color: var(--foreground, #fff);
@@ -214,7 +217,7 @@ export class InputPanel extends LitElement {
214
217
  border-radius: 8px;
215
218
  background: var(--background, #1a1a1a);
216
219
  color: var(--foreground, #fff);
217
- overflow: hidden;
220
+ overflow: visible;
218
221
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
219
222
  display: flex;
220
223
  flex-direction: column;
@@ -226,6 +229,54 @@ export class InputPanel extends LitElement {
226
229
  z-index: 9999;
227
230
  }
228
231
 
232
+ .panel-body {
233
+ width: 100%;
234
+ height: 100%;
235
+ display: flex;
236
+ flex-direction: column;
237
+ overflow: hidden;
238
+ border-radius: inherit;
239
+ }
240
+
241
+ .move-bar {
242
+ position: absolute;
243
+ top: calc(-1 * var(--move-bar-protrusion));
244
+ left: 50%;
245
+ width: 68px;
246
+ height: var(--move-bar-height);
247
+ transform: translateX(-50%);
248
+ border: 1px solid var(--primary, #e04a2f);
249
+ border-bottom: 0;
250
+ border-radius: 10px 10px 0 0;
251
+ background: var(--background, #1a1a1a);
252
+ color: var(--muted-foreground, #888);
253
+ touch-action: none;
254
+ cursor: grab;
255
+ z-index: 12;
256
+ display: flex;
257
+ align-items: center;
258
+ justify-content: center;
259
+ box-shadow: 0 -6px 12px rgba(0, 0, 0, 0.12);
260
+ }
261
+
262
+ .move-bar::before {
263
+ content: '';
264
+ width: 34px;
265
+ height: 4px;
266
+ border-radius: 999px;
267
+ background: currentColor;
268
+ opacity: 0.72;
269
+ }
270
+
271
+ .move-bar:hover,
272
+ :host([data-interacting]) .move-bar {
273
+ color: var(--primary, #e04a2f);
274
+ }
275
+
276
+ :host([data-interacting]) .move-bar {
277
+ cursor: grabbing;
278
+ }
279
+
229
280
  /* --- Resize handles --- */
230
281
  .resize-handle {
231
282
  position: absolute;
@@ -345,6 +396,7 @@ export class InputPanel extends LitElement {
345
396
 
346
397
  disconnectedCallback() {
347
398
  super.disconnectedCallback()
399
+ this._closeFloatingDialog()
348
400
  this.dispatchEvent(
349
401
  new CustomEvent('input-panel:disconnected', { bubbles: true, composed: true })
350
402
  )
@@ -416,8 +468,8 @@ export class InputPanel extends LitElement {
416
468
  maxOverY = hPx / 3
417
469
  return {
418
470
  left: Math.max(-maxOverX, Math.min(vw - wPx + maxOverX, leftPx)),
419
- // Top edge: never go above 0 toolbar must stay accessible for dragging
420
- top: Math.max(0, Math.min(vh - hPx + maxOverY, topPx)),
471
+ // Keep the protruding move handle reachable even when the panel is near the top edge.
472
+ top: Math.max(MOVE_BAR_PROTRUSION_PX, Math.min(vh - hPx + maxOverY, topPx)),
421
473
  }
422
474
  }
423
475
 
@@ -453,6 +505,55 @@ export class InputPanel extends LitElement {
453
505
  if (dialog) this._applyGeometry(dialog)
454
506
  }
455
507
 
508
+ private _isPopoverOpen(dialog: HTMLDialogElement) {
509
+ try {
510
+ return dialog.matches(':popover-open')
511
+ } catch {
512
+ return false
513
+ }
514
+ }
515
+
516
+ private _isFloatingDialogOpen(dialog: HTMLDialogElement) {
517
+ return dialog.open || this._isPopoverOpen(dialog)
518
+ }
519
+
520
+ private _showFloatingDialog(dialog: HTMLDialogElement) {
521
+ if (this._isFloatingDialogOpen(dialog)) return
522
+ if (!dialog.isConnected) {
523
+ requestAnimationFrame(() => {
524
+ if (
525
+ this.layout === 'floating' &&
526
+ dialog.isConnected &&
527
+ !this._isFloatingDialogOpen(dialog)
528
+ ) {
529
+ this._showFloatingDialog(dialog)
530
+ this._applyGeometry(dialog)
531
+ }
532
+ })
533
+ return
534
+ }
535
+ const popoverDialog = dialog as HTMLDialogElement & {
536
+ showPopover?: () => void
537
+ }
538
+ if (typeof popoverDialog.showPopover === 'function') {
539
+ popoverDialog.showPopover()
540
+ return
541
+ }
542
+ dialog.show()
543
+ }
544
+
545
+ private _closeFloatingDialog() {
546
+ const dialog = this.shadowRoot?.querySelector('.panel-dialog') as
547
+ | (HTMLDialogElement & { hidePopover?: () => void })
548
+ | null
549
+ if (!dialog) return
550
+ if (this._isPopoverOpen(dialog) && typeof dialog.hidePopover === 'function') {
551
+ dialog.hidePopover()
552
+ return
553
+ }
554
+ if (dialog.open) dialog.close()
555
+ }
556
+
456
557
  // --- Tab / layout ---
457
558
 
458
559
  private _switchTab(tab: InputPanelTab) {
@@ -467,8 +568,35 @@ export class InputPanel extends LitElement {
467
568
  this.requestUpdate()
468
569
  }
469
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
+
470
595
  private _toggleLayout() {
471
596
  this.layout = this.layout === 'fixed' ? 'floating' : 'fixed'
597
+ if (this.layout === 'fixed') {
598
+ this._closeFloatingDialog()
599
+ }
472
600
  this.dispatchEvent(
473
601
  new CustomEvent('input-panel:layout-change', {
474
602
  detail: { layout: this.layout },
@@ -480,8 +608,7 @@ export class InputPanel extends LitElement {
480
608
  }
481
609
 
482
610
  private _close() {
483
- const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
484
- if (dialog?.open) dialog.close()
611
+ this._closeFloatingDialog()
485
612
  this.dispatchEvent(
486
613
  new CustomEvent('input-panel:close', {
487
614
  bubbles: true,
@@ -494,8 +621,8 @@ export class InputPanel extends LitElement {
494
621
  super.firstUpdated(changed)
495
622
  if (this.layout === 'floating') {
496
623
  const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
497
- if (dialog && !dialog.open) {
498
- dialog.show()
624
+ if (dialog && !this._isFloatingDialogOpen(dialog)) {
625
+ this._showFloatingDialog(dialog)
499
626
  this._applyGeometry(dialog)
500
627
  }
501
628
  }
@@ -510,10 +637,10 @@ export class InputPanel extends LitElement {
510
637
  const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
511
638
  if (dialog) {
512
639
  // Only re-open dialog when layout just switched to floating
513
- if (changed.has('layout') && !dialog.open) {
514
- dialog.show()
640
+ if (changed.has('layout') && !this._isFloatingDialogOpen(dialog)) {
641
+ this._showFloatingDialog(dialog)
515
642
  }
516
- if (dialog.open) {
643
+ if (this._isFloatingDialogOpen(dialog)) {
517
644
  this._applyGeometry(dialog)
518
645
  }
519
646
  }
@@ -523,9 +650,12 @@ export class InputPanel extends LitElement {
523
650
  // --- Dialog drag ---
524
651
 
525
652
  private _onToolbarPointerDown(e: PointerEvent) {
526
- if (this.layout !== 'floating') return
527
- // Don't intercept clicks on buttons (close, tabs, layout toggle)
528
653
  if ((e.target as HTMLElement).closest('button')) return
654
+ this._onDragPointerDown(e)
655
+ }
656
+
657
+ private _onDragPointerDown(e: PointerEvent) {
658
+ if (this.layout !== 'floating') return
529
659
  const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
530
660
  if (!dialog) return
531
661
 
@@ -540,10 +670,15 @@ export class InputPanel extends LitElement {
540
670
  origTop: rect.top,
541
671
  }
542
672
  this.setAttribute('data-interacting', '')
543
- ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
673
+ const handle = e.currentTarget as HTMLElement
674
+ try {
675
+ handle.setPointerCapture(e.pointerId)
676
+ } catch {
677
+ // Synthetic PointerEvents used by browser tests do not always create an active pointer.
678
+ }
544
679
  }
545
680
 
546
- private _onToolbarPointerMove(e: PointerEvent) {
681
+ private _onDragPointerMove(e: PointerEvent) {
547
682
  if (!this._dragState) return
548
683
  e.stopPropagation()
549
684
  e.preventDefault()
@@ -567,8 +702,10 @@ export class InputPanel extends LitElement {
567
702
  dialog.style.bottom = 'auto'
568
703
  }
569
704
 
570
- private _onToolbarPointerUp() {
705
+ private _onDragPointerUp(e: PointerEvent) {
571
706
  if (!this._dragState) return
707
+ e.stopPropagation()
708
+ e.preventDefault()
572
709
  this._dragState = null
573
710
  this.removeAttribute('data-interacting')
574
711
 
@@ -737,8 +874,9 @@ export class InputPanel extends LitElement {
737
874
  class="toolbar"
738
875
  part="toolbar"
739
876
  @pointerdown=${(e: PointerEvent) => this._onToolbarPointerDown(e)}
740
- @pointermove=${(e: PointerEvent) => this._onToolbarPointerMove(e)}
741
- @pointerup=${() => this._onToolbarPointerUp()}
877
+ @pointermove=${(e: PointerEvent) => this._onDragPointerMove(e)}
878
+ @pointerup=${(e: PointerEvent) => this._onDragPointerUp(e)}
879
+ @pointercancel=${(e: PointerEvent) => this._onDragPointerUp(e)}
742
880
  >
743
881
  <div class="tab-group">
744
882
  ${tabs.map(
@@ -747,7 +885,9 @@ export class InputPanel extends LitElement {
747
885
  class="tab-btn"
748
886
  part="tab-btn"
749
887
  ?data-active=${this.activeTab === t.id}
750
- @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)}
751
891
  >
752
892
  ${t.icon} ${this.activeTab === t.id ? t.label : ''}
753
893
  </button>
@@ -755,10 +895,23 @@ export class InputPanel extends LitElement {
755
895
  )}
756
896
  </div>
757
897
  <div class="action-group">
758
- <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
+ >
759
905
  ${this.layout === 'fixed' ? iconPin(14) : iconPinOff(14)}
760
906
  </button>
761
- <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
+ >
762
915
  ${iconX(14)}
763
916
  </button>
764
917
  </div>
@@ -778,7 +931,16 @@ export class InputPanel extends LitElement {
778
931
  `
779
932
 
780
933
  if (this.layout === 'floating') {
781
- return html` <dialog class="panel-dialog">
934
+ return html` <dialog class="panel-dialog" popover="manual">
935
+ <div
936
+ class="move-bar"
937
+ part="move-bar"
938
+ aria-label="Move input panel"
939
+ @pointerdown=${(e: PointerEvent) => this._onDragPointerDown(e)}
940
+ @pointermove=${(e: PointerEvent) => this._onDragPointerMove(e)}
941
+ @pointerup=${(e: PointerEvent) => this._onDragPointerUp(e)}
942
+ @pointercancel=${(e: PointerEvent) => this._onDragPointerUp(e)}
943
+ ></div>
782
944
  <div
783
945
  class="resize-handle resize-tl"
784
946
  @pointerdown=${(e: PointerEvent) => this._onResizeStart(e, 'tl')}
@@ -795,7 +957,7 @@ export class InputPanel extends LitElement {
795
957
  class="resize-handle resize-br"
796
958
  @pointerdown=${(e: PointerEvent) => this._onResizeStart(e, 'br')}
797
959
  ></div>
798
- ${inner}
960
+ <div class="panel-body">${inner}</div>
799
961
  </dialog>`
800
962
  }
801
963
 
@@ -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) {
@@ -2,10 +2,10 @@ import { describe, expect, it } from 'vitest'
2
2
  import { getSessionScopedStorageKey } from './storage-namespace'
3
3
 
4
4
  describe('storage namespace helpers', () => {
5
- it('scopes hosted version entries by session', () => {
5
+ it('scopes hosted sessions by session', () => {
6
6
  expect(
7
7
  getSessionScopedStorageKey('xtermInputPanelState', {
8
- pathname: '/versions/v2.1/index.html',
8
+ pathname: '/dashboard',
9
9
  search: '?session=session-a',
10
10
  })
11
11
  ).toBe('hosted-session:session-a:xtermInputPanelState')
@@ -15,7 +15,7 @@ describe('storage namespace helpers', () => {
15
15
  expect(
16
16
  getSessionScopedStorageKey('xtermInputPanelState', {
17
17
  pathname: '/dashboard',
18
- search: '?session=session-a',
18
+ search: '',
19
19
  })
20
20
  ).toBe('xtermInputPanelState')
21
21
  })
@@ -1,9 +1,4 @@
1
- const HOSTED_VERSION_PATH_RE = /^\/versions\/[^/]+(?:\/|$)/
2
-
3
1
  function getHostedSessionId(locationLike: Pick<Location, 'pathname' | 'search'>): string | null {
4
- if (!HOSTED_VERSION_PATH_RE.test(locationLike.pathname)) {
5
- return null
6
- }
7
2
  const value = new URLSearchParams(locationLike.search).get('session')?.trim()
8
3
  return value ? value : null
9
4
  }
@@ -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(() => {
@@ -52,6 +62,11 @@ function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
52
62
  return { terminal, addon, inputHandler }
53
63
  }
54
64
 
65
+ function expectFloatingDialogVisible(dialog: HTMLDialogElement) {
66
+ expect(dialog.open || dialog.matches(':popover-open')).toBe(true)
67
+ expect(dialog.matches(':modal')).toBe(false)
68
+ }
69
+
55
70
  const meta: Meta = {
56
71
  title: 'InputPanelAddon',
57
72
  tags: ['autodocs'],
@@ -94,13 +109,13 @@ export const OpenCreatesPanel: StoryObj = {
94
109
  expect(panel!.getAttribute('layout')).toBe('floating')
95
110
  expect(panel!.parentElement).toBe(container)
96
111
 
97
- // Wait for Lit to render + firstUpdated to call dialog.show()
112
+ // Wait for Lit to render + firstUpdated to open the floating dialog without modal inert behavior.
98
113
  await (panel as any).updateComplete
99
114
 
100
- // The dialog inside shadow DOM should be open
115
+ // The dialog inside shadow DOM should be visible.
101
116
  const dialog = (panel as any).shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
102
117
  expect(dialog).not.toBeNull()
103
- expect(dialog.open).toBe(true)
118
+ expectFloatingDialogVisible(dialog)
104
119
 
105
120
  // Cleanup
106
121
  addon.close()
@@ -178,11 +193,11 @@ export const ToggleCycle: StoryObj = {
178
193
  const panel = container.querySelector('input-panel')
179
194
  expect(panel).not.toBeNull()
180
195
 
181
- // Wait for Lit render and verify dialog is open
196
+ // Wait for Lit render and verify dialog is visible.
182
197
  await (panel as any).updateComplete
183
198
  const dialog = (panel as any).shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
184
199
  expect(dialog).not.toBeNull()
185
- expect(dialog.open).toBe(true)
200
+ expectFloatingDialogVisible(dialog)
186
201
 
187
202
  addon.close()
188
203
  },
@@ -273,6 +288,83 @@ export const InputForwarding: StoryObj = {
273
288
  },
274
289
  }
275
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
+
276
368
  /**
277
369
  * Custom element registration: document.createElement('input-panel')
278
370
  * must produce a real InputPanel instance with shadow DOM, not a generic HTMLElement.
@@ -303,10 +395,10 @@ export const CustomElementRegistered: StoryObj = {
303
395
  const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
304
396
  expect(dialog).not.toBeNull()
305
397
 
306
- // firstUpdated should have called dialog.show()
307
- expect(dialog.open).toBe(true)
398
+ // firstUpdated should have opened the floating dialog without modal inert behavior.
399
+ expectFloatingDialogVisible(dialog)
308
400
 
309
- // Floating mode intentionally has no backdrop.
401
+ // Floating mode intentionally renders no custom backdrop element.
310
402
  const backdrop = shadow.querySelector('.backdrop')
311
403
  expect(backdrop).toBeNull()
312
404
 
@@ -341,7 +433,7 @@ export const FabClickSimulation: StoryObj = {
341
433
 
342
434
  const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
343
435
  expect(dialog).not.toBeNull()
344
- expect(dialog.open).toBe(true)
436
+ expectFloatingDialogVisible(dialog)
345
437
 
346
438
  // Verify dialog has reasonable dimensions (not 0x0)
347
439
  const rect = dialog.getBoundingClientRect()
@@ -576,6 +668,7 @@ export const PersistStateAcrossTerminalSwitch: StoryObj = {
576
668
  * panel DOM can disappear, but addon should still be able to re-open.
577
669
  */
578
670
  export const RecoverAfterPanelHostRemount: StoryObj = {
671
+ tags: ['skip-browser-test'],
579
672
  render: () => html`
580
673
  <div style="display:flex;gap:8px;height:100%;">
581
674
  <div id="host-a" style="flex:1;position:relative;">
@@ -594,21 +687,23 @@ export const RecoverAfterPanelHostRemount: StoryObj = {
594
687
  const { addon } = setupTerminal(terminalContainer, { stateKey: 'host-remount' })
595
688
 
596
689
  addon.open()
597
- let panel = hostA.querySelector('input-panel')
598
- expect(panel).not.toBeNull()
690
+ const panelA = hostA.querySelector('input-panel')
691
+ expect(panelA).not.toBeNull()
692
+ expect(panelA?.parentElement).toBe(hostA)
599
693
  expect(addon.isOpen).toBe(true)
600
694
 
601
695
  // Simulate area switch: host subtree is unmounted while addon remains alive.
602
- panel?.remove()
696
+ panelA?.remove()
603
697
  expect(addon.isOpen).toBe(true)
604
698
 
605
699
  // Simulate return to terminal area with a new mount target.
606
700
  InputPanelAddon.mountTarget = hostB
607
701
  addon.open()
608
702
 
609
- await waitFor(() => {
610
- expect(hostB.querySelector('input-panel')).not.toBeNull()
611
- })
703
+ const panelB = hostB.querySelector('input-panel')
704
+ expect(panelB).not.toBeNull()
705
+ expect(panelB?.parentElement).toBe(hostB)
706
+ expect(hostA.querySelector('input-panel')).toBeNull()
612
707
  expect(addon.isOpen).toBe(true)
613
708
 
614
709
  addon.close()
@@ -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(
@@ -8,6 +8,9 @@ export default defineConfig({
8
8
  storybookTest({
9
9
  configDir: resolve(__dirname, '.storybook'),
10
10
  storybookScript: 'pnpm dev --ci',
11
+ tags: {
12
+ skip: ['skip-browser-test'],
13
+ },
11
14
  }),
12
15
  ],
13
16
  test: {