lit-shell.js 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +438 -9
  3. package/dist/client/browser-bundle.js +61 -1
  4. package/dist/client/browser-bundle.js.map +3 -3
  5. package/dist/client/index.d.ts +1 -0
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +1 -0
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/terminal-client.d.ts +19 -0
  10. package/dist/client/terminal-client.d.ts.map +1 -1
  11. package/dist/client/terminal-client.js +61 -0
  12. package/dist/client/terminal-client.js.map +1 -1
  13. package/dist/server/index.d.ts +1 -0
  14. package/dist/server/index.d.ts.map +1 -1
  15. package/dist/server/index.js +1 -0
  16. package/dist/server/index.js.map +1 -1
  17. package/dist/server/session-manager.d.ts +6 -0
  18. package/dist/server/session-manager.d.ts.map +1 -1
  19. package/dist/server/session-manager.js +12 -2
  20. package/dist/server/session-manager.js.map +1 -1
  21. package/dist/server/terminal-server.d.ts.map +1 -1
  22. package/dist/server/terminal-server.js +23 -4
  23. package/dist/server/terminal-server.js.map +1 -1
  24. package/dist/shared/types.d.ts +6 -0
  25. package/dist/shared/types.d.ts.map +1 -1
  26. package/dist/ui/browser-bundle.js +1625 -96
  27. package/dist/ui/browser-bundle.js.map +4 -4
  28. package/dist/ui/index.d.ts +1 -0
  29. package/dist/ui/index.d.ts.map +1 -1
  30. package/dist/ui/index.js +1 -0
  31. package/dist/ui/index.js.map +1 -1
  32. package/dist/ui/lit-shell-terminal.d.ts +225 -6
  33. package/dist/ui/lit-shell-terminal.d.ts.map +1 -1
  34. package/dist/ui/lit-shell-terminal.js +1605 -60
  35. package/dist/ui/lit-shell-terminal.js.map +1 -1
  36. package/dist/ui/styles.d.ts.map +1 -1
  37. package/dist/ui/styles.js +22 -0
  38. package/dist/ui/styles.js.map +1 -1
  39. package/dist/version.d.ts +6 -0
  40. package/dist/version.d.ts.map +1 -0
  41. package/dist/version.js +6 -0
  42. package/dist/version.js.map +1 -0
  43. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  * lit-shell-terminal web component
3
3
  *
4
4
  * A ready-to-use terminal component that wraps xterm.js and
5
- * connects to a lit-shell server via WebSocket.
5
+ * connects to an lit-shell server via WebSocket.
6
6
  *
7
7
  * Usage:
8
8
  * ```html
@@ -24,6 +24,7 @@ import { LitElement, html, css, nothing } from 'lit';
24
24
  import { customElement, property, state } from 'lit/decorators.js';
25
25
  import { sharedStyles, buttonStyles, themeStyles } from './styles.js';
26
26
  import { TerminalClient } from '../client/terminal-client.js';
27
+ import { VERSION } from '../version.js';
27
28
  let LitShellTerminal = class LitShellTerminal extends LitElement {
28
29
  constructor() {
29
30
  super(...arguments);
@@ -37,9 +38,19 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
37
38
  this.noHeader = false;
38
39
  this.autoConnect = false;
39
40
  this.autoSpawn = false;
41
+ // Docker container properties
42
+ this.container = '';
43
+ this.containerShell = '';
44
+ this.containerUser = '';
45
+ this.containerCwd = '';
46
+ // UI panel options
47
+ this.showConnectionPanel = false;
48
+ this.showSettings = false;
49
+ this.showStatusBar = false;
50
+ this.showTabs = false;
40
51
  // Terminal appearance
41
52
  this.fontSize = 14;
42
- this.fontFamily = 'Menlo, Monaco, "Courier New", monospace';
53
+ this.fontFamily = '"Cascadia Mono", "Cascadia Code", Consolas, "Ubuntu Mono", "DejaVu Sans Mono", "Liberation Mono", Hack, "Fira Code", "JetBrains Mono", Menlo, Monaco, "Courier New", monospace';
43
54
  // State
44
55
  this.client = null;
45
56
  this.terminal = null;
@@ -49,17 +60,89 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
49
60
  this.loading = false;
50
61
  this.error = null;
51
62
  this.sessionInfo = null;
63
+ // Connection panel state
64
+ this.containers = [];
65
+ this.serverInfo = null;
66
+ this.selectedContainer = '';
67
+ this.selectedShell = '/bin/sh';
68
+ this.connectionMode = 'local';
69
+ // Persistence options
70
+ this.orphanTimeout = 3600000; // 1 hour default
71
+ this.useTmux = false;
72
+ // Session multiplexing state
73
+ this.availableSessions = [];
74
+ this.selectedSessionId = '';
75
+ this.clientCount = 1;
76
+ // Settings state
77
+ this.settingsMenuOpen = false;
78
+ // Reconnect dialog state
79
+ this.showReconnectDialog = false;
80
+ this.reconnectSessionId = null;
81
+ // Mobile support state
82
+ this.isMobile = false;
83
+ this.showTouchKeyboard = true;
84
+ this.showExtraKeyRows = false;
85
+ // Status bar state
86
+ this.statusMessage = '';
87
+ this.statusType = 'info';
88
+ // Tab state
89
+ this.tabs = [];
90
+ this.activeTabId = '';
91
+ this.tabCounter = 0;
52
92
  // xterm.js module (loaded dynamically)
53
93
  this.xtermModule = null;
54
94
  this.fitAddonModule = null;
55
95
  this.resizeObserver = null;
96
+ // ==================== Touch Keyboard Methods ====================
97
+ // Modifier key state for touch keyboard
98
+ this.ctrlPressed = false;
99
+ this.altPressed = false;
56
100
  }
57
101
  connectedCallback() {
58
102
  super.connectedCallback();
103
+ // Set version as data attribute for easy inspection
104
+ this.setAttribute('data-version', VERSION);
105
+ // Detect mobile devices
106
+ this.detectMobile();
107
+ // Create initial tab if tabs are enabled
108
+ if (this.showTabs && this.tabs.length === 0) {
109
+ this.createTab();
110
+ }
59
111
  if (this.autoConnect && this.url) {
60
112
  this.connect();
61
113
  }
62
114
  }
115
+ /**
116
+ * Detect if running on a mobile device
117
+ */
118
+ detectMobile() {
119
+ // Check for touch capability
120
+ const hasTouch = navigator.maxTouchPoints > 0;
121
+ // Check for mobile-sized viewport
122
+ const isMobileWidth = window.matchMedia('(max-width: 768px)').matches;
123
+ // Check for mobile user agent (backup detection)
124
+ const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
125
+ this.isMobile = hasTouch && (isMobileWidth || mobileUA);
126
+ this.updateMobileAttribute();
127
+ // Listen for viewport changes
128
+ window.matchMedia('(max-width: 768px)').addEventListener('change', (e) => {
129
+ if (navigator.maxTouchPoints > 0) {
130
+ this.isMobile = e.matches;
131
+ this.updateMobileAttribute();
132
+ }
133
+ });
134
+ }
135
+ /**
136
+ * Update mobile attribute for CSS targeting
137
+ */
138
+ updateMobileAttribute() {
139
+ if (this.isMobile) {
140
+ this.setAttribute('mobile', '');
141
+ }
142
+ else {
143
+ this.removeAttribute('mobile');
144
+ }
145
+ }
63
146
  disconnectedCallback() {
64
147
  super.disconnectedCallback();
65
148
  this.cleanup();
@@ -89,35 +172,30 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
89
172
  throw new Error('Failed to load xterm.js. Make sure it is available.');
90
173
  }
91
174
  }
92
- // Load xterm.css into shadow DOM (CSS doesn't penetrate shadow boundaries)
93
- await this.loadXtermStyles();
175
+ // Inject xterm CSS into shadow DOM (required because CSS doesn't cross shadow boundaries)
176
+ await this.injectXtermCSS();
94
177
  }
95
178
  /**
96
- * Load xterm.css into the shadow DOM
97
- * This is necessary because CSS loaded in the main document doesn't apply inside shadow DOM
179
+ * Inject xterm.js CSS into shadow DOM
98
180
  */
99
- async loadXtermStyles() {
181
+ async injectXtermCSS() {
100
182
  if (!this.shadowRoot)
101
183
  return;
102
- // Check if styles are already loaded
184
+ // Check if already injected
103
185
  if (this.shadowRoot.querySelector('#xterm-styles'))
104
186
  return;
105
187
  try {
106
- // Fetch xterm.css from CDN
188
+ // Fetch xterm CSS from CDN
107
189
  const response = await fetch('https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css');
108
- if (!response.ok) {
109
- throw new Error(`Failed to fetch xterm.css: ${response.status}`);
110
- }
111
- const cssText = await response.text();
190
+ const css = await response.text();
112
191
  // Create style element and inject into shadow DOM
113
192
  const style = document.createElement('style');
114
193
  style.id = 'xterm-styles';
115
- style.textContent = cssText;
116
- this.shadowRoot.appendChild(style);
194
+ style.textContent = css;
195
+ this.shadowRoot.prepend(style);
117
196
  }
118
- catch (error) {
119
- console.warn('[lit-shell] Failed to load xterm.css:', error);
120
- // Terminal will still work but may have visual issues
197
+ catch (e) {
198
+ console.warn('[lit-shell] Failed to load xterm CSS:', e);
121
199
  }
122
200
  }
123
201
  /**
@@ -149,27 +227,144 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
149
227
  });
150
228
  this.client.onError((err) => {
151
229
  this.error = err.message;
230
+ this.setStatus(err.message, 'error');
152
231
  this.dispatchEvent(new CustomEvent('error', { detail: { error: err }, bubbles: true, composed: true }));
153
232
  });
233
+ // Capture reference to this client for use in closures
234
+ const client = this.client;
154
235
  this.client.onData((data) => {
155
- if (this.terminal) {
236
+ // Find the terminal that belongs to this client (for multi-tab support)
237
+ if (this.showTabs) {
238
+ const tab = this.tabs.find((t) => t.client === client);
239
+ if (tab?.terminal) {
240
+ tab.terminal.write(data);
241
+ }
242
+ }
243
+ else if (this.terminal) {
156
244
  this.terminal.write(data);
157
245
  }
158
246
  });
159
247
  this.client.onExit((code) => {
160
- this.sessionActive = false;
161
- this.sessionInfo = null;
162
- if (this.terminal) {
163
- this.terminal.writeln('');
164
- this.terminal.writeln(`\x1b[1;33m[Process exited with code: ${code}]\x1b[0m`);
248
+ // Find the tab that belongs to this client
249
+ if (this.showTabs) {
250
+ const tab = this.tabs.find((t) => t.client === client);
251
+ if (tab) {
252
+ tab.sessionActive = false;
253
+ tab.sessionInfo = null;
254
+ if (tab.terminal) {
255
+ tab.terminal.writeln('');
256
+ tab.terminal.writeln(`\x1b[1;33m[Process exited with code: ${code}]\x1b[0m`);
257
+ }
258
+ // Update component state if this is the active tab
259
+ if (tab.id === this.activeTabId) {
260
+ this.sessionActive = false;
261
+ this.sessionInfo = null;
262
+ }
263
+ this.tabs = [...this.tabs]; // Trigger re-render
264
+ }
265
+ }
266
+ else {
267
+ this.sessionActive = false;
268
+ this.sessionInfo = null;
269
+ if (this.terminal) {
270
+ this.terminal.writeln('');
271
+ this.terminal.writeln(`\x1b[1;33m[Process exited with code: ${code}]\x1b[0m`);
272
+ }
165
273
  }
166
274
  this.dispatchEvent(new CustomEvent('exit', { detail: { exitCode: code }, bubbles: true, composed: true }));
167
275
  });
168
276
  this.client.onSpawned((info) => {
277
+ // Update tab-specific state
278
+ if (this.showTabs) {
279
+ const tab = this.tabs.find((t) => t.client === client);
280
+ if (tab) {
281
+ tab.sessionInfo = info;
282
+ tab.sessionActive = true;
283
+ // Update label to show shell/container
284
+ tab.label = info.container || info.shell.split('/').pop() || 'Terminal';
285
+ this.tabs = [...this.tabs];
286
+ }
287
+ }
169
288
  this.sessionInfo = info;
289
+ this.setStatus(`Session started: ${info.container || info.shell}`, 'success');
170
290
  this.dispatchEvent(new CustomEvent('spawned', { detail: { session: info }, bubbles: true, composed: true }));
171
291
  });
292
+ // Server info and container list handlers
293
+ this.client.onServerInfo((info) => {
294
+ this.serverInfo = info;
295
+ if (info.dockerEnabled) {
296
+ this.connectionMode = 'docker';
297
+ this.client?.requestContainerList();
298
+ }
299
+ this.selectedShell = info.defaultShell;
300
+ });
301
+ this.client.onContainerList((containers) => {
302
+ this.containers = containers;
303
+ if (containers.length > 0 && !this.selectedContainer) {
304
+ this.selectedContainer = containers[0].name;
305
+ }
306
+ });
307
+ // Multiplexing event handlers
308
+ this.client.onSessionList((sessions) => {
309
+ this.availableSessions = sessions;
310
+ if (sessions.length > 0 && !this.selectedSessionId) {
311
+ this.selectedSessionId = sessions[0].sessionId;
312
+ }
313
+ });
314
+ this.client.onClientJoined((sessionId, count) => {
315
+ // Update tab-specific state
316
+ if (this.showTabs) {
317
+ const tab = this.tabs.find((t) => t.client === client);
318
+ if (tab) {
319
+ tab.clientCount = count;
320
+ this.tabs = [...this.tabs];
321
+ }
322
+ }
323
+ this.clientCount = count;
324
+ this.setStatus(`Client joined (${count} total)`, 'info');
325
+ });
326
+ this.client.onClientLeft((sessionId, count) => {
327
+ // Update tab-specific state
328
+ if (this.showTabs) {
329
+ const tab = this.tabs.find((t) => t.client === client);
330
+ if (tab) {
331
+ tab.clientCount = count;
332
+ this.tabs = [...this.tabs];
333
+ }
334
+ }
335
+ this.clientCount = count;
336
+ this.setStatus(`Client left (${count} remaining)`, 'info');
337
+ });
338
+ this.client.onSessionClosed((sessionId, reason) => {
339
+ // Update tab-specific state
340
+ if (this.showTabs) {
341
+ const tab = this.tabs.find((t) => t.client === client && t.sessionInfo?.sessionId === sessionId);
342
+ if (tab) {
343
+ tab.sessionActive = false;
344
+ tab.sessionInfo = null;
345
+ this.tabs = [...this.tabs];
346
+ }
347
+ }
348
+ if (this.sessionInfo?.sessionId === sessionId) {
349
+ this.sessionActive = false;
350
+ this.sessionInfo = null;
351
+ this.setStatus(`Session closed: ${reason}`, 'info');
352
+ }
353
+ // Refresh session list
354
+ client.requestSessionList();
355
+ });
356
+ // Reconnect dialog handler
357
+ this.client.onReconnectWithSession((sessionId) => {
358
+ this.reconnectSessionId = sessionId;
359
+ this.showReconnectDialog = true;
360
+ });
172
361
  await this.client.connect();
362
+ // Request session list after connecting
363
+ this.client.requestSessionList();
364
+ // Sync state to active tab
365
+ if (this.showTabs) {
366
+ this.syncStateToActiveTab();
367
+ }
173
368
  }
174
369
  catch (err) {
175
370
  this.error = err instanceof Error ? err.message : 'Connection failed';
@@ -208,17 +403,30 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
208
403
  cols: this.terminal?.cols || this.cols,
209
404
  rows: this.terminal?.rows || this.rows,
210
405
  env: options?.env,
406
+ // Docker container options
407
+ container: options?.container || this.container || undefined,
408
+ containerShell: options?.containerShell || this.containerShell || undefined,
409
+ containerUser: options?.containerUser || this.containerUser || undefined,
410
+ containerCwd: options?.containerCwd || this.containerCwd || undefined,
211
411
  };
212
412
  const info = await this.client.spawn(spawnOptions);
213
413
  this.sessionActive = true;
214
414
  this.sessionInfo = info;
415
+ // Sync state to active tab
416
+ if (this.showTabs) {
417
+ this.syncStateToActiveTab();
418
+ }
419
+ // Refresh session list so other tabs can see this session
420
+ this.client.requestSessionList();
215
421
  // Focus terminal
216
422
  if (this.terminal) {
217
423
  this.terminal.focus();
218
424
  }
425
+ return info;
219
426
  }
220
427
  catch (err) {
221
428
  this.error = err instanceof Error ? err.message : 'Failed to spawn session';
429
+ throw err;
222
430
  }
223
431
  finally {
224
432
  this.loading = false;
@@ -232,7 +440,14 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
232
440
  return;
233
441
  await this.loadXterm();
234
442
  await this.updateComplete;
235
- const container = this.shadowRoot?.querySelector('.terminal-container');
443
+ // Get the correct container (either single terminal or tab-specific)
444
+ let container = null;
445
+ if (this.showTabs && this.activeTabId) {
446
+ container = this.shadowRoot?.querySelector(`.tab-terminal-container[data-tab-id="${this.activeTabId}"]`) ?? null;
447
+ }
448
+ else {
449
+ container = this.shadowRoot?.querySelector('.terminal-container') ?? null;
450
+ }
236
451
  if (!container)
237
452
  return;
238
453
  // Get theme colors
@@ -269,33 +484,54 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
269
484
  this.client.resize(cols, rows);
270
485
  }
271
486
  });
272
- // Setup resize observer
273
- this.resizeObserver = new ResizeObserver(() => {
274
- if (this.fitAddon) {
275
- this.fitAddon.fit();
276
- }
277
- });
487
+ // Setup resize observer for this container
488
+ if (!this.resizeObserver) {
489
+ this.resizeObserver = new ResizeObserver(() => {
490
+ // Fit all visible terminals
491
+ if (this.showTabs) {
492
+ const activeTab = this.getActiveTab();
493
+ if (activeTab?.fitAddon) {
494
+ activeTab.fitAddon.fit();
495
+ }
496
+ }
497
+ else if (this.fitAddon) {
498
+ this.fitAddon.fit();
499
+ }
500
+ });
501
+ }
278
502
  this.resizeObserver.observe(container);
503
+ // Sync to active tab if in tab mode
504
+ if (this.showTabs) {
505
+ this.syncStateToActiveTab();
506
+ }
279
507
  }
280
508
  /**
281
509
  * Get terminal theme based on component theme
282
510
  */
283
511
  getTerminalTheme() {
284
- // These will be overridden by CSS variables in the actual implementation
285
- // For now, provide sensible defaults based on theme attribute
286
- if (this.theme === 'light') {
512
+ // Determine effective theme (handle 'auto' by checking system preference)
513
+ let effectiveTheme = this.theme;
514
+ if (this.theme === 'auto') {
515
+ effectiveTheme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
516
+ }
517
+ if (effectiveTheme === 'light') {
287
518
  return {
288
519
  background: '#ffffff',
289
520
  foreground: '#1f2937',
290
521
  cursor: '#1f2937',
522
+ cursorAccent: '#ffffff',
291
523
  selection: '#b4d5fe',
524
+ selectionForeground: '#1f2937',
292
525
  };
293
526
  }
527
+ // Dark theme
294
528
  return {
295
529
  background: '#1e1e1e',
296
530
  foreground: '#cccccc',
297
531
  cursor: '#ffffff',
532
+ cursorAccent: '#1e1e1e',
298
533
  selection: '#264f78',
534
+ selectionForeground: '#ffffff',
299
535
  };
300
536
  }
301
537
  /**
@@ -358,6 +594,755 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
358
594
  }
359
595
  this.fitAddon = null;
360
596
  }
597
+ // ==================== Tab Management Methods ====================
598
+ /**
599
+ * Get the active tab
600
+ */
601
+ getActiveTab() {
602
+ return this.tabs.find((t) => t.id === this.activeTabId);
603
+ }
604
+ /**
605
+ * Create a new tab
606
+ */
607
+ createTab(label) {
608
+ this.tabCounter++;
609
+ const tab = {
610
+ id: `tab-${this.tabCounter}`,
611
+ label: label || `Terminal ${this.tabCounter}`,
612
+ client: null,
613
+ terminal: null,
614
+ fitAddon: null,
615
+ connected: false,
616
+ sessionActive: false,
617
+ sessionInfo: null,
618
+ clientCount: 1,
619
+ containerEl: null,
620
+ };
621
+ this.tabs = [...this.tabs, tab];
622
+ // Switch to the new tab
623
+ this.switchTab(tab.id);
624
+ return tab;
625
+ }
626
+ /**
627
+ * Switch to a tab
628
+ */
629
+ switchTab(tabId) {
630
+ const tab = this.tabs.find((t) => t.id === tabId);
631
+ if (!tab)
632
+ return;
633
+ this.activeTabId = tabId;
634
+ // Sync state from tab to component (for backward compatibility with single-terminal code)
635
+ this.client = tab.client;
636
+ this.terminal = tab.terminal;
637
+ this.fitAddon = tab.fitAddon;
638
+ this.connected = tab.connected;
639
+ this.sessionActive = tab.sessionActive;
640
+ this.sessionInfo = tab.sessionInfo;
641
+ this.clientCount = tab.clientCount;
642
+ // Focus the terminal and fit it
643
+ this.updateComplete.then(() => {
644
+ if (tab.terminal) {
645
+ tab.terminal.focus();
646
+ }
647
+ if (tab.fitAddon) {
648
+ tab.fitAddon.fit();
649
+ }
650
+ });
651
+ }
652
+ /**
653
+ * Close a tab
654
+ */
655
+ closeTab(tabId) {
656
+ const tabIndex = this.tabs.findIndex((t) => t.id === tabId);
657
+ if (tabIndex === -1)
658
+ return;
659
+ const tab = this.tabs[tabIndex];
660
+ // Cleanup tab resources
661
+ if (tab.terminal) {
662
+ tab.terminal.dispose();
663
+ }
664
+ if (tab.client) {
665
+ tab.client.disconnect();
666
+ }
667
+ // Remove tab
668
+ this.tabs = this.tabs.filter((t) => t.id !== tabId);
669
+ // If we closed the active tab, switch to another
670
+ if (this.activeTabId === tabId && this.tabs.length > 0) {
671
+ // Switch to previous tab, or first if we were first
672
+ const newIndex = Math.max(0, tabIndex - 1);
673
+ this.switchTab(this.tabs[newIndex].id);
674
+ }
675
+ // If no tabs left, clear state
676
+ if (this.tabs.length === 0) {
677
+ this.activeTabId = '';
678
+ this.client = null;
679
+ this.terminal = null;
680
+ this.fitAddon = null;
681
+ this.connected = false;
682
+ this.sessionActive = false;
683
+ this.sessionInfo = null;
684
+ }
685
+ }
686
+ /**
687
+ * Update the active tab's state from component state
688
+ */
689
+ syncStateToActiveTab() {
690
+ const tab = this.getActiveTab();
691
+ if (tab) {
692
+ tab.client = this.client;
693
+ tab.terminal = this.terminal;
694
+ tab.fitAddon = this.fitAddon;
695
+ tab.connected = this.connected;
696
+ tab.sessionActive = this.sessionActive;
697
+ tab.sessionInfo = this.sessionInfo;
698
+ tab.clientCount = this.clientCount;
699
+ // Trigger re-render
700
+ this.tabs = [...this.tabs];
701
+ }
702
+ }
703
+ /**
704
+ * Render the tab bar
705
+ */
706
+ renderTabBar() {
707
+ if (!this.showTabs)
708
+ return nothing;
709
+ return html `
710
+ <div class="tab-bar">
711
+ ${this.tabs.map((tab) => html `
712
+ <button
713
+ class="tab ${tab.id === this.activeTabId ? 'active' : ''}"
714
+ @click=${() => this.switchTab(tab.id)}
715
+ >
716
+ <span class="tab-status ${tab.sessionActive ? 'connected' : ''}"></span>
717
+ <span>${tab.label}</span>
718
+ ${this.tabs.length > 1
719
+ ? html `
720
+ <button
721
+ class="tab-close"
722
+ @click=${(e) => {
723
+ e.stopPropagation();
724
+ this.closeTab(tab.id);
725
+ }}
726
+ title="Close tab"
727
+ >
728
+ ×
729
+ </button>
730
+ `
731
+ : nothing}
732
+ </button>
733
+ `)}
734
+ <button class="tab-add" @click=${() => this.createTab()} title="New tab">
735
+ +
736
+ </button>
737
+ </div>
738
+ `;
739
+ }
740
+ // ==================== End Tab Management Methods ====================
741
+ /**
742
+ * Set status message
743
+ */
744
+ setStatus(message, type = 'info') {
745
+ this.statusMessage = message;
746
+ this.statusType = type;
747
+ // Auto-clear success/info messages after 5 seconds
748
+ if (type !== 'error') {
749
+ setTimeout(() => {
750
+ if (this.statusMessage === message) {
751
+ this.statusMessage = '';
752
+ }
753
+ }, 5000);
754
+ }
755
+ }
756
+ /**
757
+ * Clear status message
758
+ */
759
+ clearStatus() {
760
+ this.statusMessage = '';
761
+ this.statusType = 'info';
762
+ }
763
+ /**
764
+ * Handle theme change
765
+ */
766
+ handleThemeChange(e) {
767
+ const select = e.target;
768
+ this.theme = select.value;
769
+ // Apply theme to xterm.js terminal
770
+ this.applyTerminalTheme();
771
+ this.dispatchEvent(new CustomEvent('theme-change', {
772
+ detail: { theme: this.theme },
773
+ bubbles: true,
774
+ composed: true
775
+ }));
776
+ }
777
+ /**
778
+ * Apply current theme to xterm.js terminal
779
+ */
780
+ applyTerminalTheme() {
781
+ if (!this.terminal)
782
+ return;
783
+ const terminalTheme = this.getTerminalTheme();
784
+ this.terminal.options.theme = terminalTheme;
785
+ }
786
+ /**
787
+ * Apply current font size to xterm.js terminal
788
+ */
789
+ applyTerminalFontSize() {
790
+ if (!this.terminal)
791
+ return;
792
+ this.terminal.options.fontSize = this.fontSize;
793
+ // Re-fit the terminal after font size change
794
+ if (this.fitAddon) {
795
+ this.fitAddon.fit();
796
+ }
797
+ }
798
+ /**
799
+ * Handle connection mode change
800
+ */
801
+ handleModeChange(e) {
802
+ const select = e.target;
803
+ this.connectionMode = select.value;
804
+ if ((this.connectionMode === 'docker' || this.connectionMode === 'docker-attach') && this.client && this.connected) {
805
+ this.client.requestContainerList();
806
+ }
807
+ if (this.connectionMode === 'join' && this.client && this.connected) {
808
+ this.client.requestSessionList();
809
+ }
810
+ }
811
+ /**
812
+ * Refresh session list
813
+ */
814
+ refreshSessions() {
815
+ if (this.client && this.connected) {
816
+ this.client.requestSessionList();
817
+ }
818
+ }
819
+ /**
820
+ * Join an existing session
821
+ */
822
+ async join(sessionId, requestHistory = true) {
823
+ if (!this.client || !this.connected) {
824
+ throw new Error('Not connected to server');
825
+ }
826
+ this.loading = true;
827
+ this.error = null;
828
+ try {
829
+ // Initialize terminal UI if needed
830
+ await this.initTerminalUI();
831
+ const session = await this.client.join({
832
+ sessionId,
833
+ requestHistory,
834
+ historyLimit: 50000,
835
+ });
836
+ this.sessionActive = true;
837
+ this.sessionInfo = {
838
+ sessionId: session.sessionId,
839
+ shell: session.shell,
840
+ cwd: session.cwd,
841
+ cols: session.cols,
842
+ rows: session.rows,
843
+ createdAt: session.createdAt || new Date(),
844
+ container: session.container,
845
+ };
846
+ this.clientCount = session.clientCount;
847
+ // Sync state to active tab
848
+ if (this.showTabs) {
849
+ this.syncStateToActiveTab();
850
+ }
851
+ this.setStatus(`Joined session (${session.clientCount} clients)`, 'success');
852
+ // Focus terminal
853
+ if (this.terminal) {
854
+ this.terminal.focus();
855
+ }
856
+ return session;
857
+ }
858
+ catch (err) {
859
+ this.error = err instanceof Error ? err.message : 'Failed to join session';
860
+ throw err;
861
+ }
862
+ finally {
863
+ this.loading = false;
864
+ }
865
+ }
866
+ /**
867
+ * Leave current session without killing it
868
+ */
869
+ leave() {
870
+ if (this.client && this.sessionInfo) {
871
+ this.client.leave(this.sessionInfo.sessionId);
872
+ this.sessionActive = false;
873
+ this.sessionInfo = null;
874
+ this.setStatus('Left session', 'info');
875
+ }
876
+ }
877
+ /**
878
+ * Handle connect from connection panel
879
+ */
880
+ async handlePanelConnect() {
881
+ if (!this.connected) {
882
+ await this.connect();
883
+ }
884
+ if (this.connected) {
885
+ if (this.connectionMode === 'join' && this.selectedSessionId) {
886
+ // Join existing session
887
+ await this.join(this.selectedSessionId);
888
+ }
889
+ else if (this.connectionMode === 'docker-attach' && this.selectedContainer) {
890
+ // Docker attach mode - connect to container's main process
891
+ const options = {
892
+ container: this.selectedContainer,
893
+ attachMode: true,
894
+ orphanTimeout: this.orphanTimeout,
895
+ };
896
+ await this.spawn(options);
897
+ }
898
+ else {
899
+ // Spawn new session (local or docker exec)
900
+ const options = {
901
+ orphanTimeout: this.orphanTimeout,
902
+ useTmux: this.useTmux,
903
+ };
904
+ if (this.connectionMode === 'docker' && this.selectedContainer) {
905
+ options.container = this.selectedContainer;
906
+ options.containerShell = this.selectedShell || '/bin/sh';
907
+ }
908
+ else {
909
+ options.shell = this.selectedShell || undefined;
910
+ }
911
+ await this.spawn(options);
912
+ }
913
+ }
914
+ }
915
+ /**
916
+ * Toggle settings menu
917
+ */
918
+ toggleSettingsMenu() {
919
+ this.settingsMenuOpen = !this.settingsMenuOpen;
920
+ }
921
+ /**
922
+ * Handle reconnect dialog - Yes button
923
+ */
924
+ async handleReconnectYes() {
925
+ if (!this.reconnectSessionId || !this.client)
926
+ return;
927
+ this.showReconnectDialog = false;
928
+ try {
929
+ // Initialize terminal UI if needed
930
+ await this.initTerminalUI();
931
+ // Join the previous session with history
932
+ await this.join(this.reconnectSessionId, true);
933
+ this.setStatus('Rejoined previous session', 'success');
934
+ }
935
+ catch (err) {
936
+ this.setStatus(`Failed to rejoin: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error');
937
+ }
938
+ this.reconnectSessionId = null;
939
+ this.client?.clearPreviousSessionId();
940
+ }
941
+ /**
942
+ * Handle reconnect dialog - No button
943
+ */
944
+ handleReconnectNo() {
945
+ this.showReconnectDialog = false;
946
+ this.reconnectSessionId = null;
947
+ this.client?.clearPreviousSessionId();
948
+ }
949
+ /**
950
+ * Render reconnect dialog
951
+ */
952
+ renderReconnectDialog() {
953
+ if (!this.showReconnectDialog)
954
+ return nothing;
955
+ return html `
956
+ <div class="reconnect-dialog-overlay">
957
+ <div class="reconnect-dialog">
958
+ <h3>Session Available</h3>
959
+ <p>Your previous session is still active. Would you like to rejoin?</p>
960
+ <div class="reconnect-dialog-buttons">
961
+ <button class="btn-primary" @click=${this.handleReconnectYes}>
962
+ Yes, Rejoin
963
+ </button>
964
+ <button class="btn-secondary" @click=${this.handleReconnectNo}>
965
+ No, Start New
966
+ </button>
967
+ </div>
968
+ </div>
969
+ </div>
970
+ `;
971
+ }
972
+ /**
973
+ * Render connection panel
974
+ */
975
+ renderConnectionPanel() {
976
+ if (!this.showConnectionPanel)
977
+ return nothing;
978
+ const runningContainers = this.containers.filter(c => c.state === 'running');
979
+ const acceptingSessions = this.availableSessions.filter(s => s.accepting);
980
+ return html `
981
+ <div class="connection-panel">
982
+ <div class="connection-panel-title">
983
+ <span>Connection</span>
984
+ ${this.availableSessions.length > 0
985
+ ? html `<span style="font-size: 11px; color: var(--ls-status-connected);">${this.availableSessions.length} session(s) available</span>`
986
+ : nothing}
987
+ ${this.serverInfo?.dockerEnabled
988
+ ? html `<span style="font-size: 11px; color: var(--ls-text-muted);">Docker enabled</span>`
989
+ : nothing}
990
+ </div>
991
+ <div class="connection-form">
992
+ <div class="form-group">
993
+ <label>Mode</label>
994
+ <select
995
+ .value=${this.connectionMode}
996
+ @change=${this.handleModeChange}
997
+ ?disabled=${this.sessionActive}
998
+ >
999
+ <option value="local">New Local Shell</option>
1000
+ ${this.serverInfo?.dockerEnabled
1001
+ ? html `
1002
+ <option value="docker">Docker Exec (new shell)</option>
1003
+ <option value="docker-attach">Docker Attach (main process)</option>
1004
+ `
1005
+ : nothing}
1006
+ ${acceptingSessions.length > 0
1007
+ ? html `<option value="join">Join Existing Session</option>`
1008
+ : nothing}
1009
+ </select>
1010
+ </div>
1011
+
1012
+ ${this.connectionMode === 'join' ? html `
1013
+ <div class="form-group">
1014
+ <label style="display: flex; justify-content: space-between; align-items: center;">
1015
+ <span>Session</span>
1016
+ <button
1017
+ style="font-size: 10px; padding: 2px 6px;"
1018
+ @click=${this.refreshSessions}
1019
+ ?disabled=${!this.connected}
1020
+ >Refresh</button>
1021
+ </label>
1022
+ <select
1023
+ .value=${this.selectedSessionId}
1024
+ @change=${(e) => this.selectedSessionId = e.target.value}
1025
+ ?disabled=${this.sessionActive}
1026
+ >
1027
+ ${acceptingSessions.length === 0
1028
+ ? html `<option value="">No sessions available</option>`
1029
+ : acceptingSessions.map(s => html `
1030
+ <option value=${s.sessionId}>
1031
+ ${s.label || s.sessionId.substring(0, 12)}
1032
+ (${s.type === 'local' ? s.shell : s.container || s.type})
1033
+ - ${s.clientCount} client(s)
1034
+ </option>
1035
+ `)}
1036
+ </select>
1037
+ </div>
1038
+ ` : this.connectionMode === 'docker' || this.connectionMode === 'docker-attach' ? html `
1039
+ <div class="form-group">
1040
+ <label>Container</label>
1041
+ <select
1042
+ .value=${this.selectedContainer}
1043
+ @change=${(e) => this.selectedContainer = e.target.value}
1044
+ ?disabled=${this.sessionActive}
1045
+ >
1046
+ ${runningContainers.length === 0
1047
+ ? html `<option value="">No containers running</option>`
1048
+ : runningContainers.map(c => html `
1049
+ <option value=${c.name}>${c.name} (${c.image})</option>
1050
+ `)}
1051
+ </select>
1052
+ </div>
1053
+ ` : nothing}
1054
+
1055
+ ${this.connectionMode !== 'join' && this.connectionMode !== 'docker-attach' ? html `
1056
+ <div class="form-group">
1057
+ <label>Shell</label>
1058
+ <select
1059
+ .value=${this.selectedShell}
1060
+ @change=${(e) => this.selectedShell = e.target.value}
1061
+ ?disabled=${this.sessionActive}
1062
+ >
1063
+ ${this.serverInfo?.allowedShells.length
1064
+ ? this.serverInfo.allowedShells.map(s => html `<option value=${s}>${s}</option>`)
1065
+ : html `
1066
+ <option value="/bin/bash">/bin/bash</option>
1067
+ <option value="/bin/sh">/bin/sh</option>
1068
+ <option value="/bin/zsh">/bin/zsh</option>
1069
+ `}
1070
+ </select>
1071
+ </div>
1072
+ ` : nothing}
1073
+
1074
+ ${this.connectionMode === 'local' || this.connectionMode === 'docker' ? html `
1075
+ <div class="form-group">
1076
+ <label>Session Timeout</label>
1077
+ <select
1078
+ .value=${String(this.orphanTimeout)}
1079
+ @change=${(e) => this.orphanTimeout = parseInt(e.target.value)}
1080
+ ?disabled=${this.sessionActive}
1081
+ >
1082
+ <option value="60000">1 minute</option>
1083
+ <option value="300000">5 minutes</option>
1084
+ <option value="900000">15 minutes</option>
1085
+ <option value="3600000">1 hour</option>
1086
+ <option value="21600000">6 hours</option>
1087
+ <option value="86400000">24 hours</option>
1088
+ <option value="604800000">1 week</option>
1089
+ </select>
1090
+ </div>
1091
+ ` : nothing}
1092
+
1093
+ ${this.connectionMode === 'docker' ? html `
1094
+ <div class="form-group">
1095
+ <label style="display: flex; align-items: center; gap: 6px;">
1096
+ <input
1097
+ type="checkbox"
1098
+ .checked=${this.useTmux}
1099
+ @change=${(e) => this.useTmux = e.target.checked}
1100
+ ?disabled=${this.sessionActive}
1101
+ />
1102
+ Use tmux (persist forever)
1103
+ </label>
1104
+ </div>
1105
+ ` : nothing}
1106
+
1107
+ <div class="form-group">
1108
+ ${!this.connected
1109
+ ? html `<button class="btn-primary" @click=${this.handlePanelConnect} ?disabled=${this.loading}>
1110
+ ${this.loading ? 'Connecting...' : 'Connect'}
1111
+ </button>`
1112
+ : !this.sessionActive
1113
+ ? html `<button class="btn-primary" @click=${this.handlePanelConnect} ?disabled=${this.loading || (this.connectionMode === 'join' && !this.selectedSessionId) || ((this.connectionMode === 'docker' || this.connectionMode === 'docker-attach') && !this.selectedContainer)}>
1114
+ ${this.loading ? 'Starting...' : this.connectionMode === 'join' ? 'Join Session' : this.connectionMode === 'docker-attach' ? 'Attach' : 'Start Session'}
1115
+ </button>`
1116
+ : html `<button class="btn-danger" @click=${this.kill}>
1117
+ ${this.clientCount > 1 ? 'Leave Session' : 'Stop Session'}
1118
+ </button>`}
1119
+ </div>
1120
+ </div>
1121
+ </div>
1122
+ `;
1123
+ }
1124
+ /**
1125
+ * Render settings dropdown
1126
+ */
1127
+ renderSettingsDropdown() {
1128
+ if (!this.showSettings)
1129
+ return nothing;
1130
+ return html `
1131
+ <div class="settings-dropdown">
1132
+ <button @click=${this.toggleSettingsMenu} title="Settings">
1133
+ ⚙️
1134
+ </button>
1135
+ ${this.settingsMenuOpen ? html `
1136
+ <div class="settings-menu">
1137
+ <div class="settings-menu-item">
1138
+ <span>Theme</span>
1139
+ <select
1140
+ .value=${this.theme}
1141
+ @change=${this.handleThemeChange}
1142
+ >
1143
+ <option value="dark">Dark</option>
1144
+ <option value="light">Light</option>
1145
+ <option value="auto">Auto</option>
1146
+ </select>
1147
+ </div>
1148
+ <div class="settings-divider"></div>
1149
+ <div class="settings-menu-item">
1150
+ <span>Font Size</span>
1151
+ <select
1152
+ .value=${String(this.fontSize)}
1153
+ @change=${(e) => {
1154
+ this.fontSize = parseInt(e.target.value);
1155
+ this.applyTerminalFontSize();
1156
+ }}
1157
+ >
1158
+ <option value="12">12px</option>
1159
+ <option value="14">14px</option>
1160
+ <option value="16">16px</option>
1161
+ <option value="18">18px</option>
1162
+ </select>
1163
+ </div>
1164
+ <div class="settings-divider"></div>
1165
+ <div class="settings-menu-item" @click=${this.clear}>
1166
+ <span>Clear Terminal</span>
1167
+ </div>
1168
+ </div>
1169
+ ` : nothing}
1170
+ </div>
1171
+ `;
1172
+ }
1173
+ /**
1174
+ * Send a special key sequence to the terminal
1175
+ */
1176
+ sendKey(key) {
1177
+ if (!this.client || !this.sessionActive)
1178
+ return;
1179
+ // Apply modifiers if pressed
1180
+ let sequence = key;
1181
+ if (this.ctrlPressed) {
1182
+ // Convert to control character (Ctrl+A = \x01, Ctrl+C = \x03, etc.)
1183
+ if (key.length === 1 && key >= 'a' && key <= 'z') {
1184
+ sequence = String.fromCharCode(key.charCodeAt(0) - 96);
1185
+ }
1186
+ else if (key.length === 1 && key >= 'A' && key <= 'Z') {
1187
+ sequence = String.fromCharCode(key.charCodeAt(0) - 64);
1188
+ }
1189
+ this.ctrlPressed = false;
1190
+ }
1191
+ if (this.altPressed) {
1192
+ // Alt/Meta sends ESC prefix
1193
+ sequence = '\x1b' + sequence;
1194
+ this.altPressed = false;
1195
+ }
1196
+ this.client.write(sequence);
1197
+ this.terminal?.focus();
1198
+ }
1199
+ /**
1200
+ * Send ANSI escape sequence
1201
+ */
1202
+ sendEscape(code) {
1203
+ if (!this.client || !this.sessionActive)
1204
+ return;
1205
+ this.client.write('\x1b' + code);
1206
+ this.terminal?.focus();
1207
+ }
1208
+ /**
1209
+ * Send control character directly
1210
+ */
1211
+ sendCtrl(char) {
1212
+ if (!this.client || !this.sessionActive)
1213
+ return;
1214
+ const code = char.toUpperCase().charCodeAt(0) - 64;
1215
+ this.client.write(String.fromCharCode(code));
1216
+ this.terminal?.focus();
1217
+ }
1218
+ /**
1219
+ * Toggle Ctrl modifier
1220
+ */
1221
+ toggleCtrl() {
1222
+ this.ctrlPressed = !this.ctrlPressed;
1223
+ this.altPressed = false; // Reset alt when ctrl toggled
1224
+ }
1225
+ /**
1226
+ * Toggle Alt modifier
1227
+ */
1228
+ toggleAlt() {
1229
+ this.altPressed = !this.altPressed;
1230
+ this.ctrlPressed = false; // Reset ctrl when alt toggled
1231
+ }
1232
+ /**
1233
+ * Toggle extra key rows visibility
1234
+ */
1235
+ toggleExtraRows() {
1236
+ this.showExtraKeyRows = !this.showExtraKeyRows;
1237
+ }
1238
+ /**
1239
+ * Toggle touch keyboard visibility
1240
+ */
1241
+ toggleTouchKeyboard() {
1242
+ this.showTouchKeyboard = !this.showTouchKeyboard;
1243
+ }
1244
+ /**
1245
+ * Render touch keyboard for mobile
1246
+ */
1247
+ renderTouchKeyboard() {
1248
+ if (!this.isMobile)
1249
+ return nothing;
1250
+ return html `
1251
+ <!-- Toggle bar to show/hide keyboard -->
1252
+ <div class="touch-keyboard-toggle">
1253
+ <button @click=${this.toggleTouchKeyboard} title="${this.showTouchKeyboard ? 'Hide' : 'Show'} keyboard">
1254
+ ${this.showTouchKeyboard ? '▼' : '▲'}
1255
+ </button>
1256
+ </div>
1257
+
1258
+ ${this.showTouchKeyboard ? html `
1259
+ <div class="touch-keyboard">
1260
+ <!-- Row 1: ESC, navigation, special -->
1261
+ <div class="touch-keyboard-row">
1262
+ <button class="touch-key" @click=${() => this.sendEscape('')}>ESC</button>
1263
+ <button class="touch-key" @click=${() => this.sendKey('/')}>/</button>
1264
+ <button class="touch-key" @click=${() => this.sendKey('-')}>-</button>
1265
+ <button class="touch-key" @click=${() => this.sendEscape('[H')}>HOME</button>
1266
+ <button class="touch-key" @click=${() => this.sendEscape('[A')}>↑</button>
1267
+ <button class="touch-key" @click=${() => this.sendEscape('[F')}>END</button>
1268
+ <button class="touch-key" @click=${() => this.sendEscape('[5~')}>PGUP</button>
1269
+ </div>
1270
+
1271
+ <!-- Row 2: TAB, modifiers, arrows -->
1272
+ <div class="touch-keyboard-row">
1273
+ <button class="touch-key" @click=${() => this.sendKey('\t')}>TAB</button>
1274
+ <button class="touch-key toggle-btn ${this.ctrlPressed ? 'active' : ''}" @click=${this.toggleCtrl}>CTRL</button>
1275
+ <button class="touch-key toggle-btn ${this.altPressed ? 'active' : ''}" @click=${this.toggleAlt}>ALT</button>
1276
+ <button class="touch-key" @click=${() => this.sendEscape('[D')}>←</button>
1277
+ <button class="touch-key" @click=${() => this.sendEscape('[B')}>↓</button>
1278
+ <button class="touch-key" @click=${() => this.sendEscape('[C')}>→</button>
1279
+ <button class="touch-key" @click=${() => this.sendEscape('[6~')}>PGDN</button>
1280
+ </div>
1281
+
1282
+ <!-- Expandable extra rows -->
1283
+ <div class="touch-keyboard-extra ${this.showExtraKeyRows ? 'expanded' : ''}">
1284
+ <!-- Row 3: Common control sequences -->
1285
+ <div class="touch-keyboard-row">
1286
+ <button class="touch-key danger" @click=${() => this.sendCtrl('C')}>^C</button>
1287
+ <button class="touch-key" @click=${() => this.sendCtrl('D')}>^D</button>
1288
+ <button class="touch-key" @click=${() => this.sendCtrl('Z')}>^Z</button>
1289
+ <button class="touch-key" @click=${() => this.sendCtrl('L')}>^L</button>
1290
+ <button class="touch-key" @click=${() => this.sendCtrl('A')}>^A</button>
1291
+ <button class="touch-key" @click=${() => this.sendCtrl('E')}>^E</button>
1292
+ <button class="touch-key" @click=${() => this.sendCtrl('R')}>^R</button>
1293
+ </div>
1294
+ </div>
1295
+
1296
+ <!-- Toggle for extra rows -->
1297
+ <div class="touch-keyboard-row">
1298
+ <button
1299
+ class="touch-key wide"
1300
+ @click=${this.toggleExtraRows}
1301
+ title="${this.showExtraKeyRows ? 'Hide' : 'Show'} extra keys"
1302
+ >
1303
+ ${this.showExtraKeyRows ? '▲ Less' : '▼ More'}
1304
+ </button>
1305
+ </div>
1306
+ </div>
1307
+ ` : nothing}
1308
+ `;
1309
+ }
1310
+ // ==================== End Touch Keyboard Methods ====================
1311
+ /**
1312
+ * Render status bar
1313
+ */
1314
+ renderStatusBar() {
1315
+ if (!this.showStatusBar)
1316
+ return nothing;
1317
+ return html `
1318
+ <div class="status-bar">
1319
+ <div class="status-bar-left">
1320
+ <span class="status-dot ${this.connected ? 'connected' : ''}"></span>
1321
+ <span>${this.connected
1322
+ ? (this.sessionActive ? 'Session active' : 'Connected')
1323
+ : 'Disconnected'}</span>
1324
+ ${this.sessionInfo ? html `
1325
+ <span style="color: var(--ls-text-muted)">|</span>
1326
+ <span>${this.sessionInfo.container || this.sessionInfo.shell}</span>
1327
+ <span style="color: var(--ls-text-muted)">${this.sessionInfo.cols}x${this.sessionInfo.rows}</span>
1328
+ ` : nothing}
1329
+ </div>
1330
+ <div class="status-bar-right">
1331
+ ${this.statusMessage ? html `
1332
+ <span class="${this.statusType === 'error' ? 'status-bar-error' : this.statusType === 'success' ? 'status-bar-success' : ''}">
1333
+ ${this.statusType === 'error' ? '⚠️' : this.statusType === 'success' ? '✓' : ''}
1334
+ ${this.statusMessage}
1335
+ </span>
1336
+ <button
1337
+ style="background: none; border: none; cursor: pointer; padding: 0; font-size: 10px;"
1338
+ @click=${this.clearStatus}
1339
+ title="Dismiss"
1340
+ >✕</button>
1341
+ ` : nothing}
1342
+ </div>
1343
+ </div>
1344
+ `;
1345
+ }
361
1346
  render() {
362
1347
  return html `
363
1348
  ${this.noHeader
@@ -368,39 +1353,74 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
368
1353
  <span>Terminal</span>
369
1354
  ${this.sessionInfo
370
1355
  ? html `<span style="font-weight: normal; font-size: 12px; color: var(--ls-text-muted)">
371
- ${this.sessionInfo.shell}
1356
+ ${this.sessionInfo.container
1357
+ ? `${this.sessionInfo.container} (${this.sessionInfo.shell})`
1358
+ : this.sessionInfo.shell}
372
1359
  </span>`
373
1360
  : nothing}
374
1361
  </div>
375
1362
  <div class="header-actions">
376
- ${!this.connected
1363
+ ${!this.showConnectionPanel ? html `
1364
+ ${!this.connected
377
1365
  ? html `<button @click=${this.connect} ?disabled=${this.loading}>
378
- ${this.loading ? 'Connecting...' : 'Connect'}
379
- </button>`
1366
+ ${this.loading ? 'Connecting...' : 'Connect'}
1367
+ </button>`
380
1368
  : !this.sessionActive
381
1369
  ? html `<button @click=${() => this.spawn()} ?disabled=${this.loading}>
382
- ${this.loading ? 'Spawning...' : 'Start'}
383
- </button>`
1370
+ ${this.loading ? 'Spawning...' : 'Start'}
1371
+ </button>`
384
1372
  : html `<button @click=${this.kill}>Stop</button>`}
1373
+ ` : nothing}
385
1374
  <button @click=${this.clear} ?disabled=${!this.sessionActive}>Clear</button>
386
- <div class="status">
387
- <span class="status-dot ${this.connected ? 'connected' : ''}"></span>
388
- <span>${this.connected ? 'Connected' : 'Disconnected'}</span>
389
- </div>
1375
+ ${this.renderSettingsDropdown()}
1376
+ ${!this.showStatusBar ? html `
1377
+ <div class="status">
1378
+ <span class="status-dot ${this.connected ? 'connected' : ''}"></span>
1379
+ <span>${this.connected ? 'Connected' : 'Disconnected'}</span>
1380
+ </div>
1381
+ ` : nothing}
390
1382
  </div>
391
1383
  </div>
392
1384
  `}
393
1385
 
394
- <div class="terminal-container">
395
- ${this.loading && !this.terminal
396
- ? html `<div class="loading"><span class="loading-spinner">⏳</span> Loading...</div>`
397
- : this.error && !this.terminal
398
- ? html `<div class="error">❌ ${this.error}</div>`
399
- : nothing}
400
- </div>
1386
+ ${this.renderConnectionPanel()}
1387
+ ${this.renderTabBar()}
1388
+
1389
+ ${this.showTabs && this.tabs.length > 0
1390
+ ? html `
1391
+ <div class="terminals-wrapper">
1392
+ ${this.tabs.map((tab) => html `
1393
+ <div
1394
+ class="tab-terminal-container ${tab.id === this.activeTabId ? 'active' : ''}"
1395
+ data-tab-id=${tab.id}
1396
+ >
1397
+ ${this.loading && tab.id === this.activeTabId && !tab.terminal
1398
+ ? html `<div class="loading"><span class="loading-spinner">⏳</span> Loading...</div>`
1399
+ : this.error && tab.id === this.activeTabId && !tab.terminal
1400
+ ? html `<div class="error">❌ ${this.error}</div>`
1401
+ : nothing}
1402
+ </div>
1403
+ `)}
1404
+ </div>
1405
+ `
1406
+ : html `
1407
+ <div class="terminal-container">
1408
+ ${this.loading && !this.terminal
1409
+ ? html `<div class="loading"><span class="loading-spinner">⏳</span> Loading...</div>`
1410
+ : this.error && !this.terminal
1411
+ ? html `<div class="error">❌ ${this.error}</div>`
1412
+ : nothing}
1413
+ </div>
1414
+ `}
1415
+
1416
+ ${this.renderStatusBar()}
1417
+ ${this.renderTouchKeyboard()}
1418
+ ${this.renderReconnectDialog()}
401
1419
  `;
402
1420
  }
403
1421
  };
1422
+ /** lit-shell.js version */
1423
+ LitShellTerminal.VERSION = VERSION;
404
1424
  LitShellTerminal.styles = [
405
1425
  sharedStyles,
406
1426
  themeStyles,
@@ -504,15 +1524,450 @@ LitShellTerminal.styles = [
504
1524
  :host([no-header]) .header {
505
1525
  display: none;
506
1526
  }
507
- `,
508
- ];
509
- __decorate([
510
- property({ type: String })
511
- ], LitShellTerminal.prototype, "url", void 0);
512
- __decorate([
513
- property({ type: String })
514
- ], LitShellTerminal.prototype, "shell", void 0);
515
- __decorate([
1527
+
1528
+ /* Connection panel */
1529
+ .connection-panel {
1530
+ padding: 12px;
1531
+ background: var(--ls-bg-header);
1532
+ border-bottom: 1px solid var(--ls-border);
1533
+ }
1534
+
1535
+ .connection-panel-title {
1536
+ font-weight: 600;
1537
+ margin-bottom: 12px;
1538
+ display: flex;
1539
+ align-items: center;
1540
+ gap: 8px;
1541
+ }
1542
+
1543
+ .connection-form {
1544
+ display: grid;
1545
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1546
+ gap: 10px;
1547
+ align-items: end;
1548
+ }
1549
+
1550
+ .form-group {
1551
+ display: flex;
1552
+ flex-direction: column;
1553
+ gap: 4px;
1554
+ }
1555
+
1556
+ .form-group label {
1557
+ font-size: 11px;
1558
+ text-transform: uppercase;
1559
+ color: var(--ls-text-muted);
1560
+ letter-spacing: 0.5px;
1561
+ }
1562
+
1563
+ .form-group select,
1564
+ .form-group input {
1565
+ padding: 6px 10px;
1566
+ border: 1px solid var(--ls-border);
1567
+ border-radius: 4px;
1568
+ background: var(--ls-bg);
1569
+ color: var(--ls-text);
1570
+ font-size: 13px;
1571
+ }
1572
+
1573
+ .form-group select:focus,
1574
+ .form-group input:focus {
1575
+ outline: none;
1576
+ border-color: var(--ls-status-connected);
1577
+ }
1578
+
1579
+ /* Settings dropdown */
1580
+ .settings-dropdown {
1581
+ position: relative;
1582
+ }
1583
+
1584
+ .settings-menu {
1585
+ position: absolute;
1586
+ top: 100%;
1587
+ right: 0;
1588
+ margin-top: 4px;
1589
+ min-width: 180px;
1590
+ background: var(--ls-bg-header);
1591
+ border: 1px solid var(--ls-border);
1592
+ border-radius: 4px;
1593
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
1594
+ z-index: 100;
1595
+ padding: 8px 0;
1596
+ }
1597
+
1598
+ .settings-menu-item {
1599
+ display: flex;
1600
+ align-items: center;
1601
+ justify-content: space-between;
1602
+ padding: 8px 12px;
1603
+ font-size: 13px;
1604
+ cursor: pointer;
1605
+ }
1606
+
1607
+ .settings-menu-item:hover {
1608
+ background: var(--ls-btn-hover);
1609
+ }
1610
+
1611
+ .settings-menu-item select {
1612
+ padding: 4px 8px;
1613
+ border: 1px solid var(--ls-border);
1614
+ border-radius: 3px;
1615
+ background: var(--ls-bg);
1616
+ color: var(--ls-text);
1617
+ font-size: 12px;
1618
+ }
1619
+
1620
+ .settings-divider {
1621
+ height: 1px;
1622
+ background: var(--ls-border);
1623
+ margin: 4px 0;
1624
+ }
1625
+
1626
+ /* Status bar */
1627
+ .status-bar {
1628
+ display: flex;
1629
+ align-items: center;
1630
+ justify-content: space-between;
1631
+ padding: 4px 12px;
1632
+ background: var(--ls-bg-header);
1633
+ border-top: 1px solid var(--ls-border);
1634
+ font-size: 12px;
1635
+ color: var(--ls-text-muted);
1636
+ }
1637
+
1638
+ .status-bar-left {
1639
+ display: flex;
1640
+ align-items: center;
1641
+ gap: 12px;
1642
+ }
1643
+
1644
+ .status-bar-right {
1645
+ display: flex;
1646
+ align-items: center;
1647
+ gap: 8px;
1648
+ }
1649
+
1650
+ .status-bar-error {
1651
+ color: #ef4444;
1652
+ display: flex;
1653
+ align-items: center;
1654
+ gap: 4px;
1655
+ }
1656
+
1657
+ .status-bar-success {
1658
+ color: var(--ls-status-connected);
1659
+ }
1660
+
1661
+ /* Tab bar styles */
1662
+ .tab-bar {
1663
+ display: flex;
1664
+ align-items: center;
1665
+ background: var(--ls-bg-header);
1666
+ border-bottom: 1px solid var(--ls-border);
1667
+ padding: 0 4px;
1668
+ gap: 2px;
1669
+ min-height: 32px;
1670
+ overflow-x: auto;
1671
+ }
1672
+
1673
+ .tab-bar::-webkit-scrollbar {
1674
+ height: 4px;
1675
+ }
1676
+
1677
+ .tab-bar::-webkit-scrollbar-thumb {
1678
+ background: var(--ls-border);
1679
+ border-radius: 2px;
1680
+ }
1681
+
1682
+ .tab {
1683
+ display: flex;
1684
+ align-items: center;
1685
+ gap: 6px;
1686
+ padding: 6px 12px;
1687
+ background: transparent;
1688
+ border: none;
1689
+ border-bottom: 2px solid transparent;
1690
+ color: var(--ls-text-muted);
1691
+ font-size: 12px;
1692
+ cursor: pointer;
1693
+ white-space: nowrap;
1694
+ transition: all 0.15s ease;
1695
+ }
1696
+
1697
+ .tab:hover {
1698
+ background: var(--ls-btn-hover);
1699
+ color: var(--ls-text);
1700
+ }
1701
+
1702
+ .tab.active {
1703
+ color: var(--ls-text);
1704
+ border-bottom-color: var(--ls-status-connected);
1705
+ background: var(--ls-bg);
1706
+ }
1707
+
1708
+ .tab-status {
1709
+ width: 6px;
1710
+ height: 6px;
1711
+ border-radius: 50%;
1712
+ background: var(--ls-status-disconnected);
1713
+ }
1714
+
1715
+ .tab-status.connected {
1716
+ background: var(--ls-status-connected);
1717
+ }
1718
+
1719
+ .tab-close {
1720
+ display: flex;
1721
+ align-items: center;
1722
+ justify-content: center;
1723
+ width: 16px;
1724
+ height: 16px;
1725
+ border-radius: 3px;
1726
+ background: none;
1727
+ border: none;
1728
+ color: var(--ls-text-muted);
1729
+ font-size: 14px;
1730
+ cursor: pointer;
1731
+ opacity: 0;
1732
+ transition: opacity 0.15s ease;
1733
+ }
1734
+
1735
+ .tab:hover .tab-close {
1736
+ opacity: 1;
1737
+ }
1738
+
1739
+ .tab-close:hover {
1740
+ background: var(--ls-btn-hover);
1741
+ color: var(--ls-text);
1742
+ }
1743
+
1744
+ .tab-add {
1745
+ display: flex;
1746
+ align-items: center;
1747
+ justify-content: center;
1748
+ width: 28px;
1749
+ height: 28px;
1750
+ border-radius: 4px;
1751
+ background: none;
1752
+ border: none;
1753
+ color: var(--ls-text-muted);
1754
+ font-size: 18px;
1755
+ cursor: pointer;
1756
+ margin-left: 4px;
1757
+ }
1758
+
1759
+ .tab-add:hover {
1760
+ background: var(--ls-btn-hover);
1761
+ color: var(--ls-text);
1762
+ }
1763
+
1764
+ /* Multi-terminal container */
1765
+ .terminals-wrapper {
1766
+ flex: 1;
1767
+ position: relative;
1768
+ overflow: hidden;
1769
+ }
1770
+
1771
+ .tab-terminal-container {
1772
+ position: absolute;
1773
+ top: 0;
1774
+ left: 0;
1775
+ right: 0;
1776
+ bottom: 0;
1777
+ padding: 4px;
1778
+ background: var(--ls-terminal-bg);
1779
+ display: none;
1780
+ }
1781
+
1782
+ .tab-terminal-container.active {
1783
+ display: block;
1784
+ }
1785
+
1786
+ .tab-terminal-container .xterm {
1787
+ height: 100%;
1788
+ }
1789
+
1790
+ /* Reconnect dialog */
1791
+ .reconnect-dialog-overlay {
1792
+ position: absolute;
1793
+ top: 0;
1794
+ left: 0;
1795
+ right: 0;
1796
+ bottom: 0;
1797
+ background: rgba(0, 0, 0, 0.7);
1798
+ display: flex;
1799
+ align-items: center;
1800
+ justify-content: center;
1801
+ z-index: 1000;
1802
+ }
1803
+
1804
+ .reconnect-dialog {
1805
+ background: var(--ls-bg-header);
1806
+ border: 1px solid var(--ls-border);
1807
+ border-radius: 8px;
1808
+ padding: 24px;
1809
+ max-width: 320px;
1810
+ text-align: center;
1811
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
1812
+ }
1813
+
1814
+ .reconnect-dialog h3 {
1815
+ margin: 0 0 12px 0;
1816
+ font-size: 16px;
1817
+ color: var(--ls-text);
1818
+ }
1819
+
1820
+ .reconnect-dialog p {
1821
+ margin: 0 0 20px 0;
1822
+ font-size: 14px;
1823
+ color: var(--ls-text-muted);
1824
+ }
1825
+
1826
+ .reconnect-dialog-buttons {
1827
+ display: flex;
1828
+ gap: 12px;
1829
+ justify-content: center;
1830
+ }
1831
+
1832
+ .reconnect-dialog-buttons button {
1833
+ padding: 8px 20px;
1834
+ border-radius: 4px;
1835
+ font-size: 14px;
1836
+ cursor: pointer;
1837
+ transition: background 0.15s ease;
1838
+ }
1839
+
1840
+ .reconnect-dialog-buttons .btn-primary {
1841
+ background: var(--ls-status-connected);
1842
+ color: white;
1843
+ border: none;
1844
+ }
1845
+
1846
+ .reconnect-dialog-buttons .btn-primary:hover {
1847
+ background: #1da34d;
1848
+ }
1849
+
1850
+ .reconnect-dialog-buttons .btn-secondary {
1851
+ background: transparent;
1852
+ color: var(--ls-text-muted);
1853
+ border: 1px solid var(--ls-border);
1854
+ }
1855
+
1856
+ .reconnect-dialog-buttons .btn-secondary:hover {
1857
+ background: var(--ls-btn-hover);
1858
+ color: var(--ls-text);
1859
+ }
1860
+
1861
+ /* Touch keyboard for mobile */
1862
+ .touch-keyboard {
1863
+ display: flex;
1864
+ flex-direction: column;
1865
+ background: var(--ls-bg-header);
1866
+ border-top: 1px solid var(--ls-border);
1867
+ padding: 4px;
1868
+ gap: 4px;
1869
+ }
1870
+
1871
+ .touch-keyboard-row {
1872
+ display: flex;
1873
+ gap: 4px;
1874
+ justify-content: center;
1875
+ }
1876
+
1877
+ .touch-key {
1878
+ display: flex;
1879
+ align-items: center;
1880
+ justify-content: center;
1881
+ min-width: 40px;
1882
+ height: 36px;
1883
+ padding: 0 8px;
1884
+ background: var(--ls-bg);
1885
+ border: 1px solid var(--ls-border);
1886
+ border-radius: 4px;
1887
+ color: var(--ls-text);
1888
+ font-size: 12px;
1889
+ font-family: inherit;
1890
+ cursor: pointer;
1891
+ touch-action: manipulation;
1892
+ user-select: none;
1893
+ -webkit-user-select: none;
1894
+ transition: background 0.1s ease;
1895
+ }
1896
+
1897
+ .touch-key:active {
1898
+ background: var(--ls-btn-hover);
1899
+ }
1900
+
1901
+ .touch-key.wide {
1902
+ min-width: 50px;
1903
+ }
1904
+
1905
+ .touch-key.danger {
1906
+ color: #ef4444;
1907
+ border-color: #ef4444;
1908
+ }
1909
+
1910
+ .touch-key.toggle-btn {
1911
+ background: var(--ls-bg-header);
1912
+ min-width: 30px;
1913
+ font-size: 14px;
1914
+ }
1915
+
1916
+ .touch-key.toggle-btn.active {
1917
+ background: var(--ls-status-connected);
1918
+ color: white;
1919
+ border-color: var(--ls-status-connected);
1920
+ }
1921
+
1922
+ .touch-keyboard-toggle {
1923
+ display: flex;
1924
+ align-items: center;
1925
+ justify-content: center;
1926
+ padding: 2px;
1927
+ background: var(--ls-bg-header);
1928
+ border-top: 1px solid var(--ls-border);
1929
+ }
1930
+
1931
+ .touch-keyboard-toggle button {
1932
+ background: none;
1933
+ border: none;
1934
+ color: var(--ls-text-muted);
1935
+ font-size: 18px;
1936
+ padding: 4px 12px;
1937
+ cursor: pointer;
1938
+ }
1939
+
1940
+ .touch-keyboard-toggle button:active {
1941
+ color: var(--ls-text);
1942
+ }
1943
+
1944
+ /* Extra key rows (collapsible) */
1945
+ .touch-keyboard-extra {
1946
+ overflow: hidden;
1947
+ max-height: 0;
1948
+ opacity: 0;
1949
+ transition: max-height 0.2s ease, opacity 0.2s ease;
1950
+ }
1951
+
1952
+ .touch-keyboard-extra.expanded {
1953
+ max-height: 100px;
1954
+ opacity: 1;
1955
+ }
1956
+
1957
+ /* Hide touch keyboard on desktop */
1958
+ :host(:not([mobile])) .touch-keyboard,
1959
+ :host(:not([mobile])) .touch-keyboard-toggle {
1960
+ display: none;
1961
+ }
1962
+ `,
1963
+ ];
1964
+ __decorate([
1965
+ property({ type: String })
1966
+ ], LitShellTerminal.prototype, "url", void 0);
1967
+ __decorate([
1968
+ property({ type: String })
1969
+ ], LitShellTerminal.prototype, "shell", void 0);
1970
+ __decorate([
516
1971
  property({ type: String })
517
1972
  ], LitShellTerminal.prototype, "cwd", void 0);
518
1973
  __decorate([
@@ -533,6 +1988,30 @@ __decorate([
533
1988
  __decorate([
534
1989
  property({ type: Boolean, attribute: 'auto-spawn' })
535
1990
  ], LitShellTerminal.prototype, "autoSpawn", void 0);
1991
+ __decorate([
1992
+ property({ type: String })
1993
+ ], LitShellTerminal.prototype, "container", void 0);
1994
+ __decorate([
1995
+ property({ type: String, attribute: 'container-shell' })
1996
+ ], LitShellTerminal.prototype, "containerShell", void 0);
1997
+ __decorate([
1998
+ property({ type: String, attribute: 'container-user' })
1999
+ ], LitShellTerminal.prototype, "containerUser", void 0);
2000
+ __decorate([
2001
+ property({ type: String, attribute: 'container-cwd' })
2002
+ ], LitShellTerminal.prototype, "containerCwd", void 0);
2003
+ __decorate([
2004
+ property({ type: Boolean, attribute: 'show-connection-panel' })
2005
+ ], LitShellTerminal.prototype, "showConnectionPanel", void 0);
2006
+ __decorate([
2007
+ property({ type: Boolean, attribute: 'show-settings' })
2008
+ ], LitShellTerminal.prototype, "showSettings", void 0);
2009
+ __decorate([
2010
+ property({ type: Boolean, attribute: 'show-status-bar' })
2011
+ ], LitShellTerminal.prototype, "showStatusBar", void 0);
2012
+ __decorate([
2013
+ property({ type: Boolean, attribute: 'show-tabs' })
2014
+ ], LitShellTerminal.prototype, "showTabs", void 0);
536
2015
  __decorate([
537
2016
  property({ type: Number, attribute: 'font-size' })
538
2017
  ], LitShellTerminal.prototype, "fontSize", void 0);
@@ -563,6 +2042,72 @@ __decorate([
563
2042
  __decorate([
564
2043
  state()
565
2044
  ], LitShellTerminal.prototype, "sessionInfo", void 0);
2045
+ __decorate([
2046
+ state()
2047
+ ], LitShellTerminal.prototype, "containers", void 0);
2048
+ __decorate([
2049
+ state()
2050
+ ], LitShellTerminal.prototype, "serverInfo", void 0);
2051
+ __decorate([
2052
+ state()
2053
+ ], LitShellTerminal.prototype, "selectedContainer", void 0);
2054
+ __decorate([
2055
+ state()
2056
+ ], LitShellTerminal.prototype, "selectedShell", void 0);
2057
+ __decorate([
2058
+ state()
2059
+ ], LitShellTerminal.prototype, "connectionMode", void 0);
2060
+ __decorate([
2061
+ state()
2062
+ ], LitShellTerminal.prototype, "orphanTimeout", void 0);
2063
+ __decorate([
2064
+ state()
2065
+ ], LitShellTerminal.prototype, "useTmux", void 0);
2066
+ __decorate([
2067
+ state()
2068
+ ], LitShellTerminal.prototype, "availableSessions", void 0);
2069
+ __decorate([
2070
+ state()
2071
+ ], LitShellTerminal.prototype, "selectedSessionId", void 0);
2072
+ __decorate([
2073
+ state()
2074
+ ], LitShellTerminal.prototype, "clientCount", void 0);
2075
+ __decorate([
2076
+ state()
2077
+ ], LitShellTerminal.prototype, "settingsMenuOpen", void 0);
2078
+ __decorate([
2079
+ state()
2080
+ ], LitShellTerminal.prototype, "showReconnectDialog", void 0);
2081
+ __decorate([
2082
+ state()
2083
+ ], LitShellTerminal.prototype, "reconnectSessionId", void 0);
2084
+ __decorate([
2085
+ state()
2086
+ ], LitShellTerminal.prototype, "isMobile", void 0);
2087
+ __decorate([
2088
+ state()
2089
+ ], LitShellTerminal.prototype, "showTouchKeyboard", void 0);
2090
+ __decorate([
2091
+ state()
2092
+ ], LitShellTerminal.prototype, "showExtraKeyRows", void 0);
2093
+ __decorate([
2094
+ state()
2095
+ ], LitShellTerminal.prototype, "statusMessage", void 0);
2096
+ __decorate([
2097
+ state()
2098
+ ], LitShellTerminal.prototype, "statusType", void 0);
2099
+ __decorate([
2100
+ state()
2101
+ ], LitShellTerminal.prototype, "tabs", void 0);
2102
+ __decorate([
2103
+ state()
2104
+ ], LitShellTerminal.prototype, "activeTabId", void 0);
2105
+ __decorate([
2106
+ state()
2107
+ ], LitShellTerminal.prototype, "ctrlPressed", void 0);
2108
+ __decorate([
2109
+ state()
2110
+ ], LitShellTerminal.prototype, "altPressed", void 0);
566
2111
  LitShellTerminal = __decorate([
567
2112
  customElement('lit-shell-terminal')
568
2113
  ], LitShellTerminal);