mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 02:18:29 +00:00
feat(tui): render session forms (#35421)
This commit is contained in:
parent
652e8ff3fb
commit
5bcf8d5a0b
7 changed files with 1202 additions and 18 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
packages/tui/src/routes/session/form.tsx
Normal file
1009
packages/tui/src/routes/session/form.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue