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.
- package/apps/desktop/index.html +1 -1
- package/apps/desktop/main.js +264 -64
- package/apps/desktop/package.json +11 -3
- package/apps/desktop/preload.js +17 -0
- package/apps/desktop/renderer/agent-status.js +210 -0
- package/apps/desktop/renderer/settings.js +55 -0
- package/apps/desktop/renderer/state.js +98 -0
- package/apps/desktop/renderer/status.js +38 -0
- package/apps/desktop/renderer/tabs.js +374 -0
- package/apps/desktop/renderer/ui-utils.js +180 -0
- package/apps/desktop/renderer/video-capture.js +154 -0
- package/apps/desktop/renderer/webview-events.js +225 -0
- package/apps/desktop/renderer.js +98 -1604
- package/apps/desktop/resources/component.js +265 -55
- package/apps/desktop/webview-preload.js +19 -1
- package/bin/cli-subcommand.mjs +90 -27
- package/bin/hints.json +9 -4
- package/bin/hj.mjs +61 -2
- package/bin/test-data.mjs +291 -0
- package/bin/tosijs-dev.mjs +95 -20
- package/dist/client.js +5 -1
- package/dist/component.js +265 -55
- package/dist/index.js +444 -76
- package/dist/server.js +444 -76
- package/package.json +2 -1
package/apps/desktop/renderer.js
CHANGED
|
@@ -1,1770 +1,264 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Renderer process
|
|
2
|
+
* Renderer process — entry point.
|
|
3
|
+
* Imports modules and wires up event listeners.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
checkHaltija()
|
|
731
|
-
}
|
|
34
|
+
// Periodic status check
|
|
35
|
+
setInterval(checkHaltija, 5000)
|
|
732
36
|
|
|
733
|
-
//
|
|
37
|
+
// ============================================
|
|
38
|
+
// Event Listeners
|
|
39
|
+
// ============================================
|
|
734
40
|
|
|
735
|
-
// New tab
|
|
736
|
-
newTabButton.addEventListener('click', () => createTab())
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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.
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
|
1038
|
-
if (webview) webview.reload()
|
|
164
|
+
const wv = getActiveWebview(); if (wv) wv.reload()
|
|
1039
165
|
})
|
|
1040
|
-
|
|
1041
166
|
window.haltija.onMenuForceReloadTab?.(() => {
|
|
1042
|
-
const
|
|
1043
|
-
if (webview) webview.reloadIgnoringCache()
|
|
167
|
+
const wv = getActiveWebview(); if (wv) wv.reloadIgnoringCache()
|
|
1044
168
|
})
|
|
1045
|
-
|
|
1046
169
|
window.haltija.onMenuDevToolsTab?.(() => {
|
|
1047
|
-
const
|
|
1048
|
-
if (webview) webview.openDevTools()
|
|
170
|
+
const wv = getActiveWebview(); if (wv) wv.openDevTools()
|
|
1049
171
|
})
|
|
1050
|
-
|
|
1051
172
|
window.haltija.onMenuBack?.(() => {
|
|
1052
|
-
const
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1164
|
-
|
|
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, '&')
|
|
1604
|
-
.replace(/</g, '<')
|
|
1605
|
-
.replace(/>/g, '>')
|
|
1606
|
-
.replace(/"/g, '"')
|
|
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
|
-
//
|
|
1629
|
-
// (Borrowed from tosijs-ui xin-float/trackDrag)
|
|
260
|
+
// Deferred Init
|
|
1630
261
|
// ============================================
|
|
1631
262
|
|
|
1632
|
-
|
|
1633
|
-
|
|
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()
|