diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4b97bfb89e64..e5198ceb55d7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -520,6 +520,14 @@ export const dict = { "session.todo.title": "Todos", "session.todo.collapse": "Collapse", "session.todo.expand": "Expand", + "session.question.planEnter.title": "Switch to Plan Mode?", + "session.question.planEnter.description": "The assistant will pause edits and prepare a plan for your approval.", + "session.question.planEnter.confirm": "Enter Plan Mode", + "session.question.planEnter.dismiss": "Stay in Build Mode", + "session.question.planExit.title": "Switch to Build Mode?", + "session.question.planExit.description": "The plan is complete. Approve to start implementing.", + "session.question.planExit.confirm": "Start Building", + "session.question.planExit.dismiss": "Stay in Plan Mode", "session.new.worktree.main": "Main branch", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})", diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index cfd78ece8589..9a0e58df6e6c 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -25,6 +25,11 @@ export function SessionComposerRegion(props: { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt) + const activeDock = createMemo(() => { + if (props.state.questionRequest()) return "question" + if (props.state.permissionRequest()) return "permission" + return "none" + }) const previewPrompt = () => prompt @@ -55,15 +60,19 @@ export function SessionComposerRegion(props: { "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > - + {(request) => ( - + )} - + {(request) => ( - + ({ @@ -14,46 +15,23 @@ const permission = (id: string, sessionID: string) => sessionID, }) as PermissionRequest -const question = (id: string, sessionID: string) => - ({ - id, - sessionID, - questions: [], - }) as QuestionRequest - -describe("sessionPermissionRequest", () => { - test("prefers the current session permission", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const permissions = { - root: [permission("perm-root", "root")], - child: [permission("perm-child", "child")], - } - - expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-root") +describe("resolveSessionMode", () => { + test("defaults to build without messages", () => { + expect(resolveSessionMode(undefined)).toBe("build") }) - test("returns a nested child permission", () => { - const sessions = [ - session({ id: "root" }), - session({ id: "child", parentID: "root" }), - session({ id: "grand", parentID: "child" }), - session({ id: "other" }), - ] - const permissions = { - grand: [permission("perm-grand", "grand")], - other: [permission("perm-other", "other")], - } - - expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand") + test("uses latest user agent mode", () => { + const messages = [ + { role: "user", agent: "build" }, + { role: "assistant" }, + { role: "user", agent: "plan" }, + ] as Message[] + expect(resolveSessionMode(messages)).toBe("plan") }) - test("returns undefined without a matching tree permission", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const permissions = { - other: [permission("perm-other", "other")], - } - - expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined() + test("ignores non-build non-plan user agents", () => { + const messages = [{ role: "user", agent: "custom" }, { role: "assistant" }] as Message[] + expect(resolveSessionMode(messages)).toBe("build") }) test("skips filtered permissions in the current tree", () => { @@ -79,27 +57,33 @@ describe("sessionPermissionRequest", () => { }) }) -describe("sessionQuestionRequest", () => { - test("prefers the current session question", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const questions = { - root: [question("q-root", "root")], - child: [question("q-child", "child")], - } +describe("resolveQuestionKind", () => { + const request = { + id: "req_1", + sessionID: "ses_1", + questions: [], + tool: { + messageID: "msg_1", + callID: "call_1", + }, + } as QuestionRequest + + test("returns generic when question has no tool ref", () => { + expect(resolveQuestionKind({ request: { ...request, tool: undefined }, parts: [] })).toBe("generic") + }) - expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root") + test("detects plan_enter from matching tool part", () => { + const parts = [{ type: "tool", callID: "call_1", tool: "plan_enter" }] as Part[] + expect(resolveQuestionKind({ request, parts })).toBe("plan_enter") }) - test("returns a nested child question", () => { - const sessions = [ - session({ id: "root" }), - session({ id: "child", parentID: "root" }), - session({ id: "grand", parentID: "child" }), - ] - const questions = { - grand: [question("q-grand", "grand")], - } + test("detects plan_exit from matching tool part", () => { + const parts = [{ type: "tool", callID: "call_1", tool: "plan_exit" }] as Part[] + expect(resolveQuestionKind({ request, parts })).toBe("plan_exit") + }) - expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand") + test("returns generic on missing or mismatched part", () => { + const parts = [{ type: "tool", callID: "call_2", tool: "plan_exit" }] as Part[] + expect(resolveQuestionKind({ request, parts })).toBe("generic") }) }) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 201846177853..fa7705f71de2 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { type SessionMode, type SessionQuestionKind, resolveQuestionKind, resolveSessionMode } from "./session-mode" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" export function createSessionComposerBlocked() { @@ -23,8 +24,6 @@ export function createSessionComposerBlocked() { const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id)) return createMemo(() => { - const id = params.id - if (!id) return false return !!permissionRequest() || !!questionRequest() }) } @@ -47,11 +46,7 @@ export function createSessionComposerState() { }) }) - const blocked = createMemo(() => { - const id = params.id - if (!id) return false - return !!permissionRequest() || !!questionRequest() - }) + const blocked = createSessionComposerBlocked() const todos = createMemo((): Todo[] => { const id = params.id @@ -59,6 +54,21 @@ export function createSessionComposerState() { return globalSync.data.session_todo[id] ?? [] }) + const activeMode = createMemo((): SessionMode => { + const id = params.id + if (!id) return "build" + return resolveSessionMode(sync.data.message[id]) + }) + + const questionKind = createMemo((): SessionQuestionKind => { + const request = questionRequest() + if (!request?.tool) return "generic" + return resolveQuestionKind({ + request, + parts: sync.data.part[request.tool.messageID], + }) + }) + const [store, setStore] = createStore({ responding: undefined as string | undefined, dock: todos().length > 0, @@ -163,6 +173,8 @@ export function createSessionComposerState() { permissionResponding, decide, todos, + activeMode, + questionKind, dock: () => store.dock, closing: () => store.closing, opening: () => store.opening, diff --git a/packages/app/src/pages/session/composer/session-mode.ts b/packages/app/src/pages/session/composer/session-mode.ts new file mode 100644 index 000000000000..fbca8874f225 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-mode.ts @@ -0,0 +1,28 @@ +import type { Message, Part, QuestionRequest } from "@opencode-ai/sdk/v2" + +export type SessionMode = "build" | "plan" +export type SessionQuestionKind = "generic" | "plan_enter" | "plan_exit" + +export function resolveSessionMode(messages: Message[] | undefined): SessionMode { + if (!messages?.length) return "build" + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role !== "user") continue + if (msg.agent === "plan") return "plan" + if (msg.agent === "build") return "build" + } + return "build" +} + +export function resolveQuestionKind(input: { + request: QuestionRequest | undefined + parts: Part[] | undefined +}): SessionQuestionKind { + const request = input.request + if (!request?.tool) return "generic" + const part = input.parts?.find((part) => part.type === "tool" && part.callID === request.tool?.callID) + if (!part || part.type !== "tool") return "generic" + if (part.tool === "plan_enter") return "plan_enter" + if (part.tool === "plan_exit") return "plan_exit" + return "generic" +} diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index fd2ced3dc814..0e01c9d814b1 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" @@ -7,8 +7,13 @@ 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" +import type { SessionQuestionKind } from "./session-mode" -export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { +export const SessionQuestionDock: Component<{ + request: QuestionRequest + kind?: SessionQuestionKind + onSubmit: () => void +}> = (props) => { const sdk = useSDK() const language = useLanguage() @@ -16,6 +21,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const total = createMemo(() => questions().length) const [store, setStore] = createStore({ + requestID: props.request.id, + kind: props.kind ?? "generic", tab: 0, answers: [] as QuestionAnswer[], custom: [] as string[], @@ -31,6 +38,52 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const input = createMemo(() => store.custom[store.tab] ?? "") const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) + const customEnabled = createMemo(() => question()?.custom !== false) + const kind = createMemo(() => store.kind) + const transition = createMemo(() => kind() === "plan_enter" || kind() === "plan_exit") + const showCustom = createMemo(() => customEnabled() && !transition()) + + createEffect(() => { + if (props.request.id === store.requestID) return + setStore({ + requestID: props.request.id, + kind: props.kind ?? "generic", + tab: 0, + answers: [], + custom: [], + customOn: [], + editing: false, + }) + }) + + createEffect(() => { + if (store.editing || store.sending) return + setStore("kind", props.kind ?? "generic") + }) + + const transitionTitle = createMemo(() => { + if (kind() === "plan_enter") return language.t("session.question.planEnter.title") + if (kind() === "plan_exit") return language.t("session.question.planExit.title") + return "" + }) + + const transitionDescription = createMemo(() => { + if (kind() === "plan_enter") return language.t("session.question.planEnter.description") + if (kind() === "plan_exit") return language.t("session.question.planExit.description") + return "" + }) + + const transitionConfirm = createMemo(() => { + if (kind() === "plan_enter") return language.t("session.question.planEnter.confirm") + if (kind() === "plan_exit") return language.t("session.question.planExit.confirm") + return language.t("ui.common.submit") + }) + + const transitionDismiss = createMemo(() => { + if (kind() === "plan_enter") return language.t("session.question.planEnter.dismiss") + if (kind() === "plan_exit") return language.t("session.question.planExit.dismiss") + return language.t("ui.common.dismiss") + }) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) @@ -142,6 +195,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) + const confirmTransition = () => { + const first = questions()[0] + const choice = first?.options[0]?.label + if (!choice) return + void reply([[choice]]) + } + const pick = (answer: string, custom: boolean = false) => { setStore("answers", store.tab, [answer]) if (custom) setStore("custom", store.tab, answer) @@ -159,6 +219,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customToggle = () => { if (store.sending) return + if (!showCustom()) return if (!multi()) { setStore("customOn", store.tab, true) setStore("editing", true) @@ -181,6 +242,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customOpen = () => { if (store.sending) return + if (!showCustom()) return if (!on()) setStore("customOn", store.tab, true) setStore("editing", true) customUpdate(input(), true) @@ -189,7 +251,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const selectOption = (optIndex: number) => { if (store.sending) return - if (optIndex === options().length) { + if (showCustom() && optIndex === options().length) { customOpen() return } @@ -237,191 +299,217 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit return ( (root = el)} header={ <> - {summary()} - - - {(_, i) => ( - 0 || - (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) - } - disabled={store.sending} - onClick={() => jump(i())} - aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} - /> - )} - - + {summary()}}> + {transitionTitle()} + + + + + {(_, i) => ( + 0 || + (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) + } + disabled={store.sending} + onClick={() => jump(i())} + aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} + /> + )} + + + > } footer={ <> - {language.t("ui.common.dismiss")} + {transition() ? transitionDismiss() : language.t("ui.common.dismiss")} - - 0}> - - {language.t("ui.common.back")} + + 0}> + + {language.t("ui.common.back")} + + + + {last() ? language.t("ui.common.submit") : language.t("ui.common.next")} + + + } + > + + + {transitionConfirm()} - - - {last() ? language.t("ui.common.submit") : language.t("ui.common.next")} - - + + > } > - {question()?.question} - {language.t("ui.question.singleHint")}}> - {language.t("ui.question.multiHint")} + {question()?.question}}> + {transitionDescription()} - - - {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( - selectOption(i())} - > - + + {language.t("ui.question.singleHint")}}> + {language.t("ui.question.multiHint")} + + + + + + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + selectOption(i())} + > + + + }> + + + + + + {opt.label} + + {opt.description} + + + + ) + }} + + + + { + e.preventDefault() + e.stopPropagation() + customToggle() + }} > - }> - - + + }> + + + - - - {opt.label} - - {opt.description} - - - - ) - }} - - - - { - e.preventDefault() - e.stopPropagation() - customToggle() - }} - > - - }> - - - - - - {language.t("ui.messagePart.option.typeOwnAnswer")} - {input() || language.t("ui.question.custom.placeholder")} - - - } - > - { - if (store.sending) { - e.preventDefault() - return + + {language.t("ui.messagePart.option.typeOwnAnswer")} + + {input() || language.t("ui.question.custom.placeholder")} + + + } - if (e.target instanceof HTMLTextAreaElement) return - const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') - if (input instanceof HTMLTextAreaElement) input.focus() - }} - onSubmit={(e) => { - e.preventDefault() - commitCustom() - }} - > - { - e.preventDefault() - e.stopPropagation() - customToggle() - }} > - - }> - - - - - - {language.t("ui.messagePart.option.typeOwnAnswer")} - - setTimeout(() => { - el.focus() - el.style.height = "0px" - el.style.height = `${el.scrollHeight}px` - }, 0) - } - data-slot="question-custom-input" - placeholder={language.t("ui.question.custom.placeholder")} - value={input()} - rows={1} - disabled={store.sending} - onKeyDown={(e) => { - if (e.key === "Escape") { + { + if (store.sending) { e.preventDefault() - setStore("editing", false) return } - if (e.key !== "Enter" || e.shiftKey) return + if (e.target instanceof HTMLTextAreaElement) return + const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + }} + onSubmit={(e) => { e.preventDefault() commitCustom() }} - onInput={(e) => { - customUpdate(e.currentTarget.value) - e.currentTarget.style.height = "0px" - e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` - }} - /> - - - - + > + { + e.preventDefault() + e.stopPropagation() + customToggle() + }} + > + + }> + + + + + + {language.t("ui.messagePart.option.typeOwnAnswer")} + + setTimeout(() => { + el.focus() + el.style.height = "0px" + el.style.height = `${el.scrollHeight}px` + }, 0) + } + data-slot="question-custom-input" + placeholder={language.t("ui.question.custom.placeholder")} + value={input()} + rows={1} + disabled={store.sending} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault() + setStore("editing", false) + return + } + if (e.key !== "Enter" || e.shiftKey) return + e.preventDefault() + commitCustom() + }} + onInput={(e) => { + customUpdate(e.currentTarget.value) + e.currentTarget.style.height = "0px" + e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` + }} + /> + + + + + + ) } diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index ad802d15d186..3abc9b092759 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -26,6 +26,7 @@ import { FileTabContent } from "@/pages/session/file-tabs" import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers" import { StickyAddButton } from "@/pages/session/review-tab" import { setSessionHandoff } from "@/pages/session/handoff" +import { resolveSessionMode } from "@/pages/session/composer/session-mode" export function SessionSidePanel(props: { reviewPanel: () => JSX.Element @@ -51,16 +52,41 @@ export function SessionSidePanel(props: { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const activeMode = createMemo(() => { + if (!params.id) return "build" + return resolveSessionMode(sync.data.message[params.id]) + }) + const planFile = createMemo(() => { + if (activeMode() !== "plan") return + if (!sync.project?.vcs) return + const session = info() + const created = session?.time?.created + const slug = session?.slug + if (!created || !slug) return + return `.opencode/plans/${created}-${slug}.md` + }) + const reviewCount = createMemo(() => { + const count = Math.max(info()?.summary?.files ?? 0, diffs().length) + const currentPlan = planFile() + if (!currentPlan) return count + if (diffs().some((diff) => diff.file === currentPlan)) return count + return count + 1 + }) const hasReview = createMemo(() => reviewCount() > 0) const diffsReady = createMemo(() => { const id = params.id if (!id) return true if (!hasReview()) return true + if (planFile()) return true return sync.data.session_diff[id] !== undefined }) - const diffFiles = createMemo(() => diffs().map((d) => d.file)) + const diffFiles = createMemo(() => { + const files = diffs().map((d) => d.file) + const currentPlan = planFile() + if (!currentPlan || files.includes(currentPlan)) return files + return [currentPlan, ...files] + }) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -386,7 +412,13 @@ export function SessionSidePanel(props: { kinds={kinds()} draggable={false} active={props.activeDiff} - onFileClick={(node) => props.focusReviewDiff(node.path)} + onFileClick={(node) => { + if (node.path === planFile()) { + openTab(file.tab(node.path)) + return + } + props.focusReviewDiff(node.path) + }} /> diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 97fdba144f4c..7b0a953b252a 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -382,6 +382,10 @@ pub fn spawn_command( "OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(), "true".to_string(), ), + ( + "OPENCODE_EXPERIMENTAL_PLAN_MODE".to_string(), + "true".to_string(), + ), ("OPENCODE_CLIENT".to_string(), "desktop".to_string()), ( "XDG_STATE_HOME".to_string(), @@ -412,6 +416,7 @@ pub fn spawn_command( let mut env_prefix = vec![ "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(), "OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(), + "OPENCODE_EXPERIMENTAL_PLAN_MODE=true".to_string(), "OPENCODE_CLIENT=desktop".to_string(), "XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(), ]; @@ -419,6 +424,7 @@ pub fn spawn_command( envs.iter() .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER") + .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_PLAN_MODE") .filter(|(key, _)| key != "OPENCODE_CLIENT") .filter(|(key, _)| key != "XDG_STATE_HOME") .map(|(key, value)| format!("{}={}", key, shell_escape(value))), diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index dee3fbc54298..678e9d6a0a55 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -23,6 +23,11 @@ export namespace Pty { close: (code?: number, reason?: string) => void } + type Subscriber = { + ws: Socket + send: (data: string | Uint8Array | ArrayBuffer) => void + } + // WebSocket control frame: 0x00 + UTF-8 JSON. const meta = (cursor: number) => { const json = JSON.stringify({ cursor }) @@ -87,7 +92,7 @@ export namespace Pty { buffer: string bufferCursor: number cursor: number - subscribers: Map + subscribers: Map } const state = Instance.state( @@ -97,7 +102,8 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const [key, ws] of session.subscribers.entries()) { + for (const [key, sub] of session.subscribers.entries()) { + const ws = sub.ws try { if (ws.data === key) ws.close() } catch { @@ -170,7 +176,8 @@ export namespace Pty { ptyProcess.onData((chunk) => { session.cursor += chunk.length - for (const [key, ws] of session.subscribers.entries()) { + for (const [key, sub] of session.subscribers.entries()) { + const ws = sub.ws if (ws.readyState !== 1) { session.subscribers.delete(key) continue @@ -182,7 +189,7 @@ export namespace Pty { } try { - ws.send(chunk) + sub.send(chunk) } catch { session.subscribers.delete(key) } @@ -197,7 +204,8 @@ export namespace Pty { ptyProcess.onExit(({ exitCode }) => { log.info("session exited", { id, exitCode }) session.info.status = "exited" - for (const [key, ws] of session.subscribers.entries()) { + for (const [key, sub] of session.subscribers.entries()) { + const ws = sub.ws try { if (ws.data === key) ws.close() } catch { @@ -232,7 +240,8 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const [key, ws] of session.subscribers.entries()) { + for (const [key, sub] of session.subscribers.entries()) { + const ws = sub.ws try { if (ws.data === key) ws.close() } catch { @@ -272,7 +281,7 @@ export namespace Pty { // Optionally cleanup if the key somehow exists session.subscribers.delete(connectionKey) - session.subscribers.set(connectionKey, ws) + session.subscribers.set(connectionKey, { ws, send: ws.send.bind(ws) }) const cleanup = () => { session.subscribers.delete(connectionKey) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0751f789b7db..ed8e97e52340 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -207,6 +207,7 @@ export const BashTool = Tool.define("bash", async () => { let timedOut = false let aborted = false let exited = false + let exit: number | null = null const kill = () => Shell.killTree(proc, { exited: () => exited }) @@ -233,8 +234,9 @@ export const BashTool = Tool.define("bash", async () => { ctx.abort.removeEventListener("abort", abortHandler) } - proc.once("exit", () => { + proc.once("exit", (code) => { exited = true + exit = code cleanup() resolve() }) @@ -264,7 +266,7 @@ export const BashTool = Tool.define("bash", async () => { title: params.description, metadata: { output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, - exit: proc.exitCode, + exit: exit ?? proc.exitCode, description: params.description, }, output, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c6d7fbc1e4b2..68d02579d10e 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,4 +1,3 @@ -import { PlanExitTool } from "./plan" import { QuestionTool } from "./question" import { BashTool } from "./bash" import { EditTool } from "./edit" @@ -26,10 +25,9 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncation" - +import { PlanExitTool } from "./plan" import { ApplyPatchTool } from "./apply_patch" import { Glob } from "../util/glob" -import { pathToFileURL } from "url" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -45,7 +43,7 @@ export namespace ToolRegistry { if (matches.length) await Config.waitForDependencies() for (const match of matches) { const namespace = path.basename(match, path.extname(match)) - const mod = await import(pathToFileURL(match).href) + const mod = await import(match) for (const [id, def] of Object.entries(mod)) { custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } diff --git a/packages/ui/src/components/dock-prompt.tsx b/packages/ui/src/components/dock-prompt.tsx index d774e7f17a48..1b96413449e5 100644 --- a/packages/ui/src/components/dock-prompt.tsx +++ b/packages/ui/src/components/dock-prompt.tsx @@ -3,6 +3,7 @@ import { DockShell, DockTray } from "./dock-surface" export function DockPrompt(props: { kind: "question" | "permission" + variant?: "default" | "transition" header: JSX.Element children: JSX.Element footer: JSX.Element @@ -11,7 +12,7 @@ export function DockPrompt(props: { const slot = (name: string) => `${props.kind}-${name}` return ( - + {props.header} {props.children} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index bea33ff54cf7..8ba164e6c8e5 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1071,8 +1071,8 @@ align-items: center; justify-content: space-between; flex-shrink: 0; - padding: 32px 8px 8px; - margin-top: -24px; + padding: 8px; + margin-top: 0; } [data-slot="question-footer-actions"] { @@ -1082,6 +1082,48 @@ } } +[data-component="dock-prompt"][data-kind="question"][data-variant="transition"] { + [data-slot="question-body"] { + gap: 12px; + padding: 14px 14px 2px; + } + + [data-slot="question-header"] { + padding: 0 6px; + } + + [data-slot="question-header-title"] { + font-size: 15px; + line-height: 1.35; + } + + [data-slot="question-content"] { + gap: 10px; + padding: 4px 6px 10px; + } + + [data-slot="question-text"] { + color: var(--text-base); + padding: 0; + line-height: 1.45; + } + + [data-slot="question-footer"] { + justify-content: space-between; + border-top: 1px solid var(--border-weak-base); + padding: 10px 12px 12px; + margin-top: 2px; + } + + [data-slot="question-footer-actions"] { + gap: 10px; + } + + [data-slot="question-footer-actions"] [data-component="button"] { + min-width: 132px; + } +} + [data-component="question-answers"] { display: flex; flex-direction: column; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e877fc725f60..e9457cf64e8a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -23,9 +23,11 @@ import { ToolPart, UserMessage, Todo, + QuestionRequest, QuestionAnswer, QuestionInfo, } from "@opencode-ai/sdk/v2" +import { createStore } from "solid-js/store" import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" @@ -34,6 +36,7 @@ import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" +import { Button } from "./button" import { Card } from "./card" import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" @@ -144,22 +147,17 @@ function createThrottledValue(getValue: () => string) { return value } -function relativizeProjectPath(path: string, directory?: string) { - if (!path) return "" - if (!directory) return path - if (directory === "/") return path - if (directory === "\\") return path - if (path === directory) return "" - - const separator = directory.includes("\\") ? "\\" : "/" - const prefix = directory.endsWith(separator) ? directory : directory + separator - if (!path.startsWith(prefix)) return path - return path.slice(directory.length) +function relativizeProjectPaths(text: string, directory?: string) { + if (!text) return "" + if (!directory) return text + if (directory === "/") return text + if (directory === "\\") return text + return text.split(directory).join("") } function getDirectory(path: string | undefined) { const data = useData() - return relativizeProjectPath(_getDirectory(path), data.directory) + return relativizeProjectPaths(_getDirectory(path), data.directory) } import type { IconProps } from "./icon" @@ -250,6 +248,16 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { icon: "bubble-5", title: i18n.t("ui.tool.questions"), } + case "plan_enter": + return { + icon: "task", + title: i18n.t("ui.tool.planEnter"), + } + case "plan_exit": + return { + icon: "task", + title: i18n.t("ui.tool.planExit"), + } default: return { icon: "mcp", @@ -951,6 +959,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre } PART_MAPPING["tool"] = function ToolPartDisplay(props) { + const data = useData() const i18n = useI18n() const part = props.part as ToolPart if (part.tool === "todowrite" || part.tool === "todoread") return null @@ -959,66 +968,146 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { () => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"), ) + const permission = createMemo(() => { + const next = data.store.permission?.[props.message.sessionID]?.[0] + if (!next || !next.tool) return undefined + if (next.tool!.callID !== part.callID) return undefined + return next + }) + + const questionRequest = createMemo(() => { + const next = data.store.question?.[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 [showQuestion, setShowQuestion] = createSignal(false) + + createEffect(() => { + const perm = permission() + if (perm) { + const timeout = setTimeout(() => setShowPermission(true), 50) + onCleanup(() => clearTimeout(timeout)) + } else { + setShowPermission(false) + } + }) + + createEffect(() => { + const question = questionRequest() + if (question) { + const timeout = setTimeout(() => setShowQuestion(true), 50) + onCleanup(() => clearTimeout(timeout)) + } else { + setShowQuestion(false) + } + }) + + const [forceOpen, setForceOpen] = createSignal(false) + createEffect(() => { + if (permission() || questionRequest()) setForceOpen(true) + }) + + const respond = (response: "once" | "always" | "reject") => { + const perm = permission() + if (!perm || !data.respondToPermission) return + data.respondToPermission({ + sessionID: perm.sessionID, + permissionID: perm.id, + response, + }) + } + const emptyInput: Record = {} const emptyMetadata: Record = {} const input = () => part.state?.input ?? emptyInput // @ts-expect-error const partMetadata = () => part.state?.metadata ?? emptyMetadata + const metadata = () => { + const perm = permission() + if (perm?.metadata) return { ...perm.metadata, ...partMetadata() } + return partMetadata() + } const render = ToolRegistry.render(part.tool) ?? GenericTool + const hideToolWhenPrompting = createMemo( + () => showQuestion() && (part.tool === "plan_enter" || part.tool === "plan_exit"), + ) return ( - - - - {(error) => { - const cleaned = error().replace("Error: ", "") - if (part.tool === "question" && cleaned.includes("dismissed this question")) { + + + + + {(error) => { + const cleaned = error().replace("Error: ", "") + if (part.tool === "question" && cleaned.includes("dismissed this question")) { + return ( + + + {i18n.t("ui.tool.questions")} dismissed + + + ) + } + const [title, ...rest] = cleaned.split(": ") return ( - - - {i18n.t("ui.tool.questions")} dismissed - - + + + + + + + {title} + {rest.join(": ")} + + + + {cleaned} + + + + ) - } - const [title, ...rest] = cleaned.split(": ") - return ( - - - - - - - {title} - {rest.join(": ")} - - - - {cleaned} - - - - - ) - }} - - - - - + }} + + + + + + + + + + respond("reject")}> + {i18n.t("ui.permission.deny")} + + respond("always")}> + {i18n.t("ui.permission.allowAlways")} + + respond("once")}> + {i18n.t("ui.permission.allowOnce")} + + + + + {(request) => } ) @@ -1070,7 +1159,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return items.filter((x) => !!x).join(" \u00B7 ") }) - const displayText = () => (part.text ?? "").trim() + const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory) const throttledText = createThrottledValue(displayText) const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) @@ -1172,7 +1261,7 @@ ToolRegistry.register({ - {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} + {i18n.t("ui.tool.loaded")} {relativizeProjectPaths(filepath, data.directory)} )} @@ -1892,3 +1981,273 @@ ToolRegistry.register({ ) }, }) + +ToolRegistry.register({ + name: "plan_enter", + render(props) { + const i18n = useI18n() + return + }, +}) + +ToolRegistry.register({ + name: "plan_exit", + render(props) { + const i18n = useI18n() + return + }, +}) + +function QuestionPrompt(props: { request: QuestionRequest }) { + const data = useData() + const i18n = useI18n() + const questions = createMemo(() => props.request.questions) + const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + const transition = createMemo(() => { + const ref = props.request.tool + if (!ref) return false + const part = data.store.part[ref.messageID]?.find((part) => part.type === "tool" && part.callID === ref.callID) + if (!part || part.type !== "tool") return false + return part.tool === "plan_enter" || part.tool === "plan_exit" + }) + + const [store, setStore] = createStore({ + tab: 0, + answers: [] as QuestionAnswer[], + custom: [] as string[], + editing: 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 custom = createMemo(() => question()?.custom !== false) + const customPicked = createMemo(() => { + const value = input() + if (!value) return false + return store.answers[store.tab]?.includes(value) ?? false + }) + + function submit() { + const answers = questions().map((_, i) => store.answers[i] ?? []) + data.replyToQuestion?.({ + requestID: props.request.id, + answers, + }) + } + + function reject() { + data.rejectQuestion?.({ + 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()) { + data.replyToQuestion?.({ + requestID: props.request.id, + answers: [[answer]], + }) + return + } + setStore("tab", store.tab + 1) + } + + 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) + } + + function selectTab(index: number) { + setStore("tab", index) + setStore("editing", false) + } + + function selectOption(optIndex: number) { + if (custom() && optIndex === options().length) { + setStore("editing", true) + return + } + const opt = options()[optIndex] + if (!opt) return + if (multi()) { + toggle(opt.label) + return + } + pick(opt.label) + } + + function handleCustomSubmit(e: Event) { + e.preventDefault() + 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 ( + + + + + + {(q, index) => { + const active = () => index() === store.tab + const answered = () => (store.answers[index()]?.length ?? 0) > 0 + return ( + selectTab(index())} + > + {q.header} + + ) + }} + + selectTab(questions().length)}> + {i18n.t("ui.common.confirm")} + + + + + + + + {question()?.question} + {multi() ? " " + i18n.t("ui.question.multiHint") : ""} + + + + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + selectOption(i())}> + {opt.label} + + {opt.description} + + + + + + ) + }} + + + selectOption(options().length)} + > + {i18n.t("ui.messagePart.option.typeOwnAnswer")} + + {input()} + + + + + + + + setTimeout(() => el.focus(), 0)} + type="text" + data-slot="custom-input" + placeholder={i18n.t("ui.question.custom.placeholder")} + value={input()} + onInput={(e) => { + const inputs = [...store.custom] + inputs[store.tab] = e.currentTarget.value + setStore("custom", inputs) + }} + /> + + {multi() ? i18n.t("ui.common.add") : i18n.t("ui.common.submit")} + + setStore("editing", false)}> + {i18n.t("ui.common.cancel")} + + + + + + + + + + + {i18n.t("ui.messagePart.review.title")} + + {(q, index) => { + const value = () => store.answers[index()]?.join(", ") ?? "" + const answered = () => Boolean(value()) + return ( + + {q.question} + + {answered() ? value() : i18n.t("ui.question.review.notAnswered")} + + + ) + }} + + + + + + + {i18n.t("ui.common.dismiss")} + + + + + {i18n.t("ui.common.submit")} + + + + selectTab(store.tab + 1)} + disabled={(store.answers[store.tab]?.length ?? 0) === 0} + > + {i18n.t("ui.common.next")} + + + + + + + ) +} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e116199eb233..3b1df5af58b1 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,14 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2" +import type { + Message, + Session, + Part, + FileDiff, + SessionStatus, + ProviderListResponse, + PermissionRequest, + QuestionRequest, + QuestionAnswer, +} from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -20,12 +30,28 @@ type Data = { part: { [messageID: string]: Part[] } + permission?: { + [sessionID: string]: PermissionRequest[] + } + question?: { + [sessionID: string]: QuestionRequest[] + } } export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string +export type RespondToPermissionFn = (input: { + sessionID: string + permissionID: string + response: "once" | "always" | "reject" +}) => void + +export type ReplyToQuestionFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void + +export type RejectQuestionFn = (input: { requestID: string }) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -33,6 +59,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: string onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn + onRespondToPermission?: RespondToPermissionFn + onReplyToQuestion?: ReplyToQuestionFn + onRejectQuestion?: RejectQuestionFn }) => { return { get store() { @@ -43,6 +72,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, + respondToPermission: props.onRespondToPermission, + replyToQuestion: props.onReplyToQuestion, + rejectQuestion: props.onRejectQuestion, } }, }) diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 9739edf145d1..f2ba6f9e2bae 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -84,6 +84,8 @@ export const dict = { "ui.tool.todos": "المهام", "ui.tool.todos.read": "قراءة المهام", "ui.tool.questions": "أسئلة", + "ui.tool.planEnter": "دخول وضع الخطة", + "ui.tool.planExit": "الخروج من وضع الخطة", "ui.tool.agent": "وكيل {{type}}", "ui.common.file.one": "ملف", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 36e4fa8d8d83..ea842c145735 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -84,6 +84,8 @@ export const dict = { "ui.tool.todos": "Tarefas", "ui.tool.todos.read": "Ler tarefas", "ui.tool.questions": "Perguntas", + "ui.tool.planEnter": "Entrar no modo de planejamento", + "ui.tool.planExit": "Sair do modo de planejamento", "ui.tool.agent": "Agente {{type}}", "ui.common.file.one": "arquivo", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 6727cc50c88d..2ce3bee94f9e 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -88,6 +88,8 @@ export const dict = { "ui.tool.todos": "Lista zadataka", "ui.tool.todos.read": "Čitanje liste zadataka", "ui.tool.questions": "Pitanja", + "ui.tool.planEnter": "Uđi u način planiranja", + "ui.tool.planExit": "Izađi iz načina planiranja", "ui.tool.agent": "{{type}} agent", "ui.common.file.one": "datoteka", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 48afb6cbebc7..34ff42e6b1e0 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -83,6 +83,8 @@ export const dict = { "ui.tool.todos": "Opgaver", "ui.tool.todos.read": "Læs opgaver", "ui.tool.questions": "Spørgsmål", + "ui.tool.planEnter": "Gå til plantilstand", + "ui.tool.planExit": "Forlad plantilstand", "ui.tool.agent": "{{type}} Agent", "ui.common.file.one": "fil", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 5f42253432a0..9bf89078e32c 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -89,6 +89,8 @@ export const dict = { "ui.tool.todos": "Aufgaben", "ui.tool.todos.read": "Aufgaben lesen", "ui.tool.questions": "Fragen", + "ui.tool.planEnter": "Planmodus starten", + "ui.tool.planExit": "Planmodus verlassen", "ui.tool.agent": "{{type}} Agent", "ui.common.file.one": "Datei", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index fe1b2ee89eef..7013fa61dc8f 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -85,6 +85,8 @@ export const dict = { "ui.tool.todos": "To-dos", "ui.tool.todos.read": "Read to-dos", "ui.tool.questions": "Questions", + "ui.tool.planEnter": "Enter plan mode", + "ui.tool.planExit": "Exit plan mode", "ui.tool.agent": "{{type}} Agent", "ui.common.file.one": "file", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 124a3c3876e8..289a467c43dd 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -84,6 +84,8 @@ export const dict = { "ui.tool.todos": "Tareas", "ui.tool.todos.read": "Leer tareas", "ui.tool.questions": "Preguntas", + "ui.tool.planEnter": "Entrar en modo plan", + "ui.tool.planExit": "Salir del modo plan", "ui.tool.agent": "Agente {{type}}", "ui.common.file.one": "archivo", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 13fda5891038..27c3d29fcf50 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -84,6 +84,8 @@ export const dict = { "ui.tool.todos": "Tâches", "ui.tool.todos.read": "Lire les tâches", "ui.tool.questions": "Questions", + "ui.tool.planEnter": "Entrer en mode plan", + "ui.tool.planExit": "Quitter le mode plan", "ui.tool.agent": "Agent {{type}}", "ui.common.file.one": "fichier", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 27e7f32ab947..9ec83b38f1d7 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -83,6 +83,8 @@ export const dict = { "ui.tool.todos": "Todo", "ui.tool.todos.read": "Todo読み込み", "ui.tool.questions": "質問", + "ui.tool.planEnter": "計画モードに入る", + "ui.tool.planExit": "計画モードを終了", "ui.tool.agent": "{{type}}エージェント", "ui.common.file.one": "ファイル", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 4ac8f4a30ff8..51a767abea14 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -84,6 +84,8 @@ export const dict = { "ui.tool.todos": "할 일", "ui.tool.todos.read": "할 일 읽기", "ui.tool.questions": "질문", + "ui.tool.planEnter": "계획 모드 시작", + "ui.tool.planExit": "계획 모드 종료", "ui.tool.agent": "{{type}} 에이전트", "ui.common.file.one": "파일", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 5f414209bfee..96e2d6be5be8 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -87,6 +87,8 @@ export const dict: Record = { "ui.tool.todos": "Gjøremål", "ui.tool.todos.read": "Les gjøremål", "ui.tool.questions": "Spørsmål", + "ui.tool.planEnter": "Gå til planmodus", + "ui.tool.planExit": "Avslutt planmodus", "ui.tool.agent": "{{type}}-agent", "ui.common.file.one": "fil", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index b0ef94dd4ca8..0ec9c5cc1c52 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -83,6 +83,8 @@ export const dict = { "ui.tool.todos": "Zadania", "ui.tool.todos.read": "Czytaj zadania", "ui.tool.questions": "Pytania", + "ui.tool.planEnter": "Przejdź do trybu planu", + "ui.tool.planExit": "Wyjdź z trybu planu", "ui.tool.agent": "Agent {{type}}", "ui.common.file.one": "plik", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 6c2eb290d71a..47898be74085 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -83,6 +83,8 @@ export const dict = { "ui.tool.todos": "Задачи", "ui.tool.todos.read": "Читать задачи", "ui.tool.questions": "Вопросы", + "ui.tool.planEnter": "Перейти в режим плана", + "ui.tool.planExit": "Выйти из режима плана", "ui.tool.agent": "Агент {{type}}", "ui.common.file.one": "файл", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 091d1b70c8b7..32939454eec9 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -85,6 +85,8 @@ export const dict = { "ui.tool.todos": "รายการงาน", "ui.tool.todos.read": "อ่านรายการงาน", "ui.tool.questions": "คำถาม", + "ui.tool.planEnter": "เข้าสู่โหมดวางแผน", + "ui.tool.planExit": "ออกจากโหมดวางแผน", "ui.tool.agent": "เอเจนต์ {{type}}", "ui.common.file.one": "ไฟล์", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 8e7d9fcd2f55..b0b709698c62 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -88,6 +88,8 @@ export const dict = { "ui.tool.todos": "待办", "ui.tool.todos.read": "读取待办", "ui.tool.questions": "问题", + "ui.tool.planEnter": "进入计划模式", + "ui.tool.planExit": "退出计划模式", "ui.tool.agent": "{{type}} 智能体", "ui.common.file.one": "个文件", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 781cde4573ef..68afa4afcf0a 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -88,6 +88,8 @@ export const dict = { "ui.tool.todos": "待辦", "ui.tool.todos.read": "讀取待辦", "ui.tool.questions": "問題", + "ui.tool.planEnter": "進入計畫模式", + "ui.tool.planExit": "離開計畫模式", "ui.tool.agent": "{{type}} 代理程式", "ui.common.file.one": "個檔案",