From 602fe26e5871d416fdd8cb30a55e59a9c20707f9 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Fri, 6 Mar 2026 16:53:28 +0000 Subject: [PATCH 1/3] feat(app): add global markdown command import in settings Introduce a Commands settings tab in desktop that imports markdown files into global config so slash commands are available across projects. Add a desktop file-read bridge used by the importer. --- .../app/src/components/dialog-settings.tsx | 8 + .../app/src/components/settings-commands.tsx | 154 +++++++++++++++++- packages/app/src/context/platform.tsx | 3 + packages/desktop/src-tauri/src/lib.rs | 9 + packages/desktop/src/bindings.ts | 2 +- packages/desktop/src/index.tsx | 2 + 6 files changed, 171 insertions(+), 7 deletions(-) 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..e0a2d7ba870 100644 --- a/packages/app/src/components/settings-commands.tsx +++ b/packages/app/src/components/settings-commands.tsx @@ -1,16 +1,158 @@ -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 list = createMemo(() => { + return Object.keys(globalSync.data.config.command ?? {}).sort((a, b) => a.localeCompare(b)) + }) + + const add = async () => { + if (!platform.openFilePickerDialog || !platform.readTextFile) return + + const pick = await platform.openFilePickerDialog({ multiple: true, title: "Choose markdown command files" }) + if (!pick) return + const paths = Array.isArray(pick) ? pick : [pick] + + const files = paths.filter((item) => item.toLowerCase().endsWith(".md")) + if (files.length === 0) { + showToast({ title: language.t("common.requestFailed"), description: "Select one or more .md files." }) + return + } + + const next: Record< + string, + { template: string; description?: string; agent?: string; model?: string; subtask?: boolean } + > = {} + setStore("loading", true) + + try { + for (const file of files) { + const key = name(file) + if (!key) continue + const parsed = parse(await platform.readTextFile(file)) + 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 + } + + await globalSync.updateConfig({ command: next }) + showToast({ + variant: "success", + icon: "circle-check", + title: "Commands imported", + description: `${Object.keys(next).length} command${Object.keys(next).length > 1 ? "s" : ""} added globally.`, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + } finally { + setStore("loading", false) + } + } return ( -
-
-

{language.t("settings.commands.title")}

-

{language.t("settings.commands.description")}

+
+
+
+

{language.t("settings.commands.title")}

+

{language.t("settings.commands.description")}

+
+ +
+
+

Global command files

+
+
+
+ Import markdown files to use as slash commands in every project. +
+ +
+ + 0} + fallback={
No global commands configured yet.
} + > + + {(item) => ( +
+
/{item}
+
+ )} +
+
+
+
+ +
+ Imported commands are stored in your global OpenCode config, so they are available in all projects. +
+
+ + +
+ Command file import is only available in the desktop app. +
+
) } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 86f3321e464..5b43e14c30d 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -78,6 +78,9 @@ 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 + /** 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..3a2aa602fc2 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -208,6 +208,14 @@ 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}")) +} + #[cfg(target_os = "macos")] fn check_macos_app(app_name: &str) -> bool { // Check common installation locations @@ -400,6 +408,7 @@ fn make_specta_builder() -> tauri_specta::Builder { get_display_backend, set_display_backend, markdown::parse_markdown_command, + read_text_file, check_app_exists, wsl_path, resolve_app_path, diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 80548173e92..ba634dff03d 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -15,6 +15,7 @@ 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 }), 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 }), @@ -65,4 +66,3 @@ function makeEvent(name: string) { return Object.assign(fn, base); } - diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9afabe918b1..03d2de87273 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -368,6 +368,8 @@ const createPlatform = (): Platform => { parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), + readTextFile: (path: string) => commands.readTextFile(path), + webviewZoom, checkAppExists: async (appName: string) => { From 62ba176bcd9130cf0eaca456838c9fc6f55e8c3c Mon Sep 17 00:00:00 2001 From: anduimagui Date: Fri, 6 Mar 2026 16:56:47 +0000 Subject: [PATCH 2/3] fix(app): preserve global commands during import --- .../app/src/components/settings-commands.tsx | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/settings-commands.tsx b/packages/app/src/components/settings-commands.tsx index e0a2d7ba870..327ca18d0b5 100644 --- a/packages/app/src/components/settings-commands.tsx +++ b/packages/app/src/components/settings-commands.tsx @@ -51,21 +51,22 @@ export const SettingsCommands: Component = () => { const platform = usePlatform() const globalSync = useGlobalSync() const [store, setStore] = createStore({ loading: false }) + const canImport = createMemo(() => Boolean(platform.openFilePickerDialog && platform.readTextFile)) const list = createMemo(() => { return Object.keys(globalSync.data.config.command ?? {}).sort((a, b) => a.localeCompare(b)) }) const add = async () => { - if (!platform.openFilePickerDialog || !platform.readTextFile) return + if (!canImport()) return - const pick = await platform.openFilePickerDialog({ multiple: true, title: "Choose markdown command files" }) + const pick = await platform.openFilePickerDialog!({ multiple: true, title: "Choose command markdown files" }) if (!pick) return const paths = Array.isArray(pick) ? pick : [pick] const files = paths.filter((item) => item.toLowerCase().endsWith(".md")) if (files.length === 0) { - showToast({ title: language.t("common.requestFailed"), description: "Select one or more .md files." }) + showToast({ title: language.t("common.requestFailed"), description: "Pick one or more .md files." }) return } @@ -79,7 +80,7 @@ export const SettingsCommands: Component = () => { for (const file of files) { const key = name(file) if (!key) continue - const parsed = parse(await platform.readTextFile(file)) + const parsed = parse(await platform.readTextFile!(file)) if (!parsed.template) continue next[key] = parsed } @@ -89,12 +90,13 @@ export const SettingsCommands: Component = () => { return } - await globalSync.updateConfig({ command: next }) + 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" : ""} added globally.`, + description: `${Object.keys(next).length} command${Object.keys(next).length > 1 ? "s" : ""} saved in global config.`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -119,9 +121,14 @@ export const SettingsCommands: Component = () => {
- Import markdown files to use as slash commands in every project. + Import `.md` files and use them as slash commands in every project.
-
- Imported commands are stored in your global OpenCode config, so they are available in all projects. + Imported commands are stored in your global OpenCode config and shared across all projects.
- +
- Command file import is only available in the desktop app. + Command import is available in the desktop app.
From ffef938e0b309e4578bfa067e62aee885c9b5340 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sat, 7 Mar 2026 17:13:38 +0000 Subject: [PATCH 3/3] feat(app): import commands from files or folders --- .../app/src/components/settings-commands.tsx | 125 +++++++++++------- packages/app/src/context/platform.tsx | 3 + packages/desktop/src-tauri/src/lib.rs | 65 ++++++++- packages/desktop/src/bindings.ts | 7 + packages/desktop/src/index.tsx | 2 + 5 files changed, 154 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/settings-commands.tsx b/packages/app/src/components/settings-commands.tsx index 327ca18d0b5..bc673ec12ea 100644 --- a/packages/app/src/components/settings-commands.tsx +++ b/packages/app/src/components/settings-commands.tsx @@ -51,56 +51,73 @@ export const SettingsCommands: Component = () => { const platform = usePlatform() const globalSync = useGlobalSync() const [store, setStore] = createStore({ loading: false }) - const canImport = createMemo(() => Boolean(platform.openFilePickerDialog && platform.readTextFile)) + 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 add = async () => { - if (!canImport()) return + 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] - - const files = paths.filter((item) => item.toLowerCase().endsWith(".md")) - if (files.length === 0) { + 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 } - - const next: Record< - string, - { template: string; description?: string; agent?: string; model?: string; subtask?: boolean } - > = {} 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 { - for (const file of files) { - const key = name(file) - if (!key) continue - const parsed = parse(await platform.readTextFile!(file)) - 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.`, - }) + await apply(await platform.readMarkdownFiles!(paths)) } catch (err) { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + const msg = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: msg }) } finally { setStore("loading", false) } @@ -121,18 +138,32 @@ export const SettingsCommands: Component = () => {
- Import `.md` files and use them as slash commands in every project. + Import `.md` files or folders and use them as slash commands in every project. +
+
+
(store.loading || !files() ? undefined : void file())}> + +
+
(store.loading || !dirs() ? undefined : void dir())}> + +
-
{
- +
Command import is available in the desktop app.
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 5b43e14c30d..1fffd1fcdd7 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -81,6 +81,9 @@ export type Platform = { /** 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 3a2aa602fc2..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 { @@ -216,6 +223,61 @@ async fn read_text_file(path: String) -> Result { .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 @@ -409,6 +471,7 @@ fn make_specta_builder() -> tauri_specta::Builder { 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 ba634dff03d..3240daf6a60 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -16,6 +16,7 @@ export const commands = { 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 }), @@ -35,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, @@ -66,3 +72,4 @@ function makeEvent(name: string) { return Object.assign(fn, base); } + diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 03d2de87273..a7e0ed6d5a9 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -370,6 +370,8 @@ const createPlatform = (): Platform => { readTextFile: (path: string) => commands.readTextFile(path), + readMarkdownFiles: (paths: string[]) => commands.readMarkdownFiles(paths), + webviewZoom, checkAppExists: async (appName: string) => {