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

This commit is contained in:
Aiden Cline 2026-07-05 19:35:30 -05:00 committed by GitHub
parent 652e8ff3fb
commit 5bcf8d5a0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1202 additions and 18 deletions

View file

@ -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: {

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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" },
},

View file

@ -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)

View file

@ -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,