Compare commits

...

3 Commits

5 changed files with 454 additions and 111 deletions

View File

@@ -0,0 +1,295 @@
import { For, Show, createMemo, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
const sdk = useSDK()
const language = useLanguage()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
editing: false,
sending: false,
})
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
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
})
const fail = (err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
}
const reply = (answers: QuestionAnswer[]) => {
if (store.sending) return
setStore("sending", true)
sdk.client.question
.reply({ requestID: props.request.id, answers })
.catch(fail)
.finally(() => setStore("sending", false))
}
const reject = () => {
if (store.sending) return
setStore("sending", true)
sdk.client.question
.reject({ requestID: props.request.id })
.catch(fail)
.finally(() => setStore("sending", false))
}
const submit = () => {
reply(questions().map((_, i) => store.answers[i] ?? []))
}
const 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()) {
reply([[answer]])
return
}
setStore("tab", store.tab + 1)
}
const 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 selectTab = (index: number) => {
setStore("tab", index)
setStore("editing", false)
}
const selectOption = (optIndex: number) => {
if (store.sending) return
if (optIndex === options().length) {
setStore("editing", true)
return
}
const opt = options()[optIndex]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
const handleCustomSubmit = (e: Event) => {
e.preventDefault()
if (store.sending) return
const value = input().trim()
if (!value) {
setStore("editing", false)
return
}
if (multi()) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (!next.includes(value)) next.push(value)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(value, true)
setStore("editing", false)
}
return (
<div data-component="question-prompt">
<Show when={!single()}>
<div data-slot="question-tabs">
<For each={questions()}>
{(q, index) => {
const active = () => index() === store.tab
const answered = () => (store.answers[index()]?.length ?? 0) > 0
return (
<button
data-slot="question-tab"
data-active={active()}
data-answered={answered()}
disabled={store.sending}
onClick={() => selectTab(index())}
>
{q.header}
</button>
)
}}
</For>
<button
data-slot="question-tab"
data-active={confirm()}
disabled={store.sending}
onClick={() => selectTab(questions().length)}
>
{language.t("ui.common.confirm")}
</button>
</div>
</Show>
<Show when={!confirm()}>
<div data-slot="question-content">
<div data-slot="question-text">
{question()?.question}
{multi() ? " " + language.t("ui.question.multiHint") : ""}
</div>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<Show when={picked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
)
}}
</For>
<button
data-slot="question-option"
data-picked={customPicked()}
disabled={store.sending}
onClick={() => selectOption(options().length)}
>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<Show when={!store.editing && input()}>
<span data-slot="option-description">{input()}</span>
</Show>
<Show when={customPicked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
type="text"
data-slot="custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
disabled={store.sending}
onInput={(e) => {
const inputs = [...store.custom]
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
}}
/>
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
</Button>
<Button
type="button"
variant="ghost"
size="small"
disabled={store.sending}
onClick={() => setStore("editing", false)}
>
{language.t("ui.common.cancel")}
</Button>
</form>
</Show>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
</div>
)
}}
</For>
</div>
</Show>
<div data-slot="question-actions">
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
{language.t("ui.common.dismiss")}
</Button>
<Show when={!single()}>
<Show when={confirm()}>
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
{language.t("ui.common.submit")}
</Button>
</Show>
<Show when={!confirm() && multi()}>
<Button
variant="secondary"
size="small"
onClick={() => selectTab(store.tab + 1)}
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
>
{language.t("ui.common.next")}
</Button>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -36,6 +36,7 @@ import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { Mark } from "@opencode-ai/ui/logo"
import { QuestionDock } from "@/components/question-dock"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -270,15 +271,20 @@ export default function Page() {
const comments = useComments()
const permission = usePermission()
const request = createMemo(() => {
const permRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
const next = sync.data.permission[sessionID]?.[0]
if (!next) return
if (next.tool) return
return next
return sync.data.permission[sessionID]?.[0]
})
const questionRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const blocked = createMemo(() => !!permRequest() || !!questionRequest())
const [ui, setUi] = createStore({
responding: false,
pendingMessage: undefined as string | undefined,
@@ -292,14 +298,14 @@ export default function Page() {
createEffect(
on(
() => request()?.id,
() => permRequest()?.id,
() => setUi("responding", false),
{ defer: true },
),
)
const decide = (response: "once" | "always" | "reject") => {
const perm = request()
const perm = permRequest()
if (!perm) return
if (ui.responding) return
@@ -1351,6 +1357,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (blocked()) return
inputRef?.focus()
}
}
@@ -2693,7 +2700,31 @@ export default function Page() {
"md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
}}
>
<Show when={request()} keyed>
<Show when={questionRequest()} keyed>
{(req) => {
const count = req.questions.length
const subtitle =
count === 0
? ""
: `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
return (
<div data-component="tool-part-wrapper" data-question="true" class="mb-3">
<BasicTool
icon="bubble-5"
locked
defaultOpen
trigger={{
title: language.t("ui.tool.questions"),
subtitle,
}}
/>
<QuestionDock request={req} />
</div>
)
}}
</Show>
<Show when={permRequest()} keyed>
{(perm) => (
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
<BasicTool
@@ -2743,25 +2774,27 @@ export default function Page() {
)}
</Show>
<Show
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
</div>
}
>
<PromptInput
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
/>
<Show when={!blocked()}>
<Show
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
</div>
}
>
<PromptInput
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
/>
</Show>
</Show>
</div>
</div>

View File

@@ -145,11 +145,20 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps = []
const deps: Promise<void>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const files = ["opencode.jsonc", "opencode.json"]
const isConfigDir = dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR || dir === Global.Path.config
const configs = isConfigDir
? await Promise.all(files.map((file) => loadFile(path.join(dir, file), { resolvePlugins: false })))
: []
const plugins = configs.flatMap((config) => config.plugin ?? [])
const shouldInstall = await needsInstall(dir, plugins)
if (shouldInstall) await installDependencies(dir, plugins)
if (isConfigDir) {
for (const file of files) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
@@ -159,13 +168,6 @@ export namespace Config {
}
}
deps.push(
iife(async () => {
const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
}),
)
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir))
@@ -247,7 +249,7 @@ export namespace Config {
await Promise.all(deps)
}
export async function installDependencies(dir: string) {
export async function installDependencies(dir: string, plugins: string[] = []) {
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -258,6 +260,10 @@ export namespace Config {
...json.dependencies,
"@opencode-ai/plugin": targetVersion,
}
const pluginDeps = deps(plugins)
for (const [name, version] of Object.entries(pluginDeps)) {
json.dependencies[name] = version
}
await Bun.write(pkg, JSON.stringify(json, null, 2))
await new Promise((resolve) => setTimeout(resolve, 3000))
@@ -286,7 +292,7 @@ export namespace Config {
}
}
async function needsInstall(dir: string) {
async function needsInstall(dir: string, plugins: string[] = []) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
@@ -318,8 +324,27 @@ export namespace Config {
})
return true
}
if (depVersion === targetVersion) return false
return true
if (depVersion !== targetVersion) return true
const pluginDeps = deps(plugins)
for (const [name, version] of Object.entries(pluginDeps)) {
if (dependencies[name] !== version) return true
}
return false
}
function deps(items: string[]) {
const result: Record<string, string> = {}
for (const item of items) {
if (!item) continue
if (item.startsWith("file://")) continue
if (item.startsWith("./") || item.startsWith("../") || item.startsWith("/") || item.startsWith("~")) continue
const lastAt = item.lastIndexOf("@")
const pkg = lastAt > 0 ? item.substring(0, lastAt) : item
const version = lastAt > 0 ? item.substring(lastAt + 1) : "latest"
result[pkg] = version
}
return result
}
function rel(item: string, patterns: string[]) {
@@ -1216,7 +1241,7 @@ export namespace Config {
return result
})
async function loadFile(filepath: string): Promise<Info> {
async function loadFile(filepath: string, options: { resolvePlugins?: boolean } = {}): Promise<Info> {
log.info("loading", { path: filepath })
let text = await Bun.file(filepath)
.text()
@@ -1225,10 +1250,10 @@ export namespace Config {
throw new JsonError({ path: filepath }, { cause: err })
})
if (!text) return {}
return load(text, filepath)
return load(text, filepath, options)
}
async function load(text: string, configFilepath: string) {
async function load(text: string, configFilepath: string, options: { resolvePlugins?: boolean } = {}) {
const original = text
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
@@ -1304,7 +1329,7 @@ export namespace Config {
await Bun.write(configFilepath, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin) {
if (data.plugin && options.resolvePlugins !== false) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {

View File

@@ -3,6 +3,7 @@ import { Log } from "../util/log"
import { Installation } from "../installation"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
const log = Log.create({ service: "plugin.codex" })
@@ -370,6 +371,38 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
}
}
if (!provider.models["gpt-5.3-codex"] || true) {
const model = {
id: "gpt-5.3-codex",
providerID: "openai",
api: {
id: "gpt-5.3-codex",
url: "https://chatgpt.com/backend-api/codex",
npm: "@ai-sdk/openai",
},
name: "GPT-5.3 Codex",
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400_000, input: 272_000, output: 128_000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2026-02-05",
variants: {} as Record<string, Record<string, any>>,
family: "gpt-codex",
}
model.variants = ProviderTransform.variants(model)
provider.models["gpt-5.3-codex"] = model
}
// Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) {
model.cost = {

View File

@@ -10,7 +10,6 @@ import {
} from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
import { findLast } from "@opencode-ai/util/array"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
@@ -84,6 +83,7 @@ function AssistantMessageItem(props: {
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
hidden?: () => readonly { messageID: string; callID: string }[]
}) {
const data = useData()
const emptyParts: PartType[] = []
@@ -104,13 +104,22 @@ function AssistantMessageItem(props: {
parts = parts.filter((part) => part?.type !== "reasoning")
}
if (!props.hideResponsePart) return parts
if (props.hideResponsePart) {
const responsePartId = props.responsePartId
if (responsePartId && responsePartId === lastTextPart()?.id) {
parts = parts.filter((part) => part?.id !== responsePartId)
}
}
const responsePartId = props.responsePartId
if (!responsePartId) return parts
if (responsePartId !== lastTextPart()?.id) return parts
const hidden = props.hidden?.() ?? []
if (hidden.length === 0) return parts
return parts.filter((part) => part?.id !== responsePartId)
const id = props.message.id
return parts.filter((part) => {
if (part?.type !== "tool") return true
const tool = part as ToolPart
return !hidden.some((h) => h.messageID === id && h.callID === tool.callID)
})
})
return <Message message={props.message} parts={filteredParts()} />
@@ -140,7 +149,6 @@ export function SessionTurn(
const emptyFiles: FilePart[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: PermissionRequest[] = []
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
const emptyQuestions: QuestionRequest[] = []
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
const idle = { type: "idle" as const }
@@ -253,48 +261,18 @@ export function SessionTurn(
})
const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions)
const permissionCount = createMemo(() => permissions().length)
const nextPermission = createMemo(() => permissions()[0])
const permissionParts = createMemo(() => {
if (props.stepsExpanded) return emptyPermissionParts
const next = nextPermission()
if (!next || !next.tool) return emptyPermissionParts
const message = findLast(assistantMessages(), (m) => m.id === next.tool!.messageID)
if (!message) return emptyPermissionParts
const parts = data.store.part[message.id] ?? emptyParts
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
}
return emptyPermissionParts
})
const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions)
const nextQuestion = createMemo(() => questions()[0])
const questionParts = createMemo(() => {
if (props.stepsExpanded) return emptyQuestionParts
const next = nextQuestion()
if (!next || !next.tool) return emptyQuestionParts
const message = findLast(assistantMessages(), (m) => m.id === next.tool!.messageID)
if (!message) return emptyQuestionParts
const parts = data.store.part[message.id] ?? emptyParts
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
}
return emptyQuestionParts
const hidden = createMemo(() => {
const out: { messageID: string; callID: string }[] = []
const perm = nextPermission()
if (perm?.tool) out.push(perm.tool)
const question = nextQuestion()
if (question?.tool) out.push(question.tool)
return out
})
const answeredQuestionParts = createMemo(() => {
@@ -499,14 +477,6 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
createEffect(
on(permissionCount, (count, prev) => {
if (!count) return
if (prev !== undefined && count <= prev) return
autoScroll.forceScrollToBottom()
}),
)
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
@@ -664,6 +634,7 @@ export function SessionTurn(
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
hideReasoning={!working()}
hidden={hidden}
/>
)}
</For>
@@ -674,20 +645,6 @@ export function SessionTurn(
</Show>
</div>
</Show>
<Show when={!props.stepsExpanded && permissionParts().length > 0}>
<div data-slot="session-turn-permission-parts">
<For each={permissionParts()}>
{({ part, message }) => <Part part={part} message={message} />}
</For>
</div>
</Show>
<Show when={!props.stepsExpanded && questionParts().length > 0}>
<div data-slot="session-turn-question-parts">
<For each={questionParts()}>
{({ part, message }) => <Part part={part} message={message} />}
</For>
</div>
</Show>
<Show when={!props.stepsExpanded && answeredQuestionParts().length > 0}>
<div data-slot="session-turn-answered-question-parts">
<For each={answeredQuestionParts()}>