#18145 · @anduimagui · opened Mar 18, 2026 at 10:22 PM UTC · last updated Mar 21, 2026 at 1:41 PM UTC

feat(app): import global commands from files and folders

appfeat
37
+28476 files

Score breakdown

Impact

6.0

Clarity

7.0

Urgency

2.0

Ease Of Review

4.0

Guidelines

3.0

Readiness

2.0

Size

3.0

Trust

6.0

Traction

0.0

Summary

This draft PR introduces a new settings tab for the desktop application, enabling users to import global slash commands from markdown files and folders. It also addresses a bug by ensuring imported commands merge with existing configurations instead of overwriting them. The PR lacks a linked issue, making it difficult to assess specific user need or priority.

Open in GitHub

Description

Issue for this PR

Closes #

Type of change

  • [ ] Bug fix
  • [x] New feature
  • [x] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Adds a real Commands settings tab in desktop that imports global slash commands from either selected .md files or selected folders. Folder imports recurse to discover markdown files, parse frontmatter/body, then save commands to global config.

Also fixes import behavior to merge with existing command config instead of overwriting it, so previously configured global commands are preserved.

How did you verify your code works?

  • bun run typecheck (in packages/desktop)
  • cargo check (in packages/desktop/src-tauri)
  • cargo test test_export_types --lib (in packages/desktop/src-tauri)

Screenshots / recordings

Included screenshot in discussion showing Commands settings with import actions.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

None.

Comments

No comments.

Changed Files

packages/app/src/components/dialog-settings.tsx

+80
@@ -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 = () => {
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
<Tabs.Trigger value="commands">
<Icon name="brain" />
{language.t("settings.commands.title")}
</Tabs.Trigger>
</div>
</div>
</div>
@@ -67,6 +72,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="commands" class="no-scrollbar">
<SettingsCommands />
</Tabs.Content>
</Tabs>
</Dialog>
)

packages/app/src/components/settings-commands.tsx

+1866
@@ -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) cont

packages/app/src/context/platform.tsx

+60
@@ -78,6 +78,12 @@ export type Platform = {
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>
/** Read UTF-8 text file content (desktop only) */
readTextFile?(path: string): Promise<string>
/** Read markdown files from files/folders (desktop only) */
readMarkdownFiles?(paths: string[]): Promise<Array<{ path: string; text: string }>>
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>

packages/desktop/src-tauri/src/lib.rs

+731
@@ -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<String>) -> Result<
.map_err(|e| format!("Failed to open path: {e}"))
}
#[tauri::command]
#[specta::specta]
async fn read_text_file(path: String) -> Result<String, String> {
tokio::fs::read_to_string(path)
.await
.map_err(|e| format!("Failed to read file: {e}"))
}
fn add_markdown(path: &Path, seen: &mut HashSet<PathBuf>, out: &mut Vec<PathBuf>) -> Result<(), String> {
let full = path
.canonicalize()
.map_err(|e| format!("Failed to resolve path {}: {e}", path.display

packages/desktop/src/bindings.ts

+70
@@ -15,6 +15,8 @@ export const commands = {
getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"),
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
readTextFile: (path: string) => __TAURI_INVOKE<string>("read_text_file", { path }),
readMarkdownFiles: (paths: string[]) => __TAURI_INVOKE<MarkdownFile[]>("read_markdown_files", { paths }),
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }),
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("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,

packages/desktop/src/index.tsx

+40
@@ -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) => {