Prompt Input

Um campo de conversa que começa como uma linha discreta, cresce até virar um card conforme você escreve e desce quando a conversa começa. Enter envia, de forma segura com entrada em japonês e chinês. Cole, solte ou escolha arquivos com prévias, progresso e nova tentativa. @ adiciona arquivos e / executa comandos a partir de um menu no cursor. Um seletor de modelo com teclas numéricas, um slider de esforço que ganha vida no Max, um anel de contexto, ditado com forma de onda ao vivo, chips de ferramentas, uma fila para mensagens digitadas enquanto uma resposta é transmitida e rascunhos que sobrevivem a um recarregamento.

O composer é a parte de um chat que as pessoas mais tocam, então cada tecla precisa passar a sensação certa. O Prompt Input começa como uma linha discreta com o + à esquerda e enviar à direita, cresce até virar um card quando o texto quebra ou arquivos são adicionados e volta ao normal quando é limpo.

Enter envia e Shift+Enter adiciona uma linha, e os métodos de entrada para japonês, chinês e coreano nunca são interrompidos. Em telas de toque, Enter adiciona uma linha, já que o teclado não tem Shift. As inserções passam pela pilha de desfazer do próprio navegador, então ⌘Z sempre funciona, e colagens longas viram um anexo que você pode visualizar ou recolocar inline.

Tudo é composto a partir de partes que compartilham um único contexto. Use apenas o campo e o botão de enviar, ou adicione uploads de arquivo com progresso e nova tentativa, menus @ e / no cursor, um seletor de modelo, um slider de esforço, um anel de contexto, ditado, chips de ferramentas, uma fila para mensagens enviadas enquanto uma resposta chega em streaming e rascunhos que sobrevivem a um recarregamento.

O Prompt Input é gratuito. Instale-o sem conta nem token e use-o sob a Licença Pro em quantos projetos quiser.

pnpm dlx shadcn@latest add https://hextaui.com/r/pro/prompt-input.json

Com o AI SDK

Passe o status e o stop do useChat. Os arquivos chegam como FileUIPart com uma data URL, e uma mensagem digitada enquanto uma resposta chega em streaming entra na fila e é enviada quando ela termina.

"use client"

import { useChat } from "@ai-sdk/react"
import type { FileUIPart } from "ai"

import { PromptInputAddMenu } from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputQueue,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

function toDataUrl(file: File) {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(String(reader.result))
    reader.onerror = () => reject(reader.error)
    reader.readAsDataURL(file)
  })
}

export function Composer({ chatId }: { chatId: string }) {
  const { sendMessage, status, stop } = useChat({ id: chatId })

  const send = async ({ text, files }: PromptMessage) => {
    const parts: FileUIPart[] = await Promise.all(
      files.map(async ({ file }) => ({
        type: "file" as const,
        mediaType: file.type,
        filename: file.name,
        url: await toDataUrl(file),
      }))
    )
    await sendMessage({ text, files: parts })
  }

  return (
    <PromptInput
      status={status}
      onStop={stop}
      onSubmit={send}
      accept="image/*,.pdf"
      maxFileSize={20 * 1024 * 1024}
      draftKey={chatId}
    >
      <PromptInputQueue />
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu />
        </PromptInputTools>
        <PromptInputTextarea />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
    </PromptInput>
  )
}

Modelo, esforço e ditado

Coloque o seletor de modelo, o slider de esforço e o anel de contexto sob o composer com PromptInputMeta. Ative ferramentas pelo menu + e mostre-as como chips. Passe transcribe para enviar as gravações ao seu próprio modelo de fala e autoSend para enviar assim que o texto chegar.

"use client"

import * as React from "react"
import { IconWorld } from "@tabler/icons-react"

import { DropdownMenuCheckboxItem } from "@/components/ui/dropdown-menu"

import {
  PromptInputAddMenu,
  PromptInputContextUsage,
  PromptInputDictation,
  PromptInputEffort,
  PromptInputMeta,
  PromptInputModelSelect,
  PromptInputTool,
} from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputActions,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

const models = [
  { value: "large", label: "Large" },
  { value: "small", label: "Small" },
]

const efforts = [
  { value: "low", label: "Low" },
  { value: "medium", label: "Medium" },
  { value: "high", label: "High" },
  { value: "max", label: "Max" },
]

export function Composer({
  onSend,
  contextUsed,
  transcribe,
}: {
  onSend: (message: PromptMessage) => void
  contextUsed: number
  transcribe: (audio: Blob) => Promise<string>
}) {
  const [model, setModel] = React.useState("large")
  const [effort, setEffort] = React.useState("medium")
  const [thinking, setThinking] = React.useState(true)
  const [search, setSearch] = React.useState(false)

  return (
    <PromptInput onSubmit={onSend} focusOnType>
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu>
            <DropdownMenuCheckboxItem
              indicator="end"
              checked={search}
              onCheckedChange={setSearch}
            >
              <IconWorld />
              Web search
            </DropdownMenuCheckboxItem>
          </PromptInputAddMenu>
          {search ? (
            <PromptInputTool
              icon={<IconWorld />}
              tone="blue"
              onRemove={() => setSearch(false)}
            >
              Search
            </PromptInputTool>
          ) : null}
        </PromptInputTools>
        <PromptInputTextarea autoFocus />
        <PromptInputActions>
          <PromptInputDictation transcribe={transcribe} autoSend />
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
      <PromptInputMeta>
        <PromptInputModelSelect
          models={models}
          value={model}
          onValueChange={setModel}
        />
        <PromptInputEffort
          levels={efforts}
          value={effort}
          onValueChange={setEffort}
          recommended="medium"
          thinking={thinking}
          onThinkingChange={setThinking}
        />
        <PromptInputContextUsage used={contextUsed} limit={200000} />
      </PromptInputMeta>
    </PromptInput>
  )
}

Menções e comandos

Adicione um menu por gatilho. @ transforma uma escolha em um chip acima do texto, e / insere o comando. As setas movem, Enter ou Tab escolhem e Escape fecha, tudo sem sair do texto.

"use client"

import { IconBrandTypescript, IconTrash } from "@tabler/icons-react"

import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputSubmit,
  PromptInputTextarea,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"
import { PromptInputMenu, type PromptMenuItem } from "@/components/blocks/prompt-input/prompt-menu"

const files: PromptMenuItem[] = [
  {
    value: "app/page.tsx",
    label: "page.tsx",
    description: "app",
    icon: <IconBrandTypescript />,
  },
  {
    value: "lib/utils.ts",
    label: "utils.ts",
    description: "lib",
    icon: <IconBrandTypescript />,
  },
]

const commands: PromptMenuItem[] = [
  { value: "explain", label: "explain", description: "Explain code step by step" },
  { value: "test", label: "test", description: "Write tests for a file" },
  {
    value: "clear",
    label: "clear",
    description: "Start a new conversation",
    icon: <IconTrash />,
    insert: "",
  },
]

export function Composer({
  onSend,
  onClear,
}: {
  onSend: (message: PromptMessage) => void
  onClear: () => void
}) {
  return (
    <PromptInput onSubmit={onSend}>
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTextarea placeholder="Ask anything, @ to add files, / for commands" />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
      <PromptInputMenu trigger="@" items={files} />
      <PromptInputMenu
        trigger="/"
        items={commands}
        onSelect={(item) => {
          if (item.value === "clear") onClear()
        }}
      />
    </PromptInput>
  )
}

Enviando arquivos

Dê ao upload uma função que informe o progresso e respeite o sinal de cancelamento. Lance UploadError com retryable: false para erros que uma nova tentativa não resolve, como um arquivo grande demais para o servidor.

"use client"

import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"
import { PromptInputAddMenu } from "@/components/blocks/prompt-input/prompt-controls"
import { UploadError, type PromptUpload } from "@/components/blocks/prompt-input/use-prompt-files"

const upload: PromptUpload = (file, { signal, onProgress }) =>
  new Promise((resolve, reject) => {
    const request = new XMLHttpRequest()
    request.open("POST", "/api/upload")
    request.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress((event.loaded / event.total) * 100)
    }
    request.onload = () => {
      if (request.status < 300) {
        resolve(JSON.parse(request.responseText))
        return
      }
      reject(
        new UploadError(
          request.status === 413 ? "Too large for the server" : "Upload failed",
          { retryable: request.status === 429 || request.status >= 500 }
        )
      )
    }
    request.onerror = () => reject(new UploadError("Network error. Try again."))
    signal.addEventListener("abort", () => request.abort())
    const body = new FormData()
    body.append("file", file)
    request.send(body)
  })

export function Composer({ onSend }: { onSend: (message: PromptMessage) => void }) {
  return (
    <PromptInput
      onSubmit={onSend}
      upload={upload}
      accept="image/*,.pdf,.csv"
      maxFiles={5}
      maxFileSize={25 * 1024 * 1024}
      globalDrop
    >
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu />
        </PromptInputTools>
        <PromptInputTextarea />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
    </PromptInput>
  )
}

Harness de agente

Envolva o corpo em PromptInputInset e adicione um PromptInputFooter. Use PromptInputSelect para o modo, onde ele roda e em qual pasta trabalha.

"use client"

import * as React from "react"
import {
  IconCloud,
  IconCode,
  IconDeviceLaptop,
  IconFolder,
  IconFolderOpen,
  IconListCheck,
} from "@tabler/icons-react"

import {
  PromptInputAddMenu,
  PromptInputFooter,
  PromptInputInset,
  PromptInputModel,
  PromptInputSelect,
} from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputActions,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

const modes = [
  { value: "code", label: "Code", icon: <IconCode /> },
  { value: "plan", label: "Plan", icon: <IconListCheck /> },
]

const locations = [
  { value: "local", label: "Local", icon: <IconDeviceLaptop /> },
  { value: "cloud", label: "Cloud", icon: <IconCloud /> },
]

const folders = [
  { value: "~/code/app", label: "app", icon: <IconFolder /> },
]

const models = [
  { value: "large", label: "Large" },
  { value: "small", label: "Small" },
]

const efforts = [
  { value: "low", label: "Low" },
  { value: "medium", label: "Medium" },
  { value: "high", label: "High" },
]

export function AgentComposer({
  onSend,
}: {
  onSend: (message: PromptMessage & { mode: string }) => void
}) {
  const [mode, setMode] = React.useState("code")
  const [location, setLocation] = React.useState("local")
  const [folder, setFolder] = React.useState<string>()
  const [model, setModel] = React.useState("large")
  const [effort, setEffort] = React.useState("medium")

  return (
    <PromptInput
      layout="expanded"
      onSubmit={(message) => onSend({ ...message, mode })}
    >
      <PromptInputInset>
        <PromptInputBody>
          <PromptInputAttachments />
          <PromptInputTools>
            <PromptInputAddMenu />
            <PromptInputSelect
              label="Mode"
              options={modes}
              value={mode}
              onValueChange={setMode}
              shortcut="alt+m"
            />
          </PromptInputTools>
          <PromptInputTextarea className="min-h-16" />
          <PromptInputActions>
            <PromptInputModel
              models={models}
              model={model}
              onModelChange={setModel}
              levels={efforts}
              effort={effort}
              onEffortChange={setEffort}
            />
            <PromptInputSubmit />
          </PromptInputActions>
        </PromptInputBody>
        <PromptInputFooter>
          <PromptInputSelect
            label="Run on"
            options={locations}
            value={location}
            onValueChange={setLocation}
          />
          <PromptInputSelect
            label="Folder"
            options={folders}
            value={folder}
            onValueChange={setFolder}
            placeholder="Choose folder"
            icon={<IconFolderOpen />}
          />
        </PromptInputFooter>
      </PromptInputInset>
    </PromptInput>
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
PromptInputO formulário e o estado compartilhado: texto, arquivos, menções, fila e status.
PromptInputBodyA superfície arredondada que alterna entre compacta e expandida.
PromptInputTextareaO campo. Todo o tratamento de teclado fica aqui.
PromptInputToolsLado esquerdo: o menu + e os chips de ferramentas.
PromptInputActionsLado direito: ditado e enviar.
PromptInputSubmitEnviar, parar ou enfileirar, dependendo do status e do texto.
PromptInputAttachmentsChips de arquivos e menções acima do texto.
PromptInputMenuUm menu no cursor para um caractere de gatilho.
PromptInputMetaA linha sob o composer para os controles de modelo, esforço e contexto.
PromptInputModelUm único controle para o modelo e o esforço, com o switch de thinking.
PromptInputInsetUma casca discreta ao redor do corpo que abriga uma linha de rodapé, para harnesses de agentes.
PromptInputFooterA linha dentro da casca inset para os controles de local, pasta e sessão.
PromptInputSelectUm seletor compacto para modos, local de execução, pastas ou repositórios.
PromptInputQueue, PromptInputSuggestions, PromptInputMeterMensagens na fila, prompts iniciais e um medidor de caracteres perto de maxLength.

PromptInput

Também aceita todas as props de form, exceto onSubmit e defaultValue.

PropTipoPadrão
onSubmitRecebe { text, files, mentions }. Retorne uma promise para manter o texto até ela ser resolvida.
(message: PromptMessage) => void | Promise<unknown>–
statusPasse o status do useChat. Enquanto ocupado, enviar vira parar ou enfileirar.
"ready" | "submitted" | "streaming" | "error""ready"
onStopChamado pelo botão de parar ou por Escape.
() => void–
onEditLastRetorne a última mensagem para carregá-la com ↑ em um campo vazio.
() => string | void–
valueTexto controlado.
string–
defaultValueTexto inicial.
string""
onValueChangeChamado conforme o texto muda.
(value: string) => void–
submitOnQual tecla envia. ⌘↵ sempre envia.
"enter" | "mod+enter""enter"
pasteLimitCaracteres antes de uma colagem virar um anexo.
number | false5000
maxLengthLimite rígido, exibido pelo PromptInputMeter.
number–
draftKeySalva o texto não enviado no armazenamento local sob esta chave.
string–
globalDropAceita arquivos soltos em qualquer lugar da página.
booleanfalse
focusOnTypeDigitar enquanto nada mais tem o foco começa a digitar aqui.
booleanfalse
layoutForça um layout em vez de alternar.
"auto" | "compact" | "expanded""auto"
acceptTipos de arquivo aceitos, como o atributo do input.
string–
maxFilesMáximo de arquivos de uma vez.
number–
maxFileSizeMaior arquivo em bytes.
number–
uploadEnvia cada arquivo. Lance UploadError com retryable: false para falhas permanentes.
(file, { signal, onProgress }) => Promise<unknown>–
onRejectChamado para arquivos acima de um limite ou do tipo errado.
(file: File, reason: string) => void–
disabledDesabilita o composer inteiro.
booleanfalse
PropTipoPadrão
triggerO caractere que o abre, como @ ou /.
string–
items{ value, label, description?, icon?, hint?, keywords?, insert? }
PromptMenuItem[]–
modeAs menções adicionam um chip; text insere insert ?? label.
"mention" | "text"–
labelRótulo de seção no topo do menu.
string–
limitMáximo de linhas exibidas.
number–
onSelectChamado após uma escolha.
(item: PromptMenuItem) => void–

PromptInputModel

O seletor de modelo e o slider de esforço em um só controle. Também aceita todas as props do PromptInputEffort, exceto value e onValueChange.

PropTipoPadrão
modelsOs nove primeiros ganham os atalhos 1–9.
{ value, label }[]–
modelModelo selecionado.
string–
onModelChangeChamado ao escolher.
(value: string) => void–
levelsNíveis de esforço, do menor ao maior.
{ value, label }[]–
effortEsforço selecionado.
string–
onEffortChangeChamado enquanto o slider se move.
(value: string) => void–
PropTipoPadrão
modelsOs nove primeiros ganham os atalhos 1–9.
{ value, label }[]–
valueModelo selecionado.
string–
onValueChangeChamado ao escolher.
(value: string) => void–
PropTipoPadrão
levelsDo menor ao maior.
{ value, label }[]–
valueNível selecionado.
string–
onValueChangeChamado enquanto o slider se move.
(value: string) => void–
recommendedMarca um nível como recomendado.
string–
thinkingMostra o switch de extended-thinking quando definido.
boolean–
onThinkingChangeChamado pelo switch.
(thinking: boolean) => void–
thinkingLabelRótulo do switch.
string–
descriptionTexto de ajuda no tooltip.
string–
PropTipoPadrão
usedTokens usados.
number–
limitTamanho da janela de contexto.
number–
limitsLinhas extras, como o uso do plano.
{ label, used, detail? }[]–
limitsLabelTítulo dessas linhas.
string–
PropTipoPadrão
transcribeEnvia a gravação ao seu modelo de fala. Sem isso, o reconhecimento de fala do navegador é usado.
(audio: Blob) => Promise<string>–
shortcutInicia e finaliza uma gravação de qualquer lugar.
string | false"ctrl+shift+d"
autoSendEnvia assim que a transcrição chegar.
booleanfalse
PropTipoPadrão
iconExibido no chip, e substituído por × no hover.
ReactNode–
childrenNome da ferramenta.
string–
toneCor do chip.
"blue" | "green" | "amber" | "violet" | "pink""blue"
onRemoveChamado quando o chip é clicado.
() => void–
PropTipoPadrão
optionsAs opções. As descrições aparecem sob cada rótulo.
{ value, label, icon?, description? }[]–
valueOpção selecionada. Deixe sem definir para mostrar o placeholder.
string–
onValueChangeChamado ao escolher.
(value: string) => void–
labelNome acessível e tooltip, como Mode ou Run on.
string–
placeholderExibido quando nada está selecionado.
string–
iconExibido quando a opção selecionada não tem ícone.
ReactNode–
headingRótulo no topo do menu.
string–
shortcutPercorre as opções de qualquer lugar, como alt+m.
string–
childrenItens de menu extras abaixo das opções, como Open folder.
ReactNode–
TeclaAção
EnterEnvia. Em telas de toque, adiciona uma linha.
ShiftEnterAdiciona uma linha.
⌘EnterSempre envia, mesmo quando submitOn é mod+enter.
EscInterrompe uma resposta em streaming, fecha um menu ou cancela uma gravação.
↑Em um campo vazio, traz de volta sua última mensagem quando onEditLast está definido.
↑↓Percorre um menu @ ou / aberto.
EnterTabEscolhe a linha do menu em destaque.
1–9Escolhe um modelo enquanto o menu de modelos está aberto.
←→Move o slider de esforço. Home e End saltam para as extremidades.
CtrlShiftDInicia o ditado e insere a transcrição quando pressionado de novo.
AltMAlterna um PromptInputSelect que tem shortcut="alt+m", como o modo do agente.
  • O campo tem um placeholder visível e um nome acessível, e todo botão de ícone tem um rótulo e um tooltip com seu atalho.
  • O menu no cursor é um combobox com aria-activedescendant, então o foco nunca sai do texto enquanto você o percorre.
  • Os uploads, as rejeições, as mudanças na fila e o ditado são anunciados por uma região live polite.
  • O controle de esforço é um slider com um texto de valor e continua funcionando com as setas, Home e End.
  • Os inputs têm pelo menos 16px em telas de toque, para que o iOS nunca dê zoom ao focar.
  • Com movimento reduzido, as mudanças de altura, a forma de onda e o preenchimento do esforço se assentam sem animação.

Construído com

Os componentes gratuitos do HextaUI de que Prompt Input é feito. Cada um é instalado separadamente.

Código

10 arquivos, adicionados a components/blocks/prompt-input.