feat(tui): render forms and route question tool through form service
This commit is contained in:
parent
12d9f4b29b
commit
b181216ce5
6 changed files with 1004 additions and 21 deletions
|
|
@ -16,6 +16,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
|
|||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Form } from "../form"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
|
|
@ -69,6 +70,7 @@ export type Requirements =
|
|||
| EventV2.Service
|
||||
| FileMutation.Service
|
||||
| FileSystem.Service
|
||||
| Form.Service
|
||||
| FSUtil.Service
|
||||
| Global.Service
|
||||
| HttpClient.HttpClient
|
||||
|
|
@ -116,6 +118,7 @@ const layer = Layer.effectDiscard(
|
|||
Context.make(EventV2.Service, yield* EventV2.Service),
|
||||
Context.make(FSUtil.Service, yield* FSUtil.Service),
|
||||
Context.make(FileSystem.Service, yield* FileSystem.Service),
|
||||
Context.make(Form.Service, yield* Form.Service),
|
||||
Context.make(Global.Service, yield* Global.Service),
|
||||
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
||||
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
|
||||
|
|
@ -192,6 +195,7 @@ export const node = makeLocationNode({
|
|||
EventV2.node,
|
||||
FSUtil.node,
|
||||
FileSystem.node,
|
||||
Form.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
PermissionV2.node,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as QuestionTool from "./question"
|
|||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../form"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { Tool } from "./tool"
|
||||
|
|
@ -42,10 +43,35 @@ export const toModelOutput = (
|
|||
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
|
||||
}
|
||||
|
||||
// Each question becomes one field keyed by position; answers translate back positionally.
|
||||
export const toField = (question: QuestionV2.Prompt, index: number): Form.Field => {
|
||||
const shared = {
|
||||
key: `q${index}`,
|
||||
title: question.header,
|
||||
description: question.question,
|
||||
options: question.options.map((option) => ({
|
||||
value: option.label,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
})),
|
||||
custom: true,
|
||||
}
|
||||
if (question.multiple === true) return { ...shared, type: "multiselect" }
|
||||
return { ...shared, type: "string" }
|
||||
}
|
||||
|
||||
export const toAnswers = (questions: ReadonlyArray<QuestionV2.Prompt>, answer: Form.Answer) =>
|
||||
questions.map((_, index): QuestionV2.Answer => {
|
||||
const value = answer[`q${index}`]
|
||||
if (value === undefined) return []
|
||||
if (typeof value === "object") return Array.from(value)
|
||||
return [String(value)]
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-question-tool",
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const question = yield* QuestionV2.Service
|
||||
const forms = yield* Form.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* ctx.tool
|
||||
|
|
@ -69,15 +95,23 @@ export const Plugin = {
|
|||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||
Effect.andThen(
|
||||
question
|
||||
forms
|
||||
.ask({
|
||||
sessionID: context.sessionID,
|
||||
questions: input.questions,
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
metadata: {
|
||||
kind: "question",
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
},
|
||||
mode: "form",
|
||||
fields: input.questions.map(toField),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.map((answers) => ({ answers })),
|
||||
Effect.flatMap((state) => {
|
||||
// The runner halts the loop on this exact defect (session/runner/llm.ts).
|
||||
if (state.status !== "answered") return Effect.die(new QuestionV2.RejectedError())
|
||||
return Effect.succeed({ answers: toAnswers(input.questions, state.answer) })
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { describe, expect } from "bun:test"
|
|||
import { Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
|
|
@ -14,7 +14,7 @@ import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefiniti
|
|||
|
||||
const sessionID = SessionV2.ID.make("ses_question_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let captured: QuestionV2.AskInput | undefined
|
||||
let captured: Form.CreateInput | undefined
|
||||
let reject = false
|
||||
let deny = false
|
||||
const capturedInput = () => captured
|
||||
|
|
@ -32,28 +32,35 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const question = Layer.succeed(
|
||||
QuestionV2.Service,
|
||||
QuestionV2.Service.of({
|
||||
ask: (input: QuestionV2.AskInput) =>
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
ask: (input: Form.CreateInput) =>
|
||||
Effect.sync(() => {
|
||||
captured = input
|
||||
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
|
||||
reply: () => Effect.die("unused"),
|
||||
reject: () => Effect.die("unused"),
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync((): Form.State => (reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build" } })),
|
||||
),
|
||||
),
|
||||
create: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const questionToolNode = makeLocationNode({
|
||||
name: "test/question-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, QuestionV2.node],
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, Form.node],
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
|
||||
[PermissionV2.node, permission],
|
||||
[QuestionV2.node, question],
|
||||
[Form.node, form],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
)
|
||||
|
|
@ -124,8 +131,26 @@ describe("QuestionTool", () => {
|
|||
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
questions,
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Action",
|
||||
description: "What should happen?",
|
||||
options: [{ value: "Build", label: "Build", description: "Build it" }],
|
||||
custom: true,
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
key: "q1",
|
||||
title: "Environment",
|
||||
description: "Which environment?",
|
||||
options: [{ value: "Dev", label: "Dev", description: "Development" }],
|
||||
custom: true,
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -144,8 +169,9 @@ describe("QuestionTool", () => {
|
|||
})
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
questions: [],
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type {
|
||||
AgentV2Info,
|
||||
CommandV2Info,
|
||||
FormFormInfo,
|
||||
FormUrlInfo,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpServer,
|
||||
|
|
@ -27,6 +29,8 @@ import { createSignal, onCleanup } from "solid-js"
|
|||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
export type FormInfo = FormFormInfo | FormUrlInfo
|
||||
|
||||
type LocationData = {
|
||||
agent?: AgentV2Info[]
|
||||
command?: CommandV2Info[]
|
||||
|
|
@ -52,6 +56,8 @@ type Data = {
|
|||
message: Record<string, SessionMessage[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
question: Record<string, QuestionV2Request[]>
|
||||
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||
form: Record<string, FormInfo[]>
|
||||
}
|
||||
project: {
|
||||
permission: Record<string, PermissionSavedInfo[]>
|
||||
|
|
@ -86,6 +92,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
form: {},
|
||||
},
|
||||
project: {
|
||||
permission: {},
|
||||
|
|
@ -587,6 +594,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,
|
||||
|
|
@ -714,6 +737,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: {
|
||||
|
|
|
|||
876
packages/tui/src/routes/session/form.tsx
Normal file
876
packages/tui/src/routes/session/form.tsx
Normal file
|
|
@ -0,0 +1,876 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } 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, FormWhen } 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]
|
||||
|
||||
// Mirrors core when-evaluation in packages/core/src/form.ts.
|
||||
function matches(when: FormWhen, value: FormValue | undefined) {
|
||||
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
|
||||
}
|
||||
|
||||
function isActive(field: Field, answers: Record<string, FormValue | undefined>) {
|
||||
return (field.when ?? []).every((when) => matches(when, answers[when.key]))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Mirrors core validateField in packages/core/src/form.ts.
|
||||
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
|
||||
}
|
||||
}
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) return "Expected an email address"
|
||||
if (field.format === "uri" && !isUri(text)) return "Expected a URL"
|
||||
if (field.format === "date" && !isDate(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 isUri(value: string) {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
}
|
||||
|
||||
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 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.map(label).join(", ")
|
||||
return label(value)
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormInfo }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.form.mode === "url" && props.form}>{(form) => <UrlPrompt form={form()} />}</Match>
|
||||
<Match when={props.form.mode === "form" && props.form}>{(form) => <FieldsPrompt form={form()} />}</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const popMode = modeStack.push(FORM_MODE)
|
||||
onCleanup(popMode)
|
||||
})
|
||||
|
||||
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 defaults = Object.fromEntries(
|
||||
props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
|
||||
) as Record<string, FormValue | undefined>
|
||||
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: defaults,
|
||||
custom: {} as Record<string, string>,
|
||||
selected: 0,
|
||||
editing: false,
|
||||
error: "",
|
||||
})
|
||||
|
||||
let textarea: TextareaRenderable | undefined
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
|
||||
const fields = createMemo(() => props.form.fields.filter((field) => isActive(field, store.answers)))
|
||||
const single = createMemo(() => {
|
||||
const list = fields()
|
||||
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) => store.answers[item.key] !== undefined).length)
|
||||
const field = createMemo(() => fields()[Math.min(store.tab, fields().length - 1)])
|
||||
const confirm = createMemo(() => !single() && store.tab >= fields().length)
|
||||
const rows = createMemo(() => (field() ? fieldRows(field()!) : []))
|
||||
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 submit() {
|
||||
const entries = fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
})
|
||||
sdk.api.form
|
||||
.reply({
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: Object.fromEntries(entries),
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer"
|
||||
setStore("error", message)
|
||||
})
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
const current = field()
|
||||
if (!current) return
|
||||
answer(current.key, value)
|
||||
if (customValue !== undefined) setStore("custom", { ...store.custom, [current.key]: customValue })
|
||||
if (single()) {
|
||||
void sdk.api.form.reply({
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: { [current.key]: value },
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("tab", store.tab + 1)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
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 moveTo(index: number) {
|
||||
setStore("selected", index)
|
||||
}
|
||||
|
||||
function selectTab(index: number) {
|
||||
setStore("tab", index)
|
||||
setStore("selected", 0)
|
||||
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 submitText(text: string, direction: 1 | -1 = 1) {
|
||||
const current = field()
|
||||
if (!current) return
|
||||
const move = () => selectTab((store.tab + direction + tabs()) % tabs())
|
||||
if (!text) {
|
||||
answer(current.key, undefined)
|
||||
setStore("editing", false)
|
||||
if (!single()) move()
|
||||
return
|
||||
}
|
||||
if (current.type === "number" || current.type === "integer") {
|
||||
const value = Number(text)
|
||||
if (!Number.isFinite(value) || (current.type === "integer" && !Number.isInteger(value))) {
|
||||
setStore("error", current.type === "integer" ? "Expected an integer" : "Expected a number")
|
||||
return
|
||||
}
|
||||
if (typeof current.minimum === "number" && value < current.minimum) {
|
||||
setStore("error", `Must be at least ${current.minimum}`)
|
||||
return
|
||||
}
|
||||
if (typeof current.maximum === "number" && value > current.maximum) {
|
||||
setStore("error", `Must be at most ${current.maximum}`)
|
||||
return
|
||||
}
|
||||
answer(current.key, value)
|
||||
}
|
||||
if (current.type === "string") {
|
||||
const invalid = validateText(current, text)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return
|
||||
}
|
||||
answer(current.key, text)
|
||||
}
|
||||
setStore("custom", { ...store.custom, [current.key]: text })
|
||||
setStore("editing", false)
|
||||
move()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const popMode = modeStack.push(FORM_MODE)
|
||||
onCleanup(popMode)
|
||||
})
|
||||
|
||||
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()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
setStore("editing", false)
|
||||
},
|
||||
},
|
||||
...tuiConfig.keybinds.get("prompt.clear"),
|
||||
{
|
||||
key: "tab",
|
||||
desc: "Next field",
|
||||
group: "Form",
|
||||
cmd: () => {
|
||||
if (!textual()) return
|
||||
const text = textarea?.plainText?.trim() ?? ""
|
||||
if (text) submitText(text)
|
||||
if (!text) selectTab((store.tab + 1) % tabs())
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "shift+tab",
|
||||
desc: "Previous field",
|
||||
group: "Form",
|
||||
cmd: () => {
|
||||
if (!textual()) return
|
||||
const text = textarea?.plainText?.trim() ?? ""
|
||||
if (text) submitText(text, -1)
|
||||
if (!text) selectTab((store.tab - 1 + tabs()) % tabs())
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
desc: "Submit answer edit",
|
||||
group: "Form",
|
||||
cmd: () => {
|
||||
const text = textarea?.plainText?.trim() ?? ""
|
||||
const current = field()
|
||||
if (!current) return
|
||||
|
||||
if (multi()) {
|
||||
const prev = store.custom[current.key]
|
||||
if (!text) {
|
||||
if (prev) {
|
||||
const existing = store.answers[current.key]
|
||||
const list = Array.isArray(existing) ? existing.filter((item) => item !== prev) : []
|
||||
answer(current.key, list)
|
||||
setStore("custom", { ...store.custom, [current.key]: "" })
|
||||
}
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
const existing = store.answers[current.key]
|
||||
const list = Array.isArray(existing) ? [...existing] : []
|
||||
if (prev) {
|
||||
const index = list.indexOf(prev)
|
||||
if (index !== -1) list.splice(index, 1)
|
||||
}
|
||||
if (!list.includes(text)) list.push(text)
|
||||
answer(current.key, list)
|
||||
setStore("custom", { ...store.custom, [current.key]: text })
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (textual()) {
|
||||
submitText(text)
|
||||
return
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
answer(current.key, undefined)
|
||||
setStore("custom", { ...store.custom, [current.key]: "" })
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
pick(text, text)
|
||||
setStore("editing", false)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
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() {
|
||||
cancel()
|
||||
},
|
||||
},
|
||||
],
|
||||
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: () => submit() },
|
||||
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: () => cancel() },
|
||||
{ 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: () => {
|
||||
moveTo(index)
|
||||
selectOption()
|
||||
},
|
||||
})),
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous answer",
|
||||
group: "Form",
|
||||
cmd: () => moveTo((store.selected - 1 + total) % total),
|
||||
},
|
||||
{
|
||||
key: "k",
|
||||
desc: "Previous answer",
|
||||
group: "Form",
|
||||
cmd: () => moveTo((store.selected - 1 + total) % total),
|
||||
},
|
||||
{ key: "down", desc: "Next answer", group: "Form", cmd: () => moveTo((store.selected + 1) % total) },
|
||||
{ key: "j", desc: "Next answer", group: "Form", cmd: () => moveTo((store.selected + 1) % total) },
|
||||
{ key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() },
|
||||
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: () => cancel() },
|
||||
...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
|
||||
selectTab(index())
|
||||
}}
|
||||
>
|
||||
<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
|
||||
selectTab(fields().length)
|
||||
}}
|
||||
>
|
||||
<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={() => moveTo(i())}
|
||||
onMouseDown={() => moveTo(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={() => moveTo(rows().length)}
|
||||
onMouseDown={() => moveTo(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 = () => store.answers[item.key] !== undefined
|
||||
const missing = () => !answered() && item.required === true
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: answered() ? theme.text : missing() ? theme.error : theme.textMuted }}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) ?? []), ...(data.session.form.list("global") ?? [])]
|
||||
})
|
||||
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,8 @@ export function Session() {
|
|||
data.session.refresh(sessionID),
|
||||
data.session.permission.refresh(sessionID),
|
||||
data.session.question.refresh(sessionID),
|
||||
data.session.form.refresh(sessionID),
|
||||
data.session.form.refresh("global"),
|
||||
])
|
||||
const info = data.session.get(sessionID)
|
||||
if (!info) {
|
||||
|
|
@ -942,6 +949,11 @@ 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]} keyed>
|
||||
{(form) => <FormPrompt form={form} />}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={!disabled()}>
|
||||
<pluginRuntime.Slot
|
||||
name="session_prompt"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue