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 +18 -0
- package/package.json +1 -1
- package/src/input-method-tab.ts +19 -7
- package/src/xterm-addon.stories.ts +150 -6
- package/src/xterm-addon.ts +206 -24
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
package/src/input-method-tab.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
87
|
+
declare value: string
|
|
88
88
|
|
|
89
89
|
constructor() {
|
|
90
90
|
super()
|
|
91
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/xterm-addon.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(--
|
|
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'
|