19 / Agent UI
Questionnaire
The questions an agent asks before it starts. One at a time, with single or multiple answers, an open answer alongside them, and required ones it will not move past.
"use client"
import * as React from "react"
import {
Questionnaire,
type Question,
type QuestionnaireAnswers,
} from "mischief-ui/questionnaire"
const questions: Question[] = [
{
id: "scope",
prompt: "Which files should I rename?",
description: "I found 41 matches across the repository.",
required: true,
choices: [
{ id: "open", label: "Only the file I have open" },
{ id: "src", label: "Everything under src", description: "38 files" },
{ id: "all", label: "Every match", description: "41 files" },
],
},
{
id: "extras",
prompt: "Anything to do alongside it?",
multiple: true,
freeformPlaceholder: "Something else…",
choices: [
{ id: "tests", label: "Update the tests" },
{ id: "imports", label: "Fix the imports" },
{ id: "changelog", label: "Add a changelog entry" },
],
},
{
id: "review",
prompt: "How should I hand it back?",
required: true,
choices: [
{ id: "diff", label: "Show me the diff first" },
{ id: "commit", label: "Commit it on a branch" },
],
},
]
export function QuestionnaireDemo() {
const [answers, setAnswers] = React.useState<QuestionnaireAnswers>()
if (answers) {
return (
<div className="grid w-full max-w-md gap-3 text-sm">
<p className="font-semibold">Thanks, that is enough to start.</p>
<ul className="text-muted-foreground grid gap-1">
{questions.map((question) => (
<li key={question.id}>
{question.id}:{" "}
{(answers[question.id] ?? [])
.map((value) => value.replace("__freeform__", ""))
.join(", ") || "skipped"}
</li>
))}
</ul>
<button
className="restore-button justify-self-start"
type="button"
onClick={() => setAnswers(undefined)}
>
Ask again
</button>
</div>
)
}
return (
<Questionnaire
className="w-full max-w-md"
questions={questions}
onSubmit={setAnswers}
/>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/questionnaireimport { Questionnaire } from "mischief-ui/questionnaire"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { ArrowLeft, ArrowRight, Check, TriangleAlert } from "lucide-react"import { cn } from "@/lib/utils" export type QuestionChoice = { id: string label: React.ReactNode description?: React.ReactNode} export type Question = { id: stringUsage
const questions = [
{
id: "scope",
prompt: "What should I change?",
required: true,
choices: [
{ id: "one", label: "Only this file" },
{ id: "all", label: "Every file that matches" },
],
},
]
export function Clarify() {
return <Questionnaire questions={questions} onSubmit={start} />
}The answer shape
Answers are a record of question id to an array of strings, whatever the question. A single-choice question holds one entry, a multiple-choice question holds several, and a freeform answer is the typed text itself. One shape means reading the result never depends on how the question was configured.
{
"scope": ["invoices"],
"fields": ["total", "tax", "due-date"],
"notes": ["Skip anything before 2024"]
}A question with required set is not satisfied until its array is non-empty, and submission stays blocked until every required question is.
Freeform answers
Every question offers an open text answer by default, because the moment the choices do not cover the case, a fixed list forces a wrong answer. Turn it off for the whole set with freeform={false}, or per question, when the choices really are exhaustive.
<Questionnaire
questions={questions}
freeform={false}
onSubmit={start}
/>Keyboard
- Number keys pick the matching choice, and are ignored while a text field has focus.
- Tab reaches every choice and the freeform field in order.
- Enter submits once the required questions are answered.
API
questionsQuestion[]Prompt, optional description, choices, and flags for multiple, freeform, and required.answers, defaultAnswers, onAnswersChangeQuestionnaireAnswersChosen choice ids per question, controlled or uncontrolled.onSubmit(answers: QuestionnaireAnswers) => voidRuns with every answer once the last question is submitted.freeformbooleanOffers an open answer on every question. Defaults to true, and a question can set its own.shortcutsbooleanNumber keys pick the choice they label. Defaults to true.showProgressbooleanShows the position and a progress bar.previousLabel, nextLabel, skipLabel, submitLabel, requiredMessagestringCopy for the controls and the validation message.Question
idstringKey this question's answer is stored under.promptReactNodeThe question itself.descriptionReactNodeA clarifying line beneath the prompt.choicesQuestionChoice[]Offered answers. Omit for a purely open question.multiplebooleanAllows more than one choice.freeformbooleanOverrides the set-wide setting for this question.freeformLabel, freeformPlaceholderstring, stringWording for the open answer.requiredbooleanBlocks submission until answered.QuestionChoice
idstringWhat lands in the answer array.labelReactNodeThe choice as shown.descriptionReactNodeA second line under the choice.Accessibility
Each question is a fieldset with its prompt as the legend, so the whole question is announced rather than a run of loose options. A single answer uses radios and several uses checkboxes, which brings the right keyboard behaviour without rebuilding it. Position is reported in a polite live region, and a required question that is not answered raises an alert tied to the inputs rather than only colouring them. Number shortcuts are ignored while a freeform answer is being typed.