From 2bea6420021579c7e4cb47897420695c36b67b1b Mon Sep 17 00:00:00 2001 From: jwaterwater Date: Wed, 7 Jan 2026 09:10:01 +0800 Subject: [PATCH 1/6] fix: encode non-ASCII directory paths in HTTP headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes error when directory paths contain non-ASCII characters (e.g., Chinese) by using percent-encoding for the x-opencode-directory header, as HTTP headers must be ISO-8859-1 compatible. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/opencode/src/server/server.ts | 7 ++++++- packages/sdk/js/src/v2/client.ts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 04ec4673ec4..615d9272866 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -246,7 +246,12 @@ export namespace Server { }, ) .use(async (c, next) => { - const directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + try { + directory = decodeURIComponent(directory) + } catch { + // fallback to original value + } return Instance.provide({ directory, init: InstanceBootstrap, diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 806ad26e55a..8685be52d6a 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -19,9 +19,11 @@ export function createOpencodeClient(config?: Config & { directory?: string }) { } if (config?.directory) { + const isNonASCII = /[^\x00-\x7F]/.test(config.directory) + const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory config.headers = { ...config.headers, - "x-opencode-directory": config.directory, + "x-opencode-directory": encodedDirectory, } } From 4f3677fc77ab4bafbbfa56d7984c64b512781151 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 7 Jan 2026 22:29:42 -0500 Subject: [PATCH 2/6] core: add interactive question tool for gathering user preferences and clarifying instructions (#7268) --- STYLE_GUIDE.md | 3 +- packages/opencode/src/agent/agent.ts | 10 +- packages/opencode/src/cli/cmd/github.ts | 10 +- packages/opencode/src/cli/cmd/run.ts | 23 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 43 + .../src/cli/cmd/tui/routes/session/index.tsx | 53 +- .../cli/cmd/tui/routes/session/question.tsx | 287 + packages/opencode/src/config/config.ts | 1 + packages/opencode/src/id/id.ts | 1 + packages/opencode/src/question/index.ts | 162 + packages/opencode/src/server/question.ts | 95 + packages/opencode/src/server/server.ts | 5383 +++++++++-------- packages/opencode/src/session/processor.ts | 6 +- packages/opencode/src/tool/question.ts | 28 + packages/opencode/src/tool/question.txt | 9 + packages/opencode/src/tool/registry.ts | 2 + .../opencode/test/question/question.test.ts | 300 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 95 + packages/sdk/js/src/v2/gen/types.gen.ts | 150 + 19 files changed, 3966 insertions(+), 2695 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/question.tsx create mode 100644 packages/opencode/src/question/index.ts create mode 100644 packages/opencode/src/server/question.ts create mode 100644 packages/opencode/src/tool/question.ts create mode 100644 packages/opencode/src/tool/question.txt create mode 100644 packages/opencode/test/question/question.test.ts diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 47d008fb423..8dd3be58928 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1,7 +1,8 @@ ## Style Guide - Try to keep things in one function unless composable or reusable -- AVOID unnecessary destructuring of variables +- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } += obj` just reference it as obj.a and obj.b. this preserves context - AVOID `try`/`catch` where possible - AVOID `else` statements - AVOID using `any` type diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5d558ea14ca..ab17dd2235c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -51,6 +51,7 @@ export namespace Agent { "*": "ask", [Truncate.DIR]: "allow", }, + question: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -65,7 +66,13 @@ export namespace Agent { build: { name: "build", options: {}, - permission: PermissionNext.merge(defaults, user), + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + question: "allow", + }), + user, + ), mode: "primary", native: true, }, @@ -75,6 +82,7 @@ export namespace Agent { permission: PermissionNext.merge( defaults, PermissionNext.fromConfig({ + question: "allow", edit: { "*": "deny", ".opencode/plan/*.md": "allow", diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index f6c6b688a35..e6203d66574 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bd9d29b4deb..a86b435ec3e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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 })() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 8a14d8b2e77..0edc911344c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index e1423e22c22..78f2ff7aa83 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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() { 0}> + 0}> + + { 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 + + + @@ -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 ( ) { ) } +function Question(props: ToolProps) { + const { theme } = useTheme() + const count = createMemo(() => props.input.questions?.length ?? 0) + return ( + + + + + + {(q, i) => ( + + {q.question} + {props.metadata.answers?.[i()] || "(no answer)"} + + )} + + + + + + + Asked {count()} question{count() !== 1 ? "s" : ""} + + + + ) +} + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx new file mode 100644 index 00000000000..96883415bb5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -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 ( + + + + + + {(q, index) => { + const isActive = () => index() === store.tab + const isAnswered = () => store.answers[index()] !== undefined + return ( + + + {q.header} + + + ) + }} + + + Confirm + + + + + + + + {question()?.question} + + + + {(opt, i) => { + const active = () => i() === store.selected + const picked = () => store.answers[store.tab] === opt.label + return ( + + + + + {i() + 1}. {opt.label} + + + {picked() ? "✓" : ""} + + + {opt.description} + + + ) + }} + + + + + + {options().length + 1}. Other + + + {input() ? "✓" : ""} + + +