mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9215a2df9a | |||
| 4346bde102 | |||
| 3056187950 | |||
| bfa959847f | |||
| 5f277c4323 | |||
| 5deef980dd | |||
| a2136dd0f0 | |||
| 0667079c38 | |||
| be0cd93c8f | |||
| d1163cb5ec | |||
| 1a38f66825 | |||
| e7ce32c3f5 | |||
| a9031a852d | |||
| 749ad0e125 | |||
| 67b9498c04 | |||
| ba7b009a37 | |||
| 386b2661b2 | |||
| bb9a34a9f4 | |||
| ae8a591426 | |||
| d6472bfda7 | |||
| b181216ce5 |
@@ -63,6 +63,8 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
|
|||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Form.CancelledError", {}) {}
|
||||||
|
|
||||||
export type CreateInput =
|
export type CreateInput =
|
||||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
||||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
|
|||||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { FileMutation } from "../file-mutation"
|
import { FileMutation } from "../file-mutation"
|
||||||
|
import { Form } from "../form"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
@@ -28,7 +29,6 @@ import { Npm } from "../npm"
|
|||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { PluginRuntime } from "../plugin/runtime"
|
import { PluginRuntime } from "../plugin/runtime"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { QuestionV2 } from "../question"
|
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { Ripgrep } from "../ripgrep"
|
import { Ripgrep } from "../ripgrep"
|
||||||
import { SessionInstructions } from "../session/instructions"
|
import { SessionInstructions } from "../session/instructions"
|
||||||
@@ -69,6 +69,7 @@ export type Requirements =
|
|||||||
| EventV2.Service
|
| EventV2.Service
|
||||||
| FileMutation.Service
|
| FileMutation.Service
|
||||||
| FileSystem.Service
|
| FileSystem.Service
|
||||||
|
| Form.Service
|
||||||
| FSUtil.Service
|
| FSUtil.Service
|
||||||
| Global.Service
|
| Global.Service
|
||||||
| HttpClient.HttpClient
|
| HttpClient.HttpClient
|
||||||
@@ -80,7 +81,6 @@ export type Requirements =
|
|||||||
| Npm.Service
|
| Npm.Service
|
||||||
| PermissionV2.Service
|
| PermissionV2.Service
|
||||||
| PluginRuntime.Service
|
| PluginRuntime.Service
|
||||||
| QuestionV2.Service
|
|
||||||
| ReadToolFileSystem.Service
|
| ReadToolFileSystem.Service
|
||||||
| Reference.Service
|
| Reference.Service
|
||||||
| Ripgrep.Service
|
| Ripgrep.Service
|
||||||
@@ -116,13 +116,13 @@ const layer = Layer.effectDiscard(
|
|||||||
Context.make(EventV2.Service, yield* EventV2.Service),
|
Context.make(EventV2.Service, yield* EventV2.Service),
|
||||||
Context.make(FSUtil.Service, yield* FSUtil.Service),
|
Context.make(FSUtil.Service, yield* FSUtil.Service),
|
||||||
Context.make(FileSystem.Service, yield* FileSystem.Service),
|
Context.make(FileSystem.Service, yield* FileSystem.Service),
|
||||||
|
Context.make(Form.Service, yield* Form.Service),
|
||||||
Context.make(Global.Service, yield* Global.Service),
|
Context.make(Global.Service, yield* Global.Service),
|
||||||
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
||||||
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
|
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
|
||||||
Context.make(FileMutation.Service, yield* FileMutation.Service),
|
Context.make(FileMutation.Service, yield* FileMutation.Service),
|
||||||
Context.make(Image.Service, yield* Image.Service),
|
Context.make(Image.Service, yield* Image.Service),
|
||||||
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
|
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
|
||||||
Context.make(QuestionV2.Service, yield* QuestionV2.Service),
|
|
||||||
Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service),
|
Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service),
|
||||||
Context.make(SessionInstructions.Service, yield* SessionInstructions.Service),
|
Context.make(SessionInstructions.Service, yield* SessionInstructions.Service),
|
||||||
Context.make(SessionTodo.Service, yield* SessionTodo.Service),
|
Context.make(SessionTodo.Service, yield* SessionTodo.Service),
|
||||||
@@ -192,10 +192,10 @@ export const node = makeLocationNode({
|
|||||||
EventV2.node,
|
EventV2.node,
|
||||||
FSUtil.node,
|
FSUtil.node,
|
||||||
FileSystem.node,
|
FileSystem.node,
|
||||||
|
Form.node,
|
||||||
Global.node,
|
Global.node,
|
||||||
httpClient,
|
httpClient,
|
||||||
PermissionV2.node,
|
PermissionV2.node,
|
||||||
QuestionV2.node,
|
|
||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
SessionInstructions.node,
|
SessionInstructions.node,
|
||||||
SessionTodo.node,
|
SessionTodo.node,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Config } from "../../config"
|
|||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { QuestionV2 } from "../../question"
|
import { Form } from "../../form"
|
||||||
import { SystemContext } from "../../system-context/index"
|
import { SystemContext } from "../../system-context/index"
|
||||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||||
import { InstructionContext } from "../../instruction-context"
|
import { InstructionContext } from "../../instruction-context"
|
||||||
@@ -149,9 +149,8 @@ const layer = Layer.effect(
|
|||||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
|
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
|
||||||
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
||||||
|
|
||||||
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
|
const isFormCancelled = (cause: Cause.Cause<unknown>) =>
|
||||||
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
|
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof Form.CancelledError)
|
||||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
|
||||||
|
|
||||||
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
|
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
|
||||||
Effect.all(
|
Effect.all(
|
||||||
@@ -341,14 +340,13 @@ const layer = Layer.effect(
|
|||||||
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
|
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
|
||||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||||
const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
|
const formCancelled = settled._tag === "Failure" && isFormCancelled(settled.cause)
|
||||||
|
|
||||||
if (questionDismissed || streamInterrupted || toolsInterrupted) {
|
if (formCancelled || streamInterrupted || toolsInterrupted) {
|
||||||
yield* FiberSet.clear(toolFibers)
|
yield* FiberSet.clear(toolFibers)
|
||||||
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||||
yield* serialized(publisher.failAssistant("Step interrupted"))
|
yield* serialized(publisher.failAssistant("Step interrupted"))
|
||||||
// Match V1: dismissing a question halts the loop like an interruption.
|
if (formCancelled) return yield* Effect.interrupt
|
||||||
if (questionDismissed) return yield* Effect.interrupt
|
|
||||||
}
|
}
|
||||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||||
// implementation becomes a failed tool call the model can read, and the step still
|
// implementation becomes a failed tool call the model can read, and the step still
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as QuestionTool from "./question"
|
|||||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Form } from "../form"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { QuestionV2 } from "../question"
|
import { QuestionV2 } from "../question"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
@@ -45,7 +46,7 @@ export const toModelOutput = (
|
|||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "core-question-tool",
|
id: "core-question-tool",
|
||||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const question = yield* QuestionV2.Service
|
const forms = yield* Form.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
@@ -69,15 +70,40 @@ export const Plugin = {
|
|||||||
.pipe(
|
.pipe(
|
||||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
question
|
forms
|
||||||
.ask({
|
.ask({
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
questions: input.questions,
|
metadata: {
|
||||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
kind: "question",
|
||||||
|
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
|
},
|
||||||
|
mode: "form",
|
||||||
|
fields: input.questions.map((question, index): Form.Field => ({
|
||||||
|
key: `q${index}`,
|
||||||
|
title: question.header,
|
||||||
|
description: question.question,
|
||||||
|
type: question.multiple === true ? "multiselect" : "string",
|
||||||
|
options: question.options.map((option) => ({
|
||||||
|
value: option.label,
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
})),
|
||||||
|
custom: true,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
),
|
),
|
||||||
Effect.map((answers) => ({ answers })),
|
Effect.flatMap((state) => {
|
||||||
|
if (state.status !== "answered") return Effect.die(new Form.CancelledError())
|
||||||
|
return Effect.succeed({
|
||||||
|
answers: input.questions.map((_, index): QuestionV2.Answer => {
|
||||||
|
const value = state.answer[`q${index}`]
|
||||||
|
if (value === undefined) return []
|
||||||
|
if (typeof value === "object") return Array.from(value)
|
||||||
|
return [String(value)]
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
|||||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||||
@@ -276,7 +275,6 @@ const it = testEffect(
|
|||||||
LayerNode.group([
|
LayerNode.group([
|
||||||
Database.node,
|
Database.node,
|
||||||
EventV2.node,
|
EventV2.node,
|
||||||
QuestionV2.node,
|
|
||||||
SessionProjector.node,
|
SessionProjector.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
@@ -2822,62 +2820,6 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("interrupts runner continuation when a question is dismissed", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* setup
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const registry = yield* ToolRegistry.Service
|
|
||||||
const questions = yield* QuestionV2.Service
|
|
||||||
yield* registry.register({
|
|
||||||
question: Tool.make({
|
|
||||||
description: "Ask the user",
|
|
||||||
input: Schema.Struct({}),
|
|
||||||
output: Schema.Struct({}),
|
|
||||||
execute: (_, context) =>
|
|
||||||
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
|
|
||||||
|
|
||||||
requests.length = 0
|
|
||||||
responses = [
|
|
||||||
[
|
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
|
||||||
LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }),
|
|
||||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
|
||||||
LLMEvent.finish({ reason: "tool-calls" }),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
]
|
|
||||||
|
|
||||||
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
|
|
||||||
let pending = yield* questions.list()
|
|
||||||
while (pending.length === 0) {
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
pending = yield* questions.list()
|
|
||||||
}
|
|
||||||
yield* questions.reject(pending[0]!.id)
|
|
||||||
const exit = yield* Fiber.join(run)
|
|
||||||
|
|
||||||
expect(exit._tag).toBe("Failure")
|
|
||||||
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
|
||||||
expect(requests).toHaveLength(1)
|
|
||||||
expect(yield* session.context(sessionID)).toMatchObject([
|
|
||||||
{ type: "user", text: "Ask then stop" },
|
|
||||||
{
|
|
||||||
type: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call-question",
|
|
||||||
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("awaits started local tools before surfacing provider stream failure", () =>
|
it.effect("awaits started local tools before surfacing provider stream failure", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Effect, Exit, Fiber, Layer } from "effect"
|
import { Effect, Exit, Fiber, Layer } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { Form } from "@opencode-ai/core/form"
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
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 sessionID = SessionV2.ID.make("ses_question_tool_test")
|
||||||
const assertions: PermissionV2.AssertInput[] = []
|
const assertions: PermissionV2.AssertInput[] = []
|
||||||
let captured: QuestionV2.AskInput | undefined
|
let captured: Form.CreateInput | undefined
|
||||||
let reject = false
|
let reject = false
|
||||||
let deny = false
|
let deny = false
|
||||||
const capturedInput = () => captured
|
const capturedInput = () => captured
|
||||||
@@ -32,28 +32,35 @@ const permission = Layer.succeed(
|
|||||||
list: () => Effect.die("unused"),
|
list: () => Effect.die("unused"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const question = Layer.succeed(
|
const form = Layer.succeed(
|
||||||
QuestionV2.Service,
|
Form.Service,
|
||||||
QuestionV2.Service.of({
|
Form.Service.of({
|
||||||
ask: (input: QuestionV2.AskInput) =>
|
ask: (input: Form.CreateInput) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
captured = input
|
captured = input
|
||||||
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
|
}).pipe(
|
||||||
reply: () => Effect.die("unused"),
|
Effect.andThen(
|
||||||
reject: () => Effect.die("unused"),
|
Effect.sync((): Form.State => (reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build" } })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
create: () => Effect.die("unused"),
|
||||||
|
get: () => Effect.die("unused"),
|
||||||
list: () => Effect.die("unused"),
|
list: () => Effect.die("unused"),
|
||||||
|
state: () => Effect.die("unused"),
|
||||||
|
reply: () => Effect.die("unused"),
|
||||||
|
cancel: () => Effect.die("unused"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const questionToolNode = makeLocationNode({
|
const questionToolNode = makeLocationNode({
|
||||||
name: "test/question-tool-plugin",
|
name: "test/question-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
|
||||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, QuestionV2.node],
|
deps: [ToolRegistry.toolsNode, PermissionV2.node, Form.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
|
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
|
||||||
[PermissionV2.node, permission],
|
[PermissionV2.node, permission],
|
||||||
[QuestionV2.node, question],
|
[Form.node, form],
|
||||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -124,8 +131,26 @@ describe("QuestionTool", () => {
|
|||||||
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
||||||
expect(capturedInput()).toEqual({
|
expect(capturedInput()).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
questions,
|
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-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({
|
expect(capturedInput()).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
questions: [],
|
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
mode: "form",
|
||||||
|
fields: [],
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentV2Info,
|
AgentV2Info,
|
||||||
CommandV2Info,
|
CommandV2Info,
|
||||||
|
FormFormInfo,
|
||||||
|
FormUrlInfo,
|
||||||
IntegrationInfo,
|
IntegrationInfo,
|
||||||
LocationRef,
|
LocationRef,
|
||||||
McpServer,
|
McpServer,
|
||||||
@@ -8,7 +10,6 @@ import type {
|
|||||||
PermissionSavedInfo,
|
PermissionSavedInfo,
|
||||||
PermissionV2Request,
|
PermissionV2Request,
|
||||||
ProviderV2Info,
|
ProviderV2Info,
|
||||||
QuestionV2Request,
|
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
SessionMessageAssistant,
|
SessionMessageAssistant,
|
||||||
@@ -27,6 +28,8 @@ import { createSignal, onCleanup } from "solid-js"
|
|||||||
|
|
||||||
export type DataSessionStatus = "idle" | "running"
|
export type DataSessionStatus = "idle" | "running"
|
||||||
|
|
||||||
|
export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
|
||||||
|
|
||||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||||
|
|
||||||
type LocationData = {
|
type LocationData = {
|
||||||
@@ -53,7 +56,8 @@ type Data = {
|
|||||||
status: Record<string, DataSessionStatus>
|
status: Record<string, DataSessionStatus>
|
||||||
message: Record<string, SessionMessage[]>
|
message: Record<string, SessionMessage[]>
|
||||||
permission: Record<string, PermissionV2Request[]>
|
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: {
|
project: {
|
||||||
permission: Record<string, PermissionSavedInfo[]>
|
permission: Record<string, PermissionSavedInfo[]>
|
||||||
@@ -87,7 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
status: {},
|
status: {},
|
||||||
message: {},
|
message: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
form: {},
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
permission: {},
|
permission: {},
|
||||||
@@ -564,22 +568,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case "question.v2.asked":
|
case "form.created":
|
||||||
if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
|
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
|
||||||
setStore("session", "question", event.data.sessionID, [
|
setStore("session", "form", event.data.form.sessionID, [
|
||||||
...(store.session.question[event.data.sessionID] ?? []),
|
...(store.session.form[event.data.form.sessionID] ?? []),
|
||||||
event.data,
|
mutable({ ...event.data.form, location: event.location }),
|
||||||
])
|
])
|
||||||
break
|
break
|
||||||
case "question.v2.replied":
|
case "form.replied":
|
||||||
case "question.v2.rejected":
|
case "form.cancelled":
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
"question",
|
"form",
|
||||||
event.data.sessionID,
|
event.data.sessionID,
|
||||||
(store.session.question[event.data.sessionID] ?? []).filter(
|
(store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id),
|
||||||
(request) => request.id !== event.data.requestID,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case "shell.created":
|
case "shell.created":
|
||||||
@@ -660,6 +662,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||||
registerSession(sessionID)
|
registerSession(sessionID)
|
||||||
},
|
},
|
||||||
|
async refreshChildren(sessionID: string) {
|
||||||
|
const visit = async (parentID: string, seen: Set<string>): Promise<void> => {
|
||||||
|
const children = mutable((await sdk.api.session.list({ parentID, limit: 200 })).data).filter(
|
||||||
|
(session) => !seen.has(session.id),
|
||||||
|
)
|
||||||
|
for (const session of children) setStore("session", "info", session.id, session)
|
||||||
|
for (const session of children) registerSession(session.id)
|
||||||
|
await Promise.all(children.map((session) => visit(session.id, new Set([...seen, session.id]))))
|
||||||
|
}
|
||||||
|
await visit(sessionID, new Set([sessionID]))
|
||||||
|
},
|
||||||
message: {
|
message: {
|
||||||
ids(sessionID: string) {
|
ids(sessionID: string) {
|
||||||
return (store.session.message[sessionID] ?? []).map((message) => message.id)
|
return (store.session.message[sessionID] ?? []).map((message) => message.id)
|
||||||
@@ -700,12 +713,34 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
setStore("session", "permission", sessionID, mutable(await sdk.api.permission.list({ sessionID })))
|
setStore("session", "permission", sessionID, mutable(await sdk.api.permission.list({ sessionID })))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
question: {
|
form: {
|
||||||
list(sessionID: string) {
|
list(sessionID: string, ref?: LocationRef) {
|
||||||
return store.session.question[sessionID]
|
const forms = store.session.form[sessionID]
|
||||||
|
if (sessionID !== "global" || !ref) return forms
|
||||||
|
const key = locationKey(ref)
|
||||||
|
return forms?.filter((form) => form.location && locationKey(form.location) === key)
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
async refresh(sessionID: string, ref?: LocationRef) {
|
||||||
setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID })))
|
if (sessionID === "global") {
|
||||||
|
const result = await sdk.api.form.listRequests({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const location = { directory: result.location.directory, workspaceID: result.location.workspaceID }
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
"form",
|
||||||
|
sessionID,
|
||||||
|
mutable(
|
||||||
|
[
|
||||||
|
...(store.session.form[sessionID] ?? []).filter(
|
||||||
|
(form) =>
|
||||||
|
form.location?.directory !== location.directory || form.location.workspaceID !== location.workspaceID,
|
||||||
|
),
|
||||||
|
...result.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID })))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -849,7 +884,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
directory: defaultLocation().directory,
|
directory: defaultLocation().directory,
|
||||||
workspace: defaultLocation().workspaceID,
|
workspace: defaultLocation().workspaceID,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
"info",
|
"info",
|
||||||
@@ -858,6 +893,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
for (const session of response.data) registerSession(session.id)
|
for (const session of response.data) registerSession(session.id)
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(store.session.info).flatMap((session) =>
|
||||||
|
session.parentID ? [] : [result.session.refreshChildren(session.id)],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Promise.all([
|
||||||
|
...Object.keys(store.session.info).flatMap((sessionID) => [
|
||||||
|
result.session.permission.refresh(sessionID),
|
||||||
|
result.session.form.refresh(sessionID),
|
||||||
|
]),
|
||||||
|
result.session.form.refresh("global"),
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
sdk.api.session
|
sdk.api.session
|
||||||
.active()
|
.active()
|
||||||
|
|||||||
@@ -29,30 +29,30 @@ function sessionErrorMessage(error: SessionError) {
|
|||||||
const tui: TuiPlugin = async (api) => {
|
const tui: TuiPlugin = async (api) => {
|
||||||
const active = new Set<string>()
|
const active = new Set<string>()
|
||||||
const errored = new Set<string>()
|
const errored = new Set<string>()
|
||||||
const questions = new Set<string>()
|
const forms = new Set<string>()
|
||||||
const permissions = new Set<string>()
|
const permissions = new Set<string>()
|
||||||
|
|
||||||
api.event.on("question.asked", (event) => {
|
api.event.on("form.created", (event) => {
|
||||||
if (questions.has(event.data.id)) return
|
if (forms.has(event.data.form.id)) return
|
||||||
questions.add(event.data.id)
|
forms.add(event.data.form.id)
|
||||||
notify(api, event.data.sessionID, "Question needs input", "question")
|
notify(api, event.data.form.sessionID, "Input needs response", "question")
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("question.replied", (event) => {
|
api.event.on("form.replied", (event) => {
|
||||||
questions.delete(event.data.requestID)
|
forms.delete(event.data.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("question.rejected", (event) => {
|
api.event.on("form.cancelled", (event) => {
|
||||||
questions.delete(event.data.requestID)
|
forms.delete(event.data.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("permission.asked", (event) => {
|
api.event.on("permission.v2.asked", (event) => {
|
||||||
if (permissions.has(event.data.id)) return
|
if (permissions.has(event.data.id)) return
|
||||||
permissions.add(event.data.id)
|
permissions.add(event.data.id)
|
||||||
notify(api, event.data.sessionID, "Permission needs input", "permission")
|
notify(api, event.data.sessionID, "Permission needs input", "permission")
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("permission.replied", (event) => {
|
api.event.on("permission.v2.replied", (event) => {
|
||||||
permissions.delete(event.data.requestID)
|
permissions.delete(event.data.requestID)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { Message } from "@opencode-ai/sdk/v2"
|
||||||
import type { TuiConfig } from "../config"
|
import type { TuiConfig } from "../config"
|
||||||
import type { useEvent } from "../context/event"
|
import type { useEvent } from "../context/event"
|
||||||
import type { useRoute } from "../context/route"
|
import type { useRoute } from "../context/route"
|
||||||
@@ -120,10 +121,19 @@ function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useD
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
count() {
|
count() {
|
||||||
return sync.data.session.length
|
return data.session.list().length
|
||||||
},
|
},
|
||||||
get(sessionID) {
|
get(sessionID) {
|
||||||
return sync.session.get(sessionID)
|
const session = data.session.get(sessionID)
|
||||||
|
if (!session) return
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
slug: session.id,
|
||||||
|
workspaceID: session.location.workspaceID,
|
||||||
|
directory: session.location.directory,
|
||||||
|
path: session.subpath,
|
||||||
|
version: "2",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
diff(sessionID) {
|
diff(sessionID) {
|
||||||
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
||||||
@@ -134,13 +144,57 @@ function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useD
|
|||||||
return sync.data.todo[sessionID] ?? []
|
return sync.data.todo[sessionID] ?? []
|
||||||
},
|
},
|
||||||
messages(sessionID) {
|
messages(sessionID) {
|
||||||
return sync.data.message[sessionID] ?? []
|
return data.session.message.list(sessionID).flatMap((message): Message[] => {
|
||||||
|
const session = data.session.get(sessionID)
|
||||||
|
if (message.type === "user")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: message.id,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: message.time,
|
||||||
|
agent: session?.agent ?? "build",
|
||||||
|
model: session?.model
|
||||||
|
? { providerID: session.model.providerID, modelID: session.model.id, variant: session.model.variant }
|
||||||
|
: { providerID: "", modelID: "" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (message.type !== "assistant") return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: message.id,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant" as const,
|
||||||
|
time: message.time,
|
||||||
|
parentID: sessionID,
|
||||||
|
providerID: message.model.providerID,
|
||||||
|
modelID: message.model.id,
|
||||||
|
mode: "build",
|
||||||
|
agent: message.agent,
|
||||||
|
path: {
|
||||||
|
cwd: session?.location.directory ?? "",
|
||||||
|
root: session?.location.directory ?? "",
|
||||||
|
},
|
||||||
|
cost: message.cost ?? 0,
|
||||||
|
tokens: message.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
variant: message.model.variant,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
},
|
},
|
||||||
status(sessionID) {
|
status(sessionID) {
|
||||||
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
|
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
|
||||||
},
|
},
|
||||||
permission(sessionID) {
|
permission(sessionID) {
|
||||||
return sync.data.permission[sessionID] ?? []
|
return (data.session.permission.list(sessionID) ?? []).map((request) => ({
|
||||||
|
id: request.id,
|
||||||
|
sessionID: request.sessionID,
|
||||||
|
permission: request.action,
|
||||||
|
patterns: request.resources,
|
||||||
|
metadata: request.metadata ?? {},
|
||||||
|
always: request.save ?? [],
|
||||||
|
tool: request.source?.type === "tool" ? { messageID: request.source.messageID, callID: request.source.callID } : undefined,
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
question(sessionID) {
|
question(sessionID) {
|
||||||
return sync.data.question[sessionID] ?? []
|
return sync.data.question[sessionID] ?? []
|
||||||
|
|||||||
@@ -0,0 +1,919 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOptions(form: FormInfo) {
|
||||||
|
if (!form.location) return undefined
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
"x-opencode-directory": encodeURIComponent(form.location.directory),
|
||||||
|
...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }, requestOptions(props.form))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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 }, requestOptions(props.form))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
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: {} as Record<string, string>,
|
||||||
|
selected: 0,
|
||||||
|
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 Array.isArray(value) ? value.length > 0 : 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(() => (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 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()) {
|
||||||
|
sdk.api.form
|
||||||
|
.reply({
|
||||||
|
sessionID: props.form.sessionID,
|
||||||
|
formID: props.form.id,
|
||||||
|
answer: { [current.key]: value },
|
||||||
|
}, requestOptions(props.form))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
setStore(
|
||||||
|
"error",
|
||||||
|
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||||
|
? error.message
|
||||||
|
: "Invalid answer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
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.length === 0 ? undefined : list)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => 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 }, requestOptions(props.form))
|
||||||
|
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() ?? ""
|
||||||
|
submitText(text)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "shift+tab",
|
||||||
|
desc: "Previous field",
|
||||||
|
group: "Form",
|
||||||
|
cmd: () => {
|
||||||
|
if (!textual()) return
|
||||||
|
const text = textarea?.plainText?.trim() ?? ""
|
||||||
|
submitText(text, -1)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.length === 0 ? undefined : 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
|
||||||
|
}
|
||||||
|
if (current.type === "string") {
|
||||||
|
const invalid = validateText(current, text)
|
||||||
|
if (invalid) {
|
||||||
|
setStore("error", invalid)
|
||||||
|
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() {
|
||||||
|
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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) => validateSelection(field, store.answers[field.key]))
|
||||||
|
if (invalid) {
|
||||||
|
setStore("error", validateSelection(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]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}, requestOptions(props.form))
|
||||||
|
.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 }, requestOptions(props.form))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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 }, requestOptions(props.form))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...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={() => 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 Array.isArray(value) ? value.length > 0 : value !== undefined
|
||||||
|
}
|
||||||
|
const missing = () => !answered() && item.required === true
|
||||||
|
const invalid = () => validateSelection(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ import { usePromptRef } from "../../context/prompt"
|
|||||||
import { useEpilogue } from "../../context/epilogue"
|
import { useEpilogue } from "../../context/epilogue"
|
||||||
import { normalizePath } from "../../util/path"
|
import { normalizePath } from "../../util/path"
|
||||||
import { PermissionPrompt } from "./permission"
|
import { PermissionPrompt } from "./permission"
|
||||||
import { QuestionPrompt } from "./question"
|
import { FormPrompt } from "./form"
|
||||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||||
import { sessionEpilogue } from "../../util/presentation"
|
import { sessionEpilogue } from "../../util/presentation"
|
||||||
import { useTuiConfig } from "../../config"
|
import { useTuiConfig } from "../../config"
|
||||||
@@ -181,15 +181,18 @@ export function Session() {
|
|||||||
(sessionID) => data.session.permission.list(sessionID) ?? [],
|
(sessionID) => data.session.permission.list(sessionID) ?? [],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const questions = createMemo(() => {
|
const forms = createMemo(() => {
|
||||||
if (session()?.parentID) return []
|
const sessionIDs = session()?.parentID ? [route.sessionID] : [route.sessionID, ...descendantSessionIDs()]
|
||||||
return data.session.question.list(route.sessionID) ?? []
|
return [
|
||||||
|
...sessionIDs.flatMap((sessionID) => data.session.form.list(sessionID) ?? []),
|
||||||
|
...(data.session.form.list("global", location()) ?? []),
|
||||||
|
]
|
||||||
})
|
})
|
||||||
const [composer, setComposer] = createStore({
|
const [composer, setComposer] = createStore({
|
||||||
open: false,
|
open: false,
|
||||||
tab: undefined as string | undefined,
|
tab: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
|
const disabled = createMemo(() => permissions().length > 0 || forms().length > 0)
|
||||||
|
|
||||||
const pending = createMemo(() => {
|
const pending = createMemo(() => {
|
||||||
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
|
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
|
||||||
@@ -236,18 +239,19 @@ export function Session() {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(descendantSessionIDs, (sessionIDs) => {
|
on(descendantSessionIDs, (sessionIDs) => {
|
||||||
void Promise.all(sessionIDs.map((sessionID) => data.session.permission.refresh(sessionID)))
|
void Promise.all(
|
||||||
|
sessionIDs.flatMap((sessionID) => [
|
||||||
|
data.session.permission.refresh(sessionID),
|
||||||
|
data.session.form.refresh(sessionID),
|
||||||
|
]),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sessionID = route.sessionID
|
const sessionID = route.sessionID
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await Promise.all([
|
await data.session.refresh(sessionID)
|
||||||
data.session.refresh(sessionID),
|
|
||||||
data.session.permission.refresh(sessionID),
|
|
||||||
data.session.question.refresh(sessionID),
|
|
||||||
])
|
|
||||||
const info = data.session.get(sessionID)
|
const info = data.session.get(sessionID)
|
||||||
if (!info) {
|
if (!info) {
|
||||||
toast.show({
|
toast.show({
|
||||||
@@ -258,6 +262,12 @@ export function Session() {
|
|||||||
navigate({ type: "home" })
|
navigate({ type: "home" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!info.parentID) await data.session.refreshChildren(sessionID)
|
||||||
|
await Promise.all([
|
||||||
|
data.session.permission.refresh(sessionID),
|
||||||
|
data.session.form.refresh(sessionID),
|
||||||
|
data.session.form.refresh("global", info.location),
|
||||||
|
])
|
||||||
|
|
||||||
project.workspace.set(info.location.workspaceID)
|
project.workspace.set(info.location.workspaceID)
|
||||||
editor.reconnect(info.location.directory)
|
editor.reconnect(info.location.directory)
|
||||||
@@ -935,13 +945,18 @@ export function Session() {
|
|||||||
onClose={() => setComposer("open", false)}
|
onClose={() => setComposer("open", false)}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={composer.open || !!session()?.parentID}>{null}</Match>
|
|
||||||
<Match when={permissions().length > 0}>
|
<Match when={permissions().length > 0}>
|
||||||
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
|
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={questions().length > 0}>
|
<Match when={forms().length > 0}>
|
||||||
<QuestionPrompt request={questions()[0]} directory={session()?.location.directory} />
|
<Show when={forms()[0]?.id} keyed>
|
||||||
|
{(_) => {
|
||||||
|
const form = forms()[0]
|
||||||
|
return form ? <FormPrompt form={form} /> : null
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={composer.open || !!session()?.parentID}>{null}</Match>
|
||||||
<Match when={!disabled()}>
|
<Match when={!disabled()}>
|
||||||
<pluginRuntime.Slot
|
<pluginRuntime.Slot
|
||||||
name="session_prompt"
|
name="session_prompt"
|
||||||
|
|||||||
@@ -1,514 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
||||||
import { useRenderer } from "@opentui/solid"
|
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
|
||||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
|
||||||
import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
|
|
||||||
import { useSDK } from "../../context/sdk"
|
|
||||||
import { SplitBorder } from "../../ui/border"
|
|
||||||
import { useTuiConfig } from "../../config"
|
|
||||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
|
||||||
|
|
||||||
const QUESTION_MODE = "question"
|
|
||||||
|
|
||||||
export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) {
|
|
||||||
const sdk = useSDK()
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const renderer = useRenderer()
|
|
||||||
const tuiConfig = useTuiConfig()
|
|
||||||
const modeStack = useOpencodeModeStack()
|
|
||||||
|
|
||||||
const questions = createMemo(() => props.request.questions)
|
|
||||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
|
||||||
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
|
|
||||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
tab: 0,
|
|
||||||
answers: [] as QuestionV2Answer[],
|
|
||||||
custom: [] as string[],
|
|
||||||
selected: 0,
|
|
||||||
editing: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
let textarea: TextareaRenderable | undefined
|
|
||||||
|
|
||||||
const question = createMemo(() => questions()[store.tab])
|
|
||||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
|
||||||
const options = createMemo(() => question()?.options ?? [])
|
|
||||||
const custom = createMemo(() => question()?.custom !== false)
|
|
||||||
const other = createMemo(() => custom() && store.selected === options().length)
|
|
||||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
|
||||||
const customPicked = createMemo(() => {
|
|
||||||
const value = input()
|
|
||||||
if (!value) return false
|
|
||||||
return store.answers[store.tab]?.includes(value) ?? false
|
|
||||||
})
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
|
||||||
void sdk.api.question.reply({
|
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
requestID: props.request.id,
|
|
||||||
answers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function reject() {
|
|
||||||
void sdk.api.question.reject({
|
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
requestID: props.request.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(answer: string, custom: boolean = false) {
|
|
||||||
const answers = [...store.answers]
|
|
||||||
answers[store.tab] = [answer]
|
|
||||||
setStore("answers", answers)
|
|
||||||
if (custom) {
|
|
||||||
const inputs = [...store.custom]
|
|
||||||
inputs[store.tab] = answer
|
|
||||||
setStore("custom", inputs)
|
|
||||||
}
|
|
||||||
if (single()) {
|
|
||||||
void sdk.api.question.reply({
|
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
requestID: props.request.id,
|
|
||||||
answers: [[answer]],
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("tab", store.tab + 1)
|
|
||||||
setStore("selected", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(answer: string) {
|
|
||||||
const existing = store.answers[store.tab] ?? []
|
|
||||||
const next = [...existing]
|
|
||||||
const index = next.indexOf(answer)
|
|
||||||
if (index === -1) next.push(answer)
|
|
||||||
if (index !== -1) next.splice(index, 1)
|
|
||||||
const answers = [...store.answers]
|
|
||||||
answers[store.tab] = next
|
|
||||||
setStore("answers", answers)
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveTo(index: number) {
|
|
||||||
setStore("selected", index)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectTab(index: number) {
|
|
||||||
setStore("tab", index)
|
|
||||||
setStore("selected", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectOption() {
|
|
||||||
if (other()) {
|
|
||||||
if (!multi()) {
|
|
||||||
setStore("editing", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const value = input()
|
|
||||||
if (value && customPicked()) {
|
|
||||||
toggle(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("editing", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const opt = options()[store.selected]
|
|
||||||
if (!opt) return
|
|
||||||
if (multi()) {
|
|
||||||
toggle(opt.label)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pick(opt.label)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const popMode = modeStack.push(QUESTION_MODE)
|
|
||||||
onCleanup(popMode)
|
|
||||||
})
|
|
||||||
|
|
||||||
useBindings(() => ({
|
|
||||||
mode: QUESTION_MODE,
|
|
||||||
enabled: store.editing && !confirm(),
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
name: "prompt.clear",
|
|
||||||
title: "Clear answer edit",
|
|
||||||
category: "Question",
|
|
||||||
run() {
|
|
||||||
const text = textarea?.plainText ?? ""
|
|
||||||
if (!text) {
|
|
||||||
setStore("editing", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
textarea?.setText("")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bindings: [
|
|
||||||
{
|
|
||||||
key: "escape",
|
|
||||||
desc: "Cancel answer edit",
|
|
||||||
group: "Question",
|
|
||||||
cmd: () => {
|
|
||||||
setStore("editing", false)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...tuiConfig.keybinds.get("prompt.clear"),
|
|
||||||
{
|
|
||||||
key: "return",
|
|
||||||
desc: "Submit answer edit",
|
|
||||||
group: "Question",
|
|
||||||
cmd: () => {
|
|
||||||
const text = textarea?.plainText?.trim() ?? ""
|
|
||||||
const prev = store.custom[store.tab]
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
if (prev) {
|
|
||||||
const inputs = [...store.custom]
|
|
||||||
inputs[store.tab] = ""
|
|
||||||
setStore("custom", inputs)
|
|
||||||
|
|
||||||
const answers = [...store.answers]
|
|
||||||
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
|
|
||||||
setStore("answers", answers)
|
|
||||||
}
|
|
||||||
setStore("editing", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (multi()) {
|
|
||||||
const inputs = [...store.custom]
|
|
||||||
inputs[store.tab] = text
|
|
||||||
setStore("custom", inputs)
|
|
||||||
|
|
||||||
const existing = store.answers[store.tab] ?? []
|
|
||||||
const next = [...existing]
|
|
||||||
if (prev) {
|
|
||||||
const index = next.indexOf(prev)
|
|
||||||
if (index !== -1) next.splice(index, 1)
|
|
||||||
}
|
|
||||||
if (!next.includes(text)) next.push(text)
|
|
||||||
const answers = [...store.answers]
|
|
||||||
answers[store.tab] = next
|
|
||||||
setStore("answers", answers)
|
|
||||||
setStore("editing", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pick(text, true)
|
|
||||||
setStore("editing", false)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
|
|
||||||
useBindings(() => {
|
|
||||||
const opts = options()
|
|
||||||
const total = opts.length + (custom() ? 1 : 0)
|
|
||||||
const max = Math.min(total, 9)
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: QUESTION_MODE,
|
|
||||||
enabled: !store.editing,
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
name: "app.exit",
|
|
||||||
title: "Reject question",
|
|
||||||
category: "Question",
|
|
||||||
run() {
|
|
||||||
reject()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bindings: [
|
|
||||||
{
|
|
||||||
key: "left",
|
|
||||||
desc: "Previous question",
|
|
||||||
group: "Question",
|
|
||||||
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h",
|
|
||||||
desc: "Previous question",
|
|
||||||
group: "Question",
|
|
||||||
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
|
|
||||||
},
|
|
||||||
{ key: "right", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
|
||||||
{ key: "l", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
|
||||||
{
|
|
||||||
key: "tab",
|
|
||||||
desc: "Next question",
|
|
||||||
group: "Question",
|
|
||||||
cmd: ({ event }: { event: { shift: boolean } }) => {
|
|
||||||
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(confirm()
|
|
||||||
? [
|
|
||||||
{ key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
|
|
||||||
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
|
|
||||||
...tuiConfig.keybinds.get("app.exit"),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
...Array.from({ length: max }, (_, index) => ({
|
|
||||||
key: String(index + 1),
|
|
||||||
desc: `Select answer ${index + 1}`,
|
|
||||||
group: "Question",
|
|
||||||
cmd: () => {
|
|
||||||
moveTo(index)
|
|
||||||
selectOption()
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
key: "up",
|
|
||||||
desc: "Previous answer",
|
|
||||||
group: "Question",
|
|
||||||
cmd: () => moveTo((store.selected - 1 + total) % total),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "k",
|
|
||||||
desc: "Previous answer",
|
|
||||||
group: "Question",
|
|
||||||
cmd: () => moveTo((store.selected - 1 + total) % total),
|
|
||||||
},
|
|
||||||
{ key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
|
|
||||||
{ key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
|
|
||||||
{ key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
|
|
||||||
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
|
|
||||||
...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={!single()}>
|
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
|
||||||
<For each={questions()}>
|
|
||||||
{(q, index) => {
|
|
||||||
const isActive = () => index() === store.tab
|
|
||||||
const isAnswered = () => {
|
|
||||||
return (store.answers[index()]?.length ?? 0) > 0
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={
|
|
||||||
isActive()
|
|
||||||
? theme.accent
|
|
||||||
: tabHover() === index()
|
|
||||||
? theme.backgroundElement
|
|
||||||
: theme.backgroundPanel
|
|
||||||
}
|
|
||||||
onMouseOver={() => setTabHover(index())}
|
|
||||||
onMouseOut={() => setTabHover(null)}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
|
||||||
selectTab(index())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
isActive()
|
|
||||||
? selectedForeground(theme, theme.accent)
|
|
||||||
: isAnswered()
|
|
||||||
? theme.text
|
|
||||||
: theme.textMuted
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{q.header}
|
|
||||||
</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(questions().length)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!confirm()}>
|
|
||||||
<box paddingLeft={1} gap={1}>
|
|
||||||
<box>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{question()?.question}
|
|
||||||
{multi() ? " (select all that apply)" : ""}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box>
|
|
||||||
<For each={options()}>
|
|
||||||
{(opt, i) => {
|
|
||||||
const active = () => i() === store.selected
|
|
||||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
|
||||||
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() ? "✓" : " "}] ${opt.label}` : opt.label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show when={!multi()}>
|
|
||||||
<text fg={theme.success}>{picked() ? " ✓" : ""}</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text fg={theme.textMuted}>{opt.description}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<Show when={custom()}>
|
|
||||||
<box
|
|
||||||
onMouseOver={() => moveTo(options().length)}
|
|
||||||
onMouseDown={() => moveTo(options().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}>
|
|
||||||
{`${options().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>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={confirm() && !single()}>
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
<text fg={theme.text}>Review</text>
|
|
||||||
</box>
|
|
||||||
<For each={questions()}>
|
|
||||||
{(q, index) => {
|
|
||||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
|
||||||
const answered = () => Boolean(value())
|
|
||||||
return (
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
<text>
|
|
||||||
<span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
|
|
||||||
<span style={{ fg: answered() ? theme.text : theme.error }}>
|
|
||||||
{answered() ? value() : "(not answered)"}
|
|
||||||
</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</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()}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</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>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||||
import type { PermissionRequest, QuestionRequest, Session, V2Event } from "@opencode-ai/sdk/v2"
|
import type { PermissionV2Request, Session, V2Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
||||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||||
|
|
||||||
@@ -64,22 +64,22 @@ async function setup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function question(id: string, sessionID = "session"): QuestionRequest {
|
function form(id: string, sessionID = "session"): Extract<V2Event, { type: "form.created" }>["data"]["form"] {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
questions: [],
|
mode: "form",
|
||||||
|
fields: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function permission(id: string, sessionID = "session"): PermissionRequest {
|
function permission(id: string, sessionID = "session"): PermissionV2Request {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
permission: "edit",
|
action: "edit",
|
||||||
patterns: [],
|
resources: [],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: [],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ function stepStarted(id: string, sessionID = "session"): V2Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
|
function executionSettled(id: string, sessionID = "session", finish = "stop"): V2Event {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
created: 0,
|
created: 0,
|
||||||
@@ -132,9 +132,9 @@ function stepFailed(id: string, sessionID = "session"): V2Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const questionNotification: TuiAttentionNotifyInput = {
|
const formNotification: TuiAttentionNotifyInput = {
|
||||||
title: "Demo session",
|
title: "Demo session",
|
||||||
message: "Question needs input",
|
message: "Input needs response",
|
||||||
notification: { when: "blurred" },
|
notification: { when: "blurred" },
|
||||||
sound: { name: "question", when: "always" },
|
sound: { name: "question", when: "always" },
|
||||||
}
|
}
|
||||||
@@ -147,41 +147,41 @@ const permissionNotification: TuiAttentionNotifyInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("internal notifications TUI plugin", () => {
|
describe("internal notifications TUI plugin", () => {
|
||||||
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
|
test("notifies for form and permission requests with blurred notifications and always-on sounds", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1") })
|
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||||
harness.emit({ id: "event-2", created: 0, type: "permission.asked", data: permission("permission-1") })
|
harness.emit({ id: "event-2", created: 0, type: "permission.v2.asked", data: permission("permission-1") })
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
|
expect(harness.notifications).toEqual([formNotification, permissionNotification])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("dedupes pending questions and permissions until they are resolved", async () => {
|
test("dedupes pending forms and permissions until they are resolved", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-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-2", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||||
harness.emit({
|
harness.emit({
|
||||||
id: "event-3",
|
id: "event-3",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "question.replied",
|
type: "form.replied",
|
||||||
data: { sessionID: "session", requestID: "question-1", answers: [] },
|
data: { sessionID: "session", id: "form-1", answer: {} },
|
||||||
})
|
})
|
||||||
harness.emit({ id: "event-4", created: 0, type: "question.asked", data: question("question-1") })
|
harness.emit({ id: "event-4", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||||
|
|
||||||
harness.emit({ id: "event-5", created: 0, type: "permission.asked", data: permission("permission-1") })
|
harness.emit({ id: "event-5", created: 0, type: "permission.v2.asked", data: permission("permission-1") })
|
||||||
harness.emit({ id: "event-6", created: 0, type: "permission.asked", data: permission("permission-1") })
|
harness.emit({ id: "event-6", created: 0, type: "permission.v2.asked", data: permission("permission-1") })
|
||||||
harness.emit({
|
harness.emit({
|
||||||
id: "event-7",
|
id: "event-7",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "permission.replied",
|
type: "permission.v2.replied",
|
||||||
data: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
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-8", created: 0, type: "permission.v2.asked", data: permission("permission-1") })
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
questionNotification,
|
formNotification,
|
||||||
questionNotification,
|
formNotification,
|
||||||
permissionNotification,
|
permissionNotification,
|
||||||
permissionNotification,
|
permissionNotification,
|
||||||
])
|
])
|
||||||
@@ -190,9 +190,9 @@ describe("internal notifications TUI plugin", () => {
|
|||||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit(stepEnded("event-1"))
|
harness.emit(executionSettled("event-1"))
|
||||||
harness.emit(stepStarted("event-2"))
|
harness.emit(stepStarted("event-2"))
|
||||||
harness.emit(stepEnded("event-3"))
|
harness.emit(executionSettled("event-3"))
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
{
|
{
|
||||||
@@ -207,14 +207,14 @@ describe("internal notifications TUI plugin", () => {
|
|||||||
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
||||||
const harness = await setup()
|
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(stepStarted("event-2", "subagent"))
|
||||||
harness.emit(stepEnded("event-3", "subagent"))
|
harness.emit(executionSettled("event-3", "subagent"))
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
{
|
{
|
||||||
title: "Subagent session",
|
title: "Subagent session",
|
||||||
message: "Question needs input",
|
message: "Input needs response",
|
||||||
notification: false,
|
notification: false,
|
||||||
sound: { name: "question", when: "always" },
|
sound: { name: "question", when: "always" },
|
||||||
},
|
},
|
||||||
@@ -232,7 +232,7 @@ describe("internal notifications TUI plugin", () => {
|
|||||||
|
|
||||||
harness.emit(stepStarted("event-1"))
|
harness.emit(stepStarted("event-1"))
|
||||||
harness.emit(stepFailed("event-2"))
|
harness.emit(stepFailed("event-2"))
|
||||||
harness.emit(stepEnded("event-3"))
|
harness.emit(executionSettled("event-3"))
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -698,9 +698,10 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("adds and dismisses question requests from live events", async () => {
|
test("tracks global forms by location", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
|
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
@@ -722,44 +723,88 @@ test("adds and dismisses question requests from live events", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => data.connection.status() === "connected")
|
await wait(() => data.connection.status() === "connected")
|
||||||
emitEvent(events, {
|
events.emit({
|
||||||
id: "evt_question_asked_1",
|
id: "evt_form_created_1",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "question.v2.asked",
|
location: other,
|
||||||
|
type: "form.created",
|
||||||
data: {
|
data: {
|
||||||
id: "que_1",
|
form: {
|
||||||
sessionID: "ses_1",
|
id: "frm_1",
|
||||||
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
|
sessionID: "global",
|
||||||
|
mode: "form",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
emitEvent(events, {
|
|
||||||
id: "evt_question_asked_2",
|
await wait(() => data.session.form.list("global", other)?.length === 1)
|
||||||
created: 0,
|
expect(data.session.form.list("global", { directory }) ?? []).toEqual([])
|
||||||
type: "question.v2.asked",
|
|
||||||
data: {
|
events.emit({
|
||||||
id: "que_2",
|
id: "evt_form_replied_1",
|
||||||
sessionID: "ses_1",
|
created: 1,
|
||||||
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
|
location: other,
|
||||||
|
type: "form.replied",
|
||||||
|
data: { id: "frm_1", sessionID: "global", answer: {} },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.form.list("global", other)?.length === 0)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes global forms for the requested location", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const requests: URL[] = []
|
||||||
|
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname !== "/api/form/request") return
|
||||||
|
requests.push(url)
|
||||||
|
const requestedDirectory = url.searchParams.get("location[directory]") ?? directory
|
||||||
|
const requestedWorkspace = url.searchParams.get("location[workspace]") ?? undefined
|
||||||
|
return json({
|
||||||
|
location: {
|
||||||
|
directory: requestedDirectory,
|
||||||
|
workspaceID: requestedWorkspace,
|
||||||
|
project: { id: "proj_test", directory: requestedDirectory },
|
||||||
},
|
},
|
||||||
|
data:
|
||||||
|
requestedDirectory === other.directory
|
||||||
|
? [{ id: "frm_other", sessionID: "global", mode: "form", fields: [] }]
|
||||||
|
: [],
|
||||||
})
|
})
|
||||||
await wait(() => data.session.question.list("ses_1")?.length === 2)
|
}, events)
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
|
||||||
emitEvent(events, {
|
function Probe() {
|
||||||
id: "evt_question_replied_1",
|
data = useData()
|
||||||
created: 0,
|
return <box />
|
||||||
type: "question.v2.replied",
|
}
|
||||||
data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
|
|
||||||
})
|
|
||||||
await wait(() => data.session.question.list("ses_1")?.length === 1)
|
|
||||||
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
|
|
||||||
|
|
||||||
emitEvent(events, {
|
const app = await testRender(() => (
|
||||||
id: "evt_question_rejected_2",
|
<TestTuiContexts>
|
||||||
created: 0,
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
type: "question.v2.rejected",
|
<ProjectProvider>
|
||||||
data: { sessionID: "ses_1", requestID: "que_2" },
|
<DataProvider>
|
||||||
})
|
<Probe />
|
||||||
await wait(() => data.session.question.list("ses_1")?.length === 0)
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => requests.length > 0)
|
||||||
|
requests.length = 0
|
||||||
|
|
||||||
|
await data.session.form.refresh("global", other)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.searchParams.get("location[directory]")).toBe(other.directory)
|
||||||
|
expect(requests[0]?.searchParams.get("location[workspace]")).toBe(other.workspaceID)
|
||||||
|
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"])
|
||||||
|
expect(data.session.form.list("global", { directory }) ?? []).toEqual([])
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,8 +97,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||||||
if (url.pathname === "/api/project/proj_test/directories") return json([{ 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/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/mcp") return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||||
|
if (url.pathname === "/api/form/request") 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") return json({ data: [], cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json({ data: {}, watermarks: {} })
|
if (url.pathname === "/api/session/active") return json({ data: {}, watermarks: {} })
|
||||||
|
if (/^\/api\/session\/[^/]+\/permission$/.test(url.pathname)) return json([])
|
||||||
|
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
|
||||||
if (
|
if (
|
||||||
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
|
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
|
||||||
url.pathname,
|
url.pathname,
|
||||||
|
|||||||
Reference in New Issue
Block a user