Skip to content
edmi-ui

Prompt Input Agent

Agent composer: agent chip, @ mention list and send button.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Agents
Keepertrading
Writerfeed
Analystresearch
Agents
Keepertrading
Writerfeed
Analystresearch
Agents
Keeper trading
Writer feed
Analyst research

Edmi ✦ addition. Built on ai-prompt-input, ai-agent-avatar and button.

Terminal window
npx shadcn@latest add @edmi-ui/ai-prompt-input-agent
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-prompt-input-agent
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-prompt-input-agent
Terminal window
bunx shadcn@latest add @edmi-ui/ai-prompt-input-agent

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-prompt-input-agent
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-prompt-input-agent
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-prompt-input-agent
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-prompt-input-agent

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-prompt-input-agent.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-prompt-input-agent.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-prompt-input-agent.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-prompt-input-agent.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
PromptInput,
PromptInputBody,
PromptInputHeader,
PromptInputTextarea,
} from "@/components/ai/prompt-input";
import { PromptInputAgent } from "@/components/ai/prompt-input-agent";
<div className="w-full max-w-xl pt-36">
<div className="relative">
<PromptInput
className="[&_[data-slot=input-group]]:bg-muted"
>
<PromptInputHeader>
<PromptInputAgent />
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
className="min-h-20"
/>
</PromptInputBody>
{/* … */}
</PromptInput>
</div>
</div>
<script setup lang="ts">
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputHeader,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from "@/components/ai/prompt-input";
import {
PromptInputAgent,
PromptInputAgentMentions,
} from "@/components/ai/prompt-input-agent";
import { Button } from "@/components/ui/button";
</script>
<template>
<div class="w-full max-w-xl pt-36">
<div class="relative">
<PromptInputAgentMentions
/>
<PromptInput class="[&_[data-slot=input-group]]:bg-muted">
<PromptInputHeader>
<PromptInputAgent />
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
:placeholder="`Ask $ anything about your index… (type @ to switch)`"
class="min-h-20"
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<Button aria-label="Attach" size="icon-sm" type="button" variant="ghost">
<PlusIcon class="size-4" />
</Button>
</PromptInputTools>
<PromptInputSubmit class="size-10" size="icon-sm" variant="secondary" />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</template>
<script lang="ts">
import {
PromptInputAgentMentions,
} from "$lib/components/ai/prompt-input-agent";
</script>
<div class="w-full max-w-xl pt-36">
<div class="relative">
{#if mention.open}
<PromptInputAgentMentions
/>
{/if}
<!-- … -->
</div>
</div>
registry/components/ai/prompt-input-agent.tsx
"use client";
import { cn } from "cn";
import type { ComponentProps, KeyboardEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { AgentAvatar } from "@/registry/edmi/components/ai/agent-avatar";
import { Button } from "@/registry/edmi/ui/button";
import type { Elevation } from "@/registry/edmi/ui/elevation";
export type PromptInputAgentOption = {
id: string;
name: string;
/** Short scope shown on the right of the mention list (`trading`). */
scope?: string;
/** `chart-1` to `chart-5` or any CSS color for the identicon. */
color?: string;
};
export type PromptInputAgentProps = Omit<
ComponentProps<typeof Button>,
"children" | "elevation"
> & {
agent: PromptInputAgentOption;
/** ✦ depth of the chip: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
};
/** Chip in the composer header: who you are talking to. */
export const PromptInputAgent = ({
agent,
elevation,
className,
...props
}: PromptInputAgentProps) => (
<Button
data-slot="ai-prompt-input-agent"
variant="outline"
size="sm"
elevation={elevation}
type="button"
className={cn("gap-[7px] bg-card", className)}
{...props}
>
<AgentAvatar seed={agent.id} color={agent.color} size={19} tile={false} />
{agent.name}
</Button>
);
export type PromptInputAgentMentionsProps = Omit<
ComponentProps<"div">,
"onSelect"
> & {
agents: PromptInputAgentOption[];
activeIndex?: number;
onSelect?: (agent: PromptInputAgentOption) => void;
label?: string;
};
/**
* The @ mention list. It positions itself above the nearest `relative` ancestor, so render it next to
* (not inside) `PromptInput`, whose group clips overflow.
*/
export const PromptInputAgentMentions = ({
agents,
activeIndex = 0,
onSelect,
label = "Agents",
className,
...props
}: PromptInputAgentMentionsProps) => (
<div
data-slot="ai-prompt-input-agent-mentions"
role="listbox"
aria-label={label}
className={cn(
"absolute bottom-full left-0 z-50 mb-2 w-70 rounded-xl border border-border bg-popover p-1.5 text-popover-foreground",
className,
)}
{...props}
>
<div className="px-2 pt-1.5 pb-1 text-xs font-semibold text-muted-foreground">
{label}
</div>
{agents.map((agent, i) => (
<div
key={agent.id}
role="option"
aria-selected={i === activeIndex}
tabIndex={-1}
data-active={i === activeIndex ? "" : undefined}
className="flex h-8 cursor-default items-center gap-[9px] rounded-[7px] px-2 text-[13.5px] whitespace-nowrap data-[active]:bg-accent data-[active]:text-accent-foreground"
// keep focus in the textarea while picking
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect?.(agent)}
onKeyDown={(e) => {
if (e.key === "Enter") onSelect?.(agent);
}}
>
<AgentAvatar
seed={agent.id}
color={agent.color}
size={19}
tile={false}
/>
<span>{agent.name}</span>
{agent.scope && (
<span className="ml-auto font-mono text-[11.5px] tracking-wide text-muted-foreground">
{agent.scope}
</span>
)}
</div>
))}
</div>
);
const MENTION = /(^|\s)@(\w*)$/;
export type UseAgentMentionOptions = {
agents: PromptInputAgentOption[];
/** Controlled textarea value. */
value: string;
onValueChange: (value: string) => void;
/** Called with the chosen agent; the `@query` token is removed from the text. */
onAgentSelect: (agent: PromptInputAgentOption) => void;
};
/**
* Opens the mention list when the text ends in `@query`. Wire `onKeyDown` to the textarea and spread
* `mentions` on `PromptInputAgentMentions` while `open`.
*/
export function useAgentMention({
agents,
value,
onValueChange,
onAgentSelect,
}: UseAgentMentionOptions) {
const [activeIndex, setActiveIndex] = useState(0);
const [dismissed, setDismissed] = useState(false);
const query = MENTION.exec(value)?.[2];
const items = useMemo(
() =>
query === undefined
? []
: agents.filter((a) =>
a.name.toLowerCase().includes(query.toLowerCase()),
),
[agents, query],
);
const open = query !== undefined && items.length > 0 && !dismissed;
// A new query reopens the list and resets the highlight.
// biome-ignore lint/correctness/useExhaustiveDependencies: reset on every query change
useEffect(() => {
setDismissed(false);
setActiveIndex(0);
}, [query]);
const select = (agent: PromptInputAgentOption) => {
onValueChange(value.replace(MENTION, "$1"));
onAgentSelect(agent);
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => (i + 1) % items.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => (i - 1 + items.length) % items.length);
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const agent = items[activeIndex];
if (agent) select(agent);
} else if (e.key === "Escape") {
setDismissed(true);
}
};
return {
open,
onKeyDown,
mentions: { agents: items, activeIndex, onSelect: select },
};
}
registry/components/ai/prompt-input-agent/PromptInputAgent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { PromptInputAgentOption } from "./types"
import { AgentAvatar } from "@/registry/edmi/components/ai/agent-avatar"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import type { Elevation } from "@/registry/edmi/ui/elevation"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
agent: PromptInputAgentOption
/** ✦ depth of the chip: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation
}>(), {
elevation: undefined,
})
</script>
<template>
<!-- Chip in the composer header: who you are talking to. -->
<Button
data-slot="ai-prompt-input-agent"
variant="outline"
size="sm"
:elevation="elevation"
type="button"
:class="cn('gap-[7px] bg-card', props.class)"
>
<AgentAvatar :seed="agent.id" :color="agent.color" :size="19" :tile="false" />
{{ agent.name }}
</Button>
</template>
registry/components/ai/prompt-input-agent/PromptInputAgentMentions.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { PromptInputAgentOption } from "./types"
import { AgentAvatar } from "@/registry/edmi/components/ai/agent-avatar"
import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
agents: PromptInputAgentOption[]
activeIndex?: number
label?: string
}>(), {
activeIndex: 0,
label: "Agents",
})
const emit = defineEmits<{
(e: "select", agent: PromptInputAgentOption): void
}>()
</script>
<template>
<!--
The @ mention list. It positions itself above the nearest `relative` ancestor, so render it next to
(not inside) `PromptInput`, whose group clips overflow.
-->
<div
data-slot="ai-prompt-input-agent-mentions"
role="listbox"
:aria-label="label"
:class="cn('absolute bottom-full left-0 z-50 mb-2 w-70 rounded-xl border border-border bg-popover p-1.5 text-popover-foreground', props.class)"
>
<div class="px-2 pt-1.5 pb-1 text-xs font-semibold text-muted-foreground">
{{ label }}
</div>
<!-- keep focus in the textarea while picking -->
<div
v-for="(agent, i) in agents"
:key="agent.id"
role="option"
:aria-selected="i === activeIndex"
tabindex="-1"
:data-active="i === activeIndex ? '' : undefined"
class="flex h-8 cursor-default items-center gap-[9px] rounded-[7px] px-2 text-[13.5px] whitespace-nowrap data-[active]:bg-accent data-[active]:text-accent-foreground"
@mousedown.prevent
@click="emit('select', agent)"
@keydown.enter="emit('select', agent)"
>
<AgentAvatar :seed="agent.id" :color="agent.color" :size="19" :tile="false" />
<span>{{ agent.name }}</span>
<span v-if="agent.scope" class="ml-auto font-mono text-[11.5px] tracking-wide text-muted-foreground">
{{ agent.scope }}
</span>
</div>
</div>
</template>
registry/components/ai/prompt-input-agent/types.ts
export type PromptInputAgentOption = {
id: string;
name: string;
/** Short scope shown on the right of the mention list (`trading`). */
scope?: string;
/** `chart-1` to `chart-5` or any CSS color for the identicon. */
color?: string;
};
registry/components/ai/prompt-input-agent/useAgentMention.ts
import type { MaybeRefOrGetter } from "vue";
import { computed, ref, toValue, watch } from "vue";
import type { PromptInputAgentOption } from "./types";
const MENTION = /(^|\s)@(\w*)$/;
export type UseAgentMentionOptions = {
agents: PromptInputAgentOption[];
/** Textarea value (ref or getter), for example the lifted composer `textInput`. */
value: MaybeRefOrGetter<string>;
onValueChange: (value: string) => void;
/** Called with the chosen agent; the `@query` token is removed from the text. */
onAgentSelect: (agent: PromptInputAgentOption) => void;
};
/**
* Opens the mention list when the text ends in `@query`. Attach `onKeydown` to the textarea with the
* capture modifier (`@keydown.capture`) so it runs before the composer's own Enter handling.
*/
export function useAgentMention({
agents,
value,
onValueChange,
onAgentSelect,
}: UseAgentMentionOptions) {
const activeIndex = ref(0);
const dismissed = ref(false);
const query = computed(() => MENTION.exec(toValue(value))?.[2]);
const items = computed(() =>
query.value === undefined
? []
: agents.filter((a) =>
a.name.toLowerCase().includes((query.value as string).toLowerCase()),
),
);
const open = computed(
() =>
query.value !== undefined && items.value.length > 0 && !dismissed.value,
);
// A new query reopens the list and resets the highlight.
watch(query, () => {
dismissed.value = false;
activeIndex.value = 0;
});
const select = (agent: PromptInputAgentOption) => {
onValueChange(toValue(value).replace(MENTION, "$1"));
onAgentSelect(agent);
};
const onKeydown = (e: KeyboardEvent) => {
if (!open.value) return;
const count = items.value.length;
if (e.key === "ArrowDown") {
e.preventDefault();
activeIndex.value = (activeIndex.value + 1) % count;
} else if (e.key === "ArrowUp") {
e.preventDefault();
activeIndex.value = (activeIndex.value - 1 + count) % count;
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
// stop the composer's own Enter handler (submit)
e.stopImmediatePropagation();
const agent = items.value[activeIndex.value];
if (agent) select(agent);
} else if (e.key === "Escape") {
dismissed.value = true;
}
};
return { open, items, activeIndex, onKeydown, select };
}
registry/components/ai/prompt-input-agent/index.ts
export { default as PromptInputAgent } from "./PromptInputAgent.vue";
export { default as PromptInputAgentMentions } from "./PromptInputAgentMentions.vue";
export * from "./types";
export * from "./useAgentMention";
src/lib/registry/ai/prompt-input-agent/index.ts
import PromptInputAgent from "./prompt-input-agent.svelte";
import PromptInputAgentMentions from "./prompt-input-agent-mentions.svelte";
export * from "./types.js";
export * from "./use-agent-mention.svelte.js";
export { PromptInputAgent, PromptInputAgentMentions };
src/lib/registry/ai/prompt-input-agent/prompt-input-agent-mentions.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { AgentAvatar } from "../agent-avatar/index.js";
import type { PromptInputAgentOption } from "./types.js";
let {
ref = $bindable(null),
agents,
activeIndex = 0,
onSelect,
label = "Agents",
class: className,
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "children" | "onselect">, HTMLDivElement> & {
agents: PromptInputAgentOption[];
activeIndex?: number;
onSelect?: (agent: PromptInputAgentOption) => void;
label?: string;
} = $props();
</script>
<!--
The @ mention list. It positions itself above the nearest `relative` ancestor, so render it next to
(not inside) `PromptInput`, whose group clips overflow.
-->
<div
bind:this={ref}
data-slot="ai-prompt-input-agent-mentions"
role="listbox"
aria-label={label}
class={cn(
"absolute bottom-full left-0 z-50 mb-2 w-70 rounded-xl border border-border bg-popover p-1.5 text-popover-foreground",
className
)}
{...restProps}
>
<div class="px-2 pt-1.5 pb-1 text-xs font-semibold text-muted-foreground">{label}</div>
{#each agents as agent, i (agent.id)}
<!-- keep focus in the textarea while picking -->
<div
role="option"
aria-selected={i === activeIndex}
tabindex="-1"
data-active={i === activeIndex ? "" : undefined}
class="flex h-8 cursor-default items-center gap-[9px] rounded-[7px] px-2 text-[13.5px] whitespace-nowrap data-[active]:bg-accent data-[active]:text-accent-foreground"
onmousedown={(e) => e.preventDefault()}
onclick={() => onSelect?.(agent)}
onkeydown={(e) => {
if (e.key === "Enter") onSelect?.(agent);
}}
>
<AgentAvatar seed={agent.id} color={agent.color} size={19} tile={false} />
<span>{agent.name}</span>
{#if agent.scope}
<span class="ml-auto font-mono text-[11.5px] tracking-wide text-muted-foreground">
{agent.scope}
</span>
{/if}
</div>
{/each}
</div>
src/lib/registry/ai/prompt-input-agent/prompt-input-agent.svelte
<script lang="ts">
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
import { cn } from "$lib/utils.js";
import { AgentAvatar } from "../agent-avatar/index.js";
import type { PromptInputAgentOption } from "./types.js";
let {
ref = $bindable(null),
agent,
elevation = "auto",
class: className,
...restProps
}: Omit<ButtonProps, "href" | "children" | "elevation"> & {
agent: PromptInputAgentOption;
/** ✦ depth of the chip: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
</script>
<!-- Chip in the composer header: who you are talking to. -->
<Button
bind:ref
{elevation}
data-slot="ai-prompt-input-agent"
variant="outline"
size="sm"
type="button"
class={cn("gap-[7px] bg-card", className)}
{...restProps}
>
<AgentAvatar seed={agent.id} color={agent.color} size={19} tile={false} />
{agent.name}
</Button>
src/lib/registry/ai/prompt-input-agent/types.ts
export type PromptInputAgentOption = {
id: string;
name: string;
/** Short scope shown on the right of the mention list (`trading`). */
scope?: string;
/** `chart-1` to `chart-5` or any CSS color for the identicon. */
color?: string;
};
src/lib/registry/ai/prompt-input-agent/use-agent-mention.svelte.ts
import type { PromptInputAgentOption } from "./types.js";
const MENTION = /(^|\s)@(\w*)$/;
export type UseAgentMentionOptions = {
agents: PromptInputAgentOption[];
/** Getter for the textarea value, for example `() => controller.textInput`. */
value: () => string;
onValueChange: (value: string) => void;
/** Called with the chosen agent; the `@query` token is removed from the text. */
onAgentSelect: (agent: PromptInputAgentOption) => void;
};
/**
* Opens the mention list when the text ends in `@query`. Pass `onkeydown` to `PromptInputTextarea`
* and render `PromptInputAgentMentions` with `mentions` while `open`. Call during component init.
*/
export function useAgentMention(options: UseAgentMentionOptions) {
let activeIndex = $state(0);
let dismissed = $state(false);
const query = $derived(MENTION.exec(options.value())?.[2]);
const items = $derived(
query === undefined
? []
: options.agents.filter((a) =>
a.name.toLowerCase().includes(query.toLowerCase()),
),
);
const open = $derived(query !== undefined && items.length > 0 && !dismissed);
// A new query reopens the list and resets the highlight.
$effect(() => {
void query;
dismissed = false;
activeIndex = 0;
});
const select = (agent: PromptInputAgentOption) => {
options.onValueChange(options.value().replace(MENTION, "$1"));
options.onAgentSelect(agent);
};
const onkeydown = (event: KeyboardEvent) => {
if (!open) return;
const count = items.length;
if (event.key === "ArrowDown") {
event.preventDefault();
activeIndex = (activeIndex + 1) % count;
} else if (event.key === "ArrowUp") {
event.preventDefault();
activeIndex = (activeIndex - 1 + count) % count;
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
const agent = items[activeIndex];
if (agent) select(agent);
} else if (event.key === "Escape") {
dismissed = true;
}
};
return {
get open() {
return open;
},
get items() {
return items;
},
get activeIndex() {
return activeIndex;
},
onkeydown,
select,
};
}

See the Elevation guide for the levels, roles and layered mode.

The chip (PromptInputAgent) is an outline Button: raised and floating raise it, flat and sunken keep it flat, auto follows the scope. The composer plate is an overlay surface: pass elevation to PromptInput for it.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Part Prop Type Default
PromptInputAgent agent { id, name, scope?, color? }
PromptInputAgent elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
PromptInputAgentMentions agents, activeIndex, onSelect, label (Vue: select event) list that opens above the nearest relative ancestor
useAgentMention agents, value, onValueChange, onAgentSelect returns { open, onKeyDown, mentions }
  • Put PromptInputAgent in PromptInputHeader. Typing @ opens the mention list (arrows, Enter or Tab pick, Escape closes); render it next to PromptInput, not inside, because the input group clips overflow.
  • Pass elevation to PromptInput for the plate depth; the muted shell is a class on the form ([&_[data-slot=input-group]]:bg-muted).