mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-31 14:22:27 +00:00
core: add interactive question tool for gathering user preferences and clarifying instructions (#7268)
This commit is contained in:
@@ -515,7 +515,15 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
session = await Session.create({})
|
||||
session = await Session.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
})
|
||||
subscribeSessionEvents()
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
|
||||
@@ -292,7 +292,28 @@ export const RunCommand = cmd({
|
||||
: args.title
|
||||
: undefined
|
||||
|
||||
const result = await sdk.session.create(title ? { title } : {})
|
||||
const result = await sdk.session.create(
|
||||
title
|
||||
? {
|
||||
title,
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
return result.data?.id
|
||||
})()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Todo,
|
||||
Command,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
McpResource,
|
||||
@@ -42,6 +43,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
config: Config
|
||||
session: Session[]
|
||||
session_status: {
|
||||
@@ -80,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
permission: {},
|
||||
question: {},
|
||||
command: [],
|
||||
provider: [],
|
||||
provider_default: {},
|
||||
@@ -142,6 +147,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const requests = store.question[event.properties.sessionID]
|
||||
if (!requests) break
|
||||
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
|
||||
if (!match.found) break
|
||||
setStore(
|
||||
"question",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "question.asked": {
|
||||
const request = event.properties
|
||||
const requests = store.question[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("question", request.sessionID, [request])
|
||||
break
|
||||
}
|
||||
const match = Binary.search(requests, request.id, (r) => r.id)
|
||||
if (match.found) {
|
||||
setStore("question", request.sessionID, match.index, reconcile(request))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"question",
|
||||
request.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 0, request)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||
break
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { EditTool } from "@/tool/edit"
|
||||
import type { PatchTool } from "@/tool/patch"
|
||||
import type { WebFetchTool } from "@/tool/webfetch"
|
||||
import type { TaskTool } from "@/tool/task"
|
||||
import type { QuestionTool } from "@/tool/question"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
@@ -69,6 +70,7 @@ import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
|
||||
@@ -118,9 +120,13 @@ export function Session() {
|
||||
})
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const permissions = createMemo(() => {
|
||||
if (session()?.parentID) return sync.data.permission[route.sessionID] ?? []
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
||||
})
|
||||
const questions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.question[x.id] ?? [])
|
||||
})
|
||||
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
@@ -1037,8 +1043,11 @@ export function Session() {
|
||||
<Show when={permissions().length > 0}>
|
||||
<PermissionPrompt request={permissions()[0]} />
|
||||
</Show>
|
||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||
<QuestionPrompt request={questions()[0]} />
|
||||
</Show>
|
||||
<Prompt
|
||||
visible={!session()?.parentID && permissions().length === 0}
|
||||
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
@@ -1047,7 +1056,7 @@ export function Session() {
|
||||
r.set(route.initialPrompt)
|
||||
}
|
||||
}}
|
||||
disabled={permissions().length > 0}
|
||||
disabled={permissions().length > 0 || questions().length > 0}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
@@ -1381,6 +1390,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
||||
<Match when={props.part.tool === "todowrite"}>
|
||||
<TodoWrite {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "question"}>
|
||||
<Question {...toolprops} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<GenericTool {...toolprops} />
|
||||
</Match>
|
||||
@@ -1442,7 +1454,12 @@ function InlineTool(props: {
|
||||
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
||||
|
||||
const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule"))
|
||||
const denied = createMemo(
|
||||
() =>
|
||||
error()?.includes("rejected permission") ||
|
||||
error()?.includes("specified a rule") ||
|
||||
error()?.includes("user dismissed"),
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -1816,6 +1833,34 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
|
||||
)
|
||||
}
|
||||
|
||||
function Question(props: ToolProps<typeof QuestionTool>) {
|
||||
const { theme } = useTheme()
|
||||
const count = createMemo(() => props.input.questions?.length ?? 0)
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.answers}>
|
||||
<BlockTool title="# Questions" part={props.part}>
|
||||
<box>
|
||||
<For each={props.input.questions ?? []}>
|
||||
{(q, i) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>{q.question}</text>
|
||||
<text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
|
||||
Asked {count()} question{count() !== 1 ? "s" : ""}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
if (path.isAbsolute(input)) {
|
||||
|
||||
287
packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
Normal file
287
packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const bindings = useTextareaKeybindings()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1)
|
||||
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as string[],
|
||||
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 other = createMemo(() => store.selected === options().length)
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
|
||||
function submit() {
|
||||
// Fill in empty answers with empty strings
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? "")
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers,
|
||||
})
|
||||
}
|
||||
|
||||
function reject() {
|
||||
sdk.client.question.reject({
|
||||
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()) {
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers: [answer],
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("tab", store.tab + 1)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
// When editing "Other" textarea
|
||||
if (store.editing && !confirm()) {
|
||||
if (evt.name === "escape") {
|
||||
evt.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
const text = textarea?.plainText?.trim()
|
||||
if (text) {
|
||||
pick(text, true)
|
||||
setStore("editing", false)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Let textarea handle all other keys
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "left" || evt.name === "h") {
|
||||
evt.preventDefault()
|
||||
const next = (store.tab - 1 + tabs()) % tabs()
|
||||
setStore("tab", next)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
if (evt.name === "right" || evt.name === "l") {
|
||||
evt.preventDefault()
|
||||
const next = (store.tab + 1) % tabs()
|
||||
setStore("tab", next)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
if (confirm()) {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
submit()
|
||||
}
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
reject()
|
||||
}
|
||||
} else {
|
||||
const opts = options()
|
||||
const total = opts.length + 1 // options + "Other"
|
||||
|
||||
if (evt.name === "up" || evt.name === "k") {
|
||||
evt.preventDefault()
|
||||
setStore("selected", (store.selected - 1 + total) % total)
|
||||
}
|
||||
|
||||
if (evt.name === "down" || evt.name === "j") {
|
||||
evt.preventDefault()
|
||||
setStore("selected", (store.selected + 1) % total)
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
if (other()) {
|
||||
setStore("editing", true)
|
||||
} else {
|
||||
const opt = opts[store.selected]
|
||||
if (opt) {
|
||||
pick(opt.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
reject()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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 = () => store.answers[index()] !== undefined
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
|
||||
>
|
||||
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
|
||||
{q.header}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}>
|
||||
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!confirm()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text}>{question()?.question}</text>
|
||||
</box>
|
||||
<box>
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const active = () => i() === store.selected
|
||||
const picked = () => store.answers[store.tab] === opt.label
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
|
||||
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
|
||||
{i() + 1}. {opt.label}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
|
||||
</box>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{opt.description}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
||||
<text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
|
||||
{options().length + 1}. Other
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.success}>{input() ? "✓" : ""}</text>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
focused
|
||||
placeholder="Type your own answer"
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
keyBindings={bindings()}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<text fg={theme.textMuted}>{input()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={confirm() && !single()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text}>Review</text>
|
||||
</box>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const answer = () => store.answers[index()]
|
||||
return (
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{q.header}:</text>
|
||||
<text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</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" : single() ? "submit" : "confirm"}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user