Astro UI
Prompt Bar.
Message composer with @ sources, / commands and a model picker.
AgentComponent1 dependency Beautiful UI, MIT
Install
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import PromptBar from "../components/viewership/prompt-bar/PromptBar.astro"; --- <PromptBar />
Props 11
| Prop | Type | Default | Description |
|---|---|---|---|
shape | "rounded" | "pill" | "rounded" | Card radius, or pill (24px once text wraps). |
sources | { key, name, desc, icon?, attach?, connect?, connected? }[] | [] | The @ menu. `attach` opens the file picker; `connect` needs a connection first. Icons: clip, chart, layers, globe, flame, notes, chat, mail, or inner SVG. |
commands | { key, name, desc }[] | [] | The / menu. Names include the slash. |
models | { key, name, tag?, flagship? }[] | · | Model options. `flagship` plays a sweep. |
model | string | · | Initial model key (default: second). |
placeholder | string | "Write a message…" | Input placeholder. |
tall | boolean | false | Multi-line hero size. |
autoplay | boolean | false | Menu walkthrough in view. Stops on input and under reduced motion. |
loop | boolean | true | Repeat the walkthrough. |
dictationLang | string | · | Dictation language. Defaults to page language. |
Events | CustomEvent | · | vs-prompt-submit { text, model, sources, command, attachments, files }, vs-prompt-model { key, name }. |
Source 1 file
---
/**
* PromptBar: message composer with @ sources, / commands, model picker,
* dictation and send.
*
* Ported from Beautiful UI `components/primitives/PromptBar.tsx`
* (MIT, © 2026 Shane Levine). Some icons are Lucide (ISC).
*
* - The textarea is an ARIA combobox; the menus are listboxes driven with
* aria-activedescendant, so focus stays in the field.
* - Dictation uses the Web Speech API, only after a click.
* - `autoplay` runs a walkthrough in view, never under reduced motion.
* Any pointer or key input hands control to the user.
*
* Events (bubbling, on the root):
* vs-prompt-submit detail: { text, model: { key, name }, sources: string[], command: string | null, attachments: string[], files: File[] }
* vs-prompt-model detail: { key, name }
*
* @example
* <PromptBar shape="pill" placeholder="Ask about this week's campaigns…" />
*/
import type { HTMLAttributes } from "astro/types";
import GlideMenu from "../glide-menu/GlideMenu.astro";
export interface PromptSource {
key: string;
name: string;
desc: string;
/** Built-in icon (clip, chart, layers, globe, flame, notes, chat, mail) or inner SVG markup. */
icon?: string;
/** Picking it opens the file picker instead of inserting a mention. */
attach?: boolean;
/** Needs connecting first; shows Connect / Connected. */
connect?: boolean;
connected?: boolean;
}
export interface PromptCommand {
key: string;
/** Including the slash, e.g. "/weekly-report". */
name: string;
desc: string;
}
export interface PromptModel {
key: string;
name: string;
tag?: string;
/** Selecting it plays the rainbow sweep. */
flagship?: boolean;
}
type Props = HTMLAttributes<"div"> & {
/** Corner style: card radius or full pill. */
shape?: "rounded" | "pill";
sources?: PromptSource[];
commands?: PromptCommand[];
models?: PromptModel[];
/** Initially selected model key. Defaults to the second model (or the first). */
model?: string;
placeholder?: string;
/** Hero sizing: a multi-line input with the controls on their own row. */
tall?: boolean;
/** Self-playing walkthrough of the menus (starts in view). */
autoplay?: boolean;
/** Repeat the walkthrough. */
loop?: boolean;
/** BCP 47 language for dictation. Defaults to the page language. */
dictationLang?: string;
};
const {
shape = "rounded",
sources = [],
commands = [],
models = [
{ key: "flagship", name: "Flagship", tag: "Best", flagship: true },
{ key: "fast", name: "Fast", tag: "Basic" },
],
model,
placeholder = "Write a message…",
tall = false,
autoplay = false,
loop = true,
dictationLang,
class: className,
...rest
} = Astro.props;
const uid = `vs-pb-${Math.random().toString(36).slice(2, 8)}`;
const current = models.find((m) => m.key === model) ?? models[1] ?? models[0];
const ICONS: Record<string, string> = {
// upstream-drawn (MIT)
clip: '<path d="m21.4 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />',
layers: '<path d="M12 2 2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" />',
globe: '<circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />',
// Lucide (ISC)
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />',
notes: '<rect width="8" height="4" x="8" y="2" rx="1" ry="1" /><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /><path d="M12 11h4" /><path d="M12 16h4" /><path d="M8 11h.01" /><path d="M8 16h.01" />',
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />',
mail: '<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />',
};
const svg = (inner: string, size = 15, sw = 1.8) =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
const iconMarkup = (name?: string) => svg(!name ? ICONS.clip : name.includes("<") ? name : (ICONS[name] ?? ICONS.clip));
const clientConfig = {
uid,
sources: sources.map((s) => ({ ...s, iconSvg: iconMarkup(s.icon) })),
commands,
models,
model: current?.key ?? "",
placeholder,
autoplay,
loop,
dictationLang: dictationLang ?? null,
};
---
<div
class:list={["vs-prompt-bar", `pb--${shape}`, { "pb--tall": tall }, className]}
{...rest}
>
<script type="application/json" data-vs-config set:html={JSON.stringify(clientConfig)} />
<div class="pb-anchor" data-anchor>
<div class="pb-menu" id={`${uid}-menu`} role="listbox" aria-label="Sources" data-menu hidden>
<span class="pb-hl" data-hl aria-hidden="true"></span>
<div class="pb-options" data-options></div>
<div class="pb-empty" data-empty hidden></div>
<div class="pb-menu-foot" data-menu-foot aria-hidden="true">Type to search sources & files</div>
</div>
<div class="pb-model-menu" data-model-menu hidden>
<GlideMenu class="pb-model-list" role="menu" aria-label="Model" arrowKeys highlightBg="var(--vs-hover)" highlightRadius="6px">
{
models.map((m) => (
<button type="button" class="pb-model-row" data-menu-row role="menuitemradio" aria-checked={m.key === current?.key ? "true" : "false"} data-model={m.key}>
<span class="pb-model-name">{m.name}</span>
{m.tag && <span class="pb-model-tag">{m.tag}</span>}
<span class="pb-model-check" set:html={svg('<path d="M20 6L9 17l-5-5" />', 13, 2.5)} />
</button>
))
}
</GlideMenu>
</div>
<div class="pb-composer" data-composer>
<span class="pb-sweep" aria-hidden="true"><span class="pb-sweep-band" data-sweep></span></span>
<span class="pb-measure" data-measure aria-hidden="true"></span>
<div class="pb-attachments" data-attachments hidden></div>
<div class="pb-controls" data-controls>
<button type="button" class="pb-icon-btn pb-plus" data-plus aria-label="Add attachments and sources" aria-expanded="false" aria-controls={`${uid}-menu`}>
<Fragment set:html={svg('<path d="M12 5v14M5 12h14" />', 16, 2)} />
</button>
<textarea
class="pb-input"
data-input
rows="1"
role="combobox"
aria-label="Prompt"
aria-autocomplete="list"
aria-expanded="false"
aria-controls={`${uid}-menu`}
aria-haspopup="listbox"
placeholder={placeholder}></textarea>
<button type="button" class="pb-model-btn" data-model-btn aria-haspopup="menu" aria-expanded="false" aria-label={`Model: ${current?.name ?? ""}. Choose model`}>
<span data-model-name>{current?.name}</span>
<span class="pb-ink-3" set:html={svg('<path d="M6 9l6 6 6-6" />', 11, 2.4)} />
</button>
<button type="button" class="pb-icon-btn pb-mic" data-mic aria-label="Start dictation" aria-pressed="false">
<span class="pb-mic-icon" set:html={svg('<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />', 15, 2)} />
<span class="pb-eq" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
<button type="button" class="pb-send" data-send aria-label="Send" disabled>
<Fragment set:html={svg('<path d="M12 19V5M5 12l7-7 7 7" />', 16, 2.4)} />
</button>
</div>
</div>
<input type="file" multiple hidden data-file tabindex="-1" aria-hidden="true" />
</div>
<p class="pb-sr" aria-live="polite" data-live></p>
</div>
<script>
interface Source {
key: string;
name: string;
desc: string;
iconSvg: string;
attach?: boolean;
connect?: boolean;
connected?: boolean;
}
interface Command {
key: string;
name: string;
desc: string;
}
interface Model {
key: string;
name: string;
tag?: string;
flagship?: boolean;
}
interface Config {
uid: string;
sources: Source[];
commands: Command[];
models: Model[];
model: string;
placeholder: string;
autoplay: boolean;
loop: boolean;
dictationLang: string | null;
}
interface Row {
key: string;
name: string;
desc: string;
source?: Source;
}
interface AutoStep {
draft: string;
active?: number;
connect?: boolean;
modelOpen?: boolean;
model?: "first-flagship" | "default";
hold: number;
}
/* minimal Web Speech API surface (not in the DOM lib) */
interface SpeechResultList {
length: number;
[index: number]: { 0: { transcript: string }; isFinal: boolean };
}
interface Recognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
start(): void;
stop(): void;
abort(): void;
onresult: ((event: { resultIndex: number; results: SpeechResultList }) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
}
type RecognitionCtor = new () => Recognition;
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
/* self-running demo: walk the @ menu, then the / menu, then upgrade the model */
const AUTO_STEPS: AutoStep[] = [
{ draft: "", connect: false, model: "default", hold: 1100 },
{ draft: "@", active: 0, hold: 900 },
{ draft: "@", active: 1, hold: 620 },
{ draft: "@", active: 4, hold: 620 },
{ draft: "@", active: 6, hold: 700 },
{ draft: "@", active: 6, connect: true, hold: 1000 },
{ draft: "", hold: 700 },
{ draft: "/", active: 0, hold: 900 },
{ draft: "/", active: 1, hold: 620 },
{ draft: "/", active: 3, hold: 1000 },
{ draft: "", hold: 800 },
{ draft: "", modelOpen: true, hold: 1200 },
{ draft: "", model: "first-flagship", hold: 2400 },
{ draft: "", hold: 900 },
];
/* the last @word or /word being typed, if any */
function parseToken(draft: string): { kind: "at" | "slash"; query: string; start: number } | null {
const match = /(^|\s)([@/])([\w-]*)$/.exec(draft);
if (!match) return null;
return { kind: match[2] === "@" ? "at" : "slash", query: match[3].toLowerCase(), start: match.index + match[1].length };
}
function init(root: HTMLElement) {
const cfg = JSON.parse(root.querySelector("script[data-vs-config]")?.textContent || "{}") as Config;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const q = <T extends HTMLElement = HTMLElement>(sel: string) => root.querySelector<T>(sel)!;
const anchor = q("[data-anchor]");
const menu = q("[data-menu]");
const hl = q("[data-hl]");
const optionsEl = q("[data-options]");
const emptyEl = q("[data-empty]");
const menuFoot = q("[data-menu-foot]");
const modelMenu = q("[data-model-menu]");
const sweep = q("[data-sweep]");
const measure = q("[data-measure]");
const attachmentsEl = q("[data-attachments]");
const controls = q("[data-controls]");
const plusBtn = q<HTMLButtonElement>("[data-plus]");
const input = q<HTMLTextAreaElement>("[data-input]");
const modelBtn = q<HTMLButtonElement>("[data-model-btn]");
const modelName = q("[data-model-name]");
const micBtn = q<HTMLButtonElement>("[data-mic]");
const sendBtn = q<HTMLButtonElement>("[data-send]");
const fileInput = q<HTMLInputElement>("[data-file]");
const live = q("[data-live]");
const tall = root.classList.contains("pb--tall");
const sources = cfg.sources.map((s) => ({ ...s }));
let model = cfg.models.find((m) => m.key === cfg.model) ?? cfg.models[0];
const defaultModel = model;
let attachments: { name: string; file?: File }[] = [];
let dismissed = false;
let plusOpen = false;
let modelOpen = false;
let active = 0;
let engaged = false;
let rows: Row[] = [];
let menuKind: "at" | "slash" | null = null;
let lastKey = "";
let listening = false;
let recognition: Recognition | null = null;
/* ── menus ─────────────────────────────────────────── */
const token = () => (dismissed ? null : parseToken(input.value));
const computeMenu = () => {
const t = token();
const kind: "at" | "slash" | null = plusOpen ? "at" : (t?.kind ?? null);
const query = plusOpen ? "" : (t?.query ?? "");
const list: Row[] =
kind === "at"
? sources.filter((s) => s.name.toLowerCase().includes(query)).map((s) => ({ key: s.key, name: s.name, desc: s.desc, source: s }))
: kind === "slash"
? cfg.commands.filter((c) => c.name.replace(/^\//, "").toLowerCase().startsWith(query))
: [];
return { kind, query, list };
};
const placeHighlight = () => {
const target = optionsEl.children[active] as HTMLElement | undefined;
const show = !!target && engaged && rows.length > 0;
if (target) {
hl.style.top = `${target.offsetTop}px`;
hl.style.height = `${target.offsetHeight}px`;
}
hl.style.opacity = show ? "1" : "0";
optionsEl.querySelectorAll("[role=option]").forEach((el, i) => el.setAttribute("aria-selected", String(i === active)));
if (target && engaged) input.setAttribute("aria-activedescendant", target.id);
else input.removeAttribute("aria-activedescendant");
};
const renderMenu = () => {
const { kind, query, list } = computeMenu();
const key = `${kind}|${query}`;
if (key !== lastKey) {
active = 0;
engaged = false;
lastKey = key;
}
menuKind = kind;
rows = list;
const open = kind !== null;
const wasOpen = !menu.hidden;
menu.hidden = !open;
input.setAttribute("aria-expanded", String(open));
plusBtn.setAttribute("aria-expanded", String(plusOpen));
plusBtn.classList.toggle("is-active", plusOpen);
if (!open) {
input.removeAttribute("aria-activedescendant");
return;
}
if (!wasOpen && !reduce.matches) {
menu.classList.remove("is-anim");
void menu.offsetWidth;
menu.classList.add("is-anim");
}
menu.setAttribute("aria-label", kind === "at" ? "Sources and files" : "Commands");
optionsEl.innerHTML = list
.map((row, i) => {
const s = row.source;
const connect = s?.connect
? `<span class="pb-connect${s.connected ? " is-connected" : ""}" data-connect="${escapeHtml(s.key)}">${s.connected ? "Connected" : "Connect"}</span>`
: "";
return `<div class="pb-option" role="option" id="${cfg.uid}-opt-${i}" aria-selected="false" data-index="${i}">${
s ? `<span class="pb-option-icon">${s.iconSvg}</span>` : ""
}<span class="pb-option-name">${escapeHtml(row.name)}</span><span class="pb-option-desc">${escapeHtml(row.desc)}</span>${connect}</div>`;
})
.join("");
emptyEl.hidden = list.length > 0;
emptyEl.textContent = list.length ? "" : `No matches for “${query}”`;
menuFoot.textContent = kind === "at" ? "Type to search sources & files" : "Type to search commands";
if (active >= list.length) active = 0;
placeHighlight();
};
const closeMenus = () => {
plusOpen = false;
setModelOpen(false);
renderMenu();
};
const toggleConnect = (key: string) => {
const s = sources.find((x) => x.key === key);
if (!s) return;
s.connected = !s.connected;
live.textContent = `${s.name} ${s.connected ? "connected" : "disconnected"}.`;
renderMenu();
};
const pick = (row: Row | undefined) => {
if (!row) return;
const t = token();
const before = t ? input.value.slice(0, t.start) : input.value;
const s = row.source;
if (s?.connect && !s.connected) {
toggleConnect(s.key);
return;
}
if (s?.attach) {
if (t) input.value = before;
plusOpen = false;
fileInput.click();
} else if (menuKind === "at") {
input.value = `${before}@${row.name} `;
} else {
input.value = `${before}${row.name} `;
}
plusOpen = false;
dismissed = false;
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
sync();
};
optionsEl.addEventListener("mousedown", (e) => e.preventDefault());
optionsEl.addEventListener("mousemove", (e) => {
const opt = (e.target as Element).closest<HTMLElement>("[data-index]");
if (!opt) return;
const i = Number(opt.dataset.index);
if (i !== active || !engaged) {
active = i;
engaged = true;
placeHighlight();
}
});
menu.addEventListener("mouseleave", () => {
engaged = false;
placeHighlight();
});
optionsEl.addEventListener("click", (e) => {
const connect = (e.target as Element).closest<HTMLElement>("[data-connect]");
if (connect) {
e.stopPropagation();
toggleConnect(connect.dataset.connect!);
return;
}
const opt = (e.target as Element).closest<HTMLElement>("[data-index]");
if (opt) pick(rows[Number(opt.dataset.index)]);
});
plusBtn.addEventListener("click", () => {
setModelOpen(false);
plusOpen = !plusOpen;
renderMenu();
input.focus();
});
/* ── model menu ────────────────────────────────────── */
function setModelOpen(open: boolean, focusMenu = false) {
modelOpen = open;
modelBtn.setAttribute("aria-expanded", String(open));
if (!open) {
modelMenu.hidden = true;
return;
}
plusOpen = false;
renderMenu();
modelMenu.hidden = false;
const a = anchor.getBoundingClientRect();
const t = modelBtn.getBoundingClientRect();
modelMenu.style.left = `${Math.max(0, Math.min(t.left - a.left, a.width - modelMenu.offsetWidth))}px`;
modelMenu.style.bottom = `${a.bottom - t.top + 8}px`;
if (!reduce.matches) {
modelMenu.classList.remove("is-anim");
void modelMenu.offsetWidth;
modelMenu.classList.add("is-anim");
}
if (focusMenu) modelMenu.querySelector<HTMLElement>('[aria-checked="true"]')?.focus();
}
const selectModel = (next: Model, announce = true) => {
const changed = next.key !== model.key;
model = next;
modelName.textContent = next.name;
modelBtn.setAttribute("aria-label", `Model: ${next.name}. Choose model`);
modelMenu.querySelectorAll<HTMLElement>("[data-model]").forEach((el) => el.setAttribute("aria-checked", String(el.dataset.model === next.key)));
setModelOpen(false);
sync();
if (changed && next.flagship) celebrate();
if (announce) {
live.textContent = `Model: ${next.name}`;
root.dispatchEvent(new CustomEvent("vs-prompt-model", { bubbles: true, detail: { key: next.key, name: next.name } }));
}
};
modelBtn.addEventListener("click", (e) => setModelOpen(!modelOpen, e.detail === 0));
modelMenu.addEventListener("mousedown", (e) => e.preventDefault());
modelMenu.addEventListener("click", (e) => {
const row = (e.target as Element).closest<HTMLElement>("[data-model]");
const next = cfg.models.find((m) => m.key === row?.dataset.model);
if (!next) return;
const viaKeyboard = e.detail === 0;
selectModel(next);
(viaKeyboard ? modelBtn : input).focus();
});
modelMenu.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setModelOpen(false);
modelBtn.focus();
} else if (e.key === "Tab") setModelOpen(false);
});
/* rainbow sweep across the composer interior */
let sweepAnim: Animation | null = null;
function celebrate() {
if (reduce.matches || sweepAnim?.playState === "running") return;
sweepAnim = sweep.animate(
[
{ transform: "translateX(-110%)", opacity: 0 },
{ opacity: 1, offset: 0.2 },
{ opacity: 1, offset: 0.75 },
{ transform: "translateX(230%)", opacity: 0 },
],
{ duration: 650, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
);
}
/* ── attachments ───────────────────────────────────── */
const fileSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>';
const xSvg = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg>';
const renderAttachments = () => {
attachmentsEl.hidden = attachments.length === 0;
attachmentsEl.innerHTML = attachments
.map(
(a, i) =>
`<span class="pb-chip">${fileSvg}<span class="pb-chip-name">${escapeHtml(a.name)}</span><button type="button" class="pb-chip-x" data-remove="${i}" aria-label="Remove ${escapeHtml(a.name)}">${xSvg}</button></span>`,
)
.join("");
sync();
};
attachmentsEl.addEventListener("click", (e) => {
const btn = (e.target as Element).closest<HTMLElement>("[data-remove]");
if (!btn) return;
const removed = attachments[Number(btn.dataset.remove)];
attachments = attachments.filter((_, i) => i !== Number(btn.dataset.remove));
renderAttachments();
live.textContent = `Removed ${removed?.name ?? "attachment"}.`;
input.focus();
});
fileInput.addEventListener("change", () => {
const files = Array.from(fileInput.files ?? []);
attachments = [...attachments, ...files.map((file) => ({ name: file.name, file }))];
fileInput.value = "";
renderAttachments();
if (files.length) live.textContent = `Attached ${files.map((f) => f.name).join(", ")}.`;
input.focus();
});
/* ── layout: wrap text above the controls, then grow to a max ── */
const sync = () => {
const draft = input.value;
measure.textContent = draft;
const fixed = 28 * 3 + modelBtn.offsetWidth + 16;
const inlineWidth = controls.clientWidth - fixed;
const wide = tall || draft.includes("\n") || measure.offsetWidth + 8 > inlineWidth;
root.classList.toggle("is-wide", wide);
root.classList.toggle("has-attachments", attachments.length > 0);
const min = tall ? 68 : 28;
const max = tall ? 160 : 100;
input.style.height = "0px";
const h = input.scrollHeight;
input.style.height = `${Math.min(Math.max(h, min), max)}px`;
input.style.overflowY = h > max ? "auto" : "hidden";
sendBtn.disabled = !(draft.trim().length > 0 || attachments.length > 0);
renderMenu();
};
const send = () => {
if (sendBtn.disabled) return;
const text = input.value.trim();
const mentions = sources.filter((s) => text.includes(`@${s.name}`)).map((s) => s.name);
const command = cfg.commands.find((c) => text.startsWith(c.name) || text.includes(` ${c.name}`))?.name ?? null;
root.dispatchEvent(
new CustomEvent("vs-prompt-submit", {
bubbles: true,
detail: {
text,
model: { key: model.key, name: model.name },
sources: mentions,
command,
attachments: attachments.map((a) => a.name),
files: attachments.flatMap((a) => (a.file ? [a.file] : [])),
},
}),
);
input.value = "";
attachments = [];
renderAttachments();
closeMenus();
live.textContent = "Message sent.";
};
sendBtn.addEventListener("click", send);
input.addEventListener("input", () => {
dismissed = false;
plusOpen = false;
sync();
});
input.addEventListener("keydown", (e) => {
if (menuKind && rows.length > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (engaged) active = (active + (e.key === "ArrowDown" ? 1 : rows.length - 1)) % rows.length;
engaged = true;
placeHighlight();
return;
}
if ((e.key === "Enter" && !e.shiftKey && !e.isComposing) || e.key === "Tab") {
e.preventDefault();
pick(rows[active]);
return;
}
}
if (e.key === "Escape") {
if (menuKind || modelOpen) e.preventDefault();
dismissed = true;
closeMenus();
return;
}
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
send();
}
});
input.addEventListener("blur", () => {
// Leaving the field closes the listbox (clicks on options are kept by preventDefault).
requestAnimationFrame(() => {
if (!root.contains(document.activeElement)) {
plusOpen = false;
dismissed = true;
renderMenu();
}
});
});
/* ── dictation (Web Speech API, only on click) ─────── */
const w = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor };
const Speech = w.SpeechRecognition ?? w.webkitSpeechRecognition;
const markUnsupported = (message: string) => {
micBtn.setAttribute("aria-disabled", "true");
micBtn.classList.add("is-unsupported");
micBtn.title = message;
micBtn.setAttribute("aria-label", `Dictation unavailable: ${message}`);
live.textContent = message;
};
const setListening = (on: boolean) => {
listening = on;
micBtn.classList.toggle("is-listening", on);
micBtn.setAttribute("aria-pressed", String(on));
micBtn.setAttribute("aria-label", on ? "Stop dictation" : "Start dictation");
input.placeholder = on ? "Listening…" : cfg.placeholder;
};
micBtn.addEventListener("click", () => {
if (micBtn.getAttribute("aria-disabled") === "true") {
live.textContent = micBtn.title;
return;
}
if (!Speech) {
markUnsupported("This browser doesn't support dictation.");
return;
}
if (listening) {
recognition?.stop();
return;
}
const base = input.value.trimEnd();
recognition = new Speech();
recognition.lang = cfg.dictationLang ?? (document.documentElement.lang || navigator.language);
recognition.continuous = false;
recognition.interimResults = true;
recognition.onresult = (event) => {
let transcript = "";
for (let i = 0; i < event.results.length; i += 1) transcript += event.results[i][0].transcript;
input.value = base ? `${base} ${transcript.trim()}` : transcript.trim();
sync();
};
recognition.onerror = (event) => {
if (event.error === "not-allowed" || event.error === "service-not-allowed") markUnsupported("Microphone access is blocked.");
else if (event.error !== "no-speech" && event.error !== "aborted") live.textContent = "Dictation stopped.";
};
recognition.onend = () => {
setListening(false);
input.focus();
};
try {
recognition.start();
setListening(true);
live.textContent = "Listening…";
} catch {
markUnsupported("Dictation couldn't start.");
}
});
/* ── outside clicks close the menus ────────────────── */
const onPointerDown = (e: PointerEvent) => {
if ((modelOpen || plusOpen) && !root.contains(e.target as Node)) closeMenus();
};
document.addEventListener("pointerdown", onPointerDown);
/* ── autoplay walkthrough ──────────────────────────── */
let auto = cfg.autoplay && !reduce.matches;
let step = 0;
let autoTimer: number | undefined;
let inView = false;
const flagship = cfg.models.find((m) => m.flagship);
const applyStep = () => {
if (!auto || !inView) return;
if (step >= AUTO_STEPS.length) {
if (!cfg.loop) return stopAuto(false);
step = 0;
}
const s = AUTO_STEPS[step];
dismissed = false;
input.value = s.draft;
sync();
if (s.active !== undefined && rows.length) {
active = Math.min(s.active, rows.length - 1);
engaged = true;
placeHighlight();
}
if (s.connect !== undefined) {
const target = sources.find((x) => x.connect);
if (target && !!target.connected !== s.connect) toggleConnect(target.key);
}
if (s.modelOpen) setModelOpen(true);
if (s.model === "default" && model.key !== defaultModel.key) selectModel(defaultModel, false);
if (s.model === "first-flagship" && flagship) selectModel(flagship, false);
step += 1;
autoTimer = window.setTimeout(applyStep, s.hold);
};
const stopAuto = (clearDraft: boolean) => {
if (!auto) return;
auto = false;
window.clearTimeout(autoTimer);
io.disconnect();
if (clearDraft) input.value = "";
dismissed = false;
setModelOpen(false);
sync();
};
const takeOver = (e: Event) => {
if (!auto) return;
stopAuto(e.target === input);
};
root.addEventListener("pointerdown", takeOver, true);
root.addEventListener("keydown", takeOver, true);
root.addEventListener("focusin", takeOver, true);
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
window.clearTimeout(autoTimer);
if (inView && auto) applyStep();
});
if (auto) io.observe(root);
// Rewrap when the composer's width changes.
const ro = new ResizeObserver(() => sync());
ro.observe(controls);
sync();
return () => {
window.clearTimeout(autoTimer);
io.disconnect();
ro.disconnect();
sweepAnim?.cancel();
recognition?.abort();
document.removeEventListener("pointerdown", onPointerDown);
};
}
const teardowns = new Set<() => void>();
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-prompt-bar:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
teardowns.add(init(root));
});
boot();
document.addEventListener("astro:page-load", boot);
document.addEventListener("astro:before-swap", () => {
teardowns.forEach((fn) => fn());
teardowns.clear();
});
</script>
<style>
.vs-prompt-bar {
--pb-radius: 14px;
--pb-ctl-radius: 8px;
width: 100%;
color: var(--vs-ink);
font-family: var(--vs-font-sans);
text-align: left;
}
.pb--pill {
--pb-radius: var(--vs-radius-pill);
--pb-ctl-radius: var(--vs-radius-pill);
}
.pb--pill.is-wide,
.pb--pill.has-attachments {
--pb-radius: 24px;
}
.pb--tall {
--pb-radius: 22px;
}
.pb-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pb-ink-3 {
display: inline-flex;
color: var(--vs-ink-3);
}
.pb-anchor {
position: relative;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
button:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
/* ── @ / slash menu ─────────────────────────────────── */
.pb-menu {
position: absolute;
z-index: 10;
right: 0;
bottom: 100%;
left: 0;
margin-bottom: 8px;
padding: 4px;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-raised);
transform-origin: bottom center;
}
.pb-menu[hidden],
.pb-model-menu[hidden],
.pb-empty[hidden],
.pb-attachments[hidden] {
display: none;
}
.pb-menu.is-anim,
.pb-model-menu.is-anim {
animation: vs-pop-in 180ms var(--vs-ease-out) both;
}
.pb-model-menu.is-anim {
transform-origin: bottom left;
}
.pb-hl {
position: absolute;
right: 4px;
left: 4px;
height: 0;
border-radius: 6px;
background: var(--vs-hover);
opacity: 0;
pointer-events: none;
transition:
top 220ms var(--vs-ease-out),
height 220ms var(--vs-ease-out),
opacity 150ms ease;
}
.pb-options {
position: relative;
}
.vs-prompt-bar :global(.pb-option) {
position: relative;
display: flex;
width: 100%;
height: 36px;
align-items: center;
gap: 10px;
padding: 0 8px;
border-radius: 6px;
cursor: pointer;
}
.vs-prompt-bar :global(.pb-option-icon) {
display: flex;
width: 22px;
height: 22px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--vs-ink-2);
}
.vs-prompt-bar :global(.pb-option-name) {
flex-shrink: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--vs-ink);
}
.vs-prompt-bar :global(.pb-option-desc) {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: 12px;
color: var(--vs-ink-3);
text-overflow: ellipsis;
white-space: nowrap;
}
.vs-prompt-bar :global(.pb-connect) {
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
color: var(--vs-accent-ink);
transition: color 100ms ease-out;
}
.vs-prompt-bar :global(.pb-connect:hover) {
text-decoration: underline;
}
.vs-prompt-bar :global(.pb-connect.is-connected) {
color: var(--vs-green);
text-decoration: none;
}
.pb-empty {
display: flex;
height: 36px;
align-items: center;
padding: 0 8px;
font-size: 12px;
color: var(--vs-ink-3);
}
.pb-menu-foot {
margin-top: 4px;
padding: 6px 8px 4px;
border-top: 1px solid var(--vs-line);
font-size: 11px;
color: var(--vs-ink-3);
}
/* ── model menu ─────────────────────────────────────── */
.pb-model-menu {
position: absolute;
z-index: 10;
width: 176px;
padding: 4px;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-raised);
}
.pb-model-list {
display: flex;
flex-direction: column;
}
.pb-model-row {
display: flex;
width: 100%;
height: 30px;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 6px;
text-align: left;
}
.pb-model-row:focus-visible {
box-shadow: none;
}
.pb-model-name {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: 12.5px;
font-weight: 500;
color: var(--vs-ink);
text-overflow: ellipsis;
white-space: nowrap;
}
.pb-model-tag {
flex-shrink: 0;
font-size: 11px;
color: var(--vs-ink-3);
}
.pb-model-check {
display: inline-flex;
flex-shrink: 0;
color: var(--vs-ink);
visibility: hidden;
}
.pb-model-row[aria-checked="true"] .pb-model-check {
visibility: visible;
}
/* ── composer ───────────────────────────────────────── */
.pb-composer {
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px;
overflow: hidden;
border: 1px solid var(--vs-line);
border-radius: var(--pb-radius);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-card);
transition:
border-color 150ms ease-out,
border-radius 150ms ease-out;
}
.pb--tall .pb-composer {
gap: 10px;
padding: 14px;
}
.pb-composer:focus-within {
border-color: var(--vs-line-strong);
}
.pb-sweep {
position: absolute;
z-index: -1;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
}
.pb-sweep-band {
position: absolute;
top: -20%;
bottom: -20%;
left: 0;
width: 55%;
opacity: 0;
background: linear-gradient(
90deg,
transparent,
oklch(0.72 0.19 25 / 0.55),
oklch(0.8 0.16 65 / 0.55),
oklch(0.88 0.16 100 / 0.55),
oklch(0.78 0.17 150 / 0.55),
oklch(0.8 0.12 210 / 0.55),
oklch(0.66 0.18 260 / 0.55),
oklch(0.64 0.2 305 / 0.55),
transparent
);
filter: blur(10px);
transform: translateX(-110%);
}
.pb-measure {
position: absolute;
visibility: hidden;
pointer-events: none;
font-size: 13px;
line-height: 18px;
white-space: pre;
}
.pb-attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 2px 2px 0;
}
.pb--pill .pb-attachments {
padding-inline: 4px;
}
.vs-prompt-bar :global(.pb-chip) {
display: flex;
height: 26px;
align-items: center;
gap: 6px;
padding: 4px 4px 4px 6px;
border-radius: var(--vs-radius-chip);
background: var(--vs-field);
box-shadow: var(--vs-shadow-hairline);
font-size: 11.5px;
color: var(--vs-ink-2);
animation: vs-pop-in 200ms var(--vs-ease-out) both;
}
.pb--pill :global(.pb-chip) {
border-radius: var(--vs-radius-pill);
}
.vs-prompt-bar :global(.pb-chip-name) {
max-width: 144px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vs-prompt-bar :global(.pb-chip-x) {
display: flex;
width: 24px;
height: 24px;
margin: -4px 0;
align-items: center;
justify-content: center;
border-radius: 5px;
color: var(--vs-ink-3);
transition: background-color 100ms ease-out, color 100ms ease-out;
}
.pb--pill :global(.pb-chip-x) {
border-radius: var(--vs-radius-pill);
}
.vs-prompt-bar :global(.pb-chip-x:hover) {
background: color-mix(in srgb, var(--vs-line) 70%, transparent);
color: var(--vs-ink);
}
/* controls grid: inline, or text on its own row once it wraps */
.pb-controls {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto 28px 28px;
align-items: end;
gap: 6px 4px;
}
.pb-plus {
grid-area: 1 / 1;
}
.pb-input {
grid-area: 1 / 2;
}
.pb-model-btn {
grid-area: 1 / 3;
}
.pb-mic {
grid-area: 1 / 4;
}
.pb-send {
grid-area: 1 / 5;
}
.is-wide .pb-controls {
grid-template-columns: 28px auto minmax(0, 1fr) 28px 28px;
}
.is-wide .pb-input {
grid-area: 1 / 1 / 2 / -1;
}
.is-wide .pb-plus {
grid-area: 2 / 1;
}
.is-wide .pb-model-btn {
grid-area: 2 / 2;
justify-self: start;
}
.is-wide .pb-mic {
grid-area: 2 / 4;
}
.is-wide .pb-send {
grid-area: 2 / 5;
}
.pb-icon-btn {
display: flex;
width: 28px;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
justify-self: start;
border-radius: var(--pb-ctl-radius);
color: var(--vs-ink-3);
transition:
background-color 150ms ease-out,
color 150ms ease-out,
transform 150ms ease-out;
}
.pb-icon-btn:hover,
.pb-plus.is-active {
background: var(--vs-hover);
color: var(--vs-ink);
}
.pb-icon-btn:active {
transform: scale(0.94);
}
.pb-input {
width: 100%;
min-width: 0;
min-height: 28px;
padding: 5px 4px;
border: 0;
background: transparent;
color: var(--vs-ink);
font: inherit;
font-size: 13px;
line-height: 18px;
overflow-wrap: anywhere;
resize: none;
outline: none;
}
.pb--tall .pb-input {
min-height: 68px;
padding: 8px;
font-size: 14px;
line-height: 20px;
}
.pb-input::placeholder {
color: var(--vs-ink-3);
}
.pb-model-btn {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
gap: 4px;
padding: 0 6px;
border-radius: var(--pb-ctl-radius);
color: var(--vs-ink-2);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
transition: background-color 150ms ease-out, color 150ms ease-out;
}
.pb-model-btn:hover,
.pb-model-btn[aria-expanded="true"] {
background: var(--vs-hover);
color: var(--vs-ink);
}
/* dictation */
.pb-eq {
display: none;
height: 14px;
align-items: center;
gap: 2.5px;
}
.pb-eq span {
width: 2.5px;
height: 100%;
border-radius: var(--vs-radius-pill);
background: currentColor;
animation: vs-prompt-bar-eq 900ms ease-in-out infinite;
}
.pb-eq span:nth-child(2) {
animation-delay: 150ms;
}
.pb-eq span:nth-child(3) {
animation-delay: 300ms;
}
@keyframes vs-prompt-bar-eq {
0%,
100% {
transform: scaleY(0.35);
}
50% {
transform: scaleY(1);
}
}
.pb-mic.is-listening {
background: var(--vs-accent-tint);
color: var(--vs-accent-ink);
}
.pb-mic.is-listening .pb-eq {
display: flex;
}
.pb-mic.is-listening .pb-mic-icon {
display: none;
}
.pb-mic-icon {
display: flex;
}
.pb-mic.is-unsupported {
opacity: 0.5;
cursor: not-allowed;
}
.pb-send {
display: flex;
width: 28px;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--pb-ctl-radius);
background: var(--vs-ink);
color: var(--vs-surface);
transition:
background-color 200ms ease-out,
color 200ms ease-out,
transform 200ms ease-out;
}
.pb-send:enabled:active {
transform: scale(0.94);
}
.pb-send:disabled {
background: var(--vs-line-strong);
color: var(--vs-ink-2);
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.pb-menu.is-anim,
.pb-model-menu.is-anim,
.vs-prompt-bar :global(.pb-chip) {
animation: none;
}
.pb-hl,
.pb-composer {
transition: none;
}
.pb-eq span {
animation: none;
transform: scaleY(0.7);
}
}
@media (forced-colors: active) {
.pb-hl {
outline: 1px solid Highlight;
}
.pb-send:disabled {
opacity: 0.5;
}
}
</style> Ported from Beautiful UI, MIT. Notices.