Compare commits

...

10 Commits

Author SHA1 Message Date
Aiden Cline
1e2ac94a91 test 2026-01-08 23:29:26 -06:00
Aiden Cline
52d7475dbf fix: add back hook and remove dead code 2026-01-08 18:10:26 -06:00
Github Action
49d9f99924 Update Nix flake.lock and hashes 2026-01-08 22:56:55 +00:00
Sebastian Herrlinger
1f9e195cd8 stop esc propagation from dialogs 2026-01-08 23:53:00 +01:00
Sebastian Herrlinger
539d6baa8c upgrade opentui to v0.1.70 2026-01-08 23:52:36 +01:00
Aleksandr Bagatka
f6fc693c1f fix(ui): use full file path for fuzzy matching in autocomplete (#6705)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-08 16:43:05 -06:00
GitHub Action
50d8396c9a chore: generate 2026-01-08 22:33:10 +00:00
Dax
22dd70b75b feat(question): support multi-select questions (#7386) 2026-01-08 17:32:21 -05:00
GitHub Action
b4f8de0c0a chore: generate 2026-01-08 22:03:23 +00:00
Marc Espin
768e0553bd fix(docs): Document cargofmt (#7383) 2026-01-08 16:00:03 -06:00
21 changed files with 234 additions and 287 deletions

View File

@@ -286,8 +286,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.69",
"@opentui/solid": "0.1.69",
"@opentui/core": "0.1.70",
"@opentui/solid": "0.1.70",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -1201,21 +1201,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.69", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.69", "@opentui/core-darwin-x64": "0.1.69", "@opentui/core-linux-arm64": "0.1.69", "@opentui/core-linux-x64": "0.1.69", "@opentui/core-win32-arm64": "0.1.69", "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-BcEFnAuMq4vgfb+zxOP/l+NO1AS3fVHkYjn+E8Wpmaxr0AzWNTi2NPAMtQf+Wqufxo0NYh0gY4c9B6n8OxTjGw=="],
"@opentui/core": ["@opentui/core@0.1.70", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="],
"@opentui/solid": ["@opentui/solid@0.1.69", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.69", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-ls589N8P9gvcNW8uF+Il4xisF5Uouk0RRmSaLdzmItNJSW5J9Y0nPtMELta6hBp0yIRAurWUO1wtkKXVF+eaxg=="],
"@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View File

@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
"nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0="
}

View File

@@ -81,8 +81,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.69",
"@opentui/solid": "0.1.69",
"@opentui/core": "0.1.70",
"@opentui/solid": "0.1.70",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -53,6 +53,7 @@ export type AutocompleteRef = {
export type AutocompleteOption = {
display: string
value?: string
aliases?: string[]
disabled?: boolean
description?: string
@@ -221,6 +222,7 @@ export function Autocomplete(props: {
const isDir = item.endsWith("/")
return {
display: Locale.truncateMiddle(filename, width),
value: filename,
isDirectory: isDir,
path: item,
onSelect: () => {
@@ -259,8 +261,10 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
for (const res of Object.values(sync.data.mcp_resource)) {
const text = `${res.name} (${res.uri})`
options.push({
display: Locale.truncateMiddle(`${res.name} (${res.uri})`, width),
display: Locale.truncateMiddle(text, width),
value: text,
description: res.description,
onSelect: () => {
insertPart(res.name, {
@@ -485,7 +489,11 @@ export function Autocomplete(props: {
}
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
(obj) => obj.aliases?.join(" ") ?? "",
],
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]

View File

@@ -1840,6 +1840,12 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
function Question(props: ToolProps<typeof QuestionTool>) {
const { theme } = useTheme()
const count = createMemo(() => props.input.questions?.length ?? 0)
function format(answer?: string[]) {
if (!answer?.length) return "(no answer)"
return answer.join(", ")
}
return (
<Switch>
<Match when={props.metadata.answers}>
@@ -1849,7 +1855,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
{(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>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
)}
</For>

View File

@@ -4,7 +4,7 @@ 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 type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
@@ -17,11 +17,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
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 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 [store, setStore] = createStore({
tab: 0,
answers: [] as string[],
answers: [] as QuestionAnswer[],
custom: [] as string[],
selected: 0,
editing: false,
@@ -34,10 +34,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const options = createMemo(() => question()?.options ?? [])
const other = createMemo(() => 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() {
// Fill in empty answers with empty strings
const answers = questions().map((_, i) => store.answers[i] ?? "")
const answers = questions().map((_, i) => store.answers[i] ?? [])
sdk.client.question.reply({
requestID: props.request.id,
answers,
@@ -52,7 +57,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
function pick(answer: string, custom: boolean = false) {
const answers = [...store.answers]
answers[store.tab] = answer
answers[store.tab] = [answer]
setStore("answers", answers)
if (custom) {
const inputs = [...store.custom]
@@ -62,7 +67,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
if (single()) {
sdk.client.question.reply({
requestID: props.request.id,
answers: [answer],
answers: [[answer]],
})
return
}
@@ -70,6 +75,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
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)
}
const dialog = useDialog()
useKeyboard((evt) => {
@@ -82,11 +98,49 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim()
if (text) {
pick(text, true)
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]
if (prev) {
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
}
if (!prev) {
answers[store.tab] = []
}
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)
return
}
// Let textarea handle all other keys
@@ -133,13 +187,25 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
if (evt.name === "return") {
evt.preventDefault()
if (other()) {
setStore("editing", true)
} else {
const opt = opts[store.selected]
if (opt) {
pick(opt.label)
if (!multi()) {
setStore("editing", true)
return
}
const value = input()
if (value && customPicked()) {
toggle(value)
return
}
setStore("editing", true)
return
}
const opt = opts[store.selected]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
@@ -162,7 +228,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<For each={questions()}>
{(q, index) => {
const isActive = () => index() === store.tab
const isAnswered = () => store.answers[index()] !== undefined
const isAnswered = () => {
return (store.answers[index()]?.length ?? 0) > 0
}
return (
<box
paddingLeft={1}
@@ -185,13 +253,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<Show when={!confirm()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>{question()?.question}</text>
<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] === opt.label
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<box>
<box flexDirection="row" gap={1}>
@@ -212,17 +283,18 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{options().length + 1}. Type your own answer
</text>
</box>
<text fg={theme.success}>{input() ? "✓" : ""}</text>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
</box>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => (textarea = val)}
focused
initialValue={input()}
placeholder="Type your own answer"
textColor={theme.text}
focusedTextColor={theme.text}
@@ -247,11 +319,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
</box>
<For each={questions()}>
{(q, index) => {
const answer = () => store.answers[index()]
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
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>
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
</box>
)
}}
@@ -279,8 +352,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
</text>
</Show>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>{confirm() ? "submit" : single() ? "submit" : "confirm"}</span>
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>

View File

@@ -62,6 +62,7 @@ function init() {
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
evt.stopPropagation()
refocus()
}
})

View File

@@ -348,7 +348,7 @@ export const rustfmt: Info = {
}
export const cargofmt: Info = {
name: "cargo fmt",
name: "cargofmt",
command: ["cargo", "fmt", "--", "$FILE"],
extensions: [".rs"],
async enabled() {

View File

@@ -1,210 +0,0 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Log } from "../util/log"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import { Instance } from "../project/instance"
import { Wildcard } from "../util/wildcard"
export namespace Permission {
const log = Log.create({ service: "permission" })
function toKeys(pattern: Info["pattern"], type: string): string[] {
return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern]
}
function covered(keys: string[], approved: Record<string, boolean>): boolean {
const pats = Object.keys(approved)
return keys.every((k) => pats.some((p) => Wildcard.match(k, p)))
}
export const Info = z
.object({
id: z.string(),
type: z.string(),
pattern: z.union([z.string(), z.array(z.string())]).optional(),
sessionID: z.string(),
messageID: z.string(),
callID: z.string().optional(),
message: z.string(),
metadata: z.record(z.string(), z.any()),
time: z.object({
created: z.number(),
}),
})
.meta({
ref: "Permission",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: BusEvent.define("permission.updated", Info),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: z.string(),
permissionID: z.string(),
response: z.string(),
}),
),
}
const state = Instance.state(
() => {
const pending: {
[sessionID: string]: {
[permissionID: string]: {
info: Info
resolve: () => void
reject: (e: any) => void
}
}
} = {}
const approved: {
[sessionID: string]: {
[permissionID: string]: boolean
}
} = {}
return {
pending,
approved,
}
},
async (state) => {
for (const pending of Object.values(state.pending)) {
for (const item of Object.values(pending)) {
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata))
}
}
},
)
export function pending() {
return state().pending
}
export function list() {
const { pending } = state()
const result: Info[] = []
for (const items of Object.values(pending)) {
for (const item of Object.values(items)) {
result.push(item.info)
}
}
return result.sort((a, b) => a.id.localeCompare(b.id))
}
export async function ask(input: {
type: Info["type"]
message: Info["message"]
pattern?: Info["pattern"]
callID?: Info["callID"]
sessionID: Info["sessionID"]
messageID: Info["messageID"]
metadata: Info["metadata"]
}) {
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
messageID: input.messageID,
toolCallID: input.callID,
pattern: input.pattern,
})
const approvedForSession = approved[input.sessionID] || {}
const keys = toKeys(input.pattern, input.type)
if (covered(keys, approvedForSession)) return
const info: Info = {
id: Identifier.ascending("permission"),
type: input.type,
pattern: input.pattern,
sessionID: input.sessionID,
messageID: input.messageID,
callID: input.callID,
message: input.message,
metadata: input.metadata,
time: {
created: Date.now(),
},
}
switch (
await Plugin.trigger("permission.ask", info, {
status: "ask",
}).then((x) => x.status)
) {
case "deny":
throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata)
case "allow":
return
}
pending[input.sessionID] = pending[input.sessionID] || {}
return new Promise<void>((resolve, reject) => {
pending[input.sessionID][info.id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Updated, info)
})
}
export const Response = z.enum(["once", "always", "reject"])
export type Response = z.infer<typeof Response>
export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
log.info("response", input)
const { pending, approved } = state()
const match = pending[input.sessionID]?.[input.permissionID]
if (!match) return
delete pending[input.sessionID][input.permissionID]
Bus.publish(Event.Replied, {
sessionID: input.sessionID,
permissionID: input.permissionID,
response: input.response,
})
if (input.response === "reject") {
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
return
}
match.resolve()
if (input.response === "always") {
approved[input.sessionID] = approved[input.sessionID] || {}
const approveKeys = toKeys(match.info.pattern, match.info.type)
for (const k of approveKeys) {
approved[input.sessionID][k] = true
}
const items = pending[input.sessionID]
if (!items) return
for (const item of Object.values(items)) {
const itemKeys = toKeys(item.info.pattern, item.info.type)
if (covered(itemKeys, approved[input.sessionID])) {
respond({
sessionID: item.info.sessionID,
permissionID: item.info.id,
response: input.response,
})
}
}
}
}
export class RejectedError extends Error {
constructor(
public readonly sessionID: string,
public readonly permissionID: string,
public readonly toolCallID?: string,
public readonly metadata?: Record<string, any>,
public readonly reason?: string,
) {
super(
reason !== undefined
? reason
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
)
}
}
}

View File

@@ -120,28 +120,57 @@ export namespace PermissionNext {
async (input) => {
const s = await state()
const { ruleset, ...request } = input
for (const pattern of request.patterns ?? []) {
const ask = (request.patterns ?? []).reduce((ask, pattern) => {
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny")
if (rule.action === "deny") {
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
}
return ask || rule.action === "ask"
}, false)
if (!ask) return
const id = input.id ?? Identifier.ascending("permission")
const info: Request = {
id,
...request,
}
return new Promise<void>((resolve, reject) => {
s.pending[id] = {
info,
resolve,
reject,
}
void import("@/plugin")
.then((plugin) =>
plugin.Plugin.trigger("permission.ask", info, {
status: "ask" as "ask" | "allow" | "deny",
}),
)
.then((result) => {
const existing = s.pending[id]
if (!existing) return
if (result.status === "deny") {
delete s.pending[id]
existing.reject(new RejectedError())
return
}
s.pending[id] = {
info,
resolve,
reject,
if (result.status === "allow") {
delete s.pending[id]
existing.resolve()
return
}
Bus.publish(Event.Asked, info)
})
}
if (rule.action === "allow") continue
}
.catch((error) => {
log.error("permission.ask plugin failed", { error })
if (!s.pending[id]) return
Bus.publish(Event.Asked, info)
})
})
},
)

View File

@@ -23,6 +23,7 @@ export namespace Question {
question: z.string().describe("Complete question"),
header: z.string().max(12).describe("Very short label (max 12 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
})
.meta({
ref: "QuestionInfo",
@@ -46,8 +47,15 @@ export namespace Question {
})
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({
ref: "QuestionAnswer",
})
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z.array(z.string()).describe("User answers in order of questions"),
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
@@ -58,7 +66,7 @@ export namespace Question {
z.object({
sessionID: z.string(),
requestID: z.string(),
answers: z.array(z.string()),
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
@@ -75,7 +83,7 @@ export namespace Question {
string,
{
info: Request
resolve: (answers: string[]) => void
resolve: (answers: Answer[]) => void
reject: (e: any) => void
}
> = {}
@@ -89,13 +97,13 @@ export namespace Question {
sessionID: string
questions: Info[]
tool?: { messageID: string; callID: string }
}): Promise<string[]> {
}): Promise<Answer[]> {
const s = await state()
const id = Identifier.ascending("question")
log.info("asking", { id, questions: input.questions.length })
return new Promise<string[]>((resolve, reject) => {
return new Promise<Answer[]>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
@@ -111,7 +119,7 @@ export namespace Question {
})
}
export async function reply(input: { requestID: string; answers: string[] }): Promise<void> {
export async function reply(input: { requestID: string; answers: Answer[] }): Promise<void> {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) {

View File

@@ -52,7 +52,7 @@ export const QuestionRoute = new Hono()
requestID: z.string(),
}),
),
validator("json", z.object({ answers: z.array(z.string()) })),
validator("json", Question.Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")

View File

@@ -15,7 +15,12 @@ export const QuestionTool = Tool.define("question", {
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const formatted = params.questions.map((q, i) => `"${q.question}"="${answers[i] ?? "Unanswered"}"`).join(", ")
function format(answer: Question.Answer | undefined) {
if (!answer?.length) return "Unanswered"
return answer.join(", ")
}
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,

View File

@@ -6,4 +6,5 @@ Use this tool when you need to ask the user questions during execution. This all
Usage notes:
- Users will always be able to select "Other" to provide custom text input
- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label

View File

@@ -82,11 +82,11 @@ test("reply - resolves the pending ask with answers", async () => {
await Question.reply({
requestID,
answers: ["Option 1"],
answers: [["Option 1"]],
})
const answers = await askPromise
expect(answers).toEqual(["Option 1"])
expect(answers).toEqual([["Option 1"]])
},
})
})
@@ -115,7 +115,7 @@ test("reply - removes from pending list", async () => {
await Question.reply({
requestID: pending[0].id,
answers: ["Option 1"],
answers: [["Option 1"]],
})
const pendingAfter = await Question.list()
@@ -131,7 +131,7 @@ test("reply - does nothing for unknown requestID", async () => {
fn: async () => {
await Question.reply({
requestID: "que_unknown",
answers: ["Option 1"],
answers: [["Option 1"]],
})
// Should not throw
},
@@ -244,11 +244,11 @@ test("ask - handles multiple questions", async () => {
await Question.reply({
requestID: pending[0].id,
answers: ["Build", "Dev"],
answers: [["Build"], ["Dev"]],
})
const answers = await askPromise
expect(answers).toEqual(["Build", "Dev"])
expect(answers).toEqual([["Build"], ["Dev"]])
},
})
})

View File

@@ -25,4 +25,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}

View File

@@ -30,4 +30,4 @@
"publishConfig": {
"directory": "dist"
}
}
}

View File

@@ -84,6 +84,7 @@ import type {
PtyRemoveResponses,
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
QuestionListResponses,
QuestionRejectErrors,
QuestionRejectResponses,
@@ -1815,7 +1816,7 @@ export class Question extends HeyApiClient {
parameters: {
requestID: string
directory?: string
answers?: Array<string>
answers?: Array<QuestionAnswer>
},
options?: Options<never, ThrowOnError>,
) {

View File

@@ -541,6 +541,10 @@ export type QuestionInfo = {
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
}
export type QuestionRequest = {
@@ -561,12 +565,14 @@ export type EventQuestionAsked = {
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<string>
answers: Array<QuestionAnswer>
}
}
@@ -3630,7 +3636,10 @@ export type QuestionListResponse = QuestionListResponses[keyof QuestionListRespo
export type QuestionReplyData = {
body?: {
answers: Array<string>
/**
* User answers in order of questions (each answer is an array of selected labels)
*/
answers: Array<QuestionAnswer>
}
path: {
requestID: string

View File

@@ -3254,9 +3254,10 @@
"type": "object",
"properties": {
"answers": {
"description": "User answers in order of questions (each answer is an array of selected labels)",
"type": "array",
"items": {
"type": "string"
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
@@ -7117,6 +7118,10 @@
"items": {
"$ref": "#/components/schemas/QuestionOption"
}
},
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean"
}
},
"required": ["question", "header", "options"]
@@ -7167,6 +7172,12 @@
},
"required": ["type", "properties"]
},
"QuestionAnswer": {
"type": "array",
"items": {
"type": "string"
}
},
"Event.question.replied": {
"type": "object",
"properties": {
@@ -7186,7 +7197,7 @@
"answers": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},

View File

@@ -22,6 +22,7 @@ OpenCode comes with several built-in formatters for popular languages and framew
| ktlint | .kt, .kts | `ktlint` command available |
| ruff | .py, .pyi | `ruff` command available with config |
| rustfmt | .rs | `rustfmt` command available |
| cargofmt | .rs | `cargo fmt` command available |
| uv | .py, .pyi | `uv` command available |
| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available |