/* Slash commands, and the keyboard shortcuts that do the same jobs. Both live here, in one table, so `/help` cannot describe a shortcut that no longer exists. Every entry does something that was already possible by clicking -- none of this is new server behaviour except `/usage`, which is a question the interface could not previously answer at all. The rule that matters: a message that merely *starts* with a slash must still send. `//` escapes, an unrecognised command is left alone and posted as text, and only an exact match against this table is intercepted. Silently eating somebody's message is a far worse failure than an unknown command. Shortcuts are Alt-based rather than Ctrl+Shift: the browser owns Ctrl+Shift+T, N and W and will not give them up. They are matched on `event.code`, which is the physical key, so a Dvorak or a Slovak layout gets the same shortcuts rather than whichever letters happen to sit there. */ (function () { "use strict"; function el(selector) { return document.querySelector(selector); } function chat() { var box = el(".composer"); return (box && box.dataset.chatId) || ""; } function isAgent() { return !!el('[name="agent_mode"]'); } function post(url, options) { return fetch(url, Object.assign({ method: "POST", credentials: "same-origin" }, options || {})); } function note(message, kind) { if (window.lembas && window.lembas.notify) { window.lembas.notify(message, { kind: kind || "info" }); } } /* --- Shortcuts ---------------------------------------------------------- */ var SHORTCUTS = [ { keys: "Ctrl/⌘ + K", what: "Open the command menu" }, { keys: "Alt + 1 … 4", what: "Manual, Edit, Auto, Plan" }, { keys: "Alt + T", what: "Terminal" }, { keys: "Alt + I", what: "Inspector" }, { keys: "Alt + B", what: "Sidebar" }, { keys: "Alt + N", what: "New chat" }, { keys: "↑ in an empty box", what: "Edit your last message" }, { keys: "Esc", what: "Close what is open, or stop the reply" }, { keys: "Ctrl + Shift + C / V", what: "Copy and paste inside the terminal" } ]; /* --- The table ---------------------------------------------------------- */ var COMMANDS = [ { name: "help", summary: "Commands and keyboard shortcuts", run: function () { helpSheet(); } }, { name: "usage", summary: "Tokens and context used by this chat", when: function () { return !!chat(); }, run: function () { fetch("/api/chats/" + chat() + "/usage", { credentials: "same-origin" }) .then(function (r) { return r.text(); }) .then(function (html) { sheet("Usage", html); }) .catch(function () { note("Could not read this chat's usage.", "error"); }); } }, { name: "compact", summary: "Summarise the earlier turns so they stop costing context", when: function () { return !!chat() && !!el("#thread .msg"); }, run: function () { window.lembas.confirm( "Summarise everything before the last reply? The messages stay in the " + "transcript; they just stop being sent to the model.", { title: "Compact this chat", label: "Compact" } ).then(function (yes) { if (!yes) return; post("/api/chats/" + chat() + "/compact") .then(function (r) { return r.ok ? r.text() : Promise.reject(r); }) .then(function (html) { el("#thread").innerHTML = html; if (window.htmx) window.htmx.process(el("#thread")); }) .catch(function (r) { if (r && r.json) r.json().then(function (body) { note(body.detail, "error"); }); else note("Could not compact this chat.", "error"); }); }); } }, { name: "mode", summary: "Approval mode: manual, edit, auto or plan", argument: "manual | edit | auto | plan", when: isAgent, run: function (rest) { var select = el('[name="agent_mode"]'); var wanted = (rest || "").trim().toLowerCase(); if (!wanted) return note("Modes: manual, edit, auto, plan."); var found = Array.prototype.find.call(select.options, function (option) { return option.value === wanted; }); if (!found) return note("“" + wanted + "” is not a mode.", "error"); select.value = wanted; select.dispatchEvent(new Event("change", { bubbles: true })); note("Mode set to " + found.textContent.trim() + "."); } }, { name: "title", summary: "Rename this chat", argument: "the new title", when: function () { return !!chat(); }, run: function (rest) { var wanted = (rest || "").trim(); if (!wanted) return note("Give it a title: /title Something."); var body = new FormData(); body.append("title", wanted); fetch("/api/chats/" + chat(), { method: "PATCH", body: body, credentials: "same-origin" }) .then(function () { var heading = el("#chat-title"); // textContent, never innerHTML: this is text somebody typed. if (heading) heading.textContent = wanted; note("Renamed."); }); } }, { name: "terminal", summary: "Show or hide the terminal", when: function () { return !!el("#terminal"); }, run: function () { toggle("#terminal", "side"); } }, { name: "inspector", summary: "Show or hide the request inspector", when: function () { return !!el("#inspector"); }, run: function () { toggle("#inspector", "side"); } }, { name: "sidebar", summary: "Show or hide the sidebar", run: function () { toggle("#sidebar"); } }, { name: "theme", summary: "Switch theme", argument: "moria | shire", run: function (rest) { var wanted = (rest || "").trim().toLowerCase(); if (wanted === "moria" || wanted === "shire") window.lembas.applyTheme(wanted); else window.lembas.toggleTheme(); } }, { name: "new", summary: "Start a new chat", run: function () { window.location = "/chat"; } }, { name: "temp", summary: "Start a temporary chat, gone after a day", run: function () { window.location = "/chat?temporary=1"; } }, { name: "stop", summary: "Stop the reply being written", run: function () { var button = el('[data-composer-action="stop"]'); if (button) button.click(); else note("Nothing is being written."); } }, { name: "knowledge", summary: "Your library", run: go("/library/knowledge") }, { name: "notes", summary: "Notes the model has written", run: go("/library/notes") }, { name: "skills", summary: "Saved procedures", run: go("/library/skills") }, { name: "connections", summary: "Your SSH connections", run: go("/agents") } ]; function go(url) { return function () { window.location = url; }; } function toggle(selector, group) { var panel = el(selector); if (panel && window.lembas.setPanel) { window.lembas.setPanel(selector, panel.hasAttribute("hidden"), group); } } function available() { return COMMANDS.filter(function (command) { return !command.when || command.when(); }); } /* --- What the composer calls -------------------------------------------- */ function list(query) { var needle = (query || "").toLowerCase(); var matches = available().filter(function (command) { return command.name.indexOf(needle) === 0; }); var wrap = document.createElement("div"); if (!matches.length) { var empty = document.createElement("p"); empty.className = "muted text-sm"; empty.style.padding = "var(--sp-3)"; empty.textContent = "No command called “" + query + "”. It will be sent as a message."; wrap.appendChild(empty); return wrap; } var group = document.createElement("p"); group.className = "picker__group"; group.textContent = "Commands"; wrap.appendChild(group); var items = document.createElement("ul"); items.className = "picker__list"; matches.forEach(function (command) { var row = document.createElement("li"); var button = document.createElement("button"); button.type = "button"; button.className = "picker__option"; button.dataset.command = command.name; var body = document.createElement("span"); body.className = "picker__option-body"; var name = document.createElement("span"); name.className = "picker__option-name"; name.textContent = "/" + command.name + (command.argument ? " " + command.argument : ""); var summary = document.createElement("span"); summary.className = "picker__option-note"; summary.textContent = command.summary; body.appendChild(name); body.appendChild(summary); button.appendChild(body); row.appendChild(button); items.appendChild(row); }); wrap.appendChild(items); return wrap; } /* A command, or null -- and null is the important half. Anything not matching exactly is left for the composer to send as an ordinary message, and `//` strips one slash on the way. A chat application that swallows a message because it began with a slash has done something much worse than failing to recognise a command. */ function find(value) { if (value[0] !== "/" || value[1] === "/") return null; var match = /^\/([a-z]+)(?:\s+([\s\S]*))?$/.exec(value.trim()); if (!match) return null; var found = available().find(function (command) { return command.name === match[1]; }); return found ? { name: found.name, rest: match[2] || "" } : null; } function run(name, rest) { var found = available().find(function (command) { return command.name === name; }); if (found) found.run(rest || ""); } /* --- Sheets ------------------------------------------------------------- */ function sheet(title, html) { var dialog = document.createElement("dialog"); dialog.className = "dialog dialog--wide"; var form = document.createElement("div"); form.className = "dialog__form"; var heading = document.createElement("h2"); heading.className = "dialog__title"; heading.textContent = title; var body = document.createElement("div"); // Server-rendered and already escaped there; nothing user-typed reaches // this path as markup. body.innerHTML = html; var actions = document.createElement("div"); actions.className = "dialog__actions"; var close = document.createElement("button"); close.className = "btn"; close.type = "button"; close.textContent = "Close"; actions.appendChild(close); form.appendChild(heading); form.appendChild(body); form.appendChild(actions); dialog.appendChild(form); document.body.appendChild(dialog); function finish() { dialog.close(); setTimeout(function () { dialog.remove(); }, 200); } close.addEventListener("click", finish); dialog.addEventListener("cancel", function (event) { event.preventDefault(); finish(); }); dialog.addEventListener("click", function (event) { if (event.target === dialog) finish(); }); dialog.showModal(); } function helpSheet() { var rows = available().map(function (command) { return ( "/" + command.name + (command.argument ? " " + escapeText(command.argument) : "") + "" + escapeText(command.summary) + "" ); }); var keys = SHORTCUTS.map(function (shortcut) { return ( "" + escapeText(shortcut.keys) + "" + escapeText(shortcut.what) + "" ); }); sheet( "Commands and shortcuts", "" + rows.join("") + "
" + "

Keyboard

" + "" + keys.join("") + "
" + "

A message that starts with a slash but is not a " + "command is sent as written. Type // to start one " + "with a literal slash.

" ); } function escapeText(value) { var holder = document.createElement("span"); holder.textContent = value; return holder.innerHTML; } /* --- Keyboard ----------------------------------------------------------- */ var MODES = ["manual", "edit", "auto", "plan"]; document.addEventListener("keydown", function (event) { if (event.isComposing) return; /* Never inside the terminal: every keystroke there belongs to the shell, and a shortcut that steals one is a shortcut that breaks vim. */ if (event.target.closest && event.target.closest("#terminal")) return; if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") { event.preventDefault(); var input = document.querySelector("[data-composer-input]"); if (!input) return; input.focus(); input.value = "/"; input.dispatchEvent(new Event("input", { bubbles: true })); return; } if (!event.altKey || event.ctrlKey || event.metaKey) return; if (event.code === "KeyT" && el("#terminal")) { event.preventDefault(); return toggle("#terminal", "side"); } if (event.code === "KeyI" && el("#inspector")) { event.preventDefault(); return toggle("#inspector", "side"); } if (event.code === "KeyB") { event.preventDefault(); return toggle("#sidebar"); } if (event.code === "KeyN") { event.preventDefault(); window.location = "/chat"; return; } var digit = ["Digit1", "Digit2", "Digit3", "Digit4"].indexOf(event.code); if (digit !== -1 && isAgent()) { event.preventDefault(); run("mode", MODES[digit]); } }); /* Up-arrow in an empty box edits your last turn, the way a shell recalls the last command. Only when the box is empty, so it never eats a cursor key somebody was using to move around what they had written. */ document.addEventListener("keydown", function (event) { if (event.key !== "ArrowUp" || event.shiftKey || event.altKey) return; var input = event.target.closest && event.target.closest("[data-composer-input]"); if (!input || input.value !== "") return; var edits = document.querySelectorAll("#thread .msg--user [data-edit-message]"); if (!edits.length) return; event.preventDefault(); edits[edits.length - 1].click(); }); window.lembasCommands = { list: list, find: find, run: run, help: helpSheet }; })();