diff --git a/static/js/chat.js b/static/js/chat.js index 118399c..70f5f10 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -156,6 +156,13 @@ import createResearchSynapse from './researchSynapse.js'; initSlashCommands({ apiBase, isStreaming: () => isStreaming }); // Initialize email inbox emailInbox.init(documentModule); + // Wire the slash-command autocomplete popup on the chat composer. The + // dispatcher already handles the typed command — this just surfaces the + // registry as a discoverable menu when the user starts a message with /. + import('./slashAutocomplete.js').then(mod => { + const ta = document.getElementById('message'); + if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta); + }).catch(() => {}); } // addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen diff --git a/static/js/slashAutocomplete.js b/static/js/slashAutocomplete.js new file mode 100644 index 0000000..693fb24 --- /dev/null +++ b/static/js/slashAutocomplete.js @@ -0,0 +1,265 @@ +// static/js/slashAutocomplete.js +// Lightweight popup that surfaces the existing /command registry as users +// type. Reads COMMANDS from slashCommands.js — no command logic lives here. + +import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js'; + +const POPUP_ID = 'slash-autocomplete'; +const MAX_VISIBLE = 12; + +// Flatten the registry into a searchable list of leaf entries. Each entry is +// either a top-level command or a "cmd sub" pair (so subcommands get their +// own row when relevant — /toggle web, /session new, etc). +// Commands intentionally excluded from the autocomplete popup (pure easter +// eggs with no productivity value, or internal machinery). +const EXCLUDED = new Set(['flip','roll','8ball','fortune','odyssey','ascii']); + +// Important legacy aliases to promote to their own rows in the popup. These +// are the short forms people will actually type (/new, /clear, /web, etc.) +// rather than the full /session new, /toggle web equivalents. +const PROMOTED_ALIASES = new Set([ + 'new','clear','rename','fork','export','archive','important','star', + 'web','bash','research','doc', + 'memories','forget', +]); + +function _flatten() { + const out = []; + const seen = new Set(); + + // 1. Top-level commands and their subcommands from COMMANDS + for (const [name, def] of Object.entries(COMMANDS)) { + if (EXCLUDED.has(name)) continue; + if (def.handler) { + seen.add(`/${name}`); + out.push({ + token: `/${name}`, + aliases: (def.alias || []).map(a => `/${a}`), + category: def.category || '', + help: def.help || '', + usage: def.usage || '', + }); + } + if (def.subs) { + for (const [sub, sdef] of Object.entries(def.subs)) { + if (sub.startsWith('_')) continue; + const tok = `/${name} ${sub}`; + seen.add(tok); + out.push({ + token: tok, + aliases: (sdef.alias || []).map(a => `/${name} ${a}`), + category: def.category || '', + help: sdef.help || '', + usage: sdef.usage || '', + }); + } + } + } + + // 2. Promoted legacy aliases (/new, /clear, /web …) as convenient short rows + if (LEGACY_ALIASES) { + for (const [alias, { parent, sub }] of Object.entries(LEGACY_ALIASES)) { + if (!PROMOTED_ALIASES.has(alias)) continue; + const tok = `/${alias}`; + if (seen.has(tok)) continue; + const parentDef = COMMANDS[parent]; + const subDef = parentDef?.subs?.[sub]; + if (!subDef) continue; + seen.add(tok); + out.push({ + token: tok, + aliases: [], + category: parentDef.category || '', + help: subDef.help || '', + usage: tok, + }); + } + } + + return out; +} + +function _scoreMatch(entry, query) { + // query already starts with "/". Match against token + aliases. Prefix wins + // over substring; alias match scores slightly lower than token match. + const q = query.toLowerCase(); + const t = entry.token.toLowerCase(); + if (t === q) return 1000; + if (t.startsWith(q)) return 500 + (50 - Math.min(50, t.length - q.length)); + for (const a of entry.aliases) { + const al = a.toLowerCase(); + if (al === q) return 900; + if (al.startsWith(q)) return 400; + } + if (t.includes(q)) return 100; + if (entry.help.toLowerCase().includes(q.slice(1))) return 25; // help text + return 0; +} + +function _ensurePopup(textarea) { + let el = document.getElementById(POPUP_ID); + if (el) return el; + el = document.createElement('div'); + el.id = POPUP_ID; + el.className = 'slash-autocomplete-popup'; + el.setAttribute('role', 'listbox'); + el.setAttribute('aria-label', 'Slash commands'); + document.body.appendChild(el); + return el; +} + +function _position(popup, textarea) { + const r = textarea.getBoundingClientRect(); + const maxH = Math.min(window.innerHeight * 0.5, 360); + popup.style.maxHeight = maxH + 'px'; + // Anchor above the textarea, left-aligned with it + popup.style.left = Math.round(r.left) + 'px'; + popup.style.width = Math.max(280, Math.round(Math.min(r.width, 520))) + 'px'; + // Place above when there's enough room, otherwise below. + const aboveSpace = r.top; + if (aboveSpace > maxH + 20) { + popup.style.bottom = (window.innerHeight - r.top + 6) + 'px'; + popup.style.top = ''; + } else { + popup.style.top = (r.bottom + 6) + 'px'; + popup.style.bottom = ''; + } +} + +function _render(popup, items, selectedIdx, query) { + if (!items.length) { + popup.innerHTML = `
No commands match ${_esc(query)}
`; + return; + } + // Group by category for the headers + let html = ''; + let lastCat = null; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (it.category !== lastCat) { + html += `
${_esc(it.category || 'Other')}
`; + lastCat = it.category; + } + const sel = i === selectedIdx ? ' slash-ac-row-sel' : ''; + const usage = it.usage && it.usage !== it.token ? ` ${_esc(it.usage)}` : ''; + html += `
` + + `${_esc(it.token)}` + + `${_esc(it.help)}` + + usage + + `
`; + } + popup.innerHTML = html; + // Scroll selected into view + const selEl = popup.querySelector('.slash-ac-row-sel'); + if (selEl) selEl.scrollIntoView({ block: 'nearest' }); +} + +function _esc(s) { + return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"','\'':''' }[c])); +} + +export function initSlashAutocomplete(textarea) { + if (!textarea || textarea._slashAcWired) return; + textarea._slashAcWired = true; + + const all = _flatten(); + let popup = null; + let visible = false; + let items = []; + let selectedIdx = 0; + + const hide = () => { + if (!visible) return; + visible = false; + if (popup) popup.style.display = 'none'; + }; + + const show = () => { + if (!popup) popup = _ensurePopup(textarea); + visible = true; + popup.style.display = 'block'; + _position(popup, textarea); + }; + + const refresh = () => { + const v = textarea.value; + // Only trigger when the message starts with "/" (no leading space) and + // contains at most one space after the command (so subcommands work). + // If the user has moved past the slash command (newline, longer prose), + // the menu hides — we don't autocomplete mid-sentence. + if (!v.startsWith('/') || v.includes('\n')) { hide(); return; } + const query = v.trim(); + items = all + .map(e => ({ e, s: _scoreMatch(e, query) })) + .filter(x => x.s > 0) + .sort((a, b) => b.s - a.s) + .slice(0, MAX_VISIBLE) + .map(x => x.e); + if (!items.length && query.length > 1) { hide(); return; } + if (!items.length) { + // Just "/" with no matches — fall back to showing everything up to MAX_VISIBLE + items = all.slice(0, MAX_VISIBLE); + } + selectedIdx = 0; + show(); + _render(popup, items, selectedIdx, query); + }; + + const insert = (token) => { + textarea.value = token + ' '; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.focus(); + const len = textarea.value.length; + textarea.setSelectionRange(len, len); + hide(); + }; + + textarea.addEventListener('input', refresh); + textarea.addEventListener('focus', () => { if (textarea.value.startsWith('/')) refresh(); }); + textarea.addEventListener('blur', () => { setTimeout(hide, 120); }); // delay so click works + + textarea.addEventListener('keydown', (e) => { + if (!visible || !items.length) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + selectedIdx = (selectedIdx + 1) % items.length; + _render(popup, items, selectedIdx, textarea.value); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + selectedIdx = (selectedIdx - 1 + items.length) % items.length; + _render(popup, items, selectedIdx, textarea.value); + } else if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) { + // Tab always inserts. Enter inserts only when the user hasn't already + // typed a full command + args — i.e. the popup is still in completion + // mode, not in "ready to submit a typed-out command" mode. + const v = textarea.value.trim(); + const exactHit = items.find(it => it.token === v || it.aliases.includes(v)); + if (e.key === 'Enter' && exactHit) { + // User typed the whole command — let the normal submit path handle it + hide(); + return; + } + e.preventDefault(); + insert(items[selectedIdx].token); + } else if (e.key === 'Escape') { + e.preventDefault(); + hide(); + } + }); + + // Re-position on window resize / scroll + window.addEventListener('resize', () => { if (visible) _position(popup, textarea); }); + + // Click handler on the popup (delegated) + document.addEventListener('mousedown', (e) => { + if (!visible || !popup) return; + const row = e.target.closest?.('.slash-ac-row'); + if (row && popup.contains(row)) { + e.preventDefault(); + const tok = row.dataset.token; + if (tok) insert(tok); + } + }); +} + +export default { initSlashAutocomplete }; diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js index 81bb159..cf0c71b 100644 --- a/static/js/slashCommands.js +++ b/static/js/slashCommands.js @@ -5650,7 +5650,7 @@ const COMMANDS = { // ── Legacy aliases ──────────────────────────────────────────────── // Maps old flat command names to { parent, sub } so `/new` still works. -const LEGACY_ALIASES = { +export const LEGACY_ALIASES = { 'new': { parent: 'session', sub: 'new' }, 'create': { parent: 'session', sub: 'new' }, 'delete': { parent: 'session', sub: 'delete' }, @@ -5950,7 +5950,7 @@ export function clearSetupMode(preservePendingState = false) { } } -export { handleSlashCommand, handleSetupInput, handleSetupWizard, slashReply, typewriterReply }; +export { handleSlashCommand, handleSetupInput, handleSetupWizard, slashReply, typewriterReply, COMMANDS }; const slashCommands = { initSlashCommands, diff --git a/static/style.css b/static/style.css index 260dbc2..7e8fc9b 100644 --- a/static/style.css +++ b/static/style.css @@ -34358,3 +34358,68 @@ body.theme-frosted .modal { background-color: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent); transform: translateX(1px); } + +/* Slash command autocomplete popup, anchored to the message composer */ +.slash-autocomplete-popup { + position: fixed; + z-index: 9000; + background: var(--bg-elev-2, #1a1a1a); + border: 1px solid var(--border, rgba(255,255,255,0.08)); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.35); + font-size: 13px; + color: var(--fg, #e6e6e6); + overflow-y: auto; + padding: 4px 0; + display: none; +} +.slash-ac-cat { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted, #888); + padding: 6px 10px 2px; + opacity: 0.7; +} +.slash-ac-row { + display: flex; + align-items: baseline; + gap: 8px; + padding: 5px 10px; + cursor: pointer; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; +} +.slash-ac-row:hover { background: color-mix(in srgb, var(--fg) 6%, transparent); } +.slash-ac-row-sel { background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); } +.slash-ac-token { + font-family: 'Fira Code', ui-monospace, monospace; + color: var(--accent, var(--red)); + font-weight: 600; + flex-shrink: 0; +} +.slash-ac-help { + color: var(--fg); + opacity: 0.85; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.slash-ac-usage { + color: var(--fg-muted, #888); + font-family: 'Fira Code', ui-monospace, monospace; + font-size: 11px; + opacity: 0.55; + flex-shrink: 0; +} +.slash-ac-empty { + padding: 10px; + color: var(--fg-muted, #888); + font-style: italic; +} +.slash-ac-empty code { + font-family: 'Fira Code', ui-monospace, monospace; + color: var(--accent, var(--red)); +}