8.0k New

Prompt Input

A message field with attachments and a send button.

Epicenter

Local-first, open source apps

Special Sponsor
<script lang="ts">
  import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
  import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
  import FileTextIcon from "@lucide/svelte/icons/file-text";
  import PlusIcon from "@lucide/svelte/icons/plus";
  import XIcon from "@lucide/svelte/icons/x";
  import * as Attachment from "$lib/components/ui/attachment/index.js";
  import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
  import * as InputGroup from "$lib/components/ui/input-group/index.js";
 
  type AttachmentFile = {
    id: string;
    name: string;
  };
 
  const providers = [
    {
      name: "OpenAI",
      models: [
        { id: "gpt-5", name: "GPT-5" },
        { id: "gpt-5-mini", name: "GPT-5 mini" }
      ]
    },
    {
      name: "Anthropic",
      models: [
        { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
        { id: "claude-haiku-4-5", name: "Claude Haiku 4.5" }
      ]
    }
  ];
 
  const models = providers.flatMap((provider) => provider.models);
 
  const efforts = [
    { id: "low", name: "Low" },
    { id: "medium", name: "Medium" },
    { id: "high", name: "High" }
  ];
 
  let text = $state("");
  let files = $state<AttachmentFile[]>([]);
  let model = $state(models[0].id);
  let effort = $state(efforts[1].id);
  let form = $state<HTMLFormElement | null>(null);
  let input = $state<HTMLInputElement | null>(null);
  const currentModel = $derived(
    models.find((item) => item.id === model) ?? models[0]
  );
  const currentEffort = $derived(
    efforts.find((item) => item.id === effort) ?? efforts[1]
  );
  const canSend = $derived(text.trim().length > 0);
 
  function onSubmit(event: SubmitEvent) {
    event.preventDefault();
    if (!canSend) {
      return;
    }
 
    text = "";
    files = [];
  }
 
  function onAttach(event: Event & { currentTarget: HTMLInputElement }) {
    const selected = event.currentTarget.files;
    if (!selected) {
      return;
    }
 
    files = [
      ...files,
      ...Array.from(selected, (file) => ({
        id: crypto.randomUUID(),
        name: file.name
      }))
    ];
    event.currentTarget.value = "";
  }
</script>
 
<div class="mx-auto w-full max-w-lg py-6">
  <form bind:this={form} onsubmit={onSubmit}>
    <input
      bind:this={input}
      type="file"
      multiple
      class="sr-only"
      tabindex={-1}
      onchange={onAttach}
    />
    <InputGroup.Root>
      {#if files.length > 0}
        <InputGroup.Addon align="block-start" class="flex-wrap">
          {#each files as file (file.id)}
            <Attachment.Root size="sm">
              <Attachment.Media>
                <FileTextIcon />
              </Attachment.Media>
              <Attachment.Content>
                <Attachment.Title>{file.name}</Attachment.Title>
              </Attachment.Content>
              <Attachment.Actions>
                <Attachment.Action
                  aria-label={`Remove ${file.name}`}
                  onclick={() =>
                    (files = files.filter((item) => item.id !== file.id))}
                >
                  <XIcon />
                </Attachment.Action>
              </Attachment.Actions>
            </Attachment.Root>
          {/each}
        </InputGroup.Addon>
      {/if}
      <InputGroup.Textarea
        bind:value={text}
        placeholder="Ask the agent to change your code..."
        onkeydown={(event) => {
          if (event.key === "Enter" && !event.shiftKey) {
            event.preventDefault();
            form?.requestSubmit();
          }
        }}
        class="p-3"
      />
      <InputGroup.Addon align="block-end">
        <InputGroup.Button
          size="icon-sm"
          variant="outline"
          class="rounded-full"
          aria-label="Attach files"
          onclick={() => input?.click()}
        >
          <PlusIcon />
        </InputGroup.Button>
        <DropdownMenu.Root>
          <DropdownMenu.Trigger>
            {#snippet child({ props })}
              <InputGroup.Button
                {...props}
                size="sm"
                variant="outline"
                aria-label={`Model: ${currentModel.name}`}
                class="ml-auto"
              >
                {currentModel.name}
                <ChevronDownIcon />
              </InputGroup.Button>
            {/snippet}
          </DropdownMenu.Trigger>
          <DropdownMenu.Content align="start" class="w-48">
            <DropdownMenu.RadioGroup bind:value={model}>
              {#each providers as provider, index (provider.name)}
                {#if index > 0}
                  <DropdownMenu.Separator />
                {/if}
                <DropdownMenu.Group>
                  <DropdownMenu.Label>{provider.name}</DropdownMenu.Label>
                  {#each provider.models as item (item.id)}
                    <DropdownMenu.RadioItem value={item.id} closeOnSelect>
                      {item.name}
                    </DropdownMenu.RadioItem>
                  {/each}
                </DropdownMenu.Group>
              {/each}
            </DropdownMenu.RadioGroup>
          </DropdownMenu.Content>
        </DropdownMenu.Root>
        <DropdownMenu.Root>
          <DropdownMenu.Trigger>
            {#snippet child({ props })}
              <InputGroup.Button
                {...props}
                size="sm"
                variant="outline"
                aria-label={`Effort: ${currentEffort.name}`}
              >
                <span class="text-muted-foreground">Effort</span>
                {currentEffort.name}
                <ChevronDownIcon />
              </InputGroup.Button>
            {/snippet}
          </DropdownMenu.Trigger>
          <DropdownMenu.Content align="start" class="w-36">
            <DropdownMenu.Group>
              <DropdownMenu.Label>Effort</DropdownMenu.Label>
              <DropdownMenu.RadioGroup bind:value={effort}>
                {#each efforts as item (item.id)}
                  <DropdownMenu.RadioItem value={item.id} closeOnSelect>
                    {item.name}
                  </DropdownMenu.RadioItem>
                {/each}
              </DropdownMenu.RadioGroup>
            </DropdownMenu.Group>
          </DropdownMenu.Content>
        </DropdownMenu.Root>
        <InputGroup.Button
          type="submit"
          size="icon-sm"
          variant="default"
          class="rounded-full"
          disabled={!canSend}
          aria-label="Send"
        >
          <ArrowUpIcon />
        </InputGroup.Button>
      </InputGroup.Addon>
    </InputGroup.Root>
  </form>
</div>

Prompt Input is the field the person types into to message the agent. It is an Input Group with a textarea, an attach button, the Model Selector menus and a send button. Attached files show as Attachment chips above the text.

Installation

Prompt Input is built with the Input Group, Attachment and Dropdown Menu components.

pnpm dlx shadcn-svelte@latest add input-group attachment dropdown-menu

Usage

<script lang="ts">
  import * as InputGroup from "$lib/components/ui/input-group/index.js";
</script>
<form bind:this={form} onsubmit={onSubmit}>
  <InputGroup.Root>
    <InputGroup.Textarea
      bind:value={text}
      placeholder="Ask the agent to change your code..."
      class="p-3"
    />
    <InputGroup.Addon align="block-end">
      <InputGroup.Button
        size="icon-sm"
        variant="outline"
        class="rounded-full"
        aria-label="Attach files"
        onclick={() => input?.click()}
      >
        <PlusIcon />
      </InputGroup.Button>
      <InputGroup.Button
        type="submit"
        size="icon-sm"
        variant="default"
        class="ml-auto rounded-full"
        disabled={!canSend}
        aria-label="Send"
      >
        <ArrowUpIcon />
      </InputGroup.Button>
    </InputGroup.Addon>
  </InputGroup.Root>
</form>

Attachments

The attach button opens a hidden file input. Show each file as an Attachment in an addon with align="block-start", with a button to remove it.

Model and Effort

Put the two Model Selector menus in the block-end addon, between the attach and send buttons. Move ml-auto from the send button to the first menu trigger, so both menus sit next to the send button.

Keyboard

Enter sends the message. Shift+Enter adds a new line.

onkeydown={(event) => {
  if (event.key === "Enter" && !event.shiftKey) {
    event.preventDefault();
    form?.requestSubmit();
  }
}}

AI SDK

Send the text and the FileList from the file input.

chat.sendMessage({ text, files });

The route streams the reply with result.toUIMessageStreamResponse().