diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a0656c5fc60..b84fbae2b1e 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -16,6 +16,7 @@ import { type LspStatus, type VcsInfo, type PermissionRequest, + type ModeSwitchRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -48,6 +49,9 @@ type State = { permission: { [sessionID: string]: PermissionRequest[] } + modeswitch: { + [sessionID: string]: ModeSwitchRequest[] + } mcp: { [name: string]: McpStatus } @@ -96,6 +100,7 @@ function createGlobalSync() { session_diff: {}, todo: {}, permission: {}, + modeswitch: {}, mcp: {}, lsp: [], vcs: undefined, @@ -205,6 +210,38 @@ function createGlobalSync() { } }) }), + sdk.modeswitch.list().then((x) => { + const grouped: Record = {} + for (const req of x.data ?? []) { + if (!req?.id || !req.sessionID) continue + const existing = grouped[req.sessionID] + if (existing) { + existing.push(req) + continue + } + grouped[req.sessionID] = [req] + } + + batch(() => { + for (const sessionID of Object.keys(store.modeswitch)) { + if (grouped[sessionID]) continue + setStore("modeswitch", sessionID, []) + } + for (const [sessionID, requests] of Object.entries(grouped)) { + setStore( + "modeswitch", + sessionID, + reconcile( + requests + .filter((r) => !!r?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }), ]).then(() => { setStore("status", "complete") }) @@ -393,6 +430,43 @@ function createGlobalSync() { ) break } + case "modeswitch.asked": { + const sessionID = event.properties.sessionID + const requests = store.modeswitch[sessionID] + if (!requests) { + setStore("modeswitch", sessionID, [event.properties]) + break + } + + const result = Binary.search(requests, event.properties.id, (r) => r.id) + if (result.found) { + setStore("modeswitch", sessionID, result.index, reconcile(event.properties)) + break + } + + setStore( + "modeswitch", + sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) + break + } + case "modeswitch.replied": { + const requests = store.modeswitch[event.properties.sessionID] + if (!requests) break + const result = Binary.search(requests, event.properties.requestID, (r) => r.id) + if (!result.found) break + setStore( + "modeswitch", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } case "lsp.updated": { const sdk = createOpencodeClient({ baseUrl: globalSDK.url, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 39124637c26..7a4b0446ee7 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,7 +2,7 @@ import { createMemo, Show, type ParentProps } from "solid-js" import { useNavigate, useParams } from "@solidjs/router" import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" -import { LocalProvider } from "@/context/local" +import { LocalProvider, useLocal } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" @@ -18,30 +18,46 @@ export default function Layout(props: ParentProps) { - {iife(() => { - const sync = useSync() - const sdk = useSDK() - const respond = (input: { - sessionID: string - permissionID: string - response: "once" | "always" | "reject" - }) => sdk.client.permission.respond(input) + + {iife(() => { + const sync = useSync() + const sdk = useSDK() + const local = useLocal() + const respond = (input: { + sessionID: string + permissionID: string + response: "once" | "always" | "reject" + }) => sdk.client.permission.respond(input) - const navigateToSession = (sessionID: string) => { - navigate(`/${params.dir}/session/${sessionID}`) - } + const respondToModeSwitch = (input: { + sessionID: string + requestID: string + response: "approve" | "reject" + targetMode?: string + }) => { + sdk.client.modeswitch.reply({ requestID: input.requestID, reply: input.response }) + if (input.response === "approve" && input.targetMode) { + local.agent.set(input.targetMode) + } + } - return ( - - {props.children} - - ) - })} + const navigateToSession = (sessionID: string) => { + navigate(`/${params.dir}/session/${sessionID}`) + } + + return ( + + {props.children} + + ) + })} + diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0edc911344c..3bb3fa4d97e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -9,6 +9,7 @@ import type { Command, PermissionRequest, QuestionRequest, + ModeSwitchRequest, LspStatus, McpStatus, McpResource, @@ -46,6 +47,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ question: { [sessionID: string]: QuestionRequest[] } + modeswitch: { + [sessionID: string]: ModeSwitchRequest[] + } config: Config session: Session[] session_status: { @@ -85,6 +89,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: [], permission: {}, question: {}, + modeswitch: {}, command: [], provider: [], provider_default: {}, @@ -185,6 +190,43 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "modeswitch.replied": { + const requests = store.modeswitch[event.properties.sessionID] + if (!requests) break + const match = Binary.search(requests, event.properties.requestID, (r) => r.id) + if (!match.found) break + setStore( + "modeswitch", + event.properties.sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + break + } + + case "modeswitch.asked": { + const request = event.properties + const requests = store.modeswitch[request.sessionID] + if (!requests) { + setStore("modeswitch", request.sessionID, [request]) + break + } + const match = Binary.search(requests, request.id, (r) => r.id) + if (match.found) { + setStore("modeswitch", request.sessionID, match.index, reconcile(request)) + break + } + setStore( + "modeswitch", + 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 10e340d7f8f..c4e4101d059 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -71,6 +71,7 @@ import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" +import { ModeSwitchPrompt } from "./modeswitch" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" @@ -126,6 +127,10 @@ export function Session() { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.question[x.id] ?? []) }) + const modeswitches = createMemo(() => { + if (session()?.parentID) return [] + return children().flatMap((x) => sync.data.modeswitch[x.id] ?? []) + }) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -1011,8 +1016,16 @@ export function Session() { 0}> + 0}> + + { prompt = r promptRef.set(r) @@ -1021,7 +1034,7 @@ export function Session() { r.set(route.initialPrompt) } }} - disabled={permissions().length > 0 || questions().length > 0} + disabled={permissions().length > 0 || questions().length > 0 || modeswitches().length > 0} onSubmit={() => { toBottom() }} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/modeswitch.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/modeswitch.tsx new file mode 100644 index 00000000000..4fd1ff47281 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/modeswitch.tsx @@ -0,0 +1,120 @@ +import { createStore } from "solid-js/store" +import { For } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import { useKeybind } from "../../context/keybind" +import { useTheme, selectedForeground } from "../../context/theme" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../component/border" +import { useLocal } from "../../context/local" +import type { ModeSwitchRequest } from "@opencode-ai/sdk/v2" + +export function ModeSwitchPrompt(props: { request: ModeSwitchRequest }) { + const sdk = useSDK() + const local = useLocal() + const { theme } = useTheme() + const keybind = useKeybind() + + const [store, setStore] = createStore({ + selected: 0 as 0 | 1, + }) + + const options = ["Approve", "Reject"] as const + + function approve() { + sdk.client.modeswitch.reply({ + requestID: props.request.id, + reply: "approve", + }) + local.agent.set(props.request.targetMode) + } + + function reject() { + sdk.client.modeswitch.reply({ + requestID: props.request.id, + reply: "reject", + }) + } + + useKeyboard((evt) => { + if (evt.name === "left" || evt.name === "h") { + evt.preventDefault() + setStore("selected", store.selected === 0 ? 1 : 0) + } + + if (evt.name === "right" || evt.name === "l") { + evt.preventDefault() + setStore("selected", store.selected === 0 ? 1 : 0) + } + + if (evt.name === "return") { + evt.preventDefault() + if (store.selected === 0) { + approve() + } else { + reject() + } + } + + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + } + }) + + return ( + + + + {"▣"} + Switch to Build Mode? + + + The assistant is ready to implement the plan. + + + {props.request.reason} + + + + + + {(option, index) => ( + + + {option} + + + )} + + + + + {"⇆"} select + + + enter confirm + + + + + ) +} diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index db2920b0a45..80f5cab4ac7 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -7,6 +7,7 @@ export namespace Identifier { message: "msg", permission: "per", question: "que", + modeswitch: "msw", user: "usr", part: "prt", pty: "pty", diff --git a/packages/opencode/src/mode-switch/index.ts b/packages/opencode/src/mode-switch/index.ts new file mode 100644 index 00000000000..f90b6eb2183 --- /dev/null +++ b/packages/opencode/src/mode-switch/index.ts @@ -0,0 +1,128 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Identifier } from "@/id/id" +import { Instance } from "@/project/instance" +import { Log } from "@/util/log" +import { Session } from "@/session" +import z from "zod" + +export namespace ModeSwitch { + const log = Log.create({ service: "mode-switch" }) + + export const Request = z + .object({ + id: Identifier.schema("modeswitch"), + sessionID: Identifier.schema("session"), + targetMode: z.string().describe("The mode to switch to"), + reason: z.string().describe("Why the LLM wants to switch modes"), + tool: z + .object({ + messageID: z.string(), + callID: z.string(), + }) + .optional(), + }) + .meta({ + ref: "ModeSwitchRequest", + }) + export type Request = z.infer + + export const Reply = z.enum(["approve", "reject"]) + export type Reply = z.infer + + export const Event = { + Asked: BusEvent.define("modeswitch.asked", Request), + Replied: BusEvent.define( + "modeswitch.replied", + z.object({ + sessionID: z.string(), + requestID: z.string(), + reply: Reply, + }), + ), + } + + const state = Instance.state(async () => { + const pending: Record< + string, + { + info: Request + resolve: (approved: boolean) => void + reject: (e: any) => void + } + > = {} + + return { + pending, + } + }) + + export async function ask(input: { + sessionID: string + targetMode: string + reason: string + tool?: { messageID: string; callID: string } + }): Promise { + const s = await state() + const id = Identifier.ascending("modeswitch") + + log.info("asking", { id, targetMode: input.targetMode, reason: input.reason }) + + return new Promise((resolve, reject) => { + const info: Request = { + id, + sessionID: input.sessionID, + targetMode: input.targetMode, + reason: input.reason, + tool: input.tool, + } + s.pending[id] = { + info, + resolve, + reject, + } + Bus.publish(Event.Asked, info) + }) + } + + export async function reply(input: { requestID: string; reply: Reply }): Promise { + const s = await state() + const existing = s.pending[input.requestID] + if (!existing) { + log.warn("reply for unknown request", { requestID: input.requestID }) + return + } + delete s.pending[input.requestID] + + log.info("replied", { requestID: input.requestID, reply: input.reply }) + + if (input.reply === "approve") { + const messages = await Session.messages({ sessionID: existing.info.sessionID }) + const lastUserMsg = messages.findLast((m) => m.info.role === "user") + if (lastUserMsg?.info.role === "user") { + await Session.updateMessage({ + ...lastUserMsg.info, + agent: existing.info.targetMode, + }) + } + } + + Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: input.reply, + }) + + existing.resolve(input.reply === "approve") + } + + export class RejectedError extends Error { + constructor() { + super("The user rejected the mode switch request") + } + } + + export async function list() { + return state().then((x) => Object.values(x.pending).map((x) => x.info)) + } +} diff --git a/packages/opencode/src/server/modeswitch.ts b/packages/opencode/src/server/modeswitch.ts new file mode 100644 index 00000000000..b411a02ab63 --- /dev/null +++ b/packages/opencode/src/server/modeswitch.ts @@ -0,0 +1,70 @@ +import { Hono } from "hono" +import { describeRoute, validator } from "hono-openapi" +import { resolver } from "hono-openapi" +import { ModeSwitch } from "../mode-switch" +import z from "zod" +import { errors } from "./error" + +export const ModeSwitchRoute = new Hono() + .get( + "/", + describeRoute({ + summary: "List pending mode switch requests", + description: "Get all pending mode switch requests across all sessions.", + operationId: "modeswitch.list", + responses: { + 200: { + description: "List of pending mode switch requests", + content: { + "application/json": { + schema: resolver(ModeSwitch.Request.array()), + }, + }, + }, + }, + }), + async (c) => { + const requests = await ModeSwitch.list() + return c.json(requests) + }, + ) + .post( + "/:requestID/reply", + describeRoute({ + summary: "Reply to mode switch request", + description: "Approve or reject a mode switch request from the AI assistant.", + operationId: "modeswitch.reply", + responses: { + 200: { + description: "Mode switch request handled successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: z.string(), + }), + ), + validator( + "json", + z.object({ + reply: ModeSwitch.Reply, + }), + ), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await ModeSwitch.reply({ + requestID: params.requestID, + reply: json.reply, + }) + return c.json(true) + }, + ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 32d7a179555..ae8bda31c3f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -49,6 +49,7 @@ import { errors } from "./error" import { Pty } from "@/pty" import { PermissionNext } from "@/permission/next" import { QuestionRoute } from "./question" +import { ModeSwitchRoute } from "./modeswitch" import { Installation } from "@/installation" import { MDNS } from "./mdns" import { Worktree } from "../worktree" @@ -1695,6 +1696,7 @@ export namespace Server { }, ) .route("/question", QuestionRoute) + .route("/modeswitch", ModeSwitchRoute) .get( "/command", describeRoute({ diff --git a/packages/opencode/src/session/prompt/plan.txt b/packages/opencode/src/session/prompt/plan.txt index 1806e0eba62..27fd34a5026 100644 --- a/packages/opencode/src/session/prompt/plan.txt +++ b/packages/opencode/src/session/prompt/plan.txt @@ -20,6 +20,12 @@ Ask the user clarifying questions or ask for their opinion when weighing tradeof --- +## Switching to Build Mode + +When you have finished planning, call the `modeswitch` tool. If you asked the user a question, wait for their response before calling it. + +--- + ## Important The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. diff --git a/packages/opencode/src/tool/modeswitch.ts b/packages/opencode/src/tool/modeswitch.ts new file mode 100644 index 00000000000..22f4e4ac7b0 --- /dev/null +++ b/packages/opencode/src/tool/modeswitch.ts @@ -0,0 +1,33 @@ +import z from "zod" +import { Tool } from "./tool" +import { ModeSwitch } from "../mode-switch" +import DESCRIPTION from "./modeswitch.txt" + +export const ModeSwitchTool = Tool.define("modeswitch", { + description: DESCRIPTION, + parameters: z.object({ + reason: z.string().describe("Brief explanation of why you're ready to switch to build mode"), + }), + async execute(params, ctx) { + const approved = await ModeSwitch.ask({ + sessionID: ctx.sessionID, + targetMode: "build", + reason: params.reason, + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + if (!approved) { + throw new ModeSwitch.RejectedError() + } + + return { + title: "Mode switch approved", + output: + "The user has approved switching to build mode. You are now in build mode and can begin implementing the planned changes. Proceed with the implementation.", + metadata: { + approved: true, + targetMode: "build", + }, + } + }, +}) diff --git a/packages/opencode/src/tool/modeswitch.txt b/packages/opencode/src/tool/modeswitch.txt new file mode 100644 index 00000000000..d8ce6398749 --- /dev/null +++ b/packages/opencode/src/tool/modeswitch.txt @@ -0,0 +1,3 @@ +Request to switch from planning mode to build mode. + +Call this tool when planning is complete. If you asked the user a question, wait for their response first. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index eb76681ded4..24b56cbf7c0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -11,6 +11,7 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" +import { ModeSwitchTool } from "./modeswitch" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" @@ -107,6 +108,7 @@ export namespace ToolRegistry { WebSearchTool, CodeSearchTool, SkillTool, + ModeSwitchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...custom, @@ -126,6 +128,10 @@ export namespace ToolRegistry { if (t.id === "codesearch" || t.id === "websearch") { return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } + // Only enable modeswitch tool for the plan agent + if (t.id === "modeswitch") { + return agent?.name === "plan" + } return true }) .map(async (t) => { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f83913ea5e1..f2ce51b1c6b 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -51,6 +51,9 @@ import type { McpLocalConfig, McpRemoteConfig, McpStatusResponses, + ModeswitchListResponses, + ModeswitchReplyErrors, + ModeswitchReplyResponses, Part as Part2, PartDeleteErrors, PartDeleteResponses, @@ -1875,6 +1878,64 @@ export class Question extends HeyApiClient { } } +export class Modeswitch extends HeyApiClient { + /** + * List pending mode switch requests + * + * Get all pending mode switch requests across all sessions. + */ + public list( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/modeswitch", + ...options, + ...params, + }) + } + + /** + * Reply to mode switch request + * + * Approve or reject a mode switch request from the AI assistant. + */ + public reply( + parameters: { + requestID: string + directory?: string + reply?: "approve" | "reject" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "body", key: "reply" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/modeswitch/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Command extends HeyApiClient { /** * List commands @@ -3008,6 +3069,8 @@ export class OpencodeClient extends HeyApiClient { question = new Question({ client: this.client }) + modeswitch = new Modeswitch({ client: this.client }) + command = new Command({ client: this.client }) provider = new Provider({ client: this.client }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9cb7222aa5f..beddf2aa203 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -625,6 +625,37 @@ export type EventTodoUpdated = { } } +export type ModeSwitchRequest = { + id: string + sessionID: string + /** + * The mode to switch to + */ + targetMode: string + /** + * Why the LLM wants to switch modes + */ + reason: string + tool?: { + messageID: string + callID: string + } +} + +export type EventModeswitchAsked = { + type: "modeswitch.asked" + properties: ModeSwitchRequest +} + +export type EventModeswitchReplied = { + type: "modeswitch.replied" + properties: { + sessionID: string + requestID: string + reply: "approve" | "reject" + } +} + export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -861,6 +892,8 @@ export type Event = | EventSessionCompacted | EventFileEdited | EventTodoUpdated + | EventModeswitchAsked + | EventModeswitchReplied | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -3705,6 +3738,59 @@ export type QuestionRejectResponses = { export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] +export type ModeswitchListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/modeswitch" +} + +export type ModeswitchListResponses = { + /** + * List of pending mode switch requests + */ + 200: Array +} + +export type ModeswitchListResponse = ModeswitchListResponses[keyof ModeswitchListResponses] + +export type ModeswitchReplyData = { + body?: { + reply: "approve" | "reject" + } + path: { + requestID: string + } + query?: { + directory?: string + } + url: "/modeswitch/{requestID}/reply" +} + +export type ModeswitchReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type ModeswitchReplyError = ModeswitchReplyErrors[keyof ModeswitchReplyErrors] + +export type ModeswitchReplyResponses = { + /** + * Mode switch request handled successfully + */ + 200: boolean +} + +export type ModeswitchReplyResponse = ModeswitchReplyResponses[keyof ModeswitchReplyResponses] + export type CommandListData = { body?: never path?: never diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d59f5cfa3e3..142befd02ee 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -16,6 +16,7 @@ import { AssistantMessage, FilePart, Message as MessageType, + ModeSwitchRequest, Part as PartType, ReasoningPart, TextPart, @@ -460,7 +461,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { return next }) + const modeswitch = createMemo(() => { + const next = data.store.modeswitch?.[props.message.sessionID]?.[0] + if (!next || !next.tool) return undefined + if (next.tool.callID !== part.callID) return undefined + return next + }) + const [showPermission, setShowPermission] = createSignal(false) + const [showModeSwitch, setShowModeSwitch] = createSignal(false) createEffect(() => { const perm = permission() @@ -472,9 +481,19 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { } }) + createEffect(() => { + const ms = modeswitch() + if (ms) { + const timeout = setTimeout(() => setShowModeSwitch(true), 50) + onCleanup(() => clearTimeout(timeout)) + } else { + setShowModeSwitch(false) + } + }) + const [forceOpen, setForceOpen] = createSignal(false) createEffect(() => { - if (permission()) setForceOpen(true) + if (permission() || modeswitch()) setForceOpen(true) }) const respond = (response: "once" | "always" | "reject") => { @@ -487,6 +506,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }) } + const respondModeSwitch = (response: "approve" | "reject") => { + const ms = modeswitch() + if (!ms || !data.respondToModeSwitch) return + data.respondToModeSwitch({ + sessionID: ms.sessionID, + requestID: ms.id, + response, + targetMode: ms.targetMode, + }) + } + const emptyInput: Record = {} const emptyMetadata: Record = {} @@ -497,7 +527,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const render = ToolRegistry.render(part.tool) ?? GenericTool return ( -
+
{(error) => { @@ -553,6 +583,20 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
+ + {(ms) => ( +
+
+ + +
+
+ )} +
) } @@ -1027,3 +1071,25 @@ ToolRegistry.register({ ) }, }) + +ToolRegistry.register({ + name: "modeswitch", + render(props) { + return ( + + +
+ +
+
+
+ ) + }, +}) diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index acab99fe8f6..4451aefb8c5 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,12 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2" +import type { + Message, + Session, + Part, + FileDiff, + SessionStatus, + PermissionRequest, + ModeSwitchRequest, +} from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -16,6 +24,9 @@ type Data = { permission?: { [sessionID: string]: PermissionRequest[] } + modeswitch?: { + [sessionID: string]: ModeSwitchRequest[] + } message: { [sessionID: string]: Message[] } @@ -30,6 +41,13 @@ export type PermissionRespondFn = (input: { response: "once" | "always" | "reject" }) => void +export type ModeSwitchRespondFn = (input: { + sessionID: string + requestID: string + response: "approve" | "reject" + targetMode?: string +}) => void + export type NavigateToSessionFn = (sessionID: string) => void export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -38,6 +56,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ data: Data directory: string onPermissionRespond?: PermissionRespondFn + onModeSwitchRespond?: ModeSwitchRespondFn onNavigateToSession?: NavigateToSessionFn }) => { return { @@ -48,6 +67,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return props.directory }, respondToPermission: props.onPermissionRespond, + respondToModeSwitch: props.onModeSwitchRespond, navigateToSession: props.onNavigateToSession, } },