Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/app/src/components/dialog-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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>
Expand All @@ -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>
)
Expand Down
192 changes: 186 additions & 6 deletions packages/app/src/components/settings-commands.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-3 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
</div>
</div>

<div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">Global command files</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<div class="flex items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base">
<div class="text-14-regular text-text-weak">
Import `.md` files or folders and use them as slash commands in every project.
</div>
<div class="flex items-center gap-2 shrink-0">
<div onClick={() => (store.loading || !files() ? undefined : void file())}>
<Button
size="large"
variant="secondary"
class={store.loading || !files() ? "opacity-50 pointer-events-none" : undefined}
>
{store.loading
? `${language.t("common.loading")}${language.t("common.loading.ellipsis")}`
: "Import files"}
</Button>
</div>
<div onClick={() => (store.loading || !dirs() ? undefined : void dir())}>
<Button
size="large"
variant="secondary"
class={store.loading || !dirs() ? "opacity-50 pointer-events-none" : undefined}
>
{store.loading
? `${language.t("common.loading")}${language.t("common.loading.ellipsis")}`
: "Import folders"}
</Button>
</div>
</div>
</div>

<Show
when={list().length > 0}
fallback={<div class="py-4 text-14-regular text-text-weak">No global commands configured yet.</div>}
>
<For each={list()}>
{(item) => (
<div class="min-h-12 py-3 border-b border-border-weak-base last:border-none">
<div class="text-14-medium text-text-strong">/{item}</div>
</div>
)}
</For>
</Show>
</div>
</div>

<div class="text-12-regular text-text-weak max-w-[640px]">
Imported commands are stored in your global OpenCode config and shared across all projects.
</div>
</div>

<Show when={!files() && !dirs()}>
<div class="max-w-[720px] text-12-regular text-text-weak mt-6">
Command import is available in the desktop app.
</div>
</Show>
</div>
)
}
6 changes: 6 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
74 changes: 73 additions & 1 deletion packages/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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()))?;
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<String>) -> Result<Vec<MarkdownFile>, 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
Expand Down Expand Up @@ -400,6 +470,8 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
read_text_file,
read_markdown_files,
check_app_exists,
wsl_path,
resolve_app_path,
Expand Down
7 changes: 7 additions & 0 deletions packages/desktop/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/desktop/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading