import './style.css'; import { createTerminalUI } from './pi/terminal-ui.mjs'; import { createLoadDialog } from './load-dialog.mjs'; import { createShellUI } from './shell-ui.mjs'; import { restoredContextUsage } from './context-usage.mjs'; import { createRuntimeMeter } from './runtime-meter.mjs'; import { createFileIcon } from './file-icons.mjs'; import { createEditorUI } from './editor-ui.mjs'; import { createColumnResizer } from './column-resizer.mjs'; import { createLocalFolderUI } from './local-folder-ui.mjs'; const $ = selector => document.querySelector(selector); createColumnResizer($('main'), $('#column-resizer')); const worker = new Worker(new URL('./agent.worker.mjs', import.meta.url), { type: 'module' }); const pending = new Map(); let nextId = 1, busy = false, loaded = false, selected, dirty = false, files = {}, messages = [], readyResolve; let pendingReadPath; let previewVersion = 0, previewReady = true; const ready = new Promise(resolve => { readyResolve = resolve; }); const diagnostics = { events: [], generations: [], runtimeSamples: [], device: null, contextUsage: restoredContextUsage([]) }; const runtimeMeter = createRuntimeMeter($('#runtime-meter')); const editorUI = createEditorUI($('.editor-input')); const terminalUI = createTerminalUI($('#pi-terminal'), { onInput: text => $('#send').classList.toggle('has-input', Boolean(text.trim())), load: () => ready.then(load), prompt: text => ready.then(() => prompt(text)), newChat: () => ready.then(newChat), stop: () => rpc('stop'), shell: command => { if (dirty) throw Error('Save your file edits before running commands.'); return rpc('shell', { command }); }, }); const loadDialog = createLoadDialog($('#model-dialog'), { onClose: () => terminalUI.focus() }); const shellUI = createShellUI($('.workspace > .terminal'), { run: async command => { await ready; if (dirty) throw Error('Save your file edits before running commands.'); if (busy) throw Error('Wait for Pi to finish or stop the current task first.'); return rpc('shell', { command }); } }); const localUI = createLocalFolderUI({ rpc, notice, canSwitch() { if (busy) throw Error('Wait for Pi to finish or stop the current task first.'); if (dirty) throw Error('Save or stage the open file first.'); }, onSwitch(state) { pendingReadPath = undefined; selected = undefined; dirty = false; previewVersion++; previewReady = false; files = state.files; messages = state.messages; localUI.setState(state.localFolder); $('#filename').textContent = 'Select a file'; $('#filetype').replaceChildren(); editorUI.setFile('', ''); terminalUI.setMessages(messages, { reset: true }); updateContextUsage(restoredContextUsage(messages)); $('#shell-clear').click(); renderFiles(); const file = Object.hasOwn(files, '/workspace/README.md') ? '/workspace/README.md' : Object.keys(files).sort()[0]; if (file) selectFile(file); controls(); }, }); function rpc(action, args) { return new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); worker.postMessage({ id, action, args }); }); } function notice(text) { $('#notice').textContent = text; $('#notice').hidden = !text; } function storageHelp(error) { if (window.self === window.top || !/denied|storage directory access/i.test(error)) return error; $('#open-tab').href = location.href; $('#open-tab').hidden = false; return 'Your browser blocked storage in this embedded app. Open the workspace in a new tab to load the model and save files.'; } function controls() { $('#load').disabled = busy || loaded; $('#load').classList.toggle('is-loaded', loaded); if (loaded) $('#load').textContent = 'Loaded'; else if (!busy) $('#load').textContent = 'Load model'; $('#load').title = $('#load-status').textContent + ' ' + $('#load-detail').textContent; $('#send').disabled = busy; $('#newline').disabled = busy; terminalUI.setState({ busy, loaded, activity: $('#activity').textContent }); $('#stop').hidden = !busy; for (const id of ['#new-chat', '#add-file', '#upload']) $(id).disabled = busy; shellUI.setBusy(busy); localUI.setBusy(busy); $('#save').disabled = busy || !selected || !dirty; $('#editor').disabled = busy || !selected || !previewReady; } function renderFiles() { terminalUI.setFiles(files); $('#files').replaceChildren(); for (const file of Object.keys(files).sort()) { const button = document.createElement('button'), label = document.createElement('span'); label.className = 'file-name'; label.textContent = file.replace(/^\/workspace\//, ''); button.append(createFileIcon(file), label); button.title = file; button.classList.toggle('selected', file === selected); button.onclick = () => selectFile(file); $('#files').append(button); } if (selected && !Object.hasOwn(files, selected) && !dirty) { selected = undefined; previewVersion++; previewReady = false; $('#filename').textContent = 'Select a file'; $('#filetype').replaceChildren(); editorUI.setFile('', ''); controls(); } if (selected && !dirty) { if (files[selected] === null) loadPreview(selected); else { previewReady = true; editorUI.setFile(selected, files[selected] ?? ''); } } } async function loadPreview(file) { const version = ++previewVersion; previewReady = false; editorUI.setFile(file, '', 'Loading file…'); controls(); try { const content = await rpc('preview', { path: file }); if (version !== previewVersion || file !== selected || dirty) return; editorUI.setFile(file, content); previewReady = true; } catch (error) { if (version !== previewVersion || file !== selected) return; const message = error.code === 'BINARY_FILE' ? 'No text preview available.\nThis file is binary or isn’t UTF-8 text.' : error.code === 'FILE_TOO_LARGE' ? 'File too large to preview (1 MiB limit).' : 'Unable to preview this file.\n' + error.message; editorUI.setFile(file, '', message); } finally { if (version === previewVersion) controls(); } } function selectFile(file) { pendingReadPath = undefined; if (dirty) { notice('Save the open file before selecting another file.'); return; } selected = file; $('#filename').textContent = localUI.folderName ? file.replace(/^\/workspace(?=\/|$)/, () => '/' + localUI.folderName) : file; $('#filetype').replaceChildren(createFileIcon(file)); renderFiles(); controls(); } function followReadFile() { if (dirty) { pendingReadPath = undefined; return; } if (!pendingReadPath || !Object.hasOwn(files, pendingReadPath)) return; const file = pendingReadPath, changed = file !== selected; selectFile(file); if (changed) { $('#editor').scrollTop = 0; $('#editor').scrollLeft = 0; } // Reveal the highlighted row inside the file list, without scrolling the // page away from the conversation on narrow screens or moving focus. const row = [...$('#files').children].find(button => button.title === file); if (row) { const bounds = row.getBoundingClientRect(), list = $('#files').getBoundingClientRect(); if (bounds.top < list.top) $('#files').scrollTop += bounds.top - list.top; else if (bounds.bottom > list.bottom) $('#files').scrollTop += bounds.bottom - list.bottom; } } function renderMessages() { terminalUI.setMessages(messages); } function updateContextUsage(usage) { diagnostics.contextUsage = usage; terminalUI.setState({ contextUsage: usage }); } worker.onmessage = ({ data }) => { runtimeMeter.update(data); if (data.type === 'inference_activity') { diagnostics.runtimeSamples.push(data); if (diagnostics.runtimeSamples.length > 160) diagnostics.runtimeSamples.shift(); } if (data.type === 'reply') { const request = pending.get(data.id); pending.delete(data.id); if (data.error) request?.reject(Object.assign(Error(data.error), { name: data.errorName || 'Error', code: data.errorCode })); else request?.resolve(data.value); } else if (data.type === 'initialized') { localUI.restore(data.savedLocal); localUI.setState(data.localFolder); files = data.files; messages = data.messages; renderFiles(); renderMessages(); updateContextUsage(restoredContextUsage(messages)); if (files['/workspace/README.md']) selectFile('/workspace/README.md'); // Do not steal focus if the user chose another control during startup. if (document.activeElement === document.body) terminalUI.focus(); readyResolve(); } else if (data.type === 'busy') { if (data.action === 'load') $('#load').textContent = 'Loading…'; busy = true; $('#activity').textContent = data.action === 'prompt' ? 'Pi is working…' : data.action === 'load' ? (data.cachedOnly ? 'Loading local model…' : 'Loading model…') : 'Working…'; controls(); } else if (data.type === 'idle') { busy = false; $('#progress').hidden = true; $('#activity').textContent = localUI.active ? 'Local folder · review & apply to save changes to disk' : loaded ? 'Ready · running on your device' : 'Files and chat are saved on this device.'; controls(); } else if (data.type === 'workspace') { files = data.files; localUI.setState(data.localFolder); renderFiles(); followReadFile(); } else if (data.type === 'device') { diagnostics.device = data.device; } else if (data.type === 'load_progress') { $('#progress').hidden = false; if (data.phase === 'download') { $('#progress').hidden = false; $('#progress').value = data.loaded / data.total * 100; $('#load-status').textContent = data.loaded === data.total ? 'Download verified. Preparing model…' : 'Downloading model weights from Hugging Face…'; $('#load-detail').textContent = `${(data.loaded / 1e9).toFixed(2)} / ${(data.total / 1e9).toFixed(2)} GB · ${Math.round(data.loaded / data.total * 100)}%${data.cached ? ` · ${(data.cached / 1e9).toFixed(2)} GB cached` : ''}`; $('#load').textContent = `Loading ${Math.round(data.loaded / data.total * 100)}%`; } else { $('#load-status').textContent = data.phase === 'compile' ? 'Preparing WebGPU session…' : 'Warming up GPU shaders…'; $('#load-detail').textContent = ''; $('#progress').removeAttribute('value'); $('#load').textContent = data.phase === 'compile' ? 'Preparing…' : 'Almost ready…'; } $('#load').title = $('#load-status').textContent + ' ' + $('#load-detail').textContent; } else if (data.type === 'loaded') { loaded = true; diagnostics.device = data.device; $('#progress').hidden = true; $('#load').textContent = 'Loaded'; $('#load-status').textContent = 'Ready. Model inference and tools run entirely in this browser.'; $('#load-detail').textContent = data.cachedBytes ? 'Verified model restored from browser storage.' : 'SHA-256 verified · model cached for next time.'; controls(); } else if (data.type === 'agent_event') { const event = data.event; diagnostics.events.push(event.type); if (diagnostics.events.length > 500) diagnostics.events.shift(); if (event.message) { const index = messages.findIndex(m => m.timestamp === event.message.timestamp && m.role === event.message.role && m.toolCallId === event.message.toolCallId); if (index < 0) messages.push(event.message); else messages[index] = event.message; renderMessages(); } if (event.type === 'tool_execution_start') $('#activity').textContent = `Running ${event.toolName}…`; if (event.type === 'tool_execution_end' && event.toolName === 'read' && !event.isError && typeof event.result?.details?.path === 'string') { pendingReadPath = event.result.details.path; followReadFile(); } if (event.type === 'turn_start') $('#activity').textContent = 'Pi is thinking…'; terminalUI.setState({ activity: $('#activity').textContent }); } else if (data.type === 'context_usage') { updateContextUsage({ inputTokens: data.inputTokens, outputTokens: data.outputTokens }); } else if (data.type === 'generation') { diagnostics.generations.push(data); if (diagnostics.generations.length > 100) diagnostics.generations.shift(); } else if (data.type === 'context_trim') notice('Older turns were left out of the model context. Your full chat remains saved.'); else if (data.type === 'notice') notice(data.text); else if (data.type === 'persistence_error') notice(storageHelp(data.error)); else if (data.type === 'fatal') { loaded = false; notice(data.error); controls(); } }; worker.onerror = event => { runtimeMeter.update({ type: 'fatal' }); const error = Error(event.message || 'The worker stopped. Reload to restore your saved workspace.'); notice(error.message); for (const request of pending.values()) request.reject(error); pending.clear(); busy = false; loaded = false; $('#progress').hidden = true; controls(); }; const guarded = fn => async event => { event?.preventDefault(); try { await ready; await fn(event); } catch (e) { notice(e.message); } }; async function load({ cachedOnly = false } = {}) { if (busy) throw Error('Wait for the current operation or stop it first.'); notice(''); const query = new URLSearchParams(location.search); busy = true; $('#load').textContent = 'Loading…'; $('#activity').textContent = cachedOnly ? 'Loading local model…' : 'Loading model…'; $('#load-status').textContent = cachedOnly ? 'Loading local model…' : 'Checking your browser and saved model…'; $('#load-detail').textContent = ''; $('#progress').hidden = false; $('#progress').removeAttribute('value'); runtimeMeter.update({ type: 'busy', action: 'load', cachedOnly }); controls(); try { return await rpc('load', { runtimeURL: new URL('./runtime/', location.href).href, local: query.get('source') === 'local', origin: location.origin, limit128: query.has('limit128'), cachedOnly }); } catch (error) { error.message = storageHelp(error.message); $('#load-status').textContent = error.message; $('#progress').hidden = true; throw error; } } async function prompt(text) { if (!text.trim()) return; if (dirty) throw Error('Save your file edits before asking Pi to work on them.'); try { if (!loaded) { let loadError; if (await rpc('cache_status')) { try { terminalUI.setQueuedPrompt(text); await load({ cachedOnly: true }); } catch (error) { if (error.name === 'AbortError') throw error; if (error.code !== 'MODEL_CACHE_MISS') loadError = error.message; } } while (!loaded) { terminalUI.setQueuedPrompt(''); if (!await loadDialog.request(text, { errorMessage: loadError })) throw new DOMException('Message kept for later.', 'AbortError'); terminalUI.setQueuedPrompt(text); try { await load(); } catch (error) { if (error.name === 'AbortError') throw error; loadError = error.message; } } } terminalUI.setQueuedPrompt(''); notice(''); const result = await rpc('prompt', { text }); messages = result.messages; renderMessages(); return result; } finally { terminalUI.setQueuedPrompt(''); } } $('#load').onclick = guarded(load); $('#send').onclick = () => { terminalUI.submit(); terminalUI.focus(); }; $('#newline').onclick = () => terminalUI.newline(); $('#stop').onclick = guarded(() => rpc('stop')); $('.terminal-panel').addEventListener('click', event => { // Let Pi handle its own mouse targets, and keep controls and text selection // independent of the surrounding space that returns focus to the editor. if (event.defaultPrevented || event.button !== 0 || !(event.target instanceof Element)) return; if (event.target.closest('#pi-terminal, button, a, input, textarea, select, label, summary, [role="button"], [contenteditable]')) return; if (window.getSelection()?.isCollapsed === false) return; terminalUI.focus(); }); async function newChat() { pendingReadPath = undefined; const state = await rpc('new_chat'); messages = state.messages; updateContextUsage(restoredContextUsage(messages)); terminalUI.setMessages(messages, { reset: true }); notice(''); terminalUI.focus(); } $('#new-chat').onclick = guarded(newChat); $('#editor').oninput = () => { dirty = true; controls(); }; $('#save').onclick = guarded(async () => { await rpc('write', { path: selected, content: $('#editor').value }); dirty = false; renderFiles(); controls(); notice(''); }); $('#add-file').onclick = guarded(async () => { if (dirty) throw Error('Save the open file first.'); const name = window.prompt('File path, relative to /workspace:'); if (!name?.trim()) return; const path = name.startsWith('/') ? name : '/workspace/' + name; if (Object.hasOwn(files, path)) throw Error('That file already exists.'); await rpc('write', { path, content: '' }); selectFile(path); }); $('#upload').onchange = guarded(async () => { if (dirty) throw Error('Save the open file first.'); for (const file of $('#upload').files) { if (file.size > 1024 ** 2) throw Error('Import text files of 1 MiB or less.'); const content = await file.text(); if (content.includes('\0')) throw Error('Import supports text files only.'); const path = '/workspace/' + file.name; if (Object.hasOwn(files, path)) throw Error(file.name + ' already exists. Rename it before importing.'); await rpc('write', { path, content }); selectFile(path); } $('#upload').value = ''; }); $('#export').onclick = guarded(async () => { const state = localUI.active ? { version: 1, patches: await rpc('local_changes') } : await rpc('snapshot'); const url = URL.createObjectURL(new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = localUI.active ? 'pi-staged-changes.json' : 'pi-workspace.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); // Browser verification exercises the same worker and real Pi / WebGPU paths as the UI. window.browserPi = { ready, load, prompt, rpc, diagnostics, terminal: terminalUI, runtimeMeter, get loaded() { return loaded; }, get busy() { return busy; } }; controls();