/* Typing affordances in the composer: `@` to attach something by name, and (from the commands section below) `/` to run something instead of sending. Both are the same shape -- a token at the caret opens a menu, the menu filters as you type, and choosing replaces the token -- so they share one menu and one keyboard handler rather than fighting over the composer. Three things here are not obvious. The keydown listener is registered with `capture: true`. app.js already has a document-level Enter handler that submits the form, and listeners on the same element in the same phase fire in registration order -- app.js loads first, so a bubble-phase listener here would never get to say "that Enter chose a menu item, it did not send the message". Nothing is inserted into the composer as HTML. Every name in the menu came off somebody's filesystem or out of their library. A chip is a chip. Choosing a file posts to a route that returns the same attachment chip an upload returns, so the composer learns nothing new and the remove button, the hidden file_ids input and `claim()` on send all work already. */ (function () { "use strict"; var menu = null; var list = null; var footer = null; var open = false; var kind = ""; var pending = null; var active = -1; /* commands.js loads first and defines these. Guarded anyway so that a page which does not carry it -- or one where it failed to parse -- still gets `@`, and a message beginning with a slash is simply sent. */ function commands() { return window.lembasCommands || { list: function () { return document.createElement("div"); }, find: function () { return null; }, run: function () {} }; } function commandList(query) { return commands().list(query); } function commandIn(value) { return commands().find(value); } function runCommand(name, rest) { commands().run(name, rest); } /* --- Where we are ------------------------------------------------------- */ function composer() { return document.querySelector("[data-composer-input]"); } function context() { var box = document.querySelector(".composer"); var picker = document.querySelector('select[name="ssh_profile_id"]'); var dir = document.querySelector("[data-dir-value]"); return { chatId: (box && box.dataset.chatId) || "", /* A chat under way carries its connection on the composer; a new one is still choosing it, so the select and the hidden field are the truth. */ profileId: picker ? picker.value : (box && box.dataset.profileId) || "", projectDir: dir ? dir.value : (box && box.dataset.projectDir) || "" }; } /* The token being typed, or null. `@` is claimed anywhere it follows whitespace, so `see @src/main.py` works mid-sentence, but not inside an email address -- `a@b` is not a mention and treating it as one would open a menu every time somebody typed one. */ function tokenAt(input) { var value = input.value; var caret = input.selectionStart; if (caret !== input.selectionEnd) return null; var before = value.slice(0, caret); var at = before.lastIndexOf("@"); if (at !== -1 && (at === 0 || /\s/.test(before[at - 1]))) { var query = before.slice(at + 1); if (!/\s/.test(query)) return { kind: "@", query: query, start: at, end: caret }; } /* A slash command is only ever the first thing in the box. Anywhere else a slash is a path, a date or a fraction. */ if (before[0] === "/" && before[1] !== "/") { var word = before.slice(1); if (!/\s/.test(word) && caret === value.length) { return { kind: "/", query: word, start: 0, end: caret }; } } return null; } /* --- The menu ----------------------------------------------------------- */ function build() { if (menu) return; var host = document.querySelector(".composer__inner"); if (!host) return; menu = document.createElement("div"); menu.className = "composer-menu"; menu.setAttribute("role", "listbox"); menu.hidden = true; list = document.createElement("div"); menu.appendChild(list); /* What the keys do, in the one place somebody is looking when they need to know. Cheaper than a help sheet nobody opens. */ footer = document.createElement("div"); footer.className = "composer-menu__keys"; menu.appendChild(footer); host.insertBefore(menu, host.firstChild); menu.addEventListener("mousedown", function (event) { /* Before the composer loses focus, or the caret position the choice is about to be written at is already gone. */ event.preventDefault(); }); menu.addEventListener("click", function (event) { var option = event.target.closest("[data-mention-token], [data-command]"); if (option) choose(option); }); } function show() { build(); if (!menu) return; if (footer) { // textContent: nothing here is markup, and one day somebody will want to // put a filename in it. footer.textContent = options().length ? (kind === "/" ? "↑↓ choose · Tab complete · Enter run · Esc dismiss" : "↑↓ choose · Enter attach · Esc dismiss") : "Esc dismiss"; } menu.hidden = false; open = true; } function hide() { if (!menu) return; menu.hidden = true; open = false; kind = ""; active = -1; } function options() { return menu ? Array.prototype.slice.call( menu.querySelectorAll("[data-mention-token], [data-command]") ) : []; } function highlight(index) { var all = options(); if (!all.length) return; active = (index + all.length) % all.length; all.forEach(function (option, position) { option.classList.toggle("is-selected", position === active); }); if (all[active].scrollIntoView) all[active].scrollIntoView({ block: "nearest" }); } /* --- Filling it --------------------------------------------------------- */ function loadMentions(query) { var where = context(); var url = "/api/files/mention-picker?q=" + encodeURIComponent(query) + "&chat_id=" + encodeURIComponent(where.chatId) + "&profile_id=" + encodeURIComponent(where.profileId) + "&project_dir=" + encodeURIComponent(where.projectDir); fetch(url, { credentials: "same-origin" }) .then(function (response) { return response.text(); }) .then(function (html) { /* The reply is a round trip late: the caret may have moved off the mention, or onto a slash, by the time it lands. */ if (kind !== "@" || !list) return; list.innerHTML = html; show(); highlight(0); }) .catch(function () { hide(); }); } function refresh() { var input = composer(); if (!input) return; /* Built here, not lazily inside `show()`. It used to be, and everything below writes to `list` *before* calling `show()` -- so the first slash or at-sign ever typed threw on a null `list` and took the whole handler with it. The menu never appeared, in any browser, for the entire life of the feature. Anything that touches `list` builds first. */ build(); if (!list) return; var token = tokenAt(input); if (!token) return hide(); kind = token.kind; if (token.kind === "/") { list.innerHTML = ""; list.appendChild(commandList(token.query)); show(); highlight(0); return; } clearTimeout(pending); pending = setTimeout(function () { loadMentions(token.query); }, 150); } /* --- Choosing ----------------------------------------------------------- */ function replaceToken(text) { var input = composer(); var token = tokenAt(input); if (!input || !token) return; var head = input.value.slice(0, token.start); var tail = input.value.slice(token.end); var written = (token.kind === "@" ? "@" : "/") + text + " "; input.value = head + written + tail; var caret = head.length + written.length; input.setSelectionRange(caret, caret); if (window.lembas && window.lembas.autosize) window.lembas.autosize(input); input.focus(); } /* `complete` is Tab, `!complete` is Enter, and the difference matters for a command that takes an argument: Tab writes `/mode ` and leaves the caret after it so the value can be typed, Enter runs what is there. Picking with the mouse runs, because there is nowhere else for a click to mean. */ function choose(option, complete) { if (option.dataset.command) { if (complete) { replaceToken(option.dataset.command); hide(); /* Straight back, because `/mode ` is now a token with a space in it and the menu would otherwise stay open over the argument being typed. */ return; } var input = composer(); hide(); /* Cleared before running. A command is the whole message, never part of one, so leaving `/help` sitting in the box after running it means the next Enter runs it again. */ if (input) { input.value = ""; if (window.lembas && window.lembas.autosize) window.lembas.autosize(input); } runCommand(option.dataset.command, ""); return; } var where = context(); /* The reference stays in the sentence being written *and* the contents come along as a chip. The first is what makes "change the thing in @main.py" read as a sentence; the second is what stops a small model having to spend a round fetching it. */ replaceToken(option.dataset.mentionToken); hide(); var body = new FormData(); body.append("chat_id", where.chatId); if (option.dataset.mentionFile) { body.append("profile_id", where.profileId); body.append("path", option.dataset.mentionFile); attach("/api/files/from-project", body); } else if (option.dataset.mentionKnowledge) { body.append("document_id", option.dataset.mentionKnowledge); attach("/api/files/from-knowledge", body); } } function attach(url, body) { var target = document.getElementById("attachments"); if (!target) return; fetch(url, { method: "POST", body: body, credentials: "same-origin" }) .then(function (response) { return response.text(); }) .then(function (html) { target.insertAdjacentHTML("beforeend", html); // The chip's remove button is htmx-driven and inert until announced. if (window.htmx) window.htmx.process(target.lastElementChild); }) .catch(function () { if (window.lembas) window.lembas.notify("Could not attach that.", { kind: "error" }); }); } /* --- Marking the tokens as they are typed -------------------------------- Backgrounds only. The mirror's text is transparent and exists purely to put a rectangle in the right place; the visible glyphs are still the textarea's own. Everything here is set as textContent or built with createElement -- what is in the box is the one string a person controls exactly, and it must never become markup. */ function mirrorEl() { return document.querySelector("[data-composer-mirror]"); } /* The same rule the server applies to a sent message: `@` at the start or after whitespace, so an email address is not a file reference. */ var MENTION = /(^|\s)(@[^\s@]+)/g; function paint() { var mirror = mirrorEl(); var input = composer(); if (!mirror || !input) return; var value = input.value; mirror.replaceChildren(); /* A command is marked only when it resolves. `/thoughts on this` is not a command and must not look like one before it is sent -- that ambiguity is the whole reason `//` exists. */ var rest = value; var command = commandIn(value); if (command) { var head = "/" + command.name; mirror.appendChild(token(head, "tok-command")); rest = value.slice(head.length); } var last = 0; var match; MENTION.lastIndex = 0; while ((match = MENTION.exec(rest)) !== null) { mirror.appendChild(document.createTextNode(rest.slice(last, match.index) + match[1])); mirror.appendChild(token(match[2], "tok-mention")); last = match.index + match[0].length; } // A trailing newline collapses in a div; the space keeps the last line. mirror.appendChild(document.createTextNode(rest.slice(last) + "\n")); mirror.scrollTop = input.scrollTop; } function token(text, cls) { var span = document.createElement("span"); span.className = cls; span.textContent = text; return span; } /* --- Keys --------------------------------------------------------------- */ document.addEventListener( "keydown", function (event) { var input = event.target.closest && event.target.closest("[data-composer-input]"); if (!input) return; if (open) { if (event.key === "Escape") { event.stopPropagation(); event.preventDefault(); return hide(); } if (event.key === "ArrowDown") { event.preventDefault(); return highlight(active + 1); } if (event.key === "ArrowUp") { event.preventDefault(); return highlight(active - 1); } if (event.key === "Enter" || event.key === "Tab") { var all = options(); if (all.length && active >= 0) { /* Capture phase, so app.js's Enter-to-send never sees this one. Without stopping it the message would be sent *and* the menu item chosen. */ event.preventDefault(); event.stopPropagation(); choose(all[active], event.key === "Tab"); return; } } } if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { /* Not a menu key: a command typed in full and submitted. Handled here rather than on submit so the form is never posted at all -- a command is not a message and must not become one if it is unknown. */ var command = commandIn(input.value); if (command) { event.preventDefault(); event.stopPropagation(); input.value = ""; if (window.lembas && window.lembas.autosize) window.lembas.autosize(input); runCommand(command.name, command.rest); } } }, true ); document.addEventListener("input", function (event) { if (!event.target.closest("[data-composer-input]")) return; refresh(); paint(); }); /* The textarea scrolls once it hits data-max-height, and the mirror has to go with it or the rectangles drift off the words. */ document.addEventListener( "scroll", function (event) { var input = event.target; if (!input.closest || !input.closest("[data-composer-input]")) return; var mirror = mirrorEl(); if (mirror) mirror.scrollTop = input.scrollTop; }, true ); /* Sending, resetting, dictating and pasting all change the value without an `input` event that reaches here, so the mirror is repainted after any htmx swap and once at load. */ document.addEventListener("DOMContentLoaded", paint); document.addEventListener("htmx:afterSwap", paint); document.addEventListener("htmx:afterSettle", paint); document.addEventListener("click", function (event) { if (!event.target.closest(".composer-menu") && !event.target.closest("[data-composer-input]")) hide(); }); /* Opening the menu from a button, for anyone who would rather press than type. Inserts the character and lets the normal path take over. */ document.addEventListener("click", function (event) { var button = event.target.closest("[data-mention-open]"); if (!button) return; event.preventDefault(); var input = composer(); if (!input) return; input.focus(); var caret = input.selectionStart; var lead = caret && !/\s/.test(input.value[caret - 1]) ? " @" : "@"; input.setRangeText(lead, caret, caret, "end"); refresh(); }); window.lembas = window.lembas || {}; window.lembas.closeComposerMenu = hide; /* Anything that writes into the composer from outside -- dictation, the terminal's Send, a slash command that clears it -- calls this so the rectangles keep up. */ window.lembas.paintComposer = paint; })();