// lilt hub UI. Renders every page from the state main.js pushes over window.lilt. (() => { 'use strict'; const $ = (s, r = document) => r.querySelector(s); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const get = (o, p) => p.split('.').reduce((a, k) => a == null ? a : a[k], o); const wc = t => (String(t || '').match(/\S+/g) || []).length; // Icons (24px stroke paths, drawn in the lucide style). const I = { mic: '', rec: '', chart: '', book: '', scissors: '', type: '', wand: '', pad: '', users: '', gift: '', gear: '', help: '', sun: '', moon: '', monitor: '', bell: '', user: '', sidebar: '', search: '', x: '', play: '', copy: '', flag: '', more: '', sort: '', refresh: '', plus: '', check: '', info: '', cal: '', sliders: '', laptop: '', hash: '', plug: '', mcp: '', card: '', shield: '', trash: '', pencil: '', back: '', undo: '', inf: '', bot: '', bubble: '', mail: '', msg: '', share: '', trend: '', download: '', stop: '', folder: '', file: '', cloud: '', }; const ic = (n, cls = '') => `${I[n] || ''}`; // lilt mark: a cursive l that trails off into a lilting tail and the orange accent dot. const logo = ''; // ---------------------------------------------------------------- state let S = null; const ui = { page: 'home', tab: {}, search: {}, searchOpen: {}, sort: {}, settings: null, note: null, meeting: null, dirty: false, side: true, cmdOpen: {}, }; const toast = (msg, ms = 2600) => { const t = $('#toast'); t.textContent = msg; t.classList.remove('hidden'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.add('hidden'), ms); }; function applyTheme() { const t = S.settings.theme; const dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.dataset.theme = dark ? 'dark' : 'light'; $('#theme-btn').innerHTML = ic(dark ? 'sun' : 'moon'); } matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => S && applyTheme()); // Re-rendering while the user types would eat keystrokes; wait for blur. function editing() { const a = document.activeElement; return a && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) && a.type !== 'checkbox'; } function onState(st) { S = st; if (editing()) { ui.dirty = true; return; } render(); } document.addEventListener('focusout', () => setTimeout(() => { if (ui.dirty && !editing()) { ui.dirty = false; render(); } }, 0)); async function setSetting(path, value) { // optimistic local update so toggles feel instant const keys = path.split('.'); let o = S.settings; keys.slice(0, -1).forEach(k => { o[k] = o[k] && typeof o[k] === 'object' ? o[k] : {}; o = o[k]; }); o[keys.at(-1)] = value; render(); await lilt.set(path, value); } function go(route) { const [page, sub] = route.split('/'); if (page === 'settings') { ui.settings = sub || 'general'; renderSettings(); return; } ui.page = page; ui.note = null; ui.meeting = null; if (page === 'insights' && sub) ui.tab.insights = sub; if (page === 'scratchpad' && sub === 'new') return newNote(); closeMenus(); render(); $('#main').scrollTop = 0; } // ---------------------------------------------------------------- stats const dayKey = ts => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; const fmtK = n => n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K' : String(n); const fmtTime = ts => new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }).replace(' ', '').toLowerCase(); const fmtDay = ts => { const k = dayKey(ts), now = Date.now(); if (k === dayKey(now)) return 'TODAY'; if (k === dayKey(now - 864e5)) return 'YESTERDAY'; return new Date(ts).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }).toUpperCase(); }; const fmtDur = ms => { const s = Math.round(ms / 1000); return s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}m` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }; const TERMS = /^(st|kitty|alacritty|foot|xterm|urxvt|wezterm|ghostty|konsole|gnome-terminal|terminator|code|cursor|zed|windsurf)/i; function category(app = '') { const a = app.toLowerCase(); if (/signal|telegram|discord|whatsapp|element|messages/.test(a)) return 'personal'; if (/slack|teams|zulip|mattermost/.test(a)) return 'work'; if (/thunderbird|geary|evolution|mutt|mail/.test(a)) return 'email'; if (/libreoffice|soffice|obsidian|notion|typora|onlyoffice|abiword|writer/.test(a)) return 'docs'; if (TERMS.test(a)) return 'ai'; return 'other'; } function stats() { const h = S.history.filter(x => !x.dismissed && x.text); const words = h.reduce((a, x) => a + (x.words || wc(x.text)), 0); const timed = h.filter(x => x.ms > 1500); const mins = timed.reduce((a, x) => a + x.ms, 0) / 60000; const wpm = mins ? Math.round(timed.reduce((a, x) => a + (x.words || wc(x.text)), 0) / mins) : 0; const perDay = {}; h.forEach(x => { const k = dayKey(x.ts); perDay[k] = (perDay[k] || 0) + (x.words || wc(x.text)); }); let streak = 0, t = Date.now(); if (!perDay[dayKey(t)]) t -= 864e5; while (perDay[dayKey(t)]) { streak++; t -= 864e5; } let longest = 0, run = 0, prev = null; Object.keys(perDay).sort().forEach(k => { const d = new Date(k + 'T12:00').getTime(); run = prev && Math.round((d - prev) / 864e5) === 1 ? run + 1 : 1; longest = Math.max(longest, run); prev = d; }); const now = new Date(), m0 = new Date(now.getFullYear(), now.getMonth(), 1).getTime(), m1 = new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime(); const thisMonth = h.filter(x => x.ts >= m0).reduce((a, x) => a + (x.words || wc(x.text)), 0); const before = words - thisMonth; const growth = before ? Math.round(thisMonth / before * 100) : (thisMonth ? 100 : 0); let corrected = 0; h.forEach(x => { if (!x.raw || x.raw === x.text) return; const bag = {}; (x.raw.toLowerCase().match(/[\w']+/g) || []).forEach(w => bag[w] = (bag[w] || 0) + 1); (x.text.toLowerCase().match(/[\w']+/g) || []).forEach(w => { if (bag[w]) bag[w]--; else corrected++; }); }); const dictWords = S.dictionary.map(d => (d.to || d.word).toLowerCase()); const dictFixes = h.reduce((a, x) => a + dictWords.filter(w => w && x.text.toLowerCase().includes(w)).length, 0); const cats = { other: 0, ai: 0, docs: 0, personal: 0, email: 0, work: 0 }; h.forEach(x => cats[category(x.app)]++); const apps = new Set(h.map(x => (x.app || '').toLowerCase()).filter(Boolean)).size; return { h, words, wpm, streak, longest, perDay, growth, corrected, dictFixes, cats, apps, m1 }; } // ---------------------------------------------------------------- shell function render() { if (!S) return; applyTheme(); $('#app').classList.toggle('side-off', !ui.side); renderSide(); const pages = { home, notetaker, assistant: assistantPage, insights, dictionary, snippets, style, transforms, scratchpad }; $('#main').innerHTML = (pages[ui.page] || home)(); if (ui.settings) renderSettings(); } const NAV = [ ['home', 'mic', 'Dictation'], ['notetaker', 'rec', 'Notetaker'], ['assistant', 'bot', 'Assistant'], ['insights', 'chart', 'Insights'], ['dictionary', 'book', 'Dictionary'], ['snippets', 'scissors', 'Snippets'], ['style', 'type', 'Style'], ['transforms', 'wand', 'Transforms'], ['scratchpad', 'pad', 'Scratchpad'], ]; function renderSide() { $('#side').innerHTML = `
`; const tb = document.querySelectorAll('.topbar .icon-btn'); tb[0].innerHTML = ic('sidebar'); tb[2].innerHTML = ic('bell'); tb[3].innerHTML = ic('user'); } const banner = (cls, html, closeKey) => ``; const tabs = (key, list, tools = '') => { const cur = ui.tab[key] || list[0][0]; return `
${list.map(([id, label, extra]) => ``).join('')}${tools}
`; }; const listTools = key => ` `; const searchBox = key => ui.searchOpen[key] ? `` : ''; const matches = (key, ...fields) => { const q = (ui.search[key] || '').toLowerCase(); return !q || fields.some(f => String(f || '').toLowerCase().includes(q)); }; // ---------------------------------------------------------------- home function home() { const st = stats(), set = S.settings, rt = S.runtime; const items = S.history.filter(x => matches('home', x.text, x.raw)).sort((a, b) => b.ts - a.ts); const groups = []; items.forEach(x => { const d = fmtDay(x.ts); if (!groups.length || groups.at(-1).d !== d) groups.push({ d, xs: [] }); groups.at(-1).xs.push(x); }); const hist = groups.map((g, i) => `
${g.d}${i === 0 ? `` : ''}
${i === 0 ? searchBox('home') : ''}
${g.xs.map(histRow).join('')}
`).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 = `
TODAY
${hint}
`; return `

Welcome back, ${esc(set.firstName || 'there')}

${rt.engine !== 'ready' ? `
${engineText()}
` : ''} ${groups.length ? hist : (ui.search.home ? searchBox('home') + '
No matches
' : empty)}
${fmtK(st.words)}total words
${st.wpm}wpm
${st.streak}day streak
`; } 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 `
${fmtTime(x.ts)}This transcription was dismissed.
`; const text = x.text ? esc(x.text) : 'No speech detected'; return `
${fmtTime(x.ts)} ${text}${x.flagged ? `${ic('flag')}` : ''} ${x.audio ? `` : ''}
`; } // ---------------------------------------------------------------- notetaker function notetaker() { const rt = S.runtime; if (ui.meeting) return meetingView(); const on = rt.notetaker; const today = S.meetings.filter(m => dayKey(m.ts) === dayKey(Date.now())); const list = S.meetings.filter(m => matches('notetaker', m.title, m.summary, m.transcript)).sort((a, b) => b.ts - a.ts); let body; body = list.length ? `
${list.map(m => `
${ic('file')}${esc(m.title || 'Untitled meeting')}
${new Date(m.ts).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} · ${fmtDur(m.ms || 0)}
`).join('')}
` : `
${ic('file')}Your meetings will appear here
`; return `

Notetaker

TODAY
${on ? `
Recording microphone and system audio ${fmtDur(Date.now() - on.startedAt)}
` : ''} ${today.length ? today.map(m => `
${esc(m.title || 'Untitled meeting')}${fmtTime(m.ts)}
`).join('') : on ? '' : `
${ic('cal')}

No meetings found

Start Notetaker during a call for a transcript, summary and action items. Audio stays on this machine.

`}
${tabs('notetaker', [['past', 'Past notes']], ``)} ${searchBox('notetaker')}${body}
`; } function meetingView() { const m = S.meetings.find(x => x.id === ui.meeting); if (!m) { ui.meeting = null; return notetaker(); } return `

${esc(m.title || 'Untitled meeting')}

${new Date(m.ts).toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' })} · ${fmtDur(m.ms || 0)}

${m.audio ? `` : ''}
${m.summary ? md(m.summary) : '

Summary is being written…

'}

Transcript

${esc(m.transcript || 'Transcribing…')}
`; } function md(s) { return esc(s).split('\n').map(l => { l = l.replace(/\*\*(.+?)\*\*/g, '$1'); if (/^#{1,3} /.test(l)) return `

${l.replace(/^#+ /, '')}

`; if (/^\s*[-*] /.test(l)) return `
  • ${l.replace(/^\s*[-*] (\[[ x]\] )?/, '')}
  • `; return l.trim() ? `

    ${l}

    ` : ''; }).join('').replace(/(
  • .*?<\/li>)+/g, m => `
      ${m}
    `); } // ---------------------------------------------------------------- insights function insights() { const st = stats(), sub = ui.tab.insights || 'usage'; const head = `

    Insights

    ${tabs('insights', [['usage', 'Your usage'], ['voice', 'Your voice']])}`; if (sub === 'voice') return `
    ${head}${voiceReport(st)}
    `; const f = Math.min(st.wpm / 150, 1); const top = st.wpm >= 110 ? '1%' : st.wpm >= 90 ? '5%' : st.wpm >= 75 ? '10%' : st.wpm >= 60 ? '25%' : st.wpm >= 45 ? '50%' : null; const books = [['Dr. Seuss book', 1600], ['short story', 7500], ['novella', 30000], ['The Great Gatsby', 47094], ['Harry Potter book', 76944], ['Lord of the Rings', 481103]]; const [bName, bLen] = books.filter(b => st.words >= b[1]).at(-1) || books[0]; const nBooks = Math.max(1, Math.floor(st.words / bLen)); const total = Object.values(st.cats).reduce((a, b) => a + b, 0) || 1; const rows = [['inf', 'other', 'other tasks'], ['bot', 'ai', 'AI prompts'], ['file', 'docs', 'documents'], ['bubble', 'personal', 'personal messages'], ['mail', 'email', 'emails'], ['msg', 'work', 'work messages']]; return `
    ${head}
    ${st.wpm}
    Words per minute ${ic('info')}
    ${top ? `Top${top}` : '—'}
    ${st.corrected + st.dictFixes}
    Fixes made by lilt

    ${st.corrected} words corrected${ic('info')}
    ${st.dictFixes} dictionary fixes${ic('info')}
    ${st.words.toLocaleString()}
    ${st.growth ? `${ic('trend')}${st.growth}% this month` : ''}
    Total words dictated

    You've written ${nBooks} ${bName}${nBooks > 1 && !bName.startsWith('The') && !bName.startsWith('Lord') ? 's' : ''}!
    ${ic('monitor')}Desktop
    Desktop usageTotal apps used | ${st.apps}
    ${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 => `
    ${esc(d.word)}${d.to ? `→${esc(d.to)}` : ''}${d.auto ? ' ✨' : ''}
    `).join(''); const examples = ['Samir', 'Sara', 'Karol', 'Spyder'].filter(w => !S.dictionary.some(d => d.word === w)); return `

    Dictionary

    ${tabs('dict', [['all', 'All']], listTools('dict'))} ${searchBox('dict')} ${S.settings.dismissed?.dictBanner ? '' : banner('b-dict', `

    lilt spells the way you do.

    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 `

    Snippets

    ${tabs('snip', [['all', 'All']], listTools('snip'))} ${searchBox('snip')} ${S.settings.dismissed?.snipBanner ? '' : banner('b-snip', `

    The stuff you shouldn't have to re-type.

    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.

    ${ic('info')}

    My Transforms

    ${tf.map(t => ``).join('')}
    `; } // ---------------------------------------------------------------- assistant const modelName = id => (S.runtime.assistantModels || []).find(m => m.id === id)?.name || id || ''; const secs = ms => ms >= 60000 ? fmtDur(ms) : `${(ms / 1000).toFixed(1)} s`; function assistantPage() { const a = S.settings.assistant || {}, rt = S.runtime, running = rt.assistant === 'running'; const all = [...(S.commands || [])].sort((x, y) => y.ts - x.ts); const live = running && all[0] && !all[0].reply ? all[0] : null; const items = all.filter(c => matches('asst', c.text, c.reply)); const groups = []; items.forEach(c => { const d = fmtDay(c.ts); if (!groups.length || groups.at(-1).d !== d) groups.push({ d, xs: [] }); groups.at(-1).xs.push(c); }); const log = groups.map(g => `
    ${g.d}
    ${g.xs.map(c => cmdRow(c, live?.id)).join('')}
    `).join(''); const m = (rt.assistantModels || []).find(x => x.id === a.model); const key = esc(a.hotkey?.label || 'Left Shift'); return `

    Assistant

    COMMAND ${esc(m?.name || 'No agent selected')}
    ${a.enabled === false ? `

    The assistant key is off. Typed commands still run.

    ` : `

    Hold ${key} and say a command, or type one here.

    `}
    ${running ? '' : ''}
    ${running ? `
    Running ${esc(live?.text || '')} ${live ? `${secs(Date.now() - live.ts)}` : ''}
    ` : ''}

    System prompt

    Added to every command, after the pointer to ~/.config/agents/AGENTS.md.

    ${ui.spState || ''}
    ${tabs('asst', [['log', `Commands ${all.length}`]], ``)} ${rt.assistantLog ? `
    Every command is also appended to ${esc(rt.assistantLog)}
    ` : ''} ${searchBox('asst')} ${log || `
    ${ui.search.asst ? 'No matches' : 'No commands yet'}
    `}
    `; } function cmdRow(c, liveId) { const open = ui.cmdOpen[c.id], isLive = c.id === liveId, id = esc(c.id); const err = !isLive && c.ok === false; const reply = isLive ? ' Running…' : c.reply ? esc(c.reply) : err ? 'No reply' : 'Done'; const name = modelName(c.model); const meta = [name && esc(name), isLive ? `${secs(Date.now() - c.ts)}` : c.ms ? secs(c.ms) : ''].filter(Boolean).join(' · '); const win = [c.app, c.title].filter(Boolean).map(esc).join(' · '); return `
    ${fmtTime(c.ts)}
    ${esc(c.text || '')}${c.followUp ? 'Follow-up' : ''}
    ${reply}
    ${meta}
    ${open ? `
    Prompt sent
    ${c.prompt ? `
    ${esc(c.prompt)}
    ` : '
    Not recorded for this command.
    '}
    Focused window
    ${win || 'Not recorded'}
    ${c.prompt ? `` : ''}
    ` : ''}
    `; } // 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”
    `)}

    Recents

    ${searchBox('pad')} ${notes.length ? `
    ${notes.map(n => `

    ${esc(n.title || 'Untitled')}

    ${esc((n.body || '').split('\n').find(Boolean) || 'Empty note')}

    ${new Date(n.updated || n.ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
    `).join('')}
    ` : '
    No notes found
    '}
    `; } function noteEditor() { const n = S.notes.find(x => x.id === ui.note) || { id: ui.note, title: '', body: '' }; return `

    Scratchpad

    `; } async function newNote() { const n = await lilt.upsert('notes', { title: '', body: '', ts: Date.now(), updated: Date.now() }); S.notes = [...S.notes.filter(x => x.id !== n.id), n]; ui.page = 'scratchpad'; ui.note = n.id; render(); $('[data-note-field=body]')?.focus(); } let noteTimer; function saveNote() { clearTimeout(noteTimer); noteTimer = setTimeout(() => { const n = S.notes.find(x => x.id === ui.note) || { id: ui.note, ts: Date.now() }; const upd = { ...n, title: $('[data-note-field=title]')?.value ?? n.title, body: $('[data-note-field=body]')?.value ?? n.body, updated: Date.now() }; S.notes = [...S.notes.filter(x => x.id !== upd.id), upd]; lilt.upsert('notes', upd); }, 400); } // ---------------------------------------------------------------- settings const SET_NAV = [['general', 'sliders', 'General'], ['assistant', 'bot', 'Assistant'], ['system', 'laptop', 'System'], ['notetaker', 'rec', 'Notetaker'], ['vibe', 'hash', 'Vibe coding'], ['mcp', 'mcp', 'MCP']]; const ACC_NAV = [['account', 'user', 'Account'], ['privacy', 'shield', 'Data and Privacy']]; const LANGS = { auto: 'Auto-detect', en: 'English', es: 'Spanish', fr: 'French', de: 'German', it: 'Italian', pt: 'Portuguese', nl: 'Dutch', pl: 'Polish', ru: 'Russian', uk: 'Ukrainian', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ar: 'Arabic', hi: 'Hindi', tr: 'Turkish', sv: 'Swedish', el: 'Greek', he: 'Hebrew', la: 'Latin' }; const LLMS = [ ['claude-haiku', 'Claude Haiku 5.5', 'Claude Code CLI · low effort · uses your Claude sign-in'], ['codex-luna', 'GPT-5.6 Luna', 'Codex CLI · low reasoning · fast mode · uses your ChatGPT sign-in'], ['ollama', 'Local', 'Ollama on this machine · works offline'], ]; const tog = (path, title, desc = '') => `
    ${title}
    ${desc ? `
    ${desc}
    ` : ''}
    `; const sel = (path, title, desc, opts, def) => { const v = get(S.settings, path) ?? def; return `
    ${title}
    ${desc ? `
    ${desc}
    ` : ''}
    `; }; const act = (title, desc, label, a, extra = '') => `
    ${title}
    ${desc ? `
    ${desc}
    ` : ''}
    `; function renderSettings() { const p = ui.settings, rt = S.runtime; const navBtn = ([id, icon, label]) => ``; $('#overlay').innerHTML = `
    ${(PANES[p] || PANES.general)()}
    `; } const PANES = { general() { const s = S.settings, rt = S.runtime; const models = (rt.models || []).map(m => { const active = s.model === m.id; const right = m.progress != null && m.progress < 1 ? `
    Downloading ${Math.round(m.progress * 100)}%
    ` : !m.installed ? `` : active ? `${rt.engine === 'ready' ? 'In use' : esc(rt.engine)}` : ``; return `
    ${esc(m.name)}
    ${esc(m.engine)} · ${esc(m.size || '')} · ${esc(m.langs || '')}
    ${right}
    `; }).join(''); return `

    General

    Shortcuts
    Hold ${esc(rt.hotkeyLabel || s.hotkey?.label)} and speak. Double-tap for hands-free.
    ${act('Microphone', esc(s.micLabel || 'System default (recommended)'), 'Change', 'mic')} ${act('My languages', esc(LANGS[s.language] || s.language), 'Change', 'lang')}
    App Language
    Select your preferred app language
    Theme
    Speech models · run on your GPU through Vulkan
    ${models || '
    Waiting for the engine…
    '}
    Language model · Auto cleanup, Style, Transforms and meeting summaries
    ${LLMS.map(([id, name, desc]) => { const on = (s.llm || 'ollama') === id; const right = on ? `${rt.llm === 'ready' ? 'In use' : esc(rt.llm || 'off')}` : ``; const label = id === 'ollama' ? `${name} · ${esc(s.llmModel)}` : name; return `
    ${label}
    ${desc}
    ${right}${id === 'ollama' ? '' : ''}
    `; }).join('')}
    `; }, assistant() { const a = S.settings.assistant || {}, rt = S.runtime, running = rt.assistant === 'running'; const key = a.hotkey?.label || 'Left Shift', dir = rt.assistantDir || '~/.config/agents'; const models = (rt.assistantModels || []).map(m => { const on = a.model === m.id; const right = !m.available ? `${on ? '' : ''}Not installed` : on ? `${running ? 'Running' : 'In use'}` : ``; return `
    ${esc(m.name)}
    ${esc(m.desc || m.cli || '')}
    ${right}
    `; }).join(''); return `

    Assistant

    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 `

    Notetaker

    Recording
    ${sel(n + 'maxMinutes', 'Maximum recording length', 'Notetaker stops automatically at this length.', [[30, '30 minutes'], [60, '1 hour'], [120, '2 hours'], [240, '4 hours']], 120)}
    Notepad
    Keyboard shortcut
    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.')}
    `; }, mcp() { const url = S.runtime.mcpUrl || 'http://127.0.0.1:7865/mcp'; const row = (id, name, desc, label) => `
    ${name}
    ${desc}
    `; return `

    MCP

    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')}
    `; }, }; // ---------------------------------------------------------------- modals + menus function modal(html, onMount) { closeModal(); const el = document.createElement('div'); el.className = 'shade'; el.id = 'modal'; el.innerHTML = ``; el.addEventListener('mousedown', e => { if (e.target === el) closeModal(); }); document.body.append(el); onMount && onMount(el); el.querySelector('input,textarea')?.focus(); return el; } const closeModal = () => $('#modal')?.remove(); function confirmBox(title, text, label, fn) { modal(`

    ${title}

    ${text}

    `, el => { el.querySelector('[data-close]').onclick = closeModal; el.querySelector('[data-ok]').onclick = () => { closeModal(); fn(); }; }); } function menu(anchor, items) { closeMenus(); const r = anchor.getBoundingClientRect(); const el = document.createElement('div'); el.className = 'menu'; el.id = 'ctx'; el.style.position = 'fixed'; el.style.top = `${r.bottom + 4}px`; el.style.left = `${Math.min(r.right - 180, innerWidth - 200)}px`; items.filter(Boolean).forEach(([label, fn, cls]) => { const b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.onclick = () => { closeMenus(); fn(); }; el.append(b); }); document.body.append(el); } function closeMenus() { $('#ctx')?.remove(); $('#theme-menu').classList.add('hidden'); } function dictModal(d = {}) { modal(`

    ${d.id ? 'Edit word' : 'Add to Dictionary'}

    lilt will spell this right in dictations and meeting notes.

    `, el => { el.querySelector('#m-fix').onchange = e => el.querySelector('#m-towrap').classList.toggle('hidden', !e.target.checked); el.querySelector('[data-close]').onclick = closeModal; const save = async () => { const word = el.querySelector('#m-word').value.trim(); if (!word) return; const to = el.querySelector('#m-fix').checked ? el.querySelector('#m-to').value.trim() : ''; const item = { ...d, word }; if (to) item.to = to; else delete item.to; delete item.auto; closeModal(); await lilt.upsert('dictionary', item); }; el.querySelector('[data-ok]').onclick = save; el.addEventListener('keydown', e => { if (e.key === 'Enter') save(); }); }); } function snipModal(s = {}) { modal(`

    ${s.id ? 'Edit snippet' : 'Add snippet'}

    Say the shortcut and lilt drops in the full text.

    `, el => { el.querySelector('[data-close]').onclick = closeModal; el.querySelector('[data-ok]').onclick = async () => { const trigger = el.querySelector('#m-trig').value.trim(), text = el.querySelector('#m-text').value; if (!trigger || !text.trim()) return; closeModal(); await lilt.upsert('snippets', { ...s, trigger, text }); }; }); } function tfModal(t = {}) { const used = new Set(S.transforms.filter(x => x.id !== t.id).map(x => x.key)); const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'].filter(k => !used.has(k)); modal(`

    ${t.id ? 'Edit transform' : 'Create a transform'}

    Select text anywhere, press the shortcut, and lilt rewrites it with this prompt.

    ${t.id ? '' : ''}
    `, el => { el.querySelector('[data-close]').onclick = closeModal; el.querySelector('[data-del]')?.addEventListener('click', () => { closeModal(); lilt.remove('transforms', t.id); }); el.querySelector('[data-ok]').onclick = async () => { const name = el.querySelector('#m-name').value.trim(), prompt = el.querySelector('#m-prompt').value.trim(); if (!name || !prompt) return toast('A transform needs a name and a prompt'); closeModal(); await lilt.upsert('transforms', { ...t, name, prompt, desc: el.querySelector('#m-desc').value.trim(), key: el.querySelector('#m-key').value }); }; }); } function tryModal(text = '') { modal(`

    Try a transform

    Paste or dictate some text, pick a transform and run it.

    `, el => { el.querySelector('[data-close]').onclick = closeModal; el.querySelector('[data-copy]').onclick = () => { lilt.copy(el.querySelector('#m-out').textContent); toast('Copied'); }; el.querySelector('[data-ok]').onclick = async () => { const out = el.querySelector('#m-out'); const input = el.querySelector('#m-in').value || el.querySelector('#m-in').placeholder; out.textContent = 'Working…'; try { out.textContent = await lilt.transform(el.querySelector('#m-tf').value, input); } catch (e) { out.textContent = `Failed: ${e.message || e}`; } }; }); } async function micModal() { let devs = []; try { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); devs = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'audioinput'); } catch { /* no permission: show default only */ } const cur = S.settings.micId || ''; const opts = [{ deviceId: '', label: 'System default (recommended)' }, ...devs.filter(d => d.deviceId !== 'default')]; modal(`

    Microphone

    lilt records from this input.

    ${opts.map(d => ``).join('')}
    `, el => { el.querySelector('[data-close]').onclick = closeModal; el.querySelector('[data-ok]').onclick = async () => { const r = el.querySelector('input[name=mic]:checked'); closeModal(); if (!r) return; await lilt.set('micLabel', r.value ? r.dataset.label : ''); await setSetting('micId', r.value); }; }); } function langModal() { modal(`

    My languages

    The language you dictate in. Auto-detect works for mixed languages but is slightly slower.

    `, el => { el.querySelector('[data-close]').onclick = closeModal; el.querySelector('[data-ok]').onclick = () => { setSetting('language', el.querySelector('#m-lang').value); closeModal(); }; }); } function importFile(accept, fn) { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = accept; inp.onchange = async () => { const f = inp.files[0]; if (f) fn(await f.text(), f.name); }; inp.click(); } // ---------------------------------------------------------------- events const HELP = `

    How lilt works

    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: () => {}, }; } })();