About this snippet

A prompt box that opens a menu when you type / for a command or @ to attach a file. Keep typing to filter the list, move through it with the up and down arrow keys, and press Enter or Tab to pick the highlighted item. Picked @ files appear as removable chips above the text box, and the / and @ buttons in the bottom bar open the same menus with a click. The demo starts with one file attached and the command menu open so you can try it right away.

On every input and caret move, the script reads the text before selectionStart and matches /(?:^|\s)([/@])([^\s/@]*)$/. Requiring the start of the box or a space before the trigger means a slash in a URL or an @ in an email never opens the menu. Picking an item replaces the trigger word in place with setRangeText, which keeps the undo history intact. Clicking a menu item prevents mousedown default so focus stays in the textarea, and keydown checks isComposing and keyCode 229 before acting on Enter.

The textarea sets aria-autocomplete, aria-controls and aria-activedescendant pointing to the active option inside a listbox, while the trigger buttons update aria-expanded so screen readers follow the menu while you type. A live status region announces how many matches are open or which file was attached. Replace the commands and files arrays at the top of the script with your own routes or workspace index.

Size
18.4 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
0
<main class="page">
  <section class="workspace" aria-labelledby="workspace-title">
    <h1 class="sr-only" id="workspace-title">AI Prompt Composer</h1>
    <div class="log" id="log" role="log" aria-label="Sent prompts" tabindex="0" hidden></div>
    <form class="composer" id="composer">
      <div class="menu-wrap" id="menu-wrap" hidden>
        <div class="menu-head">
          <span class="menu-title" id="menu-title">Commands</span>
          <span class="menu-kbd">Up / Down &middot; Enter</span>
        </div>
        <ul class="menu" id="menu" role="listbox" aria-labelledby="menu-title"></ul>
      </div>
      <ul class="chips" id="chips" aria-label="Attached context"></ul>
      <label class="sr-only" for="prompt">Prompt</label>
      <textarea id="prompt" rows="2" aria-autocomplete="list" aria-controls="menu" placeholder="Ask anything, type / for commands or @ to attach a file"></textarea>
      <div class="composer-bar">
        <button type="button" class="trigger-btn" id="btn-slash" aria-label="Insert slash command" aria-expanded="false" aria-controls="menu">
          <span class="trigger-key">/</span>
          <span>Commands</span>
        </button>
        <button type="button" class="trigger-btn" id="btn-mention" aria-label="Mention a file" aria-expanded="false" aria-controls="menu">
          <span class="trigger-key">@</span>
          <span>Files</span>
        </button>
        <span class="hint">Enter to send</span>
        <button type="submit" class="send-btn" id="send" aria-label="Send prompt">
          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"></path></svg>
        </button>
      </div>
    </form>
    <p class="sr-only" id="status" role="status"></p>
  </section>
</main>
*, *::before, *::after {
  box-sizing: border-box;
}

:root {
  --page: #f1f5f9;
  --card: #ffffff;
  --text: #0f172a;
  --muted: #64748b;
  --soft: #f1f5f9;
  --hover: #e2e8f0;
  --accent: #2563eb;
  --accent-soft: #eff6ff;
  --accent-text: #1d4ed8;
  --ring: rgba(15, 23, 42, 0.06);
  --shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 16px 36px -12px rgba(15, 23, 42, 0.14);
  --menu-shadow: 0 2px 6px rgba(15, 23, 42, 0.05), 0 24px 48px -12px rgba(15, 23, 42, 0.18);
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  margin: 0;
  background: var(--page);
  color: var(--text);
}

.page {
  display: grid;
  grid-template-columns: minmax(0, 580px);
  justify-content: center;
  align-content: end;
  min-height: 100vh;
  padding: 20px 16px 36px;
}

.workspace {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.log {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 110px;
  padding: 4px 2px;
  overflow-y: auto;
}

.log[hidden] {
  display: none;
}

.log:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 10px;
}

.sent {
  align-self: flex-end;
  max-width: 88%;
  margin: 0;
  padding: 10px 14px;
  border-radius: 18px 18px 4px 18px;
  background: var(--card);
  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
  font-size: 0.875rem;
  line-height: 1.45;
  overflow-wrap: anywhere;
}

.sent-files {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 4px;
}

.sent-file {
  padding: 2px 8px;
  border-radius: 6px;
  background: var(--accent-soft);
  color: var(--accent-text);
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.75rem;
  font-weight: 600;
}

.composer {
  position: relative;
  padding: 14px 14px 12px;
  border-radius: 22px;
  background: var(--card);
  box-shadow: 0 0 0 1px var(--ring), var(--shadow);
  transition: box-shadow 0.15s ease;
}

.composer:focus-within {
  box-shadow: 0 0 0 2px var(--accent), var(--shadow);
}

.menu-wrap {
  position: absolute;
  right: 0;
  bottom: calc(100% + 10px);
  left: 0;
  z-index: 10;
  padding: 6px;
  border-radius: 18px;
  background: var(--card);
  box-shadow: 0 0 0 1px var(--ring), var(--menu-shadow);
}

.menu-wrap[hidden] {
  display: none;
}

.menu-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 10px 8px;
}

.menu-title {
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--muted);
}

.menu-kbd {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.6875rem;
  color: var(--muted);
}

.menu {
  max-height: 224px;
  margin: 0;
  padding: 0;
  overflow-y: auto;
  list-style: none;
}

.menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 9px 10px;
  border-radius: 12px;
  cursor: pointer;
}

.menu-item[aria-selected="true"] {
  background: var(--accent-soft);
}

.item-left {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

.item-ico {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  border-radius: 8px;
  background: var(--soft);
  color: var(--muted);
}

.item-ico svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.menu-item[aria-selected="true"] .item-ico {
  background: var(--accent);
  color: #ffffff;
}

.item-key {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--text);
}

.menu-item[aria-selected="true"] .item-key {
  color: var(--accent-text);
}

.item-meta {
  overflow: hidden;
  font-size: 0.8125rem;
  color: var(--muted);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
  padding: 0 2px 10px;
  list-style: none;
}

.chips:empty {
  display: none;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 4px 6px 4px 8px;
  border-radius: 10px;
  background: var(--soft);
  font-size: 0.8125rem;
}

.chip-ico {
  display: grid;
  place-items: center;
  color: var(--accent);
}

.chip-ico svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.chip-name {
  font-family: ui-monospace, Consolas, monospace;
  font-weight: 600;
}

.chip-meta {
  color: var(--muted);
  font-size: 0.75rem;
}

.chip-remove {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}

.chip-remove svg {
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

.chip-remove:hover {
  background: var(--hover);
  color: var(--text);
}

textarea {
  display: block;
  width: 100%;
  padding: 2px 4px 8px;
  border: 0;
  outline: 0;
  resize: none;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 0.9375rem;
  line-height: 1.5;
}

textarea::placeholder {
  color: var(--muted);
}

.composer-bar {
  display: flex;
  align-items: center;
  gap: 6px;
  padding-top: 4px;
}

.trigger-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 32px;
  padding: 4px 11px 4px 8px;
  border: 0;
  border-radius: 999px;
  background: var(--soft);
  color: var(--text);
  font: inherit;
  font-size: 0.8125rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

.trigger-btn:hover {
  background: var(--hover);
}

.trigger-btn[aria-expanded="true"] {
  background: var(--accent-soft);
  color: var(--accent-text);
}

.trigger-key {
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--card);
  color: var(--muted);
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.75rem;
  font-weight: 700;
}

.trigger-btn[aria-expanded="true"] .trigger-key {
  background: var(--accent);
  color: #ffffff;
}

.hint {
  margin-left: auto;
  padding-right: 4px;
  font-size: 0.75rem;
  color: var(--muted);
}

.send-btn {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--text);
  color: var(--card);
  cursor: pointer;
  transition: transform 0.15s ease, opacity 0.15s ease;
}

.send-btn:hover:not(:disabled) {
  transform: translateY(-1px);
}

.send-btn:disabled {
  background: var(--soft);
  color: var(--muted);
  cursor: default;
}

.send-btn svg {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.1;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.trigger-btn:focus-visible,
.send-btn:focus-visible,
.chip-remove:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (max-width: 480px) {
  .hint {
    display: none;
  }

  .send-btn {
    margin-left: auto;
  }
}

@media (pointer: coarse) {
  .trigger-btn,
  .menu-item,
  textarea {
    min-height: 44px;
  }

  .send-btn,
  .chip-remove {
    width: 44px;
    height: 44px;
  }
}

@media (prefers-color-scheme: dark) {
  :root {
    --page: #0b1120;
    --card: #151e30;
    --text: #f1f5f9;
    --muted: #94a3b8;
    --soft: #1e293b;
    --hover: #293548;
    --accent: #3b82f6;
    --accent-soft: #172554;
    --accent-text: #93c5fd;
    --ring: rgba(255, 255, 255, 0.08);
    --shadow: 0 18px 40px -14px rgba(0, 0, 0, 0.6);
    --menu-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.75);
  }
}

@media (forced-colors: active) {
  .composer,
  .menu-wrap {
    border: 1px solid CanvasText;
  }

  .menu-item[aria-selected="true"] {
    outline: 2px solid Highlight;
  }
}
const COMMANDS = [
  { key: "/explain", desc: "Explain how the selected code works", icon: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0Z" },
  { key: "/summarize", desc: "Summarize key points and decisions", icon: "M4 6h16M4 12h16M4 18h10" },
  { key: "/refactor", desc: "Clean up structure without changing behavior", icon: "M16 18l6-6-6-6M8 6l-6 6 6 6" },
  { key: "/tests", desc: "Write unit tests for edge cases", icon: "M9 12l2 2 4-4M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0Z" }
];

const FILES = [
  { name: "schema.sql", meta: "42 lines", icon: "M12 8c4.42 0 8-1.34 8-3s-3.58-3-8-3-8 1.34-8 3 3.58 3 8 3ZM4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3" },
  { name: "router.js", meta: "118 lines", icon: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6ZM14 2v6h6M10 13l-2 2 2 2M14 13l2 2-2 2" },
  { name: "auth.js", meta: "84 lines", icon: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" },
  { name: "README.md", meta: "64 lines", icon: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6ZM14 2v6h6M8 13h8M8 17h5" }
];

const composer = document.getElementById("composer");
const prompt = document.getElementById("prompt");
const menuWrap = document.getElementById("menu-wrap");
const menuTitle = document.getElementById("menu-title");
const menu = document.getElementById("menu");
const chips = document.getElementById("chips");
const log = document.getElementById("log");
const sendBtn = document.getElementById("send");
const slashBtn = document.getElementById("btn-slash");
const mentionBtn = document.getElementById("btn-mention");
const status = document.getElementById("status");

let attached = [];
let activeTrigger = null;
let matches = [];
let activeIndex = 0;

function el(tag, className, text) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text) node.textContent = text;
  return node;
}

function makeIcon(pathData) {
  const ns = "http://www.w3.org/2000/svg";
  const svg = document.createElementNS(ns, "svg");
  const path = document.createElementNS(ns, "path");
  svg.setAttribute("viewBox", "0 0 24 24");
  svg.setAttribute("aria-hidden", "true");
  path.setAttribute("d", pathData);
  svg.append(path);
  return svg;
}

// Match a / or @ token right before the caret
function readTrigger() {
  const caret = prompt.selectionStart;
  const before = prompt.value.slice(0, caret);
  const hit = before.match(/(?:^|\s)([/@])([^\s/@]*)$/);
  if (!hit) return null;
  const symbol = hit[1];
  const query = hit[2].toLowerCase();
  const start = caret - query.length - 1;
  return { symbol: symbol, query: query, start: start, end: caret };
}

function closeMenu() {
  activeTrigger = null;
  matches = [];
  activeIndex = 0;
  menuWrap.hidden = true;
  menu.textContent = "";
  slashBtn.setAttribute("aria-expanded", "false");
  mentionBtn.setAttribute("aria-expanded", "false");
  prompt.removeAttribute("aria-activedescendant");
}

function syncActiveOption() {
  const items = menu.querySelectorAll(".menu-item");
  items.forEach(function(item, i) {
    const selected = i === activeIndex;
    item.setAttribute("aria-selected", String(selected));
    if (selected) {
      prompt.setAttribute("aria-activedescendant", item.id);
      item.scrollIntoView({ block: "nearest" });
    }
  });
}

function renderMenu() {
  menu.textContent = "";
  const isCommand = activeTrigger.symbol === "/";
  menuTitle.textContent = isCommand ? "Commands" : "Workspace files";
  matches.forEach(function(item, i) {
    const li = el("li", "menu-item");
    const left = el("div", "item-left");
    const ico = el("span", "item-ico");
    ico.append(makeIcon(item.icon));
    const key = el("span", "item-key", isCommand ? item.key : "@" + item.name);
    const meta = el("span", "item-meta", isCommand ? item.desc : item.meta);
    li.id = "menu-opt-" + i;
    li.setAttribute("role", "option");
    li.setAttribute("aria-selected", String(i === activeIndex));
    left.append(ico, key);
    li.append(left, meta);
    li.addEventListener("mousedown", function(e) {
      e.preventDefault();
      pickItem(i);
    });
    menu.append(li);
  });
  menuWrap.hidden = false;
  slashBtn.setAttribute("aria-expanded", String(isCommand));
  mentionBtn.setAttribute("aria-expanded", String(!isCommand));
  syncActiveOption();
  status.textContent = matches.length + (matches.length === 1 ? " option" : " options") + " available";
}

function updateMenu() {
  const trigger = readTrigger();
  if (!trigger) {
    closeMenu();
    return;
  }
  const list = trigger.symbol === "/"
    ? COMMANDS.filter(function(c) { return c.key.slice(1).indexOf(trigger.query) === 0; })
    : FILES.filter(function(f) { return f.name.toLowerCase().indexOf(trigger.query) !== -1; });

  if (!list.length) {
    closeMenu();
    return;
  }
  activeTrigger = trigger;
  matches = list;
  if (activeIndex >= matches.length) activeIndex = 0;
  renderMenu();
}

function renderChips() {
  chips.textContent = "";
  attached.forEach(function(file) {
    const li = el("li", "chip");
    const ico = el("span", "chip-ico");
    ico.append(makeIcon(file.icon));
    const name = el("span", "chip-name", file.name);
    const meta = el("span", "chip-meta", file.meta);
    const btn = el("button", "chip-remove");
    btn.type = "button";
    btn.setAttribute("aria-label", "Remove " + file.name);
    btn.append(makeIcon("M18 6L6 18M6 6l12 12"));
    btn.addEventListener("click", function() {
      attached = attached.filter(function(f) { return f.name !== file.name; });
      renderChips();
      updateSend();
      status.textContent = "Removed " + file.name;
      prompt.focus();
    });
    li.append(ico, name, meta, btn);
    chips.append(li);
  });
}

function pickItem(index) {
  const item = matches[index];
  if (!item || !activeTrigger) return;
  if (activeTrigger.symbol === "/") {
    const replacement = item.key + " ";
    prompt.setRangeText(replacement, activeTrigger.start, activeTrigger.end, "end");
    status.textContent = "Inserted " + item.key;
  } else {
    prompt.setRangeText("", activeTrigger.start, activeTrigger.end, "end");
    if (!attached.some(function(f) { return f.name === item.name; })) {
      attached.push(item);
      renderChips();
    }
    status.textContent = "Attached " + item.name;
  }
  closeMenu();
  updateSend();
  prompt.focus();
}

function updateSend() {
  sendBtn.disabled = !prompt.value.trim() && !attached.length;
}

function insertTrigger(symbol) {
  prompt.focus();
  const pos = prompt.selectionStart;
  const before = prompt.value.slice(0, pos);
  const pad = before.length && !/\s$/.test(before) ? " " : "";
  prompt.setRangeText(pad + symbol, pos, prompt.selectionEnd, "end");
  updateMenu();
  updateSend();
}

function send() {
  const text = prompt.value.trim();
  if (!text && !attached.length) return;
  log.hidden = false;
  const card = el("div", "sent");
  if (attached.length) {
    const filesRow = el("div", "sent-files");
    attached.forEach(function(f) {
      filesRow.append(el("span", "sent-file", "@" + f.name));
    });
    card.append(filesRow);
  }
  if (text) card.append(el("div", "", text));
  log.append(card);
  log.scrollTop = log.scrollHeight;
  status.textContent = "Sent prompt";
  prompt.value = "";
  attached = [];
  renderChips();
  closeMenu();
  updateSend();
}

prompt.addEventListener("keydown", function(e) {
  if (e.isComposing || e.keyCode === 229) return;
  if (!menuWrap.hidden) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      activeIndex = (activeIndex + 1) % matches.length;
      syncActiveOption();
      return;
    }
    if (e.key === "ArrowUp") {
      e.preventDefault();
      activeIndex = (activeIndex - 1 + matches.length) % matches.length;
      syncActiveOption();
      return;
    }
    if (e.key === "Enter" || e.key === "Tab") {
      e.preventDefault();
      pickItem(activeIndex);
      return;
    }
    if (e.key === "Escape") {
      e.preventDefault();
      closeMenu();
      return;
    }
  }
  if (e.key === "Backspace" && !prompt.value && attached.length) {
    const removed = attached.pop();
    renderChips();
    updateSend();
    status.textContent = "Removed " + removed.name;
    return;
  }
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    send();
  }
});

prompt.addEventListener("input", function() {
  updateMenu();
  updateSend();
});

prompt.addEventListener("click", updateMenu);
prompt.addEventListener("keyup", function(e) {
  if (e.key === "ArrowLeft" || e.key === "ArrowRight") updateMenu();
});

slashBtn.addEventListener("click", function() {
  insertTrigger("/");
});

mentionBtn.addEventListener("click", function() {
  insertTrigger("@");
});

composer.addEventListener("submit", function(e) {
  e.preventDefault();
  send();
});

document.addEventListener("pointerdown", function(e) {
  if (!composer.contains(e.target)) closeMenu();
});

// Start with a file attached and the command menu open
attached = [FILES[0]];
renderChips();
prompt.value = "/";
prompt.setSelectionRange(1, 1);
updateMenu();
updateSend();
Esc
↑↓ moveEnter select

Start from a template

Open an .html file, or HTML, CSS, JS, Markdown, Less or TypeScript files. You can also drop them here. Styles and scripts go into their panes.

Share

Source

Keyboard shortcuts

Save