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

This commit is contained in:
Aiden Cline 2026-07-05 19:35:30 -05:00 committed by GitHub
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"