Mike0021's picture
Show the selected local folder name in the editor path
3a32466 verified
Raw History Blame Contribute Delete
18 kB
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();