1
0
Эх сурвалжийг харах

feat(tui): render session forms (#35421)

Aiden Cline 1 сар өмнө
parent
commit
5bcf8d5a0b

+ 31 - 0
packages/tui/src/context/data.tsx

@@ -1,6 +1,8 @@
 import type {
   AgentV2Info,
   CommandV2Info,
+  FormFormInfo,
+  FormUrlInfo,
   IntegrationInfo,
   LocationRef,
   McpServer,
@@ -29,6 +31,8 @@ export type DataSessionStatus = "idle" | "running"
 
 const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
 
+export type FormInfo = FormFormInfo | FormUrlInfo
+
 type LocationData = {
   agent?: AgentV2Info[]
   command?: CommandV2Info[]
@@ -54,6 +58,8 @@ type Data = {
     message: Record<string, SessionMessage[]>
     permission: Record<string, PermissionV2Request[]>
     question: Record<string, QuestionV2Request[]>
+    // Pending forms keyed by session ID.
+    form: Record<string, FormInfo[]>
   }
   project: {
     permission: Record<string, PermissionSavedInfo[]>
@@ -88,6 +94,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         message: {},
         permission: {},
         question: {},
+        form: {},
       },
       project: {
         permission: {},
@@ -602,6 +609,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             ),
           )
           break
+        case "form.created":
+          if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
+          setStore("session", "form", event.data.form.sessionID, [
+            ...(store.session.form[event.data.form.sessionID] ?? []),
+            mutable(event.data.form),
+          ])
+          break
+        case "form.replied":
+        case "form.cancelled":
+          setStore(
+            "session",
+            "form",
+            event.data.sessionID,
+            (store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id),
+          )
+          break
         case "shell.created":
           setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
             ...data,
@@ -728,6 +751,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID })))
           },
         },
+        form: {
+          list(sessionID: string) {
+            return store.session.form[sessionID]
+          },
+          async refresh(sessionID: string) {
+            setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID })))
+          },
+        },
       },
       project: {
         permission: {

+ 16 - 0
packages/tui/src/feature-plugins/system/notifications.ts

@@ -29,9 +29,25 @@ function sessionErrorMessage(error: SessionError) {
 const tui: TuiPlugin = async (api) => {
   const active = new Set<string>()
   const errored = new Set<string>()
+  const forms = new Set<string>()
   const questions = new Set<string>()
   const permissions = new Set<string>()
 
+  api.event.on("form.created", (event) => {
+    if (event.data.form.sessionID === "global") return
+    if (forms.has(event.data.form.id)) return
+    forms.add(event.data.form.id)
+    notify(api, event.data.form.sessionID, "Input needs response", "question")
+  })
+
+  api.event.on("form.replied", (event) => {
+    forms.delete(event.data.id)
+  })
+
+  api.event.on("form.cancelled", (event) => {
+    forms.delete(event.data.id)
+  })
+
   api.event.on("question.asked", (event) => {
     if (questions.has(event.data.id)) return
     questions.add(event.data.id)

+ 1009 - 0
packages/tui/src/routes/session/form.tsx

@@ -0,0 +1,1009 @@
+import { createStore } from "solid-js/store"
+import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { useRenderer, useTerminalDimensions } from "@opentui/solid"
+import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
+import open from "open"
+import { selectedForeground, tint, useTheme } from "../../context/theme"
+import type { FormFormInfo, FormValue } from "@opencode-ai/sdk/v2"
+import type { FormInfo } from "../../context/data"
+import { useSDK } from "../../context/sdk"
+import { SplitBorder } from "../../ui/border"
+import { useTuiConfig } from "../../config"
+import { useBindings, useOpencodeModeStack } from "../../keymap"
+
+const FORM_MODE = "form"
+
+type Field = FormFormInfo["fields"][number]
+
+function fieldLabel(field: Field) {
+  return field.title ?? field.key
+}
+
+function truncate(label: string, max: number) {
+  return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
+}
+
+function validateText(field: Field, text: string): string | undefined {
+  if (field.type !== "string") return
+  if (field.minLength !== undefined && text.length < field.minLength)
+    return `Must be at least ${field.minLength} characters`
+  if (field.maxLength !== undefined && text.length > field.maxLength)
+    return `Must be at most ${field.maxLength} characters`
+  if (field.pattern !== undefined) {
+    try {
+      if (!new RegExp(field.pattern).test(text)) return `Must match pattern: ${field.pattern}`
+    } catch {
+      return `Invalid pattern: ${field.pattern}`
+    }
+  }
+  if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) return "Expected an email address"
+  if (field.format === "uri") {
+    try {
+      new URL(text)
+    } catch {
+      return "Expected a URL"
+    }
+  }
+  if (field.format === "date") {
+    const date = new Date(`${text}T00:00:00.000Z`)
+    if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text)
+      return "Expected a date (YYYY-MM-DD)"
+  }
+  if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time"
+}
+
+function validateSelection(field: Field, value: FormValue | undefined) {
+  if (field.type !== "multiselect" || value === undefined) return
+  if (!Array.isArray(value)) return "Expected selections"
+  if (field.required && value.length === 0) return "Select at least one option"
+  if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
+  if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
+}
+
+function validateValue(field: Field, value: FormValue | undefined) {
+  if (value === undefined) return field.required ? "Answer required" : undefined
+  if (field.required && (value === "" || (Array.isArray(value) && value.length === 0))) {
+    return field.type === "multiselect" ? "Select at least one option" : "Answer required"
+  }
+  if (field.type === "string") {
+    if (typeof value !== "string") return "Expected text"
+    const invalid = validateText(field, value)
+    if (invalid) return invalid
+    if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
+      return "Select an available option"
+    }
+    return
+  }
+  if (field.type === "number" || field.type === "integer") {
+    if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
+    if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer"
+    if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}`
+    if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}`
+    return
+  }
+  if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
+  const invalid = validateSelection(field, value)
+  if (invalid) return invalid
+  if (
+    Array.isArray(value) &&
+    !field.custom &&
+    value.some((item) => !field.options.some((option) => option.value === item))
+  ) {
+    return "Select only available options"
+  }
+}
+
+function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] {
+  if (field.type === "boolean")
+    return [
+      { value: true, label: "Yes" },
+      { value: false, label: "No" },
+    ]
+  if (field.type === "multiselect" || (field.type === "string" && field.options))
+    return (field.options ?? []).map((option) => ({
+      value: option.value,
+      label: option.label,
+      description: option.description,
+    }))
+  return []
+}
+
+function selectedRow(field: Field | undefined, value: FormValue | undefined) {
+  if (!field || value === undefined || Array.isArray(value)) return 0
+  const rows = fieldRows(field)
+  const index = rows.findIndex((row) => row.value === value)
+  if (index !== -1) return index
+  if (typeof value === "string" && field.type === "string" && field.options && field.custom) return rows.length
+  return 0
+}
+
+function customDefault(field: Field) {
+  if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return
+  if (!field.options.some((option) => option.value === field.default)) return field.default
+}
+
+function display(field: Field, value: FormValue | undefined) {
+  if (value === undefined) return ""
+  const label = (item: string | number | boolean) =>
+    fieldRows(field).find((row) => row.value === item)?.label ?? String(item)
+  if (Array.isArray(value)) return value.length === 0 ? "(none)" : value.map(label).join(", ")
+  return label(value)
+}
+
+export function FormPrompt(props: { form: FormInfo }) {
+  return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
+}
+
+function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
+  const sdk = useSDK()
+  const { theme } = useTheme()
+  const modeStack = useOpencodeModeStack()
+  const message = createMemo(() => {
+    const value = props.form.metadata?.["message"]
+    return typeof value === "string" ? value : undefined
+  })
+
+  onMount(() => onCleanup(modeStack.push(FORM_MODE)))
+
+  useBindings(() => ({
+    mode: FORM_MODE,
+    enabled: true,
+    commands: [
+      {
+        name: "app.exit",
+        title: "Dismiss form",
+        category: "Form",
+        run() {
+          void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
+        },
+      },
+    ],
+    bindings: [
+      {
+        key: "return",
+        desc: "Open link",
+        group: "Form",
+        cmd: () => {
+          void open(props.form.url)
+        },
+      },
+      {
+        key: "escape",
+        desc: "Dismiss form",
+        group: "Form",
+        cmd: () => {
+          void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
+        },
+      },
+    ],
+  }))
+
+  return (
+    <box
+      backgroundColor={theme.backgroundPanel}
+      border={["left"]}
+      borderColor={theme.accent}
+      customBorderChars={SplitBorder.customBorderChars}
+    >
+      <box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
+        <text fg={theme.text}>{props.form.title ?? "Input requested"}</text>
+        <Show when={message()}>
+          <text fg={theme.textMuted}>{message()}</text>
+        </Show>
+        <text fg={theme.secondary}>{props.form.url}</text>
+      </box>
+      <box flexDirection="row" flexShrink={0} gap={2} paddingLeft={2} paddingRight={3} paddingBottom={1}>
+        <text fg={theme.text}>
+          enter <span style={{ fg: theme.textMuted }}>open link</span>
+        </text>
+        <text fg={theme.text}>
+          esc <span style={{ fg: theme.textMuted }}>dismiss</span>
+        </text>
+      </box>
+    </box>
+  )
+}
+
+function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
+  const sdk = useSDK()
+  const { theme } = useTheme()
+  const renderer = useRenderer()
+  const dimensions = useTerminalDimensions()
+  const tuiConfig = useTuiConfig()
+  const modeStack = useOpencodeModeStack()
+
+  const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
+  const [store, setStore] = createStore({
+    tab: 0,
+    answers: Object.fromEntries(
+      props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
+    ) as Record<string, FormValue | undefined>,
+    custom: Object.fromEntries(
+      props.form.fields.flatMap((field) => {
+        const value = customDefault(field)
+        return value === undefined ? [] : [[field.key, value]]
+      }),
+    ) as Record<string, string>,
+    selected: selectedRow(props.form.fields[0], props.form.fields[0]?.default),
+    editing: false,
+    error: "",
+  })
+
+  let textarea: TextareaRenderable | undefined
+  let review: ScrollBoxRenderable | undefined
+
+  const fields = createMemo(() => {
+    const answers: Record<string, FormValue | undefined> = {}
+    return props.form.fields.filter((field) => {
+      const active = (field.when ?? []).every((when) => {
+        const value = answers[when.key]
+        if (value === undefined) return false
+        const hit = Array.isArray(value) ? value.some((item) => item === when.value) : value === when.value
+        return when.op === "eq" ? hit : !hit
+      })
+      if (active) answers[field.key] = store.answers[field.key]
+      return active
+    })
+  })
+  const single = createMemo(() => {
+    const list = fields()
+    if (props.form.fields.length !== 1) return false
+    if (list.length !== 1) return false
+    const field = list[0]!
+    return field.type === "boolean" || (field.type === "string" && field.options !== undefined)
+  })
+  const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
+  const tabbed = createMemo(() => {
+    const width = fields().reduce((sum, item) => sum + truncate(fieldLabel(item), 24).length + 3, "Confirm".length + 3)
+    return width <= dimensions().width - 8
+  })
+  const answered = createMemo(
+    () =>
+      fields().filter((item) => {
+        const value = store.answers[item.key]
+        return value !== undefined
+      }).length,
+  )
+  const field = createMemo(() => fields()[Math.min(store.tab, fields().length - 1)])
+  const confirm = createMemo(() => !single() && store.tab >= fields().length)
+  const rows = createMemo(() => {
+    const current = field()
+    if (!current) return []
+    const configured = fieldRows(current)
+    const value = store.answers[current.key]
+    if (current.type !== "multiselect" || !Array.isArray(value)) return configured
+    const known = new Set(configured.map((row) => row.value))
+    return [
+      ...configured,
+      ...value.filter((item) => !known.has(item)).map((item) => ({ value: item, label: item, description: undefined })),
+    ]
+  })
+  const textual = createMemo(() => {
+    if (confirm()) return false
+    const current = field()
+    if (!current) return false
+    if (current.type === "number" || current.type === "integer") return true
+    return current.type === "string" && current.options === undefined
+  })
+  const custom = createMemo(() => {
+    const current = field()
+    if (!current) return false
+    if (current.type === "string" && current.options !== undefined) return current.custom === true
+    if (current.type === "multiselect") return current.custom === true
+    return false
+  })
+  const multi = createMemo(() => field()?.type === "multiselect")
+  const placeholder = createMemo(() => {
+    const current = field()
+    if (current?.type === "string") {
+      if (current.placeholder) return current.placeholder
+      if (current.format === "email") return "name@example.com"
+      if (current.format === "uri") return "https://example.com"
+      if (current.format === "date") return "YYYY-MM-DD"
+      if (current.format === "date-time") return "YYYY-MM-DDTHH:MM:SSZ"
+    }
+    if (current?.type === "number" || current?.type === "integer") {
+      const minimum = typeof current.minimum === "number" ? current.minimum : undefined
+      const maximum = typeof current.maximum === "number" ? current.maximum : undefined
+      if (minimum !== undefined && maximum !== undefined) return `${minimum}-${maximum}`
+      if (minimum !== undefined) return `at least ${minimum}`
+      if (maximum !== undefined) return `at most ${maximum}`
+    }
+    return "Type your answer"
+  })
+  const other = createMemo(() => custom() && store.selected === rows().length)
+  const input = createMemo(() => store.custom[field()?.key ?? ""] ?? "")
+  const customPicked = createMemo(() => {
+    const value = input()
+    if (!value) return false
+    const answer = store.answers[field()?.key ?? ""]
+    if (Array.isArray(answer)) return answer.includes(value)
+    return answer === value
+  })
+
+  function answer(key: string, value: FormValue | undefined) {
+    setStore("answers", { ...store.answers, [key]: value })
+    setStore("error", "")
+  }
+
+  function replySingle(field: Field, value: FormValue) {
+    sdk.api.form
+      .reply({
+        sessionID: props.form.sessionID,
+        formID: props.form.id,
+        answer: { [field.key]: value },
+      })
+      .catch((error: unknown) => {
+        setStore(
+          "error",
+          typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
+            ? error.message
+            : "Invalid answer",
+        )
+      })
+  }
+
+  function pick(value: FormValue, customValue?: string) {
+    const current = field()
+    if (!current) return
+    const invalid = validateValue(current, value)
+    if (invalid) {
+      setStore("error", invalid)
+      return
+    }
+    answer(current.key, value)
+    if (customValue !== undefined) setStore("custom", { ...store.custom, [current.key]: customValue })
+    if (single()) {
+      replySingle(current, value)
+      return
+    }
+    selectTab(store.tab + 1)
+  }
+
+  function toggle(value: string) {
+    const current = field()
+    if (!current) return
+    const existing = store.answers[current.key]
+    const list = Array.isArray(existing) ? [...existing] : []
+    const index = list.indexOf(value)
+    if (index === -1) list.push(value)
+    if (index !== -1) list.splice(index, 1)
+    answer(current.key, list)
+  }
+
+  function validateCurrent() {
+    if (confirm()) return true
+    const current = field()
+    if (!current) return true
+    const invalid = validateValue(current, store.answers[current.key])
+    if (!invalid) return true
+    setStore("error", invalid)
+    return false
+  }
+
+  function selectTab(index: number) {
+    if (!confirm() && index > store.tab && !validateCurrent()) return
+    const next = fields()[index]
+    setStore("tab", index)
+    setStore("selected", selectedRow(next, next ? store.answers[next.key] : undefined))
+    setStore("editing", false)
+    setStore("error", "")
+  }
+
+  function selectOption() {
+    if (other()) {
+      if (!multi()) {
+        setStore("editing", true)
+        return
+      }
+      const value = input()
+      if (value && customPicked()) {
+        toggle(value)
+        return
+      }
+      setStore("editing", true)
+      return
+    }
+    const row = rows()[store.selected]
+    if (!row) return
+    if (multi()) {
+      toggle(String(row.value))
+      return
+    }
+    pick(row.value)
+  }
+
+  function commitInput(text: string) {
+    const current = field()
+    if (!current) return false
+    const isTextual = textual()
+    const isMulti = multi()
+    if (!text) {
+      const previous = store.custom[current.key]
+      const existing = store.answers[current.key]
+      const values = Array.isArray(existing) ? existing.filter((value) => value !== previous) : []
+      const value = !isTextual && isMulti && Array.isArray(existing) ? values : undefined
+      const invalid = validateValue(current, value)
+      if (invalid) {
+        setStore("error", invalid)
+        return false
+      }
+      answer(current.key, value)
+      setStore("custom", { ...store.custom, [current.key]: "" })
+      setStore("editing", false)
+      return true
+    }
+
+    if (isTextual && (current.type === "number" || current.type === "integer")) {
+      const value = Number(text)
+      const invalid = validateValue(current, value)
+      if (invalid) {
+        setStore("error", invalid)
+        return false
+      }
+      answer(current.key, value)
+    }
+
+    if (isTextual && current.type === "string") {
+      const invalid = validateValue(current, text)
+      if (invalid) {
+        setStore("error", invalid)
+        return false
+      }
+      answer(current.key, text)
+    }
+
+    if (!isTextual && isMulti) {
+      const previous = store.custom[current.key]
+      const existing = store.answers[current.key]
+      const values = Array.isArray(existing) ? [...existing] : []
+      if (previous) {
+        const index = values.indexOf(previous)
+        if (index !== -1) values.splice(index, 1)
+      }
+      if (!values.includes(text)) values.push(text)
+      answer(current.key, values)
+    }
+
+    if (!isTextual && !isMulti) {
+      const invalid = validateValue(current, text)
+      if (invalid) {
+        setStore("error", invalid)
+        return false
+      }
+      answer(current.key, text)
+    }
+
+    const configured = current.type === "string" && current.options?.some((option) => option.value === text)
+    setStore("custom", { ...store.custom, [current.key]: isMulti || configured ? "" : text })
+    setStore("editing", false)
+    return true
+  }
+
+  function submitInput(text: string, direction: 1 | -1 = 1) {
+    if (!commitInput(text)) {
+      if (direction === -1) selectTab((store.tab + direction + tabs()) % tabs())
+      return
+    }
+    if (!single()) selectTab((store.tab + direction + tabs()) % tabs())
+  }
+
+  function selectTabFromMouse(target?: Field) {
+    const targetIndex = () => {
+      const index = target ? fields().findIndex((field) => field.key === target.key) : fields().length
+      return index === -1 ? fields().length : index
+    }
+    const move = () => selectTab(targetIndex())
+    if (!textual() && !store.editing) {
+      move()
+      return
+    }
+    if (!commitInput(textarea?.plainText?.trim() ?? "")) {
+      if (targetIndex() < store.tab) move()
+      return
+    }
+    move()
+  }
+
+  onMount(() => onCleanup(modeStack.push(FORM_MODE)))
+
+  useBindings(() => ({
+    mode: FORM_MODE,
+    enabled: (store.editing || textual()) && !confirm(),
+    commands: [
+      {
+        name: "prompt.clear",
+        title: "Clear answer edit",
+        category: "Form",
+        run() {
+          const text = textarea?.plainText ?? ""
+          if (!text) {
+            setStore("editing", false)
+            return
+          }
+          textarea?.setText("")
+        },
+      },
+    ],
+    bindings: [
+      {
+        key: "escape",
+        desc: "Cancel answer edit",
+        group: "Form",
+        cmd: () => {
+          if (textual()) {
+            void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
+            return
+          }
+          setStore("editing", false)
+        },
+      },
+      ...tuiConfig.keybinds.get("prompt.clear"),
+      {
+        key: "tab",
+        desc: "Next field",
+        group: "Form",
+        cmd: () => {
+          const text = textarea?.plainText?.trim() ?? ""
+          submitInput(text)
+        },
+      },
+      {
+        key: "shift+tab",
+        desc: "Previous field",
+        group: "Form",
+        cmd: () => {
+          const text = textarea?.plainText?.trim() ?? ""
+          submitInput(text, -1)
+        },
+      },
+      {
+        key: "return",
+        desc: "Submit answer edit",
+        group: "Form",
+        cmd: () => {
+          const text = textarea?.plainText?.trim() ?? ""
+          const current = field()
+          if (!current) return
+          if (textual()) {
+            submitInput(text)
+            return
+          }
+          const wasMulti = multi()
+          if (!commitInput(text) || wasMulti || !text) return
+          if (single()) {
+            replySingle(current, text)
+            return
+          }
+          selectTab(store.tab + 1)
+        },
+      },
+    ],
+  }))
+
+  useBindings(() => {
+    const total = rows().length + (custom() ? 1 : 0)
+    const max = Math.min(total, 9)
+
+    return {
+      mode: FORM_MODE,
+      enabled: !store.editing && !textual(),
+      commands: [
+        {
+          name: "app.exit",
+          title: "Dismiss form",
+          category: "Form",
+          run() {
+            void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
+          },
+        },
+      ],
+      bindings: [
+        {
+          key: "left",
+          desc: "Previous field",
+          group: "Form",
+          cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
+        },
+        {
+          key: "h",
+          desc: "Previous field",
+          group: "Form",
+          cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
+        },
+        { key: "right", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
+        { key: "l", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
+        {
+          key: "tab",
+          desc: "Next field",
+          group: "Form",
+          cmd: () => selectTab((store.tab + 1) % tabs()),
+        },
+        {
+          key: "shift+tab",
+          desc: "Previous field",
+          group: "Form",
+          cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
+        },
+        ...(confirm()
+          ? [
+              {
+                key: "return",
+                desc: "Submit form",
+                group: "Form",
+                cmd: () => {
+                  const invalid = fields().find((field) => validateValue(field, store.answers[field.key]))
+                  if (invalid) {
+                    setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
+                    return
+                  }
+                  sdk.api.form
+                    .reply({
+                      sessionID: props.form.sessionID,
+                      formID: props.form.id,
+                      answer: Object.fromEntries(
+                        fields().flatMap((field) => {
+                          const value = store.answers[field.key]
+                          return value === undefined ? [] : [[field.key, value] as const]
+                        }),
+                      ),
+                    })
+                    .catch((error: unknown) => {
+                      setStore(
+                        "error",
+                        typeof error === "object" &&
+                          error !== null &&
+                          "message" in error &&
+                          typeof error.message === "string"
+                          ? error.message
+                          : "Invalid answer",
+                      )
+                    })
+                },
+              },
+              {
+                key: "escape",
+                desc: "Dismiss form",
+                group: "Form",
+                cmd: () => {
+                  void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
+                },
+              },
+              { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
+              { key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
+              { key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
+              { key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
+              ...tuiConfig.keybinds.get("app.exit"),
+            ]
+          : [
+              ...Array.from({ length: max }, (_, index) => ({
+                key: String(index + 1),
+                desc: `Select answer ${index + 1}`,
+                group: "Form",
+                cmd: () => {
+                  setStore("selected", index)
+                  selectOption()
+                },
+              })),
+              {
+                key: "up",
+                desc: "Previous answer",
+                group: "Form",
+                cmd: () => setStore("selected", (store.selected - 1 + total) % total),
+              },
+              {
+                key: "k",
+                desc: "Previous answer",
+                group: "Form",
+                cmd: () => setStore("selected", (store.selected - 1 + total) % total),
+              },
+              {
+                key: "down",
+                desc: "Next answer",
+                group: "Form",
+                cmd: () => setStore("selected", (store.selected + 1) % total),
+              },
+              {
+                key: "j",
+                desc: "Next answer",
+                group: "Form",
+                cmd: () => setStore("selected", (store.selected + 1) % total),
+              },
+              { key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() },
+              {
+                key: "escape",
+                desc: "Dismiss form",
+                group: "Form",
+                cmd: () => {
+                  void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
+                },
+              },
+              ...tuiConfig.keybinds.get("app.exit"),
+            ]),
+      ],
+    }
+  })
+
+  return (
+    <box
+      backgroundColor={theme.backgroundPanel}
+      border={["left"]}
+      borderColor={theme.accent}
+      customBorderChars={SplitBorder.customBorderChars}
+    >
+      <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
+        <Show when={props.form.title}>
+          <box paddingLeft={1}>
+            <text fg={theme.textMuted}>{props.form.title}</text>
+          </box>
+        </Show>
+        <Show when={!single() && !tabbed()}>
+          <box flexDirection="row" gap={1} paddingLeft={1}>
+            <text fg={theme.textMuted}>
+              {confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
+            </text>
+            <text fg={theme.textMuted}>
+              · {answered()}/{fields().length} answered
+            </text>
+          </box>
+        </Show>
+        <Show when={!single() && tabbed()}>
+          <box flexDirection="row" gap={1} paddingLeft={1}>
+            <For each={fields()}>
+              {(item, index) => {
+                const isTab = () => index() === store.tab
+                const isAnswered = () => store.answers[item.key] !== undefined
+                return (
+                  <box
+                    paddingLeft={1}
+                    paddingRight={1}
+                    backgroundColor={
+                      isTab() ? theme.accent : tabHover() === index() ? theme.backgroundElement : theme.backgroundPanel
+                    }
+                    onMouseOver={() => setTabHover(index())}
+                    onMouseOut={() => setTabHover(null)}
+                    onMouseUp={() => {
+                      if (renderer.getSelection()?.getSelectedText()) return
+                      selectTabFromMouse(item)
+                    }}
+                  >
+                    <text
+                      fg={
+                        isTab() ? selectedForeground(theme, theme.accent) : isAnswered() ? theme.text : theme.textMuted
+                      }
+                    >
+                      {truncate(fieldLabel(item), 24)}
+                    </text>
+                  </box>
+                )
+              }}
+            </For>
+            <box
+              paddingLeft={1}
+              paddingRight={1}
+              backgroundColor={
+                confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
+              }
+              onMouseOver={() => setTabHover("confirm")}
+              onMouseOut={() => setTabHover(null)}
+              onMouseUp={() => {
+                if (renderer.getSelection()?.getSelectedText()) return
+                selectTabFromMouse()
+              }}
+            >
+              <text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
+            </box>
+          </box>
+        </Show>
+
+        <Show when={!confirm() && field()}>
+          <box paddingLeft={1} gap={1}>
+            <box>
+              <text fg={theme.text}>
+                {field()!.description ?? fieldLabel(field()!)}
+                {field()!.required ? " (required)" : ""}
+                {multi() ? " (select all that apply)" : ""}
+              </text>
+            </box>
+            <Show when={textual() ? field()!.key : undefined} keyed>
+              <box paddingLeft={1}>
+                <textarea
+                  ref={(val: TextareaRenderable) => {
+                    textarea = val
+                    val.traits = { status: "ANSWER" }
+                    queueMicrotask(() => {
+                      val.focus()
+                      val.gotoLineEnd()
+                    })
+                  }}
+                  initialValue={input() || display(field()!, store.answers[field()!.key])}
+                  placeholder={placeholder()}
+                  placeholderColor={theme.textMuted}
+                  minHeight={1}
+                  maxHeight={6}
+                  textColor={theme.text}
+                  focusedTextColor={theme.text}
+                  cursorColor={theme.primary}
+                />
+              </box>
+            </Show>
+            <Show when={!textual()}>
+              <box>
+                <For each={rows()}>
+                  {(row, i) => {
+                    const active = () => i() === store.selected
+                    const picked = () => {
+                      const value = store.answers[field()?.key ?? ""]
+                      if (Array.isArray(value)) return value.includes(String(row.value))
+                      return value === row.value
+                    }
+                    return (
+                      <box
+                        onMouseOver={() => setStore("selected", i())}
+                        onMouseDown={() => setStore("selected", i())}
+                        onMouseUp={() => {
+                          if (renderer.getSelection()?.getSelectedText()) return
+                          selectOption()
+                        }}
+                      >
+                        <box flexDirection="row">
+                          <box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
+                            <text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
+                              {`${i() + 1}.`}
+                            </text>
+                          </box>
+                          <box backgroundColor={active() ? theme.backgroundElement : undefined}>
+                            <text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
+                              {multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
+                            </text>
+                          </box>
+                          <Show when={!multi()}>
+                            <text fg={theme.success}>{picked() ? " ✓" : ""}</text>
+                          </Show>
+                        </box>
+                        <Show when={row.description}>
+                          <box paddingLeft={3}>
+                            <text fg={theme.textMuted}>{row.description}</text>
+                          </box>
+                        </Show>
+                      </box>
+                    )
+                  }}
+                </For>
+                <Show when={custom()}>
+                  <box
+                    onMouseOver={() => setStore("selected", rows().length)}
+                    onMouseDown={() => setStore("selected", rows().length)}
+                    onMouseUp={() => {
+                      if (renderer.getSelection()?.getSelectedText()) return
+                      selectOption()
+                    }}
+                  >
+                    <box flexDirection="row">
+                      <box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
+                        <text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
+                          {`${rows().length + 1}.`}
+                        </text>
+                      </box>
+                      <box backgroundColor={other() ? theme.backgroundElement : undefined}>
+                        <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
+                          {multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
+                        </text>
+                      </box>
+                      <Show when={!multi()}>
+                        <text fg={theme.success}>{customPicked() ? " ✓" : ""}</text>
+                      </Show>
+                    </box>
+                    <Show when={store.editing}>
+                      <box paddingLeft={3}>
+                        <textarea
+                          ref={(val: TextareaRenderable) => {
+                            textarea = val
+                            val.traits = { status: "ANSWER" }
+                            queueMicrotask(() => {
+                              val.focus()
+                              val.gotoLineEnd()
+                            })
+                          }}
+                          initialValue={input()}
+                          placeholder="Type your own answer"
+                          placeholderColor={theme.textMuted}
+                          minHeight={1}
+                          maxHeight={6}
+                          textColor={theme.text}
+                          focusedTextColor={theme.text}
+                          cursorColor={theme.primary}
+                        />
+                      </box>
+                    </Show>
+                    <Show when={!store.editing && input()}>
+                      <box paddingLeft={3}>
+                        <text fg={theme.textMuted}>{input()}</text>
+                      </box>
+                    </Show>
+                  </box>
+                </Show>
+              </box>
+            </Show>
+          </box>
+        </Show>
+
+        <Show when={confirm()}>
+          <Show when={tabbed()}>
+            <box paddingLeft={1}>
+              <text fg={theme.text}>Review</text>
+            </box>
+          </Show>
+          <scrollbox
+            maxHeight={Math.min(fields().length, Math.max(3, dimensions().height - 14))}
+            scrollbarOptions={{ visible: false }}
+            ref={(r: ScrollBoxRenderable) => (review = r)}
+          >
+            <For each={fields()}>
+              {(item) => {
+                const value = () => display(item, store.answers[item.key])
+                const answered = () => {
+                  const value = store.answers[item.key]
+                  return value !== undefined
+                }
+                const missing = () => !answered() && item.required === true
+                const invalid = () => validateValue(item, store.answers[item.key])
+                return (
+                  <box paddingLeft={1}>
+                    <text>
+                      <span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
+                      <span
+                        style={{ fg: invalid() || missing() ? theme.error : answered() ? theme.text : theme.textMuted }}
+                      >
+                        {invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
+                      </span>
+                    </text>
+                  </box>
+                )
+              }}
+            </For>
+          </scrollbox>
+        </Show>
+      </box>
+      <box
+        flexDirection="row"
+        flexShrink={0}
+        gap={1}
+        paddingLeft={2}
+        paddingRight={3}
+        paddingBottom={1}
+        justifyContent="space-between"
+      >
+        <box flexDirection="row" gap={2}>
+          <Show when={!single()}>
+            <text fg={theme.text}>
+              {"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
+            </text>
+          </Show>
+          <Show when={!confirm() && !textual()}>
+            <text fg={theme.text}>
+              {"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
+            </text>
+          </Show>
+          <Show when={confirm()}>
+            <text fg={theme.text}>
+              {"↑↓"} <span style={{ fg: theme.textMuted }}>scroll</span>
+            </text>
+          </Show>
+          <text fg={theme.text}>
+            enter{" "}
+            <span style={{ fg: theme.textMuted }}>
+              {confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
+            </span>
+          </text>
+          <text fg={theme.text}>
+            esc <span style={{ fg: theme.textMuted }}>dismiss</span>
+          </text>
+        </box>
+        <Show when={store.error}>
+          <text fg={theme.error}>{store.error}</text>
+        </Show>
+      </box>
+    </box>
+  )
+}

+ 15 - 2
packages/tui/src/routes/session/index.tsx

@@ -59,6 +59,7 @@ import { useEpilogue } from "../../context/epilogue"
 import { normalizePath } from "../../util/path"
 import { PermissionPrompt } from "./permission"
 import { QuestionPrompt } from "./question"
+import { FormPrompt } from "./form"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 import { sessionEpilogue } from "../../util/presentation"
 import { useTuiConfig } from "../../config"
@@ -185,11 +186,15 @@ export function Session() {
     if (session()?.parentID) return []
     return data.session.question.list(route.sessionID) ?? []
   })
+  const forms = createMemo(() => {
+    if (session()?.parentID) return []
+    return data.session.form.list(route.sessionID) ?? []
+  })
   const [composer, setComposer] = createStore({
     open: false,
     tab: undefined as string | undefined,
   })
-  const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
+  const disabled = createMemo(() => permissions().length > 0 || questions().length > 0 || forms().length > 0)
 
   const pending = createMemo(() => {
     const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
@@ -247,6 +252,7 @@ export function Session() {
         data.session.refresh(sessionID),
         data.session.permission.refresh(sessionID),
         data.session.question.refresh(sessionID),
+        data.session.form.refresh(sessionID),
       ])
       const info = data.session.get(sessionID)
       if (!info) {
@@ -258,7 +264,6 @@ export function Session() {
         navigate({ type: "home" })
         return
       }
-
       project.workspace.set(info.location.workspaceID)
       editor.reconnect(info.location.directory)
       if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
@@ -942,6 +947,14 @@ export function Session() {
                   <Match when={questions().length > 0}>
                     <QuestionPrompt request={questions()[0]} directory={session()?.location.directory} />
                   </Match>
+                  <Match when={forms().length > 0}>
+                    <Show when={forms()[0]?.id} keyed>
+                      {(_) => {
+                        const form = forms()[0]
+                        return form ? <FormPrompt form={form} /> : null
+                      }}
+                    </Show>
+                  </Match>
                   <Match when={!disabled()}>
                     <pluginRuntime.Slot
                       name="session_prompt"

+ 56 - 14
packages/tui/test/cli/cmd/tui/notifications.test.ts

@@ -72,6 +72,15 @@ function question(id: string, sessionID = "session"): QuestionRequest {
   }
 }
 
+function form(id: string, sessionID = "session"): Extract<V2Event, { type: "form.created" }>["data"]["form"] {
+  return {
+    id,
+    sessionID,
+    mode: "form",
+    fields: [],
+  }
+}
+
 function permission(id: string, sessionID = "session"): PermissionRequest {
   return {
     id,
@@ -139,6 +148,13 @@ const questionNotification: TuiAttentionNotifyInput = {
   sound: { name: "question", when: "always" },
 }
 
+const formNotification: TuiAttentionNotifyInput = {
+  title: "Demo session",
+  message: "Input needs response",
+  notification: { when: "blurred" },
+  sound: { name: "question", when: "always" },
+}
+
 const permissionNotification: TuiAttentionNotifyInput = {
   title: "Demo session",
   message: "Permission needs input",
@@ -147,39 +163,60 @@ const permissionNotification: TuiAttentionNotifyInput = {
 }
 
 describe("internal notifications TUI plugin", () => {
-  test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
+  test("notifies for form, question, and permission requests with blurred notifications and always-on sounds", async () => {
     const harness = await setup()
 
-    harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1") })
-    harness.emit({ id: "event-2", created: 0, type: "permission.asked", data: permission("permission-1") })
+    harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } })
+    harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") })
+    harness.emit({ id: "event-3", created: 0, type: "permission.asked", data: permission("permission-1") })
 
-    expect(harness.notifications).toEqual([questionNotification, permissionNotification])
+    expect(harness.notifications).toEqual([formNotification, questionNotification, permissionNotification])
   })
 
-  test("dedupes pending questions and permissions until they are resolved", async () => {
+  test("ignores global forms until the TUI can render them", async () => {
     const harness = await setup()
 
-    harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1") })
-    harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") })
+    harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1", "global") } })
+
+    expect(harness.notifications).toEqual([])
+  })
+
+  test("dedupes pending forms, questions, and permissions until they are resolved", async () => {
+    const harness = await setup()
+
+    harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } })
+    harness.emit({ id: "event-2", created: 0, type: "form.created", data: { form: form("form-1") } })
     harness.emit({
       id: "event-3",
       created: 0,
+      type: "form.cancelled",
+      data: { sessionID: "session", id: "form-1" },
+    })
+    harness.emit({ id: "event-4", created: 0, type: "form.created", data: { form: form("form-1") } })
+
+    harness.emit({ id: "event-5", created: 0, type: "question.asked", data: question("question-1") })
+    harness.emit({ id: "event-6", created: 0, type: "question.asked", data: question("question-1") })
+    harness.emit({
+      id: "event-7",
+      created: 0,
       type: "question.replied",
       data: { sessionID: "session", requestID: "question-1", answers: [] },
     })
-    harness.emit({ id: "event-4", created: 0, type: "question.asked", data: question("question-1") })
+    harness.emit({ id: "event-8", created: 0, type: "question.asked", data: question("question-1") })
 
-    harness.emit({ id: "event-5", created: 0, type: "permission.asked", data: permission("permission-1") })
-    harness.emit({ id: "event-6", created: 0, type: "permission.asked", data: permission("permission-1") })
+    harness.emit({ id: "event-9", created: 0, type: "permission.asked", data: permission("permission-1") })
+    harness.emit({ id: "event-10", created: 0, type: "permission.asked", data: permission("permission-1") })
     harness.emit({
-      id: "event-7",
+      id: "event-11",
       created: 0,
       type: "permission.replied",
       data: { sessionID: "session", requestID: "permission-1", reply: "once" },
     })
-    harness.emit({ id: "event-8", created: 0, type: "permission.asked", data: permission("permission-1") })
+    harness.emit({ id: "event-12", created: 0, type: "permission.asked", data: permission("permission-1") })
 
     expect(harness.notifications).toEqual([
+      formNotification,
+      formNotification,
       questionNotification,
       questionNotification,
       permissionNotification,
@@ -207,14 +244,19 @@ describe("internal notifications TUI plugin", () => {
   test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
     const harness = await setup()
 
-    harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1", "subagent") })
+    harness.emit({
+      id: "event-1",
+      created: 0,
+      type: "form.created",
+      data: { form: form("form-1", "subagent") },
+    })
     harness.emit(stepStarted("event-2", "subagent"))
     harness.emit(stepEnded("event-3", "subagent"))
 
     expect(harness.notifications).toEqual([
       {
         title: "Subagent session",
-        message: "Question needs input",
+        message: "Input needs response",
         notification: false,
         sound: { name: "question", when: "always" },
       },

+ 70 - 0
packages/tui/test/cli/tui/data.test.tsx

@@ -807,6 +807,76 @@ test("adds and dismisses permission requests from live events", async () => {
   }
 })
 
+test("adds, dismisses, and refreshes form requests", async () => {
+  const events = createEventStream()
+  const calls = createFetch((url) => {
+    if (url.pathname !== "/api/session/ses_1/form") return
+    return json({ data: [{ id: "frm_remote", sessionID: "ses_1", mode: "form", fields: [] }] })
+  }, events)
+  let data!: ReturnType<typeof useData>
+
+  function Probe() {
+    data = useData()
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await wait(() => data.connection.status() === "connected")
+    emitEvent(events, {
+      id: "evt_form_created_1",
+      created: 0,
+      type: "form.created",
+      data: { form: { id: "frm_1", sessionID: "ses_1", mode: "form", fields: [] } },
+    })
+    emitEvent(events, {
+      id: "evt_form_created_duplicate",
+      created: 1,
+      type: "form.created",
+      data: { form: { id: "frm_1", sessionID: "ses_1", mode: "form", fields: [] } },
+    })
+    await wait(() => data.session.form.list("ses_1")?.length === 1)
+
+    emitEvent(events, {
+      id: "evt_form_replied_1",
+      created: 2,
+      type: "form.replied",
+      data: { sessionID: "ses_1", id: "frm_1", answer: {} },
+    })
+    await wait(() => data.session.form.list("ses_1")?.length === 0)
+
+    emitEvent(events, {
+      id: "evt_form_created_2",
+      created: 3,
+      type: "form.created",
+      data: { form: { id: "frm_2", sessionID: "ses_1", mode: "form", fields: [] } },
+    })
+    emitEvent(events, {
+      id: "evt_form_cancelled_2",
+      created: 4,
+      type: "form.cancelled",
+      data: { sessionID: "ses_1", id: "frm_2" },
+    })
+    await wait(() => data.session.form.list("ses_1")?.length === 0)
+
+    await data.session.form.refresh("ses_1")
+    expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual(["frm_remote"])
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("adds and dismisses question requests from live events", async () => {
   const events = createEventStream()
   const calls = createFetch(undefined, events)

+ 5 - 2
packages/tui/test/fixture/tui-sdk.ts

@@ -95,10 +95,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
     if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
     if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
     if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
-    if (url.pathname === "/api/shell") return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
-    if (url.pathname === "/api/mcp") return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
+    if (url.pathname === "/api/shell")
+      return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
+    if (url.pathname === "/api/mcp")
+      return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
     if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
     if (url.pathname === "/api/session/active") return json({ data: {}, watermarks: {} })
+    if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
     if (
       ["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
         url.pathname,