diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 47d008fb42..8dd3be5892 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1,7 +1,8 @@ ## Style Guide - Try to keep things in one function unless composable or reusable -- AVOID unnecessary destructuring of variables +- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } += obj` just reference it as obj.a and obj.b. this preserves context - AVOID `try`/`catch` where possible - AVOID `else` statements - AVOID using `any` type diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 52c2225e1e..cd9507f345 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ use tauri::{ }; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; +use tauri_plugin_store::StoreExt; use tokio::net::TcpSocket; use crate::window_customizer::PinchZoomDisablePlugin; @@ -45,6 +46,65 @@ impl ServerState { struct LogState(Arc>>); const MAX_LOG_ENTRIES: usize = 200; +const GLOBAL_STORAGE: &str = "opencode.global.dat"; + +/// Check if a URL's origin matches any configured server in the store. +/// Returns true if the URL should be allowed for internal navigation. +fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool { + // Always allow localhost and 127.0.0.1 + if let Some(host) = url.host_str() { + if host == "localhost" || host == "127.0.0.1" { + return true; + } + } + + // Try to read the server list from the store + let Ok(store) = app.store(GLOBAL_STORAGE) else { + return false; + }; + + let Some(server_data) = store.get("server") else { + return false; + }; + + // Parse the server list from the stored JSON + let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else { + return false; + }; + + // Get the origin of the navigation URL (scheme + host + port) + let url_origin = format!( + "{}://{}{}", + url.scheme(), + url.host_str().unwrap_or(""), + url.port().map(|p| format!(":{}", p)).unwrap_or_default() + ); + + // Check if any configured server matches the URL's origin + for server in list { + let Some(server_url) = server.as_str() else { + continue; + }; + + // Parse the server URL to extract its origin + let Ok(parsed) = tauri::Url::parse(server_url) else { + continue; + }; + + let server_origin = format!( + "{}://{}{}", + parsed.scheme(), + parsed.host_str().unwrap_or(""), + parsed.port().map(|p| format!(":{}", p)).unwrap_or_default() + ); + + if url_origin == server_origin { + return true; + } + } + + false +} #[tauri::command] fn kill_sidecar(app: AppHandle) { @@ -236,6 +296,7 @@ pub fn run() { .unwrap_or(LogicalSize::new(1920, 1080)); // Create window immediately with serverReady = false + let app_for_nav = app.clone(); let mut window_builder = WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) .title("OpenCode") @@ -243,6 +304,22 @@ pub fn run() { .decorations(true) .zoom_hotkeys_enabled(true) .disable_drag_drop_handler() + .on_navigation(move |url| { + // Allow internal navigation (tauri:// scheme) + if url.scheme() == "tauri" { + return true; + } + // Allow navigation to configured servers (localhost, 127.0.0.1, or remote) + if is_allowed_server(&app_for_nav, url) { + return true; + } + // Open external http/https URLs in default browser + if url.scheme() == "http" || url.scheme() == "https" { + let _ = app_for_nav.shell().open(url.as_str(), None); + return false; // Cancel internal navigation + } + true + }) .initialization_script(format!( r#" window.__OPENCODE__ ??= {{}}; diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 854afc00b6..ab17dd2235 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,6 +4,7 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" +import { Truncate } from "../tool/truncation" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -46,7 +47,11 @@ export namespace Agent { const defaults = PermissionNext.fromConfig({ "*": "allow", doom_loop: "ask", - external_directory: "ask", + external_directory: { + "*": "ask", + [Truncate.DIR]: "allow", + }, + question: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -61,7 +66,13 @@ export namespace Agent { build: { name: "build", options: {}, - permission: PermissionNext.merge(defaults, user), + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + question: "allow", + }), + user, + ), mode: "primary", native: true, }, @@ -71,6 +82,7 @@ export namespace Agent { permission: PermissionNext.merge( defaults, PermissionNext.fromConfig({ + question: "allow", edit: { "*": "deny", ".opencode/plan/*.md": "allow", @@ -110,6 +122,9 @@ export namespace Agent { websearch: "allow", codesearch: "allow", read: "allow", + external_directory: { + [Truncate.DIR]: "allow", + }, }), user, ), diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index f6c6b688a3..e6203d6657 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -515,7 +515,15 @@ export const GithubRunCommand = cmd({ // Setup opencode session const repoData = await fetchRepo() - session = await Session.create({}) + session = await Session.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }) subscribeSessionEvents() shareId = await (async () => { if (share === false) return diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bd9d29b4de..a86b435ec3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -292,7 +292,28 @@ export const RunCommand = cmd({ : args.title : undefined - const result = await sdk.session.create(title ? { title } : {}) + const result = await sdk.session.create( + title + ? { + title, + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + } + : { + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }, + ) return result.data?.id })() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 8a14d8b2e7..0edc911344 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -8,6 +8,7 @@ import type { Todo, Command, PermissionRequest, + QuestionRequest, LspStatus, McpStatus, McpResource, @@ -42,6 +43,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ permission: { [sessionID: string]: PermissionRequest[] } + question: { + [sessionID: string]: QuestionRequest[] + } config: Config session: Session[] session_status: { @@ -80,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ status: "loading", agent: [], permission: {}, + question: {}, command: [], provider: [], provider_default: {}, @@ -142,6 +147,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "question.replied": + case "question.rejected": { + const requests = store.question[event.properties.sessionID] + if (!requests) break + const match = Binary.search(requests, event.properties.requestID, (r) => r.id) + if (!match.found) break + setStore( + "question", + event.properties.sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + break + } + + case "question.asked": { + const request = event.properties + const requests = store.question[request.sessionID] + if (!requests) { + setStore("question", request.sessionID, [request]) + break + } + const match = Binary.search(requests, request.id, (r) => r.id) + if (match.found) { + setStore("question", request.sessionID, match.index, reconcile(request)) + break + } + setStore( + "question", + request.sessionID, + produce((draft) => { + draft.splice(match.index, 0, request) + }), + ) + break + } + case "todo.updated": setStore("todo", event.properties.sessionID, event.properties.todos) break diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index aa331ca0f0..0037f23bba 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -41,6 +41,7 @@ import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" +import type { QuestionTool } from "@/tool/question" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" @@ -69,6 +70,7 @@ import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" import { PermissionPrompt } from "./permission" +import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" @@ -90,7 +92,6 @@ const context = createContext<{ conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean - usernameVisible: () => boolean showDetails: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType @@ -118,9 +119,13 @@ export function Session() { }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => { - if (session()?.parentID) return sync.data.permission[route.sessionID] ?? [] + if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) }) + const questions = createMemo(() => { + if (session()?.parentID) return [] + return children().flatMap((x) => sync.data.question[x.id] ?? []) + }) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -135,7 +140,6 @@ export function Session() { const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true)) const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show") - const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) @@ -459,20 +463,6 @@ export function Session() { dialog.clear() }, }, - { - title: usernameVisible() ? "Hide username" : "Show username", - value: "session.username_visible.toggle", - keybind: "username_toggle", - category: "Session", - onSelect: (dialog) => { - setUsernameVisible((prev) => { - const next = !prev - kv.set("username_visible", next) - return next - }) - dialog.clear() - }, - }, { title: "Toggle code concealment", value: "session.toggle.conceal", @@ -907,7 +897,6 @@ export function Session() { conceal, showThinking, showTimestamps, - usernameVisible, showDetails, diffWrapMode, sync, @@ -1037,13 +1026,20 @@ export function Session() { 0}> + 0}> + + { prompt = r promptRef.set(r) + // Apply initial prompt when prompt component mounts (e.g., from fork) + if (route.initialPrompt) { + r.set(route.initialPrompt) + } }} - disabled={permissions().length > 0} + disabled={permissions().length > 0 || questions().length > 0} onSubmit={() => { toBottom() }} @@ -1090,6 +1086,7 @@ function UserMessage(props: { const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent))) + const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) @@ -1119,7 +1116,7 @@ function UserMessage(props: { > {text()?.text} - + {(file) => { const bg = createMemo(() => { @@ -1137,23 +1134,22 @@ function UserMessage(props: { - - {ctx.usernameVisible() ? `${sync.data.config.username ?? "You "}` : "You "} - + + - {ctx.usernameVisible() ? " · " : " "} {Locale.todayTimeOrDateTime(props.message.time.created)} - - } - > - + + + } + > + QUEUED - - + + @@ -1377,6 +1373,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + + + @@ -1438,7 +1437,12 @@ function InlineTool(props: { const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) - const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule")) + const denied = createMemo( + () => + error()?.includes("rejected permission") || + error()?.includes("specified a rule") || + error()?.includes("user dismissed"), + ) return ( ) { ) } +function Question(props: ToolProps) { + const { theme } = useTheme() + const count = createMemo(() => props.input.questions?.length ?? 0) + return ( + + + + + + {(q, i) => ( + + {q.question} + {props.metadata.answers?.[i()] || "(no answer)"} + + )} + + + + + + + Asked {count()} question{count() !== 1 ? "s" : ""} + + + + ) +} + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx new file mode 100644 index 0000000000..82a6a021cf --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -0,0 +1,291 @@ +import { createStore } from "solid-js/store" +import { createMemo, For, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" +import { useKeybind } from "../../context/keybind" +import { useTheme } from "../../context/theme" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../component/border" +import { useTextareaKeybindings } from "../../component/textarea-keybindings" +import { useDialog } from "../../ui/dialog" + +export function QuestionPrompt(props: { request: QuestionRequest }) { + const sdk = useSDK() + const { theme } = useTheme() + const keybind = useKeybind() + const bindings = useTextareaKeybindings() + + const questions = createMemo(() => props.request.questions) + const single = createMemo(() => questions().length === 1) + const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single) + const [store, setStore] = createStore({ + tab: 0, + answers: [] as string[], + custom: [] as string[], + selected: 0, + editing: false, + }) + + let textarea: TextareaRenderable | undefined + + const question = createMemo(() => questions()[store.tab]) + const confirm = createMemo(() => !single() && store.tab === questions().length) + const options = createMemo(() => question()?.options ?? []) + const other = createMemo(() => store.selected === options().length) + const input = createMemo(() => store.custom[store.tab] ?? "") + + function submit() { + // Fill in empty answers with empty strings + const answers = questions().map((_, i) => store.answers[i] ?? "") + sdk.client.question.reply({ + requestID: props.request.id, + answers, + }) + } + + function reject() { + sdk.client.question.reject({ + requestID: props.request.id, + }) + } + + function pick(answer: string, custom: boolean = false) { + const answers = [...store.answers] + answers[store.tab] = answer + setStore("answers", answers) + if (custom) { + const inputs = [...store.custom] + inputs[store.tab] = answer + setStore("custom", inputs) + } + if (single()) { + sdk.client.question.reply({ + requestID: props.request.id, + answers: [answer], + }) + return + } + setStore("tab", store.tab + 1) + setStore("selected", 0) + } + + const dialog = useDialog() + + useKeyboard((evt) => { + // When editing "Other" textarea + if (store.editing && !confirm()) { + if (evt.name === "escape") { + evt.preventDefault() + setStore("editing", false) + return + } + if (evt.name === "return") { + evt.preventDefault() + const text = textarea?.plainText?.trim() + if (text) { + pick(text, true) + setStore("editing", false) + } + return + } + // Let textarea handle all other keys + return + } + + if (evt.name === "left" || evt.name === "h") { + evt.preventDefault() + const next = (store.tab - 1 + tabs()) % tabs() + setStore("tab", next) + setStore("selected", 0) + } + + if (evt.name === "right" || evt.name === "l") { + evt.preventDefault() + const next = (store.tab + 1) % tabs() + setStore("tab", next) + setStore("selected", 0) + } + + if (confirm()) { + if (evt.name === "return") { + evt.preventDefault() + submit() + } + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + } + } else { + const opts = options() + const total = opts.length + 1 // options + "Other" + + if (evt.name === "up" || evt.name === "k") { + evt.preventDefault() + setStore("selected", (store.selected - 1 + total) % total) + } + + if (evt.name === "down" || evt.name === "j") { + evt.preventDefault() + setStore("selected", (store.selected + 1) % total) + } + + if (evt.name === "return") { + evt.preventDefault() + if (other()) { + setStore("editing", true) + } else { + const opt = opts[store.selected] + if (opt) { + pick(opt.label) + } + } + } + + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + } + } + }) + + return ( + + + + + + {(q, index) => { + const isActive = () => index() === store.tab + const isAnswered = () => store.answers[index()] !== undefined + return ( + + + {q.header} + + + ) + }} + + + Confirm + + + + + + + + {question()?.question} + + + + {(opt, i) => { + const active = () => i() === store.selected + const picked = () => store.answers[store.tab] === opt.label + return ( + + + + + {i() + 1}. {opt.label} + + + {picked() ? "✓" : ""} + + + {opt.description} + + + ) + }} + + + + + + {options().length + 1}. Type your own answer + + + {input() ? "✓" : ""} + + + +