`).join('');
const hint = `Hold ${esc(rt.hotkeyLabel || set.hotkey?.label || 'Right Alt')} and speak into any text field. Double-tap it for hands-free.`;
const empty = `
`;
}
function engineText() {
const rt = S.runtime;
if (rt.engine === 'loading') return 'Loading the speech model onto the GPU…';
if (rt.engine === 'error') return `Speech engine error: ${esc(rt.engineError || 'unknown')}`;
return 'Speech engine is off. Pick a downloaded model in Settings.';
}
function histRow(x) {
if (x.dismissed) return `
${rows.map(([icon, k, label]) => { const p = Math.round(st.cats[k] / total * 100); return `
${ic(icon)}
${p}%${st.cats[k]} ${label}
`; }).join('')}
${st.streak} day streakLongest streak | ${st.longest} days
${heatmap(st)}
`;
}
function heatmap(st) {
const weeks = 18, end = new Date(); end.setHours(12, 0, 0, 0);
const start = new Date(end); start.setDate(end.getDate() - end.getDay() - (weeks - 1) * 7);
const vals = Object.values(st.perDay).sort((a, b) => a - b);
const q = p => vals[Math.floor(p * (vals.length - 1))] || 0;
const level = v => !v ? '' : v >= q(.75) ? 'l4' : v >= q(.5) ? 'l3' : v >= q(.25) ? 'l2' : 'l1';
let months = '', lastM = -1;
for (let w = 0; w < weeks; w++) {
const d = new Date(start); d.setDate(start.getDate() + w * 7);
months += `${d.getMonth() !== lastM ? d.toLocaleDateString('en-US', { month: 'short' }) : ''}`; lastM = d.getMonth();
}
const today = dayKey(Date.now());
let cells = '';
['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].forEach((name, r) => {
cells += `${name}`;
for (let w = 0; w < weeks; w++) {
const d = new Date(start); d.setDate(start.getDate() + w * 7 + r);
const k = dayKey(d.getTime());
cells += d > end ? '' : ``;
}
});
return `
${months}${cells}
More Less
Current streak
`;
}
function voiceReport(st) {
if (!st.h.length) return '
Dictate a few times and your voice profile will show up here.
';
const stop = new Set('the a an and or but to of in on at for is it i you we that this be are was with as so my me just like um uh do not have can will your our if what about from they them he she its im dont'.split(' '));
const freq = {};
st.h.forEach(x => (x.text.toLowerCase().match(/[a-z][a-z']+/g) || []).forEach(w => { if (!stop.has(w) && w.length > 2 && !w.includes("'")) freq[w] = (freq[w] || 0) + 1; }));
const top = Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, 24);
const hours = Array(24).fill(0); st.h.forEach(x => hours[new Date(x.ts).getHours()]++);
const peak = hours.indexOf(Math.max(...hours));
const apps = {}; st.h.forEach(x => { if (x.app) apps[x.app] = (apps[x.app] || 0) + 1; });
const fav = Object.entries(apps).sort((a, b) => b[1] - a[1])[0];
const longest = st.h.reduce((a, x) => (x.words || 0) > (a.words || 0) ? x : a, st.h[0]);
const avg = Math.round(st.words / st.h.length);
const hourName = h => new Date(2000, 0, 1, h).toLocaleTimeString('en-US', { hour: 'numeric' }).toLowerCase().replace(' ', '');
return `
${avg}
Words per dictation
${hourName(peak)}
Most active hour
${esc(fav ? fav[0] : '—')}
Where you dictate most
Your words
${top.map(([w, n]) => `${esc(w)}`).join('')}
Longest dictation
${longest.words || wc(longest.text)} words · ${new Date(longest.ts).toLocaleDateString()}
${esc(longest.text)}
`;
}
// ---------------------------------------------------------------- dictionary + snippets
function dictionary() {
const sorted = [...S.dictionary].filter(d => matches('dict', d.word, d.to));
if (ui.sort.dict) sorted.sort((a, b) => a.word.localeCompare(b.word));
const rows = sorted.map(d => `
Correct a spelling once or add it here manually, so your personal terms, jargon, or uncommon names are spelled right in both dictations and meeting notes.
${examples.map(w => ``).join('')}
`, 'dictBanner')}
${rows ? `
${rows}
` : '
No words yet
'}
`;
}
function snippets() {
const sorted = [...S.snippets].filter(x => matches('snip', x.trigger, x.text));
if (ui.sort.snip) sorted.sort((a, b) => a.trigger.localeCompare(b.trigger));
const rows = sorted.map(x => `
${esc(x.trigger)}→${esc(x.text)}
`).join('');
const ex = [['“my LinkedIn”', 'https://www.linkedin.com/in/john-doe/'], ['“rewrite prompt”', 'Rewrite this to be more concise...'], ['“intro email”', 'Hey, would love to find some time to chat later...']];
return `
Save text you type often — an email, intro, or prompt — then say a word to drop it in instantly.
${ex.map(([a, b]) => `${a}${b}`).join('')}
`, 'snipBanner')}
${rows ? `
${rows}
` : '
No snippets yet
'}
`;
}
// ---------------------------------------------------------------- style
const STYLE_APPS = { personal: 'Signal, Telegram, Discord, WhatsApp, Element', work: 'Slack, Teams, Zulip, Mattermost', email: 'Thunderbird, Geary, Gmail, Proton Mail, Outlook', other: 'everything else: terminals, editors, browsers, notes' };
const STYLE_SAMPLES = {
personal: ['Hey, are you free for lunch tomorrow? Let’s do 12 if that works for you.', 'Hey are you free for lunch tomorrow? Let’s do 12 if that works for you', 'hey are you free for lunch tomorrow? let’s do 12 if that works for you'],
work: ['Hi team, the release is ready for review. Please take a look before Friday.', 'Hi team, the release is ready for review. Take a look before Friday', 'hi team, release is ready for review. take a look before friday'],
email: ['Hi Sarah,\n\nThanks for the update. I’ll review the draft and send notes by Thursday.\n\nBest,\nAda', 'Hi Sarah,\n\nThanks for the update. I’ll look over the draft and send notes by Thursday\n\nAda', 'hi sarah\n\nthanks for the update, i’ll look over the draft and send notes by thursday\n\nada'],
other: ['Update the config so the server restarts cleanly after a deploy.', 'Update the config so the server restarts cleanly after a deploy', 'update the config so the server restarts cleanly after a deploy'],
};
function style() {
const sub = ui.tab.style || 'personal';
const head = `
Style
${tabs('style', [['personal', 'Personal messages'], ['work', 'Work messages'], ['email', 'Email'], ['other', 'Other'], ['cleanup', 'Auto cleanup', ' Beta']])}`;
if (sub === 'cleanup') {
const lv = S.settings.cleanup || 'light';
const cards = [['none', 'None', 'Transcribes exactly what you said, including mistakes', 'hey joey, we still on for coffee or? I think we maybe should leave earlier to make it there in time there might um be traffic. What are you thinking?'],
['light', 'Light', 'Cleans up filler words and grammar', 'Hey Joey, are we still on for coffee? I think we should leave earlier to make it there in time. There might be traffic. What are you thinking?'],
['medium', 'Medium', 'Edits for clarity and conciseness', 'Hey Joey, are we still on for coffee? We should leave earlier; there might be traffic. What do you think?']];
return head + banner('b-clean', `
Auto Cleanup applies to all your dictations
Choose the level of cleanup that’s automatically applied every time, across all apps.
Note your original dictation is never lost. Just go to the three dots next to a recent dictation in the Home tab, then click “Undo AI edit.”
`)
+ `
${cards.map(([id, t, d, s]) => ``).join('')}
`;
}
const cur = S.settings.styles?.[sub] || 'formal';
const sm = STYLE_SAMPLES[sub];
const cards = [['formal', 'Formal.', 'Caps + Punctuation', sm[0]], ['casual', 'Casual', 'Caps + Less punctuation', sm[1]], ['veryCasual', 'very casual', 'No caps + Less punctuation', sm[2]]];
return head + banner('b-voice', `
Make lilt sound like you
Set up different writing styles for different apps.
`)
+ `
Applies in ${STYLE_APPS[sub]}.
${cards.map(([id, t, d, s]) => ``).join('')}
`;
}
// ---------------------------------------------------------------- transforms
const DEFAULT_TRANSFORMS = [
{ id: 'polish', name: 'Polish', desc: 'Improve clarity and conciseness', key: '1', prompt: 'Rewrite the text to be clearer and more concise. Fix grammar and lilt. Keep the meaning, voice and language. Output only the rewritten text.' },
{ id: 'prompt-engineer', name: 'Prompt Engineer', desc: 'Constructs optimal prompts', key: '2', prompt: 'Turn the text into a clear, well-structured prompt for an AI model. State the goal, context, constraints and the expected output format. Output only the prompt.' },
];
function transforms() {
const st = S.settings, tf = S.transforms;
const keyBadge = k => `CtrlAlt${esc(k)}`;
const dots = [['S', 18, 4], ['G', 108, 4], ['in', 200, 4], ['N', 62, 40], ['T', 152, 40], ['Z', 18, 76], ['M', 108, 76], ['C', 200, 76]];
return `
Transforms Beta
Opt in ${ic('info')}CtrlAlt1–9 on selected text
${banner('b-trans', `
${dots.map(([t, x, y]) => `${t}`).join('')}
Transform works anywhere you write
Apply a Transform to rewrite, clean up, or restructure text after you dictate.
`)}
Auto Apply After Dictation
Run a transform on everything you dictate, automatically.
`;
}
// The system prompt saves on blur and on Save, never per keystroke.
async function saveSysPrompt(loud) {
const ta = $('#asst-sp'); if (!ta) return;
const v = ta.value, a = S.settings.assistant = S.settings.assistant || {};
if (v !== (a.systemPrompt || '')) {
a.systemPrompt = v; ui.spState = 'Saved';
const st = $('#asst-sp-state'); if (st) st.textContent = 'Saved';
await lilt.set('assistant.systemPrompt', v);
}
if (loud) toast('Saved', 1400);
}
// ---------------------------------------------------------------- scratchpad
function scratchpad() {
if (ui.note) return noteEditor();
const notes = [...S.notes].filter(n => matches('pad', n.title, n.body)).sort((a, b) => (b.updated || b.ts) - (a.updated || a.ts));
return `
Scratchpad Beta
Add to lilt bar ${ic('info')}
${banner('b-scratch', `
For quick thoughts you want to come back to
Drop a to-do list, polish a message before you send it, brain dump an idea. Scratchpad is your safe space to save, create, and explore.
Untitled
The core principle is about agency and stakes. A progress bar says “you have a shrinking window to do something”
Say a command and an agent carries it out on this machine. The system prompt, typed commands and every reply are in the
${tog('assistant.enabled', 'Assistant key', 'Off leaves only the dictation key active.')}
Shortcut
Hold ${esc(key)} and say a command. Double-tap for hands-free.
Agent folder
Every command tells the agent to read ${esc(dir)}/AGENTS.md first.
${rt.assistantDirOk === false ? '
No AGENTS.md there yet
' : ''}
Agent · runs headless with full tool access
${models || '
No agents found
'}
`;
},
system() {
return `
System
App settings
${tog('launchAtLogin', 'Launch app at login')}
${tog('showBarAlways', 'Show lilt bar at all times', 'Off shows the bar only while you dictate')}
${tog('showInDock', 'Show app in tray')}
Sound
${tog('sounds', 'Dictation and notification sounds')}
${tog('muteWhileDictating', 'Mute all audio while dictating')}
`;
},
notetaker() {
const n = 'notetaker.';
return `
Press Ctrl + Alt + M to start or stop the Notetaker
${tog(n + 'liveTranscript', 'Show live transcript', 'Shows the words on screen while the Notetaker is on')}
Meeting detection
${tog(n + 'autoDetect', 'Automatically detect any call', 'Asks to start a note when a call app opens the microphone')}
${tog(n + 'autoStart', 'Start Notetaker automatically', 'Start Notetaker when a call begins')}
${tog(n + 'stopOnCallEnd', 'Stop Notetaker when a call ends', 'Notetaker stops automatically so you don’t have to')}
Import
${act('Import your meeting notes', 'Bring in old notes as Scratchpad notes (.txt or .md).', 'Import', 'import-notes')}
`;
},
vibe() {
return `
Vibe coding
${tog('vibe.variableRecognition', 'Variable recognition (terminals, Zed, VS Code, Cursor)', 'Formats spoken code names like get user id as getUserId when you dictate into a code tool.')}
${tog('vibe.fileTagging', 'File Tagging in Chat', 'Turns “at main dot js” into @main.js in coding agents.')}
Sync Notetaker with your favorite AI apps, so you can ask about your meeting transcripts and notes. The lilt MCP does not have access to your dictations.
${row('claude', 'Claude Code', 'Runs claude mcp add for you', 'Add to Claude')}
${row('cursor', 'Cursor', 'Allow Cursor to access your meeting notes and transcripts', 'Copy config')}
${row('gemini', 'Gemini CLI', 'Allow Gemini to access your meeting notes and transcripts', 'Copy config')}
All other apps:
1. Copy your connection URL ${esc(url)}
2. Add it to your AI tool as a custom connector (streamable HTTP).
3. Ask your AI about your Notetaker meetings and Scratchpad notes.
`;
},
account() {
const s = S.settings;
return `
Account
First name
Last name
Email
Profile picture
${esc((s.firstName || '?')[0])}
`;
},
privacy() {
return `
Data and Privacy
${tog('privacy.storeAudio', 'Keep dictation audio', 'Stores the recording next to each transcript so you can replay or retry it. Off deletes audio right after transcription.')}
${tog('privacy.contextAwareness', 'Context awareness', 'Lets lilt read the name of the focused app to pick a Style and spell names correctly.')}
Local data storage
Everything lives in ${esc(S.runtime.dataDir || '~/.local/share/lilt')}
${act('Download your data', 'A copy of your dictations, notes, meetings and settings as JSON.', 'Request a copy', 'export')}
${act('Delete your data', 'Permanently delete dictations, notes, meetings and audio from this device.', 'Delete data…', 'delete-data')}
Hold ${'$KEY'} anywhere, speak, and let go. lilt transcribes on your GPU, cleans the text up, and pastes it where your cursor is.
Hands-free: double-tap the key, talk as long as you like, tap again to stop.
Cancel: press Escape while recording.
Dictionary: names and jargon you add are fed to Whisper and fixed after transcription.
Snippets: say a shortcut phrase and lilt drops in the saved text.
Transforms: select text in any app and press Ctrl + Alt + 1–9.
Notetaker: Ctrl + Alt + M records your mic and speakers, then writes a summary.
`;
document.addEventListener('click', async e => {
const t = e.target.closest('[data-go],[data-act],[data-tab],[data-toggle],[data-hist],[data-spane],[data-note],[data-meeting]');
if (!t) { if (!e.target.closest('.menu')) closeMenus(); return; }
if (t.dataset.go) return go(t.dataset.go);
if (t.dataset.tab) { const [k, v] = t.dataset.tab.split(':'); ui.tab[k] = v; return render(); }
if (t.dataset.toggle) return setSetting(t.dataset.toggle, !get(S.settings, t.dataset.toggle));
if (t.dataset.spane) { ui.settings = t.dataset.spane; return renderSettings(); }
if (t.dataset.note) { ui.note = t.dataset.note; return render(); }
if (t.dataset.meeting) { ui.meeting = t.dataset.meeting; render(); return loadMeetingAudio(); }
if (t.dataset.hist) return lilt.historyAction(t.dataset.id, t.dataset.hist);
const a = t.dataset.act, id = t.dataset.id;
if (a.startsWith('go:')) return go(a.slice(3));
switch (a) {
case 'close-settings': if (e.target === t) { ui.settings = null; $('#overlay').innerHTML = ''; } return;
case 'toggle-side': ui.side = !ui.side; return render();
case 'theme-menu': {
const m = $('#theme-menu'), cur = S.settings.theme;
m.innerHTML = [['light', 'sun', 'Light'], ['dark', 'moon', 'Dark'], ['system', 'monitor', 'System']].map(([k, i, l]) => ``).join('');
m.classList.toggle('hidden'); e.stopPropagation(); return;
}
case 'theme': closeMenus(); return setSetting('theme', id);
case 'try-dark': await lilt.set('dismissed.darkPromo', true); return setSetting('theme', 'dark');
case 'bell': return toast(S.runtime.engine === 'ready' ? 'No new notifications' : engineText().replace(/<[^>]+>/g, ''));
case 'help': return modal(HELP.replace('$KEY', esc(S.runtime.hotkeyLabel || 'Right Alt')), el => el.querySelector('[data-close]').onclick = closeModal);
case 'dismiss': return setSetting(`dismissed.${t.dataset.key}`, true);
case 'set': return setSetting(t.dataset.path, t.dataset.val);
case 'search': ui.searchOpen[t.dataset.key] = !ui.searchOpen[t.dataset.key]; if (!ui.searchOpen[t.dataset.key]) ui.search[t.dataset.key] = ''; render(); return $(`[data-search=${t.dataset.key}]`)?.focus();
case 'sort': ui.sort[t.dataset.key] = !ui.sort[t.dataset.key]; return render();
case 'refresh': S = await lilt.state(); render(); return toast('Up to date', 1200);
case 'copy': { const x = S.history.find(h => h.id === id); lilt.copy(x?.text || ''); return toast('Copied to clipboard', 1400); }
case 'copy-text': lilt.copy(t.dataset.text || ''); return toast('Copied to clipboard', 1400);
case 'play': { const url = await lilt.audioUrl(id); if (url) new Audio(url).play(); else toast('No audio kept for this dictation'); return; }
case 'hist-more': {
const x = S.history.find(h => h.id === id);
return menu(t, [
x.raw && x.raw !== x.text && ['Copy original', () => { lilt.copy(x.raw); toast('Copied original'); }],
x.raw && x.raw !== x.text && ['Undo AI edit', () => lilt.historyAction(id, 'undo')],
x.audio && ['Retry transcription', () => { lilt.historyAction(id, 'retry'); toast('Transcribing again…'); }],
['Dismiss', () => lilt.historyAction(id, 'dismiss')],
['Delete', () => lilt.historyAction(id, 'delete'), 'danger'],
]);
}
case 'remove': return lilt.remove(t.dataset.coll, id);
case 'dict-edit': return dictModal(S.dictionary.find(d => d.id === id));
case 'dict-quick': await lilt.upsert('dictionary', { word: t.dataset.word }); return toast(`Added “${t.dataset.word}”`);
case 'snip-edit': return snipModal(S.snippets.find(s => s.id === id));
case 'import': {
const coll = t.dataset.coll;
return importFile('.txt,.csv,.tsv', async text => {
let n = 0;
for (const line of text.split(/\r?\n/)) {
const [a, ...rest] = line.split(/\t|\s*->\s*|\s*→\s*|,/); const b = rest.join(', ').trim();
if (!a || !a.trim()) continue;
if (coll === 'dictionary') await lilt.upsert('dictionary', b ? { word: a.trim(), to: b } : { word: a.trim() });
else if (b) await lilt.upsert('snippets', { trigger: a.trim(), text: b }); else continue;
n++;
}
toast(`Imported ${n} ${coll === 'dictionary' ? 'words' : 'snippets'}`);
});
}
case 'tf-edit': return tfModal(S.transforms.find(x => x.id === id));
case 'tf-try': return tryModal();
case 'tf-how': return modal(`
How Transforms work
Select text in any app, then press Ctrl + Alt plus the transform's number. lilt reads the selection, rewrites it with your local language model, and pastes the result over it.
Turn on Auto Apply to run one transform on every dictation after cleanup.
`, el => el.querySelector('[data-close]').onclick = closeModal);
case 'tf-reset': return confirmBox('Reset transforms?', 'Your custom transforms will be deleted and Polish and Prompt Engineer restored.', 'Reset', async () => {
for (const x of S.transforms) await lilt.remove('transforms', x.id);
for (const x of DEFAULT_TRANSFORMS) await lilt.upsert('transforms', { ...x });
});
case 'note-new': return newNote();
case 'note-back': ui.note = null; return render();
case 'note-delete': return confirmBox('Delete note?', 'This cannot be undone.', 'Delete', () => { lilt.remove('notes', ui.note); ui.note = null; render(); });
case 'copy-note': lilt.copy($('[data-note-field=body]').value); return toast('Copied');
case 'note-polish': {
const ta = $('[data-note-field=body]'); if (!ta.value.trim()) return;
toast('Polishing…');
try { ta.value = await lilt.transform(S.transforms[0]?.id || 'polish', ta.value); saveNote(); toast('Polished', 1200); } catch (err) { toast(`Failed: ${err.message || err}`); }
return;
}
case 'notetaker-start': return lilt.notetaker(true);
case 'notetaker-stop': return lilt.notetaker(false);
case 'meeting-back': ui.meeting = null; return render();
case 'meeting-delete': return confirmBox('Delete meeting?', 'The transcript, summary and audio will be removed.', 'Delete', () => { lilt.remove('meetings', id); ui.meeting = null; render(); });
case 'share-insights': { const st = stats(); lilt.copy(`lilt: ${st.words.toLocaleString()} words dictated at ${st.wpm} wpm, ${st.streak} day streak.`); return toast('Copied your stats'); }
case 'hotkey': {
ui.capturing = true; renderSettings();
try { const k = await lilt.captureHotkey(); await setSetting('hotkey', k); toast(`Hold ${k.label} to dictate`); } finally { ui.capturing = false; renderSettings(); }
return;
}
case 'asst-hotkey': {
if (ui.capturingAsst) return;
ui.capturingAsst = true; renderSettings();
try { const k = await lilt.captureHotkey('assistant'); if (k) { await setSetting('assistant.hotkey', k); toast(`Hold ${k.label} to give a command`); } } finally { ui.capturingAsst = false; renderSettings(); }
return;
}
case 'asst-open': return lilt.open('file://' + (S.runtime.assistantDir || ''));
case 'asst-run': return runAssistant();
case 'asst-stop': await lilt.stopAssistant(); return toast('Stopped', 1400);
case 'asst-panel': ui.settings = null; $('#overlay').innerHTML = ''; return go('assistant');
case 'asst-sp-save': return saveSysPrompt(true);
case 'asst-log': return lilt.open('file://' + (S.runtime.assistantLog || ''));
case 'cmd-toggle': if (String(getSelection())) return; ui.cmdOpen[id] = !ui.cmdOpen[id]; return render();
case 'cmd-copy': { const c = (S.commands || []).find(x => x.id === id); lilt.copy(c?.[t.dataset.field] || ''); return toast('Copied to clipboard', 1400); }
case 'cmd-rerun': { const c = (S.commands || []).find(x => x.id === id); return c?.text && runAssistant(c.text); }
case 'mic': return micModal();
case 'lang': return langModal();
case 'llm': return modal(`
Language model
Any Ollama model tag. Small instruct models (3–8B) keep cleanup under a second on your GPU.
`, el => {
el.querySelector('[data-close]').onclick = closeModal;
el.querySelector('[data-ok]').onclick = () => { const v = el.querySelector('#m-llm').value.trim(); if (v) setSetting('llmModel', v); closeModal(); };
});
case 'model-dl': lilt.downloadModel(id); return toast('Downloading model…');
case 'mcp-add': {
const msg = await lilt.mcpAdd(id);
if (id === 'claude') setSetting('mcpAdded', true);
return toast(msg || 'Done', 5000);
}
case 'save-account': {
for (const inp of document.querySelectorAll('[data-acct]')) await lilt.set(inp.dataset.acct, inp.value.trim());
S = await lilt.state(); render(); return toast('Saved');
}
case 'open-data': return lilt.open('data');
case 'export': { const p = await lilt.exportData(); return toast(`Saved to ${p}`, 5000); }
case 'delete-data': return confirmBox('Delete your data?', 'Dictations, Scratchpad notes, meetings and audio will be permanently deleted from this device. Settings, Dictionary and Snippets stay.', 'Delete data', async () => { await lilt.deleteData(); toast('Deleted'); });
case 'import-notes': return importFile('.txt,.md', async (text, name) => { await lilt.upsert('notes', { title: name.replace(/\.(txt|md)$/, ''), body: text, ts: Date.now(), updated: Date.now() }); toast('Imported into Scratchpad'); });
}
});
document.addEventListener('input', e => {
const t = e.target;
if (t.dataset.search) {
ui.search[t.dataset.search] = t.value;
const pos = t.selectionStart; render();
const n = $(`[data-search=${t.dataset.search}]`); if (n) { n.focus(); n.setSelectionRange(pos, pos); }
}
if (t.dataset.noteField) saveNote();
if (t.id === 'asst-sp') { ui.spState = 'Not saved'; const st = $('#asst-sp-state'); if (st) st.textContent = 'Not saved'; }
});
document.addEventListener('focusout', e => { if (e.target.id === 'asst-sp') saveSysPrompt(false); });
document.addEventListener('change', e => {
const t = e.target;
if (t.dataset.set) setSetting(t.dataset.set, /^\d+$/.test(t.value) ? Number(t.value) : t.value);
});
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if ($('#modal')) return closeModal();
if ($('#ctx') || !$('#theme-menu').classList.contains('hidden')) return closeMenus();
if (ui.settings) { ui.settings = null; $('#overlay').innerHTML = ''; }
});
document.addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.id === 'asst-in') runAssistant(); });
// Typed assistant command. The record shows up in S.commands (and the panel log) via the state push.
async function runAssistant(again) {
const inp = $('#asst-in'), text = (again || inp?.value || '').trim();
if (!text) return;
if (inp && !again) { inp.value = ''; inp.blur(); }
if (ui.page !== 'assistant') toast('Running…', 4000);
try { await lilt.assistant(text); } catch (err) { toast(`Failed: ${err.message || err}`, 6000); }
}
async function loadMeetingAudio() {
const el = $('[data-audio]'); if (!el) return;
const url = await lilt.audioUrl(el.dataset.audio); if (url) el.src = url; else el.remove();
}
setInterval(() => {
const t = $('#nt-timer'); if (t) t.textContent = fmtDur(Date.now() - Number(t.dataset.start));
document.querySelectorAll('[data-elapsed]').forEach(el => { el.textContent = secs(Date.now() - Number(el.dataset.elapsed)); });
}, 1000);
const lilt = window.lilt || mockLilt();
lilt.onState(onState);
lilt.onNavigate?.(r => go(r));
lilt.state().then(onState);
// ---------------------------------------------------------------- browser preview stub
// Lets index.html open in a plain browser (no Electron) with sample data for UI work.
function mockLilt() {
const now = Date.now(), H = 36e5;
const st = {
settings: { theme: 'system', firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', hotkey: { keycode: 108, label: 'Right Alt' }, micId: '', language: 'en', model: 'large-v3-turbo', llmModel: 'qwen3.5:4b', cleanup: 'light', styles: { personal: 'casual', work: 'formal', email: 'formal', other: 'casual' }, transformsOptIn: true, autoTransform: { enabled: false, id: 'polish' }, showBarAlways: true, showInDock: true, sounds: true, privacy: { storeAudio: true, contextAwareness: true }, notetaker: { maxMinutes: 120, systemAudio: true }, vibe: { fileTagging: true }, dismissed: {}, assistant: { model: 'claude-sonnet', hotkey: { keycode: 50, label: 'Left Shift' }, enabled: true, systemPrompt: 'Prefer dwm keybindings over xdotool when moving windows. Answer in one sentence.' } },
commands: [
{ id: 'c3', ts: now - H / 4, text: 'Now move it to tag 3', prompt: 'Read ~/.config/agents/AGENTS.md first.\n\nPrefer dwm keybindings over xdotool when moving windows. Answer in one sentence.\n\nCommand: Now move it to tag 3', reply: 'Moved the pcmanfm window to tag 3.', model: 'claude-sonnet', ms: 5400, ok: true, app: 'St', title: 'fish ~/src/lilt', followUp: true },
{ id: 'c1', ts: now - H / 2, text: 'Open my notes folder in the file manager', prompt: 'Read ~/.config/agents/AGENTS.md first.\n\nPrefer dwm keybindings over xdotool when moving windows. Answer in one sentence.\n\nCommand: Open my notes folder in the file manager', reply: 'Opened ~/lit/notes in pcmanfm.', model: 'claude-sonnet', ms: 8200, ok: true, app: 'St', title: 'fish ~/src/lilt', followUp: false },
{ id: 'c2', ts: now - 25 * H, text: 'Mute the speakers', reply: 'pactl: connection refused', model: 'codex', ms: 3100, ok: false },
],
history: [
{ id: 'h1', ts: now - H, text: "It's daytime. I'm up.", raw: "it's daytime i'm up", app: 'St', words: 4, ms: 2000 },
{ id: 'h2', ts: now - 2 * H, text: 'Okay, Claude, you can continue now.', app: 'St', words: 6, ms: 2600, audio: 'h2.wav' },
{ id: 'h3', ts: now - 26 * H, text: "Hey, what's the status? How far are we?", app: 'Signal', words: 8, ms: 3000 },
{ id: 'h4', ts: now - 27 * H, text: 'Lotto dog', app: 'Brave-browser', words: 2, ms: 1500, dismissed: true },
],
dictionary: [{ id: 'd1', word: 'kubectl', auto: true }, { id: 'd2', word: 'Ada' }, { id: 'd3', word: 'btw', to: 'by the way' }],
snippets: [{ id: 's1', trigger: 'my email address', text: 'ada@example.com' }],
transforms: DEFAULT_TRANSFORMS.map(x => ({ ...x })), notes: [], meetings: [],
runtime: { version: '1.0.0', engine: 'ready', llm: 'ready', hotkeyLabel: 'Right Alt', mcpUrl: 'http://127.0.0.1:7865/mcp', dataDir: '~/.local/share/lilt', models: [{ id: 'large-v3-turbo', name: 'Whisper large-v3-turbo', engine: 'whisper.cpp', size: '1.6 GB', langs: '99 languages', installed: true }, { id: 'parakeet-v3', name: 'Parakeet TDT 0.6B v3', engine: 'parakeet', size: '1.3 GB', langs: '25 languages', installed: true }, { id: 'large-v3', name: 'Whisper large-v3', engine: 'whisper.cpp', size: '3.1 GB', langs: '99 languages', installed: false }],
assistant: false, recordingKind: 'dictate', assistantDir: '/home/ada/.config/agents', assistantDirOk: false, assistantLog: '/home/ada/.local/share/lilt/commands.jsonl',
assistantModels: [{ id: 'claude-sonnet', name: 'Claude Sonnet', cli: 'claude', desc: 'Claude Code CLI · uses your Claude sign-in', available: true }, { id: 'codex', name: 'GPT-5.6', cli: 'codex', desc: 'Codex CLI · uses your ChatGPT sign-in', available: true }, { id: 'grok', name: 'Grok', cli: 'grok', desc: 'Grok CLI', available: false }] },
};
let cb = () => {};
const push = () => cb(JSON.parse(JSON.stringify(st)));
let n = 0;
return {
state: async () => JSON.parse(JSON.stringify(st)), onState: f => { cb = f; },
set: async (p, v) => { const k = p.split('.'); let o = st.settings; k.slice(0, -1).forEach(x => o = o[x] ??= {}); o[k.at(-1)] = v; push(); },
upsert: async (c, it) => { it = { ...it, id: it.id || `m${++n}` }; st[c] = [...st[c].filter(x => x.id !== it.id), it]; push(); return it; },
remove: async (c, id) => { st[c] = st[c].filter(x => x.id !== id); push(); },
historyAction: async (id, a) => { const x = st.history.find(h => h.id === id); if (a === 'dismiss') x.dismissed = true; if (a === 'recover') x.dismissed = false; if (a === 'flag') x.flagged = !x.flagged; if (a === 'delete') st.history = st.history.filter(h => h.id !== id); push(); },
copy: t => navigator.clipboard?.writeText(t), audioUrl: async () => null, notetaker: async () => {}, downloadModel: () => {},
captureHotkey: async target => target === 'assistant' ? { keycode: 50, label: 'Left Shift' } : { keycode: 108, label: 'Right Alt' },
assistant: async text => {
const prompt = `Read ~/.config/agents/AGENTS.md first.\n\n${st.settings.assistant.systemPrompt || ''}\n\nCommand: ${text}`;
const c = { id: `cmd${Date.now()}`, ts: Date.now(), text, prompt, reply: '', model: st.settings.assistant.model, ms: 0, ok: false, app: 'St', title: 'fish ~/src/lilt', followUp: Date.now() - (st.commands[0]?.ts || 0) < 3e5 };
st.commands = [c, ...st.commands]; st.runtime.assistant = 'running'; push();
await new Promise(r => setTimeout(r, 3000));
Object.assign(c, { reply: `Stub reply to: ${text}`, ms: Date.now() - c.ts, ok: true });
st.runtime.assistant = false; push(); return c;
},
stopAssistant: async () => { st.runtime.assistant = false; push(); }, transform: async (id, t) => t, mcpAdd: async () => 'stub', open: () => {},
exportData: async () => '~/dl/lilt-export.json', deleteData: async () => {}, win: () => {},
};
}
})();