diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx
index 83cea131f5d..2a720685e2f 100644
--- a/packages/app/src/components/dialog-settings.tsx
+++ b/packages/app/src/components/dialog-settings.tsx
@@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
+import { SettingsCommands } from "./settings-commands"
export const DialogSettings: Component = () => {
const language = useLanguage()
@@ -45,6 +46,10 @@ export const DialogSettings: Component = () => {
{language.t("settings.models.title")}
+
+
+ {language.t("settings.commands.title")}
+
@@ -67,6 +72,9 @@ export const DialogSettings: Component = () => {
+
+
+
)
diff --git a/packages/app/src/components/settings-commands.tsx b/packages/app/src/components/settings-commands.tsx
index e158d231cee..bc673ec12ea 100644
--- a/packages/app/src/components/settings-commands.tsx
+++ b/packages/app/src/components/settings-commands.tsx
@@ -1,16 +1,196 @@
-import { Component } from "solid-js"
+import { showToast } from "@opencode-ai/ui/toast"
+import { Button } from "@opencode-ai/ui/button"
+import { getFilename } from "@opencode-ai/util/path"
+import { Component, For, Show, createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+
+function clean(input: string) {
+ const quoted = (input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))
+ if (!quoted) return input
+ return input.slice(1, -1)
+}
+
+function parse(markdown: string) {
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
+ if (!match) return { template: markdown.trim() }
+
+ const meta = {
+ description: undefined as string | undefined,
+ agent: undefined as string | undefined,
+ model: undefined as string | undefined,
+ subtask: undefined as boolean | undefined,
+ }
+
+ for (const line of match[1].split(/\r?\n/)) {
+ const item = line.match(/^\s*([a-z_]+)\s*:\s*(.*?)\s*$/)
+ if (!item) continue
+ const key = item[1]
+ const val = clean(item[2])
+ if (key === "description") meta.description = val
+ if (key === "agent") meta.agent = val
+ if (key === "model") meta.model = val
+ if (key === "subtask") meta.subtask = val === "true"
+ }
+
+ return {
+ ...meta,
+ template: match[2].trim(),
+ }
+}
+
+function name(path: string) {
+ const file = getFilename(path).replace(/\.md$/i, "").trim()
+ return file.replace(/\s+/g, "-")
+}
export const SettingsCommands: Component = () => {
- // TODO: Replace this placeholder with full commands settings controls.
const language = useLanguage()
+ const platform = usePlatform()
+ const globalSync = useGlobalSync()
+ const [store, setStore] = createStore({ loading: false })
+ const files = createMemo(() => Boolean(platform.openFilePickerDialog && platform.readTextFile))
+ const dirs = createMemo(() => Boolean(platform.openDirectoryPickerDialog && platform.readMarkdownFiles))
+
+ const list = createMemo(() => {
+ return Object.keys(globalSync.data.config.command ?? {}).sort((a, b) => a.localeCompare(b))
+ })
+
+ const apply = async (input: Array<{ path: string; text: string }>) => {
+ const next: Record<
+ string,
+ { template: string; description?: string; agent?: string; model?: string; subtask?: boolean }
+ > = {}
+ for (const item of input) {
+ const key = name(item.path)
+ if (!key) continue
+ const parsed = parse(item.text)
+ if (!parsed.template) continue
+ next[key] = parsed
+ }
+ if (Object.keys(next).length === 0) {
+ showToast({ title: language.t("common.requestFailed"), description: "No valid commands were imported." })
+ return
+ }
+ const curr = globalSync.data.config.command ?? {}
+ await globalSync.updateConfig({ command: { ...curr, ...next } })
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: "Commands imported",
+ description: `${Object.keys(next).length} command${Object.keys(next).length > 1 ? "s" : ""} saved in global config.`,
+ })
+ }
+
+ const file = async () => {
+ if (!files()) return
+ const pick = await platform.openFilePickerDialog!({ multiple: true, title: "Choose command markdown files" })
+ if (!pick) return
+ const paths = (Array.isArray(pick) ? pick : [pick]).filter((item) => item.toLowerCase().endsWith(".md"))
+ if (paths.length === 0) {
+ showToast({ title: language.t("common.requestFailed"), description: "Pick one or more .md files." })
+ return
+ }
+ setStore("loading", true)
+ try {
+ await apply(await Promise.all(paths.map(async (path) => ({ path, text: await platform.readTextFile!(path) }))))
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ showToast({ title: language.t("common.requestFailed"), description: msg })
+ } finally {
+ setStore("loading", false)
+ }
+ }
+
+ const dir = async () => {
+ if (!dirs()) return
+ const pick = await platform.openDirectoryPickerDialog!({
+ multiple: true,
+ title: "Choose folders with command files",
+ })
+ if (!pick) return
+ const paths = Array.isArray(pick) ? pick : [pick]
+ setStore("loading", true)
+ try {
+ await apply(await platform.readMarkdownFiles!(paths))
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ showToast({ title: language.t("common.requestFailed"), description: msg })
+ } finally {
+ setStore("loading", false)
+ }
+ }
return (
-
-
-
{language.t("settings.commands.title")}
-
{language.t("settings.commands.description")}
+
)
}
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 86f3321e464..1fffd1fcdd7 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -78,6 +78,12 @@ export type Platform = {
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise
+ /** Read UTF-8 text file content (desktop only) */
+ readTextFile?(path: string): Promise
+
+ /** Read markdown files from files/folders (desktop only) */
+ readMarkdownFiles?(paths: string[]): Promise>
+
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index 137692cdf73..ed475a00358 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -17,9 +17,10 @@ use futures::{
future::{self, Shared},
};
use std::{
+ collections::HashSet,
env,
net::TcpListener,
- path::PathBuf,
+ path::{Path, PathBuf},
process::Command,
sync::{Arc, Mutex},
time::Duration,
@@ -46,6 +47,12 @@ struct ServerReadyData {
is_sidecar: bool,
}
+#[derive(Clone, serde::Serialize, specta::Type, Debug)]
+struct MarkdownFile {
+ path: String,
+ text: String,
+}
+
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
#[serde(tag = "phase", rename_all = "snake_case")]
enum InitStep {
@@ -208,6 +215,69 @@ fn open_path(_app: AppHandle, path: String, app_name: Option) -> Result<
.map_err(|e| format!("Failed to open path: {e}"))
}
+#[tauri::command]
+#[specta::specta]
+async fn read_text_file(path: String) -> Result {
+ tokio::fs::read_to_string(path)
+ .await
+ .map_err(|e| format!("Failed to read file: {e}"))
+}
+
+fn add_markdown(path: &Path, seen: &mut HashSet, out: &mut Vec) -> Result<(), String> {
+ let full = path
+ .canonicalize()
+ .map_err(|e| format!("Failed to resolve path {}: {e}", path.display()))?;
+ if !seen.insert(full.clone()) {
+ return Ok(());
+ }
+
+ if full.is_file() {
+ if full
+ .extension()
+ .and_then(|v| v.to_str())
+ .is_some_and(|v| v.eq_ignore_ascii_case("md"))
+ {
+ out.push(full);
+ }
+ return Ok(());
+ }
+
+ if !full.is_dir() {
+ return Ok(());
+ }
+
+ for item in std::fs::read_dir(&full).map_err(|e| format!("Failed to read dir {}: {e}", full.display()))? {
+ let item = item.map_err(|e| format!("Failed to read dir entry in {}: {e}", full.display()))?;
+ add_markdown(&item.path(), seen, out)?;
+ }
+
+ Ok(())
+}
+
+#[tauri::command]
+#[specta::specta]
+async fn read_markdown_files(paths: Vec) -> Result, String> {
+ let mut seen = HashSet::new();
+ let mut files = Vec::new();
+
+ for path in paths {
+ add_markdown(Path::new(&path), &mut seen, &mut files)?;
+ }
+
+ let mut out = Vec::new();
+ for path in files {
+ let text = tokio::fs::read_to_string(&path)
+ .await
+ .map_err(|e| format!("Failed to read file {}: {e}", path.display()))?;
+ out.push(MarkdownFile {
+ path: path.to_string_lossy().to_string(),
+ text,
+ });
+ }
+
+ Ok(out)
+}
+
#[cfg(target_os = "macos")]
fn check_macos_app(app_name: &str) -> bool {
// Check common installation locations
@@ -400,6 +470,8 @@ fn make_specta_builder() -> tauri_specta::Builder {
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
+ read_text_file,
+ read_markdown_files,
check_app_exists,
wsl_path,
resolve_app_path,
diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts
index 80548173e92..3240daf6a60 100644
--- a/packages/desktop/src/bindings.ts
+++ b/packages/desktop/src/bindings.ts
@@ -15,6 +15,8 @@ export const commands = {
getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"),
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }),
+ readTextFile: (path: string) => __TAURI_INVOKE("read_text_file", { path }),
+ readMarkdownFiles: (paths: string[]) => __TAURI_INVOKE("read_markdown_files", { paths }),
checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }),
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE("wsl_path", { path, mode }),
resolveAppPath: (appName: string) => __TAURI_INVOKE("resolve_app_path", { appName }),
@@ -34,6 +36,11 @@ export type LinuxDisplayBackend = "wayland" | "auto";
export type LoadingWindowComplete = null;
+export type MarkdownFile = {
+ path: string,
+ text: string,
+ };
+
export type ServerReadyData = {
url: string,
username: string | null,
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 9afabe918b1..a7e0ed6d5a9 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -368,6 +368,10 @@ const createPlatform = (): Platform => {
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
+ readTextFile: (path: string) => commands.readTextFile(path),
+
+ readMarkdownFiles: (paths: string[]) => commands.readMarkdownFiles(paths),
+
webviewZoom,
checkAppExists: async (appName: string) => {