haltija 1.1.21 → 1.2.2

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.
@@ -1,1770 +1,264 @@
1
1
  /**
2
- * Renderer process - handles the UI, tabs, and webviews
2
+ * Renderer process entry point.
3
+ * Imports modules and wires up event listeners.
3
4
  */
4
5
 
5
- // Settings (loaded from localStorage)
6
- const DEFAULT_SETTINGS = {
7
- serverMode: 'builtin', // 'builtin' | 'external' | 'auto'
8
- serverUrl: 'http://localhost:8700',
9
- confirmNewTabs: false,
10
- }
11
-
12
- let settings = { ...DEFAULT_SETTINGS }
6
+ import { loadSettings, initElements, tabs, activeTabId, el, getServerUrl, setLastCwd } from './renderer/state.js'
7
+ import { showNotification } from './renderer/ui-utils.js'
8
+ import {
9
+ createTab, createTerminalTab, activateTab, closeTab,
10
+ getActiveTab, getActiveWebview, navigate,
11
+ findTabByWindowId, changeTerminalDirectory, openFolderPicker,
12
+ } from './renderer/tabs.js'
13
+ import { setTabFunctions } from './renderer/webview-events.js'
14
+ import { checkHaltija } from './renderer/status.js'
15
+ import { initSettingsListeners, hideSettings, hideNewTabDialog } from './renderer/settings.js'
16
+ import { initAgentStatusBar } from './renderer/agent-status.js'
17
+ import { initVideoCapture } from './renderer/video-capture.js'
13
18
 
14
- function loadSettings() {
15
- try {
16
- const saved = localStorage.getItem('haltija-settings')
17
- if (saved) {
18
- settings = { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }
19
- }
20
- } catch (e) {
21
- console.error('[Haltija Desktop] Failed to load settings:', e)
22
- }
23
- }
24
-
25
- function saveSettings() {
26
- try {
27
- localStorage.setItem('haltija-settings', JSON.stringify(settings))
28
- } catch (e) {
29
- console.error('[Haltija Desktop] Failed to save settings:', e)
30
- }
31
- }
19
+ // ============================================
20
+ // Initialize
21
+ // ============================================
32
22
 
33
23
  loadSettings()
24
+ initElements()
34
25
 
35
- // Server URL (from settings)
36
- function getServerUrl() {
37
- return settings.serverUrl || DEFAULT_SETTINGS.serverUrl
38
- }
39
-
40
- // Elements
41
- const tabBar = document.getElementById('tabs')
42
- const newTabButton = document.getElementById('new-tab')
43
- const toolbar = document.getElementById('toolbar')
44
- const urlInput = document.getElementById('url-input')
45
- const goButton = document.getElementById('go')
46
- const backButton = document.getElementById('back')
47
- const forwardButton = document.getElementById('forward')
48
- const refreshButton = document.getElementById('refresh')
49
- const webviewContainer = document.getElementById('webview-container')
50
- const statusDot = document.getElementById('haltija-status')
51
- const settingsBtn = document.getElementById('settings-btn')
52
- const settingsModal = document.getElementById('settings-modal')
53
- const closeSettingsBtn = document.getElementById('close-settings')
54
- const saveSettingsBtn = document.getElementById('save-settings')
55
- const newTabDialog = document.getElementById('new-tab-dialog')
56
- const newTabUrlEl = document.getElementById('new-tab-url')
57
- const allowNewTabBtn = document.getElementById('allow-new-tab')
58
- const denyNewTabBtn = document.getElementById('deny-new-tab')
59
- const agentStatusBar = document.getElementById('agent-status-bar')
60
- const agentStatusItems = document.getElementById('agent-status-items')
61
- const agentSelect = document.getElementById('agent-select')
62
-
63
- // Tab management
64
- let tabs = []
65
- let activeTabId = null
66
- let tabIdCounter = 0
67
- let pendingNewTabUrl = null
68
- let pendingNewTabResolve = null
69
- let lastCwd = localStorage.getItem('haltija-lastCwd') || null // Persisted across restarts
26
+ // Expose tab functions for modules that need them (avoids circular imports)
27
+ window._tabs = { getActiveTab, getActiveWebview, createTab, activateTab, closeTab, navigate }
28
+ setTabFunctions({ navigate, createTab, activateTab, closeTab })
70
29
 
71
- // Default URL
72
- function getDefaultUrl() {
73
- return `${getServerUrl()}/test`
74
- }
75
-
76
- // Create a new tab
77
- function createTab(url, activate = true) {
78
- const tabId = `tab-${++tabIdCounter}`
79
- const tabUrl = url || getDefaultUrl()
80
-
81
- // Create tab element
82
- const tabEl = document.createElement('div')
83
- tabEl.className = 'tab'
84
- tabEl.dataset.tabId = tabId
85
- tabEl.innerHTML = `
86
- <span class="tab-title">New Tab</span>
87
- <button class="tab-close" title="Close tab">×</button>
88
- `
89
-
90
- // Tab click handlers
91
- tabEl.addEventListener('click', (e) => {
92
- if (!e.target.classList.contains('tab-close')) {
93
- activateTab(tabId)
94
- }
95
- })
96
-
97
- tabEl.querySelector('.tab-close').addEventListener('click', (e) => {
98
- e.stopPropagation()
99
- closeTab(tabId)
100
- })
101
-
102
- tabBar.appendChild(tabEl)
103
-
104
- // Create webview
105
- const webview = document.createElement('webview')
106
- webview.id = tabId
107
- webview.src = 'about:blank'
108
- // Use preload path from main preload script (exposes window.haltija.capturePage to web content)
109
- if (window.haltija?.webviewPreloadPath) {
110
- webview.setAttribute(
111
- 'preload',
112
- 'file://' + window.haltija.webviewPreloadPath,
113
- )
114
- }
115
- webview.setAttribute(
116
- 'webpreferences',
117
- 'contextIsolation=yes, nodeIntegration=no, webSecurity=no, allowRunningInsecureContent=yes',
118
- )
119
- webview.setAttribute('allowpopups', '')
120
-
121
- webviewContainer.appendChild(webview)
122
-
123
- // Store tab data
124
- const tab = {
125
- id: tabId,
126
- url: tabUrl,
127
- title: 'New Tab',
128
- element: tabEl,
129
- webview: webview,
130
- }
131
- tabs.push(tab)
132
-
133
- // Setup webview events
134
- setupWebviewEvents(tab)
135
-
136
- // Activate and navigate
137
- if (activate) {
138
- activateTab(tabId)
139
- }
140
-
141
- // Navigate after webview is ready
142
- webview.addEventListener(
143
- 'did-attach',
144
- () => {
145
- navigate(tabUrl, tabId)
146
- },
147
- { once: true },
148
- )
149
-
150
- // Fallback navigation (webview only, did-attach sometimes doesn't fire)
151
- const fallbackTabId = tabId
152
- setTimeout(() => {
153
- const t = tabs.find(tt => tt.id === fallbackTabId)
154
- if (t && !t.isTerminal && (webview.getURL() === 'about:blank' || !webview.getURL())) {
155
- navigate(tabUrl, fallbackTabId)
156
- }
157
- }, 500)
158
-
159
- return tab
160
- }
161
-
162
- // Create a terminal tab (iframe-based, no webview)
163
- // mode: 'human' or 'agent'
164
- async function createTerminalTab(mode = 'human') {
165
- const tabId = `tab-${++tabIdCounter}`
166
- const isAgent = mode === 'agent'
167
- const prefix = isAgent ? '<span class="agent-status">*</span>' : '>'
168
-
169
- // For agent tabs, check if hj CLI is installed
170
- if (isAgent) {
171
- await checkHjInstalled()
172
- }
173
-
174
- // Determine initial cwd: use active terminal's cwd, or lastCwd from localStorage
175
- const activeTab = getActiveTab()
176
- const initialCwd = (activeTab?.isTerminal && activeTab?.cwd) || lastCwd || ''
177
-
178
- // For agent tabs, use a default name; for human, show 'shell' until cwd is known
179
- const label = isAgent ? 'agent' : 'shell'
180
-
181
- // Create tab element
182
- const tabEl = document.createElement('div')
183
- tabEl.className = `tab ${isAgent ? 'agent ready' : 'terminal'}`
184
- tabEl.dataset.tabId = tabId
185
- tabEl.innerHTML = `
186
- <span class="tab-title">${prefix} ${label}</span>
187
- <button class="tab-close" title="Close tab">×</button>
188
- `
189
-
190
- tabEl.addEventListener('click', (e) => {
191
- if (!e.target.classList.contains('tab-close')) {
192
- activateTab(tabId)
193
- }
194
- })
195
-
196
- tabEl.querySelector('.tab-close').addEventListener('click', (e) => {
197
- e.stopPropagation()
198
- closeTab(tabId)
199
- })
200
-
201
- tabBar.appendChild(tabEl)
202
-
203
- // Create iframe (not webview — terminal is local, no widget injection needed)
204
- const iframe = document.createElement('iframe')
205
- iframe.id = tabId
206
- const cwdParam = initialCwd ? `&cwd=${encodeURIComponent(initialCwd)}` : ''
207
- iframe.src = `terminal.html?port=${window.haltija?.port || 8700}&mode=${mode}${cwdParam}`
208
- iframe.className = 'terminal-frame'
209
-
210
- webviewContainer.appendChild(iframe)
211
-
212
- // Store tab data
213
- const tab = {
214
- id: tabId,
215
- url: 'terminal',
216
- title: label,
217
- element: tabEl,
218
- webview: iframe, // reuse field for consistency with closeTab/activateTab
219
- isTerminal: true,
220
- terminalMode: mode, // 'human' or 'agent'
221
- }
222
- tabs.push(tab)
223
-
224
- activateTab(tabId)
225
- return tab
226
- }
227
-
228
- // Rename a terminal tab (updates shell name on server + tab title)
229
- function renameTerminalTab(tab, name) {
230
- if (!name) return
231
- tab.shellName = name
232
- tab.title = name
233
- const prefix = tab.terminalMode === 'agent' ? '*' : '>'
234
- tab.element.querySelector('.tab-title').textContent = `${prefix} ${name}`
235
- document.title = name
236
- // Send whoami to server via the terminal iframe
237
- const iframe = tab.webview
238
- if (iframe && iframe.contentWindow) {
239
- iframe.contentWindow.postMessage({ type: 'rename', name }, '*')
240
- }
241
- }
242
-
243
- // Activate a tab
244
- function activateTab(tabId) {
245
- const tab = tabs.find((t) => t.id === tabId)
246
- if (!tab) return
247
-
248
- // Deactivate all tabs
249
- tabs.forEach((t) => {
250
- t.element.classList.remove('active')
251
- t.webview.classList.remove('active')
252
- })
253
-
254
- // Activate this tab
255
- tab.element.classList.add('active')
256
- tab.webview.classList.add('active')
257
- activeTabId = tabId
258
-
259
- // Update URL bar, Go button, toolbar, and title based on tab type
260
- toolbar.classList.remove('terminal', 'agent')
261
- if (tab.isTerminal || tab.url === 'terminal') {
262
- urlInput.value = tab.cwd || '~'
263
- urlInput.placeholder = 'working directory'
264
- goButton.textContent = 'Pick folder…'
265
- goButton.title = 'Pick folder…'
266
- toolbar.classList.add(tab.terminalMode === 'agent' ? 'agent' : 'terminal')
267
-
268
- // Show status bar for terminal tabs
269
- if (currentStatusLine) {
270
- agentStatusBar.classList.remove('hidden')
271
- }
272
-
273
- // If this is an agent tab, notify server it's now the active agent
274
- if (tab.terminalMode === 'agent' && tab.shellId) {
275
- fetch(`${getServerUrl()}/terminal/agent-focus`, {
276
- method: 'POST',
277
- headers: { 'Content-Type': 'application/json' },
278
- body: JSON.stringify({ shellId: tab.shellId }),
279
- }).catch(() => {}) // Ignore errors
280
- }
281
- } else {
282
- urlInput.value = tab.url || ''
283
- urlInput.placeholder = 'Enter URL...'
284
- goButton.textContent = 'Go'
285
- goButton.title = 'Go'
286
-
287
- // Hide status bar for browser tabs
288
- agentStatusBar.classList.add('hidden')
289
- }
290
- document.title = tab.title || 'Haltija'
291
-
292
- // Update nav buttons
293
- updateNavButtons()
294
- }
295
-
296
- // Close a tab
297
- function closeTab(tabId) {
298
- const tabIndex = tabs.findIndex((t) => t.id === tabId)
299
- if (tabIndex === -1) return
300
-
301
- const tab = tabs[tabIndex]
302
-
303
- // Proper webview teardown — stop rendering before removal
304
- if (tab.webview && tab.webview.tagName === 'WEBVIEW') {
305
- try {
306
- tab.webview.stop()
307
- tab.webview.src = 'about:blank'
308
- } catch (e) {}
309
- }
310
-
311
- // Remove elements
312
- tab.element.remove()
313
- tab.webview.remove()
314
-
315
- // Remove from array
316
- tabs.splice(tabIndex, 1)
317
-
318
- // If this was the active tab, activate another
319
- if (activeTabId === tabId) {
320
- if (tabs.length > 0) {
321
- // Activate the tab to the left, or the first tab
322
- const newIndex = Math.max(0, tabIndex - 1)
323
- activateTab(tabs[newIndex].id)
324
- } else {
325
- // Last tab — close the window (quit on non-macOS, hide on macOS)
326
- window.haltija.closeWindow()
327
- }
328
- }
329
- }
330
-
331
- // Get active tab
332
- function getActiveTab() {
333
- return tabs.find((t) => t.id === activeTabId)
334
- }
335
-
336
- // Get active webview
337
- function getActiveWebview() {
338
- const tab = getActiveTab()
339
- return tab ? tab.webview : null
340
- }
341
-
342
- // Navigate to URL (with https->http fallback for any URL without explicit protocol)
343
- function navigate(url, tabId = activeTabId) {
344
- const tab = tabs.find((t) => t.id === tabId)
345
- if (!tab) return
346
-
347
- // Track if we added the protocol (vs user explicitly typed it)
348
- let addedHttps = false
349
-
350
- // Add protocol if missing (but preserve blob:, data:, file:, etc.)
351
- if (url && !url.match(/^(https?|blob|data|file|about|javascript):\/?\/?/i)) {
352
- if (
353
- url.includes('.') ||
354
- url === 'localhost' ||
355
- url.startsWith('localhost:')
356
- ) {
357
- // Looks like a URL - try https first, will fallback to http on failure
358
- addedHttps = true
359
- url = 'https://' + url
360
- } else {
361
- url = 'https://www.google.com/search?q=' + encodeURIComponent(url)
362
- }
363
- }
364
-
365
- tab.url = url || getDefaultUrl()
366
-
367
- // If we added https://, set up fallback to http:// on connection failure
368
- if (addedHttps) {
369
- const httpUrl = tab.url.replace(/^https:/, 'http:')
370
-
371
- // Listen for SSL/connection errors to fall back to http
372
- const failHandler = (e) => {
373
- // Common error codes: -501 (insecure), -102 (connection refused), -118 (connection timed out), -200+ (SSL errors)
374
- if (e.errorCode < 0) {
375
- console.log(`[Haltija] HTTPS failed (${e.errorCode}), trying HTTP...`)
376
- tab.webview.removeEventListener('did-fail-load', failHandler)
377
- tab.url = httpUrl
378
- tab.webview.src = httpUrl
379
- if (tabId === activeTabId) {
380
- urlInput.value = httpUrl
381
- }
382
- }
383
- }
384
- tab.webview.addEventListener('did-fail-load', failHandler, { once: true })
385
-
386
- // Clean up handler on success
387
- tab.webview.addEventListener(
388
- 'did-finish-load',
389
- () => {
390
- tab.webview.removeEventListener('did-fail-load', failHandler)
391
- },
392
- { once: true },
393
- )
394
- }
395
-
396
- tab.webview.src = tab.url
397
-
398
- if (tabId === activeTabId && !tab.isTerminal) {
399
- urlInput.value = tab.url
400
- }
401
- }
402
-
403
- // Update UI state
404
- function updateNavButtons() {
405
- const webview = getActiveWebview()
406
- if (webview) {
407
- try {
408
- backButton.disabled = !webview.canGoBack()
409
- forwardButton.disabled = !webview.canGoForward()
410
- } catch (e) {
411
- backButton.disabled = true
412
- forwardButton.disabled = true
413
- }
414
- } else {
415
- backButton.disabled = true
416
- forwardButton.disabled = true
417
- }
418
- }
419
-
420
- // Check Haltija server status
421
- async function checkHaltija() {
422
- try {
423
- const response = await fetch(`${getServerUrl()}/status`)
424
- if (response.ok) {
425
- statusDot.className = 'status-dot connected'
426
- statusDot.title = 'Haltija: Connected'
427
- } else {
428
- throw new Error('Not OK')
429
- }
430
- } catch {
431
- statusDot.className = 'status-dot disconnected'
432
- statusDot.title = 'Haltija: Disconnected - Start server with: bunx haltija'
433
- }
434
- }
435
-
436
- // Inject widget into webview
437
- async function injectWidget(webview) {
438
- const currentUrl = webview.getURL()
439
- if (!currentUrl || currentUrl === 'about:blank') {
440
- return
441
- }
442
-
443
- const serverUrl = getServerUrl()
444
- const script = `
445
- (function() {
446
- if (document.getElementById('haltija-widget')) {
447
- return;
448
- }
449
-
450
- fetch('${serverUrl}/inject.js')
451
- .then(r => r.text())
452
- .then(code => eval(code))
453
- .catch(e => console.error('[Haltija] Injection failed:', e));
454
- })();
455
- `
456
-
457
- try {
458
- await webview.executeJavaScript(script)
459
- } catch (err) {
460
- console.error('[Haltija Desktop] Injection failed:', err)
461
- }
462
- }
463
-
464
- // Setup webview events
465
- function setupWebviewEvents(tab) {
466
- const webview = tab.webview
467
-
468
- webview.addEventListener('did-start-loading', () => {
469
- webview.classList.add('loading')
470
- if (tab.id === activeTabId) {
471
- statusDot.className = 'status-dot connecting'
472
- }
473
- })
474
-
475
- webview.addEventListener('did-stop-loading', () => {
476
- webview.classList.remove('loading')
477
- if (tab.id === activeTabId) {
478
- updateNavButtons()
479
- checkHaltija()
480
- }
481
- // Widget injection is handled by main.js
482
- })
483
-
484
- webview.addEventListener('did-navigate', (e) => {
485
- tab.url = e.url
486
- if (tab.id === activeTabId && !tab.isTerminal) {
487
- urlInput.value = e.url
488
- updateNavButtons()
489
- }
490
- })
491
-
492
- webview.addEventListener('did-navigate-in-page', (e) => {
493
- tab.url = e.url
494
- if (tab.id === activeTabId && !tab.isTerminal) {
495
- urlInput.value = e.url
496
- updateNavButtons()
497
- }
498
- })
499
-
500
- webview.addEventListener('did-finish-load', () => {
501
- // Widget injection is handled by main.js
502
- if (window.haltija) {
503
- window.haltija.webviewReady(webview.getWebContentsId())
504
- }
505
- })
506
-
507
- webview.addEventListener('dom-ready', () => {
508
- // Widget injection is handled by main.js
509
-
510
- // Force light mode for blob/data URLs (they inherit dark mode from system/shell)
511
- const url = webview.getURL()
512
- if (url.startsWith('blob:') || url.startsWith('data:')) {
513
- webview.insertCSS(':root { color-scheme: light !important; }')
514
- }
515
- })
516
-
517
- webview.addEventListener('page-title-updated', (e) => {
518
- tab.title = e.title || 'New Tab'
519
- tab.element.querySelector('.tab-title').textContent = tab.title
520
- if (tab.id === activeTabId) {
521
- document.title = tab.title || 'Haltija'
522
- }
523
- })
524
-
525
- // Handle new window requests
526
- webview.addEventListener('new-window', async (e) => {
527
- e.preventDefault()
528
-
529
- if (settings.confirmNewTabs) {
530
- const allowed = await showNewTabDialog(e.url)
531
- if (allowed) {
532
- createTab(e.url)
533
- }
534
- } else {
535
- createTab(e.url)
536
- }
537
- })
538
-
539
- webview.addEventListener('console-message', (e) => {
540
- const prefix = e.level === 2 ? '[warn]' : e.level === 3 ? '[error]' : ''
541
- console.log(`[Tab ${tab.id}${prefix}]`, e.message)
542
- })
543
-
544
- // Right-click context menu with Inspect Element
545
- webview.addEventListener('context-menu', (e) => {
546
- e.preventDefault()
547
- const { x, y } = e.params
548
-
549
- // Create and show context menu
550
- const menu = document.createElement('div')
551
- menu.className = 'context-menu'
552
- menu.style.cssText = `
553
- position: fixed;
554
- left: ${e.clientX || x}px;
555
- top: ${e.clientY || y}px;
556
- background: var(--surface);
557
- border: 1px solid var(--border);
558
- border-radius: 6px;
559
- padding: 4px 0;
560
- min-width: 150px;
561
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
562
- z-index: 10000;
563
- `
564
-
565
- const items = [
566
- {
567
- label: 'Back',
568
- action: () => webview.canGoBack() && webview.goBack(),
569
- enabled: webview.canGoBack(),
570
- },
571
- {
572
- label: 'Forward',
573
- action: () => webview.canGoForward() && webview.goForward(),
574
- enabled: webview.canGoForward(),
575
- },
576
- { label: 'Reload', action: () => webview.reload() },
577
- { type: 'separator' },
578
- { label: 'Inspect Element', action: () => webview.inspectElement(x, y) },
579
- ]
580
-
581
- items.forEach((item) => {
582
- if (item.type === 'separator') {
583
- const sep = document.createElement('div')
584
- sep.style.cssText =
585
- 'height: 1px; background: var(--border); margin: 4px 0;'
586
- menu.appendChild(sep)
587
- } else {
588
- const menuItem = document.createElement('div')
589
- menuItem.textContent = item.label
590
- menuItem.style.cssText = `
591
- padding: 6px 12px;
592
- cursor: ${item.enabled === false ? 'default' : 'pointer'};
593
- opacity: ${item.enabled === false ? '0.5' : '1'};
594
- color: var(--text);
595
- `
596
- if (item.enabled !== false) {
597
- menuItem.addEventListener('mouseenter', () => {
598
- menuItem.style.background = 'var(--hover)'
599
- })
600
- menuItem.addEventListener('mouseleave', () => {
601
- menuItem.style.background = 'transparent'
602
- })
603
- menuItem.addEventListener('click', () => {
604
- document.body.removeChild(menu)
605
- item.action()
606
- })
607
- }
608
- menu.appendChild(menuItem)
609
- }
610
- })
611
-
612
- document.body.appendChild(menu)
613
-
614
- // Close menu on click outside
615
- const closeMenu = (evt) => {
616
- if (!menu.contains(evt.target)) {
617
- if (document.body.contains(menu)) {
618
- document.body.removeChild(menu)
619
- }
620
- document.removeEventListener('click', closeMenu)
621
- }
622
- }
623
- setTimeout(() => document.addEventListener('click', closeMenu), 0)
624
- })
625
-
626
- // Intercept keyboard shortcuts inside webview
627
- // This ensures Cmd+R reloads the webview, not the outer shell
628
- // Note: before-input-event provides input info via e.input property in Electron webview
629
- webview.addEventListener('before-input-event', (e) => {
630
- const input = e.input || e // Handle both webview event structure and potential variations
631
- const { type, key, meta, control } = input
632
- if (type !== 'keyDown') return
633
-
634
- // Cmd/Ctrl + R = refresh this webview (prevent outer shell refresh)
635
- if ((meta || control) && key === 'r') {
636
- e.preventDefault()
637
- e.stopPropagation()
638
- webview.reload()
639
- return
640
- }
641
-
642
- // Cmd/Ctrl + L = focus address bar
643
- if ((meta || control) && key === 'l') {
644
- e.preventDefault()
645
- e.stopPropagation()
646
- urlInput.focus()
647
- urlInput.select()
648
- return
649
- }
650
-
651
- // Cmd/Ctrl + T = new tab
652
- if ((meta || control) && key === 't') {
653
- e.preventDefault()
654
- e.stopPropagation()
655
- createTab()
656
- return
657
- }
658
-
659
- // Cmd/Ctrl + W = close this tab
660
- if ((meta || control) && key === 'w') {
661
- e.preventDefault()
662
- e.stopPropagation()
663
- closeTab(tab.id)
664
- return
665
- }
666
-
667
- // Cmd/Ctrl + [ = back
668
- if ((meta || control) && key === '[') {
669
- e.preventDefault()
670
- e.stopPropagation()
671
- if (webview.canGoBack()) webview.goBack()
672
- return
673
- }
674
-
675
- // Cmd/Ctrl + ] = forward
676
- if ((meta || control) && key === ']') {
677
- e.preventDefault()
678
- e.stopPropagation()
679
- if (webview.canGoForward()) webview.goForward()
680
- return
681
- }
682
- })
683
- }
684
-
685
- // Show new tab confirmation dialog
686
- function showNewTabDialog(url) {
687
- return new Promise((resolve) => {
688
- pendingNewTabUrl = url
689
- pendingNewTabResolve = resolve
690
- newTabUrlEl.textContent = url
691
- newTabDialog.classList.remove('hidden')
692
- })
693
- }
694
-
695
- function hideNewTabDialog(allowed) {
696
- newTabDialog.classList.add('hidden')
697
- if (pendingNewTabResolve) {
698
- pendingNewTabResolve(allowed)
699
- pendingNewTabResolve = null
700
- pendingNewTabUrl = null
701
- }
702
- }
703
-
704
- // Settings modal
705
- function showSettings() {
706
- // Populate form
707
- document.querySelector(
708
- `input[name="server-mode"][value="${settings.serverMode}"]`,
709
- ).checked = true
710
- document.getElementById('server-url').value = settings.serverUrl
711
- document.getElementById('confirm-new-tabs').checked = settings.confirmNewTabs
712
-
713
- settingsModal.classList.remove('hidden')
714
- }
715
-
716
- function hideSettings() {
717
- settingsModal.classList.add('hidden')
718
- }
719
-
720
- function applySettings() {
721
- settings.serverMode = document.querySelector(
722
- 'input[name="server-mode"]:checked',
723
- ).value
724
- settings.serverUrl =
725
- document.getElementById('server-url').value || DEFAULT_SETTINGS.serverUrl
726
- settings.confirmNewTabs = document.getElementById('confirm-new-tabs').checked
30
+ console.log('[Haltija Desktop] Initializing with tabs...')
31
+ checkHaltija()
32
+ createTab()
727
33
 
728
- saveSettings()
729
- hideSettings()
730
- checkHaltija()
731
- }
34
+ // Periodic status check
35
+ setInterval(checkHaltija, 5000)
732
36
 
733
- // Event listeners
37
+ // ============================================
38
+ // Event Listeners
39
+ // ============================================
734
40
 
735
- // New tab button
736
- newTabButton.addEventListener('click', () => createTab())
737
- const newTerminalBtn = document.getElementById('new-terminal')
738
- newTerminalBtn.addEventListener('click', () => createTerminalTab('human'))
739
- const newAgentBtn = document.getElementById('new-agent')
740
- newAgentBtn.addEventListener('click', () => createTerminalTab('agent'))
41
+ // New tab buttons
42
+ el.newTabButton.addEventListener('click', () => createTab())
43
+ document.getElementById('new-terminal').addEventListener('click', () => createTerminalTab('human'))
44
+ document.getElementById('new-agent').addEventListener('click', () => createTerminalTab('agent'))
741
45
 
742
46
  // Address bar
743
- urlInput.addEventListener('keydown', (e) => {
47
+ el.urlInput.addEventListener('keydown', (e) => {
744
48
  if (e.key === 'Enter') {
745
49
  const tab = tabs.find(t => t.id === activeTabId)
746
50
  if (tab && tab.isTerminal) {
747
- // cd to the entered path
748
- changeTerminalDirectory(tab, urlInput.value.trim())
749
- urlInput.blur()
51
+ changeTerminalDirectory(tab, el.urlInput.value.trim())
52
+ el.urlInput.blur()
750
53
  } else {
751
- navigate(urlInput.value)
54
+ navigate(el.urlInput.value)
752
55
  }
753
56
  }
754
57
  })
755
58
 
756
- goButton.addEventListener('click', () => {
59
+ el.goButton.addEventListener('click', () => {
757
60
  const tab = tabs.find(t => t.id === activeTabId)
758
61
  if (tab && tab.isTerminal) {
759
- // Open folder picker for terminal tabs
760
62
  openFolderPicker(tab)
761
63
  } else {
762
- navigate(urlInput.value)
64
+ navigate(el.urlInput.value)
763
65
  }
764
66
  })
765
67
 
766
- // Change terminal working directory via cd command
767
- async function changeTerminalDirectory(tab, path) {
768
- if (!path) return
769
- try {
770
- const resp = await fetch(`${getServerUrl()}/terminal/command`, {
771
- method: 'POST',
772
- headers: { 'Content-Type': 'application/json' },
773
- body: JSON.stringify({ command: `cd ${path}`, shellId: tab.shellId }),
774
- })
775
- const result = await resp.text()
776
- // If successful, the cwd-changed message will update the URL bar
777
- if (result.startsWith('cd:')) {
778
- // Error - show notification
779
- showNotification(result, 3000)
780
- }
781
- } catch (err) {
782
- showNotification(`Error: ${err.message}`, 3000)
783
- }
784
- }
785
-
786
- // Open native folder picker and cd to selected folder
787
- async function openFolderPicker(tab) {
788
- if (window.haltija?.showOpenDialog) {
789
- const result = await window.haltija.showOpenDialog({
790
- properties: ['openDirectory', 'createDirectory'],
791
- defaultPath: tab.cwd || undefined,
792
- })
793
- if (result && !result.canceled && result.filePaths?.[0]) {
794
- const picked = result.filePaths[0]
795
- lastCwd = picked
796
- localStorage.setItem('haltija-lastCwd', picked)
797
- changeTerminalDirectory(tab, picked)
798
- }
799
- } else {
800
- showNotification('Folder picker not available', 2000)
801
- }
802
- }
803
-
804
68
  // Navigation buttons
805
- backButton.addEventListener('click', () => {
69
+ el.backButton.addEventListener('click', () => {
806
70
  const webview = getActiveWebview()
807
71
  if (webview) webview.goBack()
808
72
  })
809
73
 
810
- forwardButton.addEventListener('click', () => {
74
+ el.forwardButton.addEventListener('click', () => {
811
75
  const webview = getActiveWebview()
812
76
  if (webview) webview.goForward()
813
77
  })
814
78
 
815
- refreshButton.addEventListener('click', () => {
79
+ el.refreshButton.addEventListener('click', () => {
816
80
  const webview = getActiveWebview()
817
81
  if (webview) webview.reload()
818
82
  })
819
83
 
820
84
  // Settings
821
- settingsBtn.addEventListener('click', showSettings)
822
- closeSettingsBtn.addEventListener('click', hideSettings)
823
- saveSettingsBtn.addEventListener('click', applySettings)
824
-
825
- // Click outside modal to close
826
- settingsModal.addEventListener('click', (e) => {
827
- if (e.target === settingsModal) hideSettings()
828
- })
829
-
830
- // New tab dialog
831
- allowNewTabBtn.addEventListener('click', () => hideNewTabDialog(true))
832
- denyNewTabBtn.addEventListener('click', () => hideNewTabDialog(false))
85
+ initSettingsListeners()
833
86
 
834
87
  // Keyboard shortcuts
835
88
  document.addEventListener('keydown', (e) => {
836
- // Cmd/Ctrl + L = focus address bar
837
89
  if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
838
90
  e.preventDefault()
839
- urlInput.focus()
840
- urlInput.select()
91
+ el.urlInput.focus()
92
+ el.urlInput.select()
841
93
  }
842
-
843
- // Cmd/Ctrl + R = refresh
844
94
  if ((e.metaKey || e.ctrlKey) && e.key === 'r') {
845
95
  e.preventDefault()
846
96
  const webview = getActiveWebview()
847
97
  if (webview) webview.reload()
848
98
  }
849
-
850
- // Cmd/Ctrl + T = new tab
851
99
  if ((e.metaKey || e.ctrlKey) && e.key === 't') {
852
100
  e.preventDefault()
853
101
  createTab()
854
102
  }
855
-
856
- // Cmd/Ctrl + W = close tab
857
103
  if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
858
104
  e.preventDefault()
859
105
  if (activeTabId) closeTab(activeTabId)
860
106
  }
861
-
862
- // Cmd/Ctrl + [ = back
863
107
  if ((e.metaKey || e.ctrlKey) && e.key === '[') {
864
108
  e.preventDefault()
865
109
  const webview = getActiveWebview()
866
110
  if (webview && webview.canGoBack()) webview.goBack()
867
111
  }
868
-
869
- // Cmd/Ctrl + ] = forward
870
112
  if ((e.metaKey || e.ctrlKey) && e.key === ']') {
871
113
  e.preventDefault()
872
114
  const webview = getActiveWebview()
873
115
  if (webview && webview.canGoForward()) webview.goForward()
874
116
  }
875
-
876
- // Cmd/Ctrl + 1-9 = switch to tab
877
117
  if ((e.metaKey || e.ctrlKey) && e.key >= '1' && e.key <= '9') {
878
118
  e.preventDefault()
879
119
  const index = parseInt(e.key) - 1
880
- if (tabs[index]) {
881
- activateTab(tabs[index].id)
882
- }
120
+ if (tabs[index]) activateTab(tabs[index].id)
883
121
  }
884
-
885
- // Escape = close modals
886
122
  if (e.key === 'Escape') {
887
123
  hideSettings()
888
124
  hideNewTabDialog(false)
889
125
  }
890
126
  })
891
127
 
892
- // Initialize
893
- console.log('[Haltija Desktop] Initializing with tabs...')
894
- checkHaltija()
895
-
896
- // Create initial tab
897
- createTab()
898
-
899
- // Periodic status check
900
- setInterval(checkHaltija, 5000)
901
-
902
- // Expose API for widget to manage tabs
903
- window.haltija = window.haltija || {}
904
-
905
- window.haltija.openTab = async (url) => {
906
- if (settings.confirmNewTabs) {
907
- const allowed = await showNewTabDialog(url)
908
- if (allowed) {
909
- createTab(url)
910
- return true
911
- }
912
- return false
913
- } else {
914
- createTab(url)
915
- return true
916
- }
917
- }
918
-
919
- window.haltija.closeTab = (windowId) => {
920
- // Find tab by windowId (stored in webview's window tracking)
921
- // For now, we close the active tab if no specific ID
922
- // TODO: implement proper windowId to tab mapping
923
- if (activeTabId) {
924
- closeTab(activeTabId)
925
- return true
926
- }
927
- return false
928
- }
929
-
930
- window.haltija.openAgentTab = async () => {
931
- // Create a new agent tab and return its info once initialized
932
- const tab = await createTerminalTab('agent')
933
- // Wait for the agent to initialize and get its shellId
934
- return new Promise((resolve) => {
935
- const checkShellId = setInterval(() => {
936
- if (tab.shellId) {
937
- clearInterval(checkShellId)
938
- resolve({ shellId: tab.shellId, name: tab.label || 'agent' })
939
- }
940
- }, 100)
941
- // Timeout after 5 seconds
942
- setTimeout(() => {
943
- clearInterval(checkShellId)
944
- resolve({ shellId: tab.shellId || null, name: tab.label || 'agent' })
945
- }, 5000)
946
- })
947
- }
948
-
949
- window.haltija.focusTab = (windowId) => {
950
- // Find tab by windowId and activate it
951
- // TODO: implement proper windowId to tab mapping
952
- // For now, this is a no-op since we'd need to map window IDs to tab IDs
953
- return false
954
- }
955
-
956
- // Listen for open-url-in-tab from main process
957
- // This handles window.open() calls that should become tabs instead of windows
958
- if (window.haltija && window.haltija.onOpenUrlInTab) {
959
- window.haltija.onOpenUrlInTab((url) => {
960
- console.log('[Haltija Desktop] Opening URL in new tab:', url)
961
- createTab(url)
962
- })
963
- }
964
-
965
- // Simple toast notification
966
- function showNotification(message, duration = 2000) {
967
- // Remove existing notification if any
968
- const existing = document.getElementById('toast-notification')
969
- if (existing) existing.remove()
970
-
971
- const toast = document.createElement('div')
972
- toast.id = 'toast-notification'
973
- toast.textContent = message
974
- toast.style.cssText = `
975
- position: fixed;
976
- bottom: 20px;
977
- left: 50%;
978
- transform: translateX(-50%);
979
- background: var(--accent, #6366f1);
980
- color: white;
981
- padding: 12px 24px;
982
- border-radius: 8px;
983
- font-size: 14px;
984
- z-index: 10000;
985
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
986
- animation: toast-in 0.2s ease-out;
987
- `
128
+ // ============================================
129
+ // IPC Bridge expose tab APIs to widget/main
130
+ // ============================================
988
131
 
989
- // Add animation style if not present
990
- if (!document.getElementById('toast-styles')) {
991
- const style = document.createElement('style')
992
- style.id = 'toast-styles'
993
- style.textContent = `
994
- @keyframes toast-in { from { opacity: 0; transform: translateX(-50%) translateY(10px); } }
995
- @keyframes toast-out { to { opacity: 0; transform: translateX(-50%) translateY(10px); } }
996
- `
997
- document.head.appendChild(style)
998
- }
132
+ // Tab management from widget (via main process IPC)
133
+ window.haltija?.onOpenTab?.((data) => createTab(data.url))
134
+ window.haltija?.onCloseTab?.((data) => {
135
+ const tab = findTabByWindowId(data.windowId)
136
+ if (tab) closeTab(tab.id)
137
+ })
138
+ window.haltija?.onFocusTab?.((data) => {
139
+ const tab = findTabByWindowId(data.windowId)
140
+ if (tab) activateTab(tab.id)
141
+ })
999
142
 
1000
- document.body.appendChild(toast)
143
+ // Open URL from main process (window.open intercepted by main.js)
144
+ window.haltija?.onOpenUrlInTab?.((url) => {
145
+ console.log('[Haltija Desktop] Opening URL in new tab:', url)
146
+ createTab(url)
147
+ })
1001
148
 
1002
- setTimeout(() => {
1003
- toast.style.animation = 'toast-out 0.2s ease-in forwards'
1004
- setTimeout(() => toast.remove(), 200)
1005
- }, duration)
1006
- }
149
+ // ============================================
150
+ // Menu Commands from Main Process
151
+ // ============================================
1007
152
 
1008
- // Listen for menu commands from main process
1009
- // These handle Cmd+R, Cmd+T, etc. from the application menu
1010
153
  if (window.haltija) {
1011
- // Notifications
1012
154
  window.haltija.onShowNotification?.(showNotification)
1013
-
1014
- window.haltija.onMenuNewTab?.(() => {
1015
- createTab()
1016
- })
1017
-
1018
- window.haltija.onMenuNewTerminalTab?.(() => {
1019
- createTerminalTab()
1020
- })
1021
-
1022
- window.haltija.onMenuCloseTab?.(() => {
1023
- if (activeTabId) closeTab(activeTabId)
1024
- })
1025
-
155
+ window.haltija.onMenuNewTab?.(() => createTab())
156
+ window.haltija.onMenuNewTerminalTab?.(() => createTerminalTab())
157
+ window.haltija.onMenuCloseTab?.(() => { if (activeTabId) closeTab(activeTabId) })
1026
158
  window.haltija.onMenuCloseOtherTabs?.(() => {
1027
159
  if (activeTabId && tabs.length > 1) {
1028
- // Get IDs of tabs to close (all except active)
1029
- const tabsToClose = tabs
1030
- .filter((t) => t.id !== activeTabId)
1031
- .map((t) => t.id)
1032
- tabsToClose.forEach((tabId) => closeTab(tabId))
160
+ tabs.filter(t => t.id !== activeTabId).map(t => t.id).forEach(id => closeTab(id))
1033
161
  }
1034
162
  })
1035
-
1036
163
  window.haltija.onMenuReloadTab?.(() => {
1037
- const webview = getActiveWebview()
1038
- if (webview) webview.reload()
164
+ const wv = getActiveWebview(); if (wv) wv.reload()
1039
165
  })
1040
-
1041
166
  window.haltija.onMenuForceReloadTab?.(() => {
1042
- const webview = getActiveWebview()
1043
- if (webview) webview.reloadIgnoringCache()
167
+ const wv = getActiveWebview(); if (wv) wv.reloadIgnoringCache()
1044
168
  })
1045
-
1046
169
  window.haltija.onMenuDevToolsTab?.(() => {
1047
- const webview = getActiveWebview()
1048
- if (webview) webview.openDevTools()
170
+ const wv = getActiveWebview(); if (wv) wv.openDevTools()
1049
171
  })
1050
-
1051
172
  window.haltija.onMenuBack?.(() => {
1052
- const webview = getActiveWebview()
1053
- if (webview && webview.canGoBack()) webview.goBack()
173
+ const wv = getActiveWebview(); if (wv && wv.canGoBack()) wv.goBack()
1054
174
  })
1055
-
1056
175
  window.haltija.onMenuForward?.(() => {
1057
- const webview = getActiveWebview()
1058
- if (webview && webview.canGoForward()) webview.goForward()
176
+ const wv = getActiveWebview(); if (wv && wv.canGoForward()) wv.goForward()
1059
177
  })
1060
-
1061
178
  window.haltija.onMenuFocusUrl?.(() => {
1062
- urlInput.focus()
1063
- urlInput.select()
179
+ el.urlInput.focus(); el.urlInput.select()
1064
180
  })
1065
181
 
1066
- // Handle agent tab creation requests from widget (via main process)
1067
182
  window.haltija.onCreateAgentTab?.(async (data) => {
1068
183
  console.log('[Haltija Desktop] Creating agent tab for widget, requestId:', data.requestId)
1069
184
  try {
1070
185
  const tab = await createTerminalTab('agent')
1071
-
1072
- // Wait for shellId to be set (terminal sends message when ready)
1073
186
  const result = await new Promise((resolve) => {
1074
187
  const checkReady = setInterval(() => {
1075
188
  if (tab.shellId) {
1076
189
  clearInterval(checkReady)
1077
- resolve({
1078
- requestId: data.requestId,
1079
- shellId: tab.shellId,
1080
- name: tab.label || 'agent'
1081
- })
190
+ resolve({ requestId: data.requestId, shellId: tab.shellId, name: tab.label || 'agent' })
1082
191
  }
1083
192
  }, 100)
1084
-
1085
- // Timeout after 8 seconds
1086
193
  setTimeout(() => {
1087
194
  clearInterval(checkReady)
1088
- resolve({
1089
- requestId: data.requestId,
1090
- shellId: tab.shellId || null,
1091
- name: tab.label || 'agent',
1092
- error: tab.shellId ? null : 'Timeout waiting for agent init'
1093
- })
195
+ resolve({ requestId: data.requestId, shellId: tab.shellId || null, name: tab.label || 'agent', error: tab.shellId ? null : 'Timeout waiting for agent init' })
1094
196
  }, 8000)
1095
197
  })
1096
-
1097
198
  window.haltija.agentTabCreated(result)
1098
199
  } catch (err) {
1099
- window.haltija.agentTabCreated({
1100
- requestId: data.requestId,
1101
- error: err.message
1102
- })
200
+ window.haltija.agentTabCreated({ requestId: data.requestId, error: err.message })
1103
201
  }
1104
202
  })
1105
-
1106
- // Handle navigation requests from widget (via main process)
1107
- // Uses the smart navigate() function with https->http fallback
203
+
1108
204
  window.haltija.onNavigateUrl?.((data) => {
1109
205
  console.log('[Haltija Desktop] Navigate request from widget:', data.url)
1110
206
  navigate(data.url)
1111
207
  })
1112
208
  }
1113
209
 
1114
- // Listen for messages from terminal iframes (cwd changes)
210
+ // ============================================
211
+ // Window Messages (terminal iframe communication)
212
+ // ============================================
213
+
1115
214
  window.addEventListener('message', (event) => {
1116
215
  if (event.data?.type === 'terminal-cwd') {
1117
216
  const { cwd, shellId } = event.data
1118
- // Find the terminal tab that sent this message
1119
217
  const tab = tabs.find(t => t.isTerminal && t.webview?.contentWindow === event.source)
1120
218
  if (tab) {
1121
219
  tab.cwd = cwd
1122
- tab.shellId = shellId // Track shellId for sending commands
220
+ tab.shellId = shellId
1123
221
 
1124
-
1125
- // Only update tab title for human terminals (show directory name)
1126
- // Agent tabs keep their agent name
1127
222
  if (tab.terminalMode !== 'agent') {
1128
223
  const dirName = cwd.replace(/\/$/, '').split('/').pop() || cwd
1129
224
  tab.title = dirName
1130
225
  tab.element.querySelector('.tab-title').innerHTML = `> ${dirName}`
1131
226
  }
1132
-
1133
- // Update URL bar if this tab is active
227
+
1134
228
  if (tab.id === activeTabId) {
1135
- urlInput.value = cwd
229
+ el.urlInput.value = cwd
1136
230
  const displayTitle = tab.terminalMode === 'agent' ? tab.title : cwd.replace(/\/$/, '').split('/').pop()
1137
231
  document.title = displayTitle
1138
232
  }
1139
233
  }
1140
234
  }
1141
- // Shell renamed (update agent tab title)
235
+
1142
236
  if (event.data?.type === 'shell-renamed') {
1143
237
  const { shellId, name } = event.data
1144
- // Match by shellId or by event source (in case shellId not yet set on tab)
1145
238
  const tab = tabs.find(t => t.isTerminal && (t.shellId === shellId || t.webview?.contentWindow === event.source))
1146
239
  if (tab && tab.terminalMode === 'agent' && name) {
1147
- tab.shellId = shellId // Ensure shellId is set
240
+ tab.shellId = shellId
1148
241
  tab.title = name
1149
242
  tab.element.querySelector('.tab-title').innerHTML = `<span class="agent-status">*</span> ${name}`
1150
- if (tab.id === activeTabId) {
1151
- document.title = name
1152
- }
243
+ if (tab.id === activeTabId) document.title = name
1153
244
  }
1154
245
  }
1155
-
1156
- // Agent status updates
246
+
1157
247
  if (event.data?.type === 'agent-status') {
1158
248
  const { status } = event.data
1159
249
  const tab = tabs.find(t => t.isTerminal && t.terminalMode === 'agent' && t.webview?.contentWindow === event.source)
1160
250
  if (tab) {
1161
251
  tab.element.classList.remove('thinking', 'ready', 'error')
1162
- if (status === 'thinking') {
1163
- tab.element.classList.add('thinking')
1164
- } else if (status === 'error') {
1165
- tab.element.classList.add('error')
1166
- } else {
1167
- tab.element.classList.add('ready')
1168
- }
252
+ if (status === 'thinking') tab.element.classList.add('thinking')
253
+ else if (status === 'error') tab.element.classList.add('error')
254
+ else tab.element.classList.add('ready')
1169
255
  }
1170
256
  }
1171
257
  })
1172
258
 
1173
- // ==========================================
1174
- // Session Restore
1175
- // ==========================================
1176
-
1177
- /**
1178
- * Check for saved agent transcripts and offer to restore them.
1179
- * DISABLED - restore feature is broken, causing HTML dumps
1180
- */
1181
- async function checkForSavedSessions() {
1182
- // Restore disabled until we fix the underlying issues
1183
- return
1184
- }
1185
-
1186
- /**
1187
- * Format a timestamp as relative time (e.g., "5 minutes ago")
1188
- */
1189
- function formatTimeAgo(timestamp) {
1190
- const seconds = Math.floor((Date.now() - timestamp) / 1000)
1191
-
1192
- if (seconds < 60) return 'just now'
1193
- if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`
1194
- if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
1195
- return `${Math.floor(seconds / 86400)} days ago`
1196
- }
1197
-
1198
- /**
1199
- * Show a restore prompt for a saved session
1200
- */
1201
- function showRestorePrompt(transcript, timeAgo) {
1202
- const prompt = document.createElement('div')
1203
- prompt.className = 'restore-prompt'
1204
- prompt.innerHTML = `
1205
- <div class="restore-content">
1206
- <span class="restore-icon">*</span>
1207
- <span class="restore-text">Restore "${transcript.name}" session? (${transcript.entryCount} messages, ${timeAgo})</span>
1208
- <button class="restore-yes" title="Restore session">Restore</button>
1209
- <button class="restore-no" title="Dismiss">×</button>
1210
- </div>
1211
- `
1212
-
1213
- prompt.querySelector('.restore-yes').addEventListener('click', async () => {
1214
- prompt.remove()
1215
- await restoreSession(transcript.filename)
1216
- })
1217
-
1218
- prompt.querySelector('.restore-no').addEventListener('click', () => {
1219
- prompt.remove()
1220
- })
1221
-
1222
- // Auto-dismiss after 15 seconds
1223
- setTimeout(() => {
1224
- if (prompt.parentNode) {
1225
- prompt.style.animation = 'toast-out 0.2s ease-in forwards'
1226
- setTimeout(() => prompt.remove(), 200)
1227
- }
1228
- }, 15000)
1229
-
1230
- document.body.appendChild(prompt)
1231
- }
1232
-
1233
- /**
1234
- * Restore a session from a saved transcript file.
1235
- * Creates a fresh agent tab with the same name; condensed context
1236
- * from the old session is prepended to the first prompt.
1237
- */
1238
- async function restoreSession(filename) {
1239
- try {
1240
- // Create an agent tab first
1241
- const tab = await createTerminalTab('agent')
1242
-
1243
- // Wait for the terminal iframe to initialize and get its shellId
1244
- await new Promise(resolve => setTimeout(resolve, 500))
1245
-
1246
- // Get the shellId from the tab (set via message from terminal.html)
1247
- if (!tab.shellId) {
1248
- await new Promise((resolve, reject) => {
1249
- const timeout = setTimeout(() => reject(new Error('Timeout waiting for shellId')), 5000)
1250
- const checkShellId = setInterval(() => {
1251
- if (tab.shellId) {
1252
- clearInterval(checkShellId)
1253
- clearTimeout(timeout)
1254
- resolve()
1255
- }
1256
- }, 100)
1257
- })
1258
- }
1259
-
1260
- // Restore the session on the server (creates fresh session with condensed context)
1261
- const response = await fetch(`${getServerUrl()}/terminal/transcript/restore`, {
1262
- method: 'POST',
1263
- headers: { 'Content-Type': 'application/json' },
1264
- body: JSON.stringify({ filename, shellId: tab.shellId })
1265
- })
1266
-
1267
- if (!response.ok) {
1268
- throw new Error('Failed to restore session')
1269
- }
1270
-
1271
- const result = await response.json()
1272
-
1273
- // Update tab with restored session name
1274
- tab.title = result.name
1275
- tab.element.querySelector('.tab-title').innerHTML = `<span class="agent-status">*</span> ${result.name}`
1276
-
1277
- showNotification(`Restored "${result.name}" — context will be included with your first message`)
1278
- } catch (err) {
1279
- console.error('[Haltija Desktop] Failed to restore session:', err)
1280
- showNotification('Failed to restore session', 3000)
1281
- }
1282
- }
1283
-
1284
- // Check for saved sessions after a brief delay (let server start)
1285
- setTimeout(checkForSavedSessions, 2000)
1286
-
1287
- // ==========================================
1288
- // hj CLI Installation Check
1289
- // ==========================================
1290
-
1291
- let hjCheckDone = false
1292
-
1293
- /**
1294
- * Check if hj CLI is globally installed. If not, prompt user to install.
1295
- * Only prompts once per session.
1296
- */
1297
- async function checkHjInstalled() {
1298
- if (hjCheckDone) return
1299
- hjCheckDone = true
1300
-
1301
- try {
1302
- const response = await fetch(`${getServerUrl()}/terminal/hj-status`)
1303
- if (!response.ok) return
1304
-
1305
- const { installed, installCommand, message } = await response.json()
1306
- if (installed) return
1307
-
1308
- // Show install prompt
1309
- showHjInstallPrompt(installCommand, message)
1310
- } catch (err) {
1311
- console.log('[Haltija Desktop] Could not check hj status:', err.message)
1312
- }
1313
- }
1314
-
1315
- /**
1316
- * Show a prompt to install the hj CLI
1317
- */
1318
- function showHjInstallPrompt(installCommand, message) {
1319
- const prompt = document.createElement('div')
1320
- prompt.className = 'hj-install-prompt'
1321
- prompt.innerHTML = `
1322
- <div class="hj-install-content">
1323
- <div class="hj-install-header">
1324
- <span class="hj-install-icon">⚠️</span>
1325
- <span class="hj-install-title">hj CLI not installed</span>
1326
- </div>
1327
- <p class="hj-install-message">${message}</p>
1328
- <div class="hj-install-command">
1329
- <code>${installCommand}</code>
1330
- <button class="hj-copy-btn" title="Copy command">Copy</button>
1331
- </div>
1332
- <div class="hj-install-actions">
1333
- <button class="hj-install-later">Later</button>
1334
- </div>
1335
- </div>
1336
- `
1337
-
1338
- prompt.querySelector('.hj-copy-btn').addEventListener('click', () => {
1339
- navigator.clipboard.writeText(installCommand)
1340
- showNotification('Command copied! Paste in Terminal and enter your password.', 5000)
1341
- })
1342
-
1343
- prompt.querySelector('.hj-install-later').addEventListener('click', () => {
1344
- prompt.remove()
1345
- })
1346
-
1347
- document.body.appendChild(prompt)
1348
- }
1349
-
1350
- // ============================================
1351
- // Agent Status Bar - Shows what agents see
1352
- // ============================================
1353
-
1354
- let agentStatusWs = null
1355
- let currentStatusLine = ''
1356
- let connectedShells = new Map() // shellId -> { name, isAgent }
1357
-
1358
- /**
1359
- * Connect to the terminal WebSocket to receive status updates
1360
- */
1361
- function connectAgentStatusWs() {
1362
- if (agentStatusWs && agentStatusWs.readyState === WebSocket.OPEN) return
1363
-
1364
- const wsUrl = `ws://localhost:${window.haltija?.port || 8700}/ws/terminal`
1365
- agentStatusWs = new WebSocket(wsUrl)
1366
-
1367
- agentStatusWs.onopen = () => {
1368
- console.log('[Agent Status] Connected to terminal WebSocket')
1369
- }
1370
-
1371
- agentStatusWs.onmessage = (event) => {
1372
- try {
1373
- const msg = JSON.parse(event.data)
1374
- handleAgentStatusMessage(msg)
1375
- } catch (err) {
1376
- // Ignore non-JSON messages
1377
- }
1378
- }
1379
-
1380
- agentStatusWs.onclose = () => {
1381
- console.log('[Agent Status] WebSocket closed, reconnecting in 3s...')
1382
- setTimeout(connectAgentStatusWs, 3000)
1383
- }
1384
-
1385
- agentStatusWs.onerror = (err) => {
1386
- console.log('[Agent Status] WebSocket error:', err)
1387
- }
1388
- }
1389
-
1390
- /**
1391
- * Handle messages from the terminal WebSocket
1392
- */
1393
- function handleAgentStatusMessage(msg) {
1394
- switch (msg.type) {
1395
- case 'status':
1396
- currentStatusLine = msg.line || ''
1397
- renderAgentStatusBar(currentStatusLine)
1398
- break
1399
-
1400
- case 'shell-joined':
1401
- connectedShells.set(msg.shellId, { name: msg.name, isAgent: msg.name?.includes('agent') })
1402
- updateAgentSelector()
1403
- break
1404
-
1405
- case 'shell-left':
1406
- connectedShells.delete(msg.shellId)
1407
- updateAgentSelector()
1408
- break
1409
-
1410
- case 'shell-renamed':
1411
- if (connectedShells.has(msg.shellId)) {
1412
- connectedShells.get(msg.shellId).name = msg.name
1413
- updateAgentSelector()
1414
- }
1415
- break
1416
- }
1417
- }
1418
-
1419
- /**
1420
- * Parse and render the status line in the GUI
1421
- * Format: "hj localhost:8700 'title' | hj memos 2 active"
1422
- * Each segment is a self-documenting hj command.
1423
- */
1424
- function renderAgentStatusBar(line) {
1425
- if (!line) {
1426
- agentStatusBar.classList.add('hidden')
1427
- return
1428
- }
1429
-
1430
- // Only show if active tab is a terminal tab
1431
- const activeTab = getActiveTab()
1432
- if (activeTab && !activeTab.isTerminal) {
1433
- agentStatusBar.classList.add('hidden')
1434
- return
1435
- }
1436
-
1437
- agentStatusBar.classList.remove('hidden')
1438
-
1439
- // Split by | and render each segment
1440
- // Each segment starts with "hj" - e.g. "hj localhost:8700" or "hj memos 2 active"
1441
- const segments = line.split(' | ')
1442
- let html = ''
1443
-
1444
- for (const segment of segments) {
1445
- const trimmed = segment.trim()
1446
- if (!trimmed) continue
1447
-
1448
- // All segments now start with "hj" - parse "hj <tool> <state>" or "hj <state>"
1449
- let label = ''
1450
- let value = trimmed
1451
-
1452
- if (trimmed.startsWith('hj ')) {
1453
- const rest = trimmed.substring(3) // strip "hj "
1454
- // Check if second word is a known tool name
1455
- const words = rest.split(' ')
1456
- if (words.length > 1 && /^(memos|board|tasks)$/.test(words[0])) {
1457
- label = words[0]
1458
- value = words.slice(1).join(' ')
1459
- } else {
1460
- label = 'hj'
1461
- value = rest
1462
- }
1463
- }
1464
-
1465
- // Determine color class based on content
1466
- let cls = 'status-segment'
1467
- if (/fail|error|no browser/i.test(trimmed)) cls += ' error'
1468
- else if (/warn|blocked|pending/i.test(trimmed)) cls += ' alert'
1469
- else if (/ready|connected|pass|active/i.test(trimmed)) cls += ' ok'
1470
-
1471
- html += `<div class="${cls}" data-segment="${escapeHtml(label || 'status')}">`
1472
- if (label) {
1473
- html += `<span class="label">${escapeHtml(label)}:</span>`
1474
- }
1475
- html += `<span class="value">${escapeHtml(value)}</span>`
1476
- html += `</div>`
1477
- }
1478
-
1479
- agentStatusItems.innerHTML = html
1480
-
1481
- // Add click handlers to segments
1482
- agentStatusItems.querySelectorAll('.status-segment').forEach(seg => {
1483
- seg.addEventListener('click', (e) => {
1484
- const segmentName = seg.dataset.segment
1485
- handleStatusSegmentClick(segmentName, e.target)
1486
- })
1487
- })
1488
- }
1489
-
1490
- /**
1491
- * Handle clicks on status bar segments
1492
- */
1493
- function handleStatusSegmentClick(segmentName, target) {
1494
- switch (segmentName) {
1495
- case 'memos':
1496
- showMemosPanel(target)
1497
- break
1498
- case 'hj':
1499
- // Could show connection details or browser info
1500
- break
1501
- default:
1502
- console.log('[Agent Status] Clicked segment:', segmentName)
1503
- }
1504
- }
1505
-
1506
- /**
1507
- * Show the memos panel as a floating window
1508
- */
1509
- async function showMemosPanel(target) {
1510
- // Create content for the panel
1511
- const content = document.createElement('div')
1512
- content.className = 'memos-panel-content'
1513
- content.innerHTML = '<div class="loading">Loading memos...</div>'
1514
-
1515
- const panel = createFloatPanel({
1516
- target,
1517
- content,
1518
- title: 'Memos',
1519
- position: 's'
1520
- })
1521
-
1522
- if (!panel) return // Panel was toggled off
1523
-
1524
- // Load tasks
1525
- try {
1526
- const resp = await fetch(`${getServerUrl()}/terminal/command`, {
1527
- method: 'POST',
1528
- headers: { 'Content-Type': 'application/json' },
1529
- body: JSON.stringify({ tool: 'tasks', command: 'board' })
1530
- })
1531
- const result = await resp.json()
1532
-
1533
- if (result.boardJson?.items) {
1534
- renderMemosPanel(content, result.boardJson.items)
1535
- } else {
1536
- content.innerHTML = '<div class="empty">No memos</div>'
1537
- }
1538
- } catch (err) {
1539
- content.innerHTML = `<div class="error">Failed to load memos: ${escapeHtml(err.message)}</div>`
1540
- }
1541
- }
1542
-
1543
- /**
1544
- * Render memos in a simple list view (for now)
1545
- */
1546
- function renderMemosPanel(container, items) {
1547
- const columns = ['in_progress', 'blocked', 'queued', 'review']
1548
- const columnNames = {
1549
- in_progress: '🔄 In Progress',
1550
- blocked: '🚧 Blocked',
1551
- queued: '📋 Queued',
1552
- review: '👀 Review'
1553
- }
1554
-
1555
- let html = '<div class="memos-list">'
1556
-
1557
- for (const col of columns) {
1558
- const colItems = items.filter(i => i.column === col)
1559
- if (colItems.length === 0) continue
1560
-
1561
- html += `<div class="memos-column">
1562
- <div class="memos-column-header">${columnNames[col]} (${colItems.length})</div>`
1563
-
1564
- for (const item of colItems) {
1565
- html += `<div class="memo-item" data-id="${item.id}">
1566
- <span class="memo-title">${escapeHtml(item.title)}</span>
1567
- </div>`
1568
- }
1569
-
1570
- html += '</div>'
1571
- }
1572
-
1573
- if (html === '<div class="memos-list">') {
1574
- html += '<div class="empty">No active memos</div>'
1575
- }
1576
-
1577
- html += '</div>'
1578
- container.innerHTML = html
1579
- }
1580
-
1581
- /**
1582
- * Update the agent selector dropdown
1583
- */
1584
- function updateAgentSelector() {
1585
- const agents = Array.from(connectedShells.entries())
1586
- .filter(([_, info]) => info.isAgent)
1587
-
1588
- if (agents.length === 0) {
1589
- agentSelect.innerHTML = '<option value="">No agents</option>'
1590
- } else {
1591
- agentSelect.innerHTML = agents.map(([id, info]) =>
1592
- `<option value="${id}">${escapeHtml(info.name || id)}</option>`
1593
- ).join('')
1594
- }
1595
- }
1596
-
1597
- /**
1598
- * Escape HTML for safe rendering
1599
- */
1600
- function escapeHtml(str) {
1601
- if (!str) return ''
1602
- return str
1603
- .replace(/&/g, '&amp;')
1604
- .replace(/</g, '&lt;')
1605
- .replace(/>/g, '&gt;')
1606
- .replace(/"/g, '&quot;')
1607
- }
1608
-
1609
- // Fetch initial status and connect WebSocket
1610
- async function initAgentStatusBar() {
1611
- try {
1612
- const response = await fetch(`${getServerUrl()}/terminal/status`)
1613
- if (response.ok) {
1614
- const line = await response.text()
1615
- renderAgentStatusBar(line)
1616
- }
1617
- } catch (err) {
1618
- console.log('[Agent Status] Could not fetch initial status:', err.message)
1619
- }
1620
-
1621
- connectAgentStatusWs()
1622
- }
1623
-
1624
- // Initialize agent status bar after a short delay (let server start)
1625
- setTimeout(initAgentStatusBar, 1000)
1626
-
1627
259
  // ============================================
1628
- // Float Panel - Draggable floating UI panels
1629
- // (Borrowed from tosijs-ui xin-float/trackDrag)
260
+ // Deferred Init
1630
261
  // ============================================
1631
262
 
1632
- /**
1633
- * Track a drag operation from mousedown/touchstart
1634
- */
1635
- function trackDrag(event, callback, cursor = 'move') {
1636
- const isTouchEvent = event.type.startsWith('touch')
1637
-
1638
- if (!isTouchEvent) {
1639
- const origX = event.clientX
1640
- const origY = event.clientY
1641
-
1642
- // Create overlay to capture all mouse events during drag
1643
- const tracker = document.createElement('div')
1644
- tracker.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:99999;cursor:' + cursor
1645
- document.body.appendChild(tracker)
1646
-
1647
- const onMove = (e) => {
1648
- const dx = e.clientX - origX
1649
- const dy = e.clientY - origY
1650
- if (callback(dx, dy, e) === true) {
1651
- tracker.removeEventListener('mousemove', onMove)
1652
- tracker.removeEventListener('mouseup', onMove)
1653
- tracker.remove()
1654
- }
1655
- }
1656
-
1657
- tracker.addEventListener('mousemove', onMove, { passive: true })
1658
- tracker.addEventListener('mouseup', onMove, { passive: true })
1659
- } else if (event.touches) {
1660
- const touch = event.touches[0]
1661
- const touchId = touch.identifier
1662
- const origX = touch.clientX
1663
- const origY = touch.clientY
1664
- const target = event.target
1665
-
1666
- const onTouch = (e) => {
1667
- const t = [...e.touches].find(t => t.identifier === touchId)
1668
- const dx = t ? t.clientX - origX : 0
1669
- const dy = t ? t.clientY - origY : 0
1670
- if (callback(dx, dy, e) === true || !t) {
1671
- target.removeEventListener('touchmove', onTouch)
1672
- target.removeEventListener('touchend', onTouch)
1673
- target.removeEventListener('touchcancel', onTouch)
1674
- }
1675
- }
1676
-
1677
- target.addEventListener('touchmove', onTouch)
1678
- target.addEventListener('touchend', onTouch, { passive: true })
1679
- target.addEventListener('touchcancel', onTouch, { passive: true })
1680
- }
1681
- }
1682
-
1683
- /**
1684
- * Find highest z-index in document
1685
- */
1686
- function findHighestZ() {
1687
- return [...document.querySelectorAll('body *')]
1688
- .map(el => parseFloat(getComputedStyle(el).zIndex))
1689
- .filter(z => !isNaN(z))
1690
- .reduce((max, z) => Math.max(max, z), 0)
1691
- }
1692
-
1693
- /**
1694
- * Create a floating panel positioned near a target element
1695
- */
1696
- function createFloatPanel({ target, content, title = '', position = 's', onClose }) {
1697
- // Remove existing panel with same title
1698
- const existing = document.querySelector(`.float-panel[data-title="${title}"]`)
1699
- if (existing) {
1700
- existing.remove()
1701
- return null
1702
- }
1703
-
1704
- const panel = document.createElement('div')
1705
- panel.className = 'float-panel'
1706
- panel.dataset.title = title
1707
- panel.style.zIndex = findHighestZ() + 1
1708
-
1709
- panel.innerHTML = `
1710
- <div class="float-header">
1711
- <span class="float-title">${escapeHtml(title)}</span>
1712
- <button class="float-close" title="Close">×</button>
1713
- </div>
1714
- <div class="float-content"></div>
1715
- `
1716
-
1717
- panel.querySelector('.float-content').appendChild(content)
1718
-
1719
- // Make header draggable
1720
- const header = panel.querySelector('.float-header')
1721
- header.addEventListener('mousedown', (e) => {
1722
- if (e.target.closest('.float-close')) return
1723
- panel.style.zIndex = findHighestZ() + 1
1724
- const x = panel.offsetLeft
1725
- const y = panel.offsetTop
1726
- trackDrag(e, (dx, dy, evt) => {
1727
- panel.style.left = `${x + dx}px`
1728
- panel.style.top = `${y + dy}px`
1729
- panel.style.right = 'auto'
1730
- panel.style.bottom = 'auto'
1731
- return evt.type === 'mouseup'
1732
- })
1733
- })
1734
-
1735
- // Close button
1736
- panel.querySelector('.float-close').addEventListener('click', () => {
1737
- panel.remove()
1738
- onClose?.()
1739
- })
1740
-
1741
- document.body.appendChild(panel)
1742
-
1743
- // Position near target
1744
- if (target) {
1745
- const rect = target.getBoundingClientRect()
1746
- const panelRect = panel.getBoundingClientRect()
1747
-
1748
- let left, top
1749
- switch (position) {
1750
- case 'n': // above
1751
- left = rect.left + rect.width / 2 - panelRect.width / 2
1752
- top = rect.top - panelRect.height - 8
1753
- break
1754
- case 's': // below (default)
1755
- default:
1756
- left = rect.left + rect.width / 2 - panelRect.width / 2
1757
- top = rect.bottom + 8
1758
- break
1759
- }
1760
-
1761
- // Keep on screen
1762
- left = Math.max(8, Math.min(left, window.innerWidth - panelRect.width - 8))
1763
- top = Math.max(8, Math.min(top, window.innerHeight - panelRect.height - 8))
1764
-
1765
- panel.style.left = `${left}px`
1766
- panel.style.top = `${top}px`
1767
- }
1768
-
1769
- return panel
1770
- }
263
+ setTimeout(() => initAgentStatusBar(), 1000)
264
+ initVideoCapture()