From 3f25a7797ed214996bea34a8458550820704b6e3 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 10:20:24 +0200 Subject: [PATCH 1/8] feat: add skills settings and /skill picker --- src/App.tsx | 10 +- .../useComposerAutocompleteState.test.tsx | 85 ++++++++- .../hooks/useComposerAutocompleteState.ts | 174 ++++++++++++++++-- .../settings/components/SettingsView.test.tsx | 21 +++ .../settings/components/SettingsView.tsx | 27 ++- .../settings/components/SkillsView.test.tsx | 49 +++++ .../settings/components/SkillsView.tsx | 151 +++++++++++++++ src/styles/skills.css | 118 ++++++++++++ 8 files changed, 620 insertions(+), 15 deletions(-) create mode 100644 src/features/settings/components/SkillsView.test.tsx create mode 100644 src/features/settings/components/SkillsView.tsx create mode 100644 src/styles/skills.css diff --git a/src/App.tsx b/src/App.tsx index 3c8896bd5..d83545354 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import "./styles/worktree-modal.css"; import "./styles/clone-modal.css"; import "./styles/branch-switcher-modal.css"; import "./styles/settings.css"; +import "./styles/skills.css"; import "./styles/compact-base.css"; import "./styles/compact-phone.css"; import "./styles/compact-tablet.css"; @@ -468,7 +469,7 @@ function MainApp() { reasoningSupported, onFocusComposer: () => composerInputRef.current?.focus(), }); - const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry }); + const { skills, refreshSkills } = useSkills({ activeWorkspace, onDebug: addDebugEntry }); const { apps } = useApps({ activeWorkspace, enabled: appSettings.experimentalAppsEnabled, @@ -2360,6 +2361,13 @@ function MainApp() { onDownloadDictationModel: dictationModel.download, onCancelDictationDownload: dictationModel.cancel, onRemoveDictationModel: dictationModel.remove, + skills, + onUseSkill: (name: string) => { + handleInsertComposerText(`/skill ${name}`); + }, + onRefreshSkills: () => { + void refreshSkills(); + }, }} /> diff --git a/src/features/composer/hooks/useComposerAutocompleteState.test.tsx b/src/features/composer/hooks/useComposerAutocompleteState.test.tsx index 43145bc99..ef1c093a7 100644 --- a/src/features/composer/hooks/useComposerAutocompleteState.test.tsx +++ b/src/features/composer/hooks/useComposerAutocompleteState.test.tsx @@ -106,10 +106,11 @@ describe("useComposerAutocompleteState slash commands", () => { "new", "resume", "review", + "skill", "status", ]), ); - expect(labels.slice(0, 8)).toEqual([ + expect(labels.slice(0, 9)).toEqual([ "apps", "compact", "fork", @@ -117,6 +118,7 @@ describe("useComposerAutocompleteState slash commands", () => { "new", "resume", "review", + "skill", "status", ]); }); @@ -148,7 +150,86 @@ describe("useComposerAutocompleteState slash commands", () => { const labels = result.current.autocompleteMatches.map((item) => item.label); expect(labels).not.toContain("apps"); - expect(labels).toEqual(["compact", "fork", "mcp", "new", "resume", "review", "status"]); + expect(labels).toEqual([ + "compact", + "fork", + "mcp", + "new", + "resume", + "review", + "skill", + "status", + ]); + }); +}); + +describe("useComposerAutocompleteState /skill completions", () => { + it("shows skills after /skill and a space", () => { + const text = "/skill "; + const selectionStart = text.length; + const textareaRef = createRef(); + textareaRef.current = { + focus: vi.fn(), + setSelectionRange: vi.fn(), + } as unknown as HTMLTextAreaElement; + + const { result } = renderHook(() => + useComposerAutocompleteState({ + text, + selectionStart, + disabled: false, + appsEnabled: true, + skills: [ + { name: "build_project", description: "Build it" }, + { name: "debug_app", description: "Debug it" }, + ], + apps: [], + prompts: [], + files: [], + textareaRef, + setText: vi.fn(), + setSelectionStart: vi.fn(), + }), + ); + + expect(result.current.isAutocompleteOpen).toBe(true); + expect(result.current.autocompleteMatches.map((item) => item.label)).toEqual([ + "build_project", + "debug_app", + ]); + }); + + it("filters skills after /skill with a query", () => { + const text = "/skill deb"; + const selectionStart = text.length; + const textareaRef = createRef(); + textareaRef.current = { + focus: vi.fn(), + setSelectionRange: vi.fn(), + } as unknown as HTMLTextAreaElement; + + const { result } = renderHook(() => + useComposerAutocompleteState({ + text, + selectionStart, + disabled: false, + appsEnabled: true, + skills: [ + { name: "build_project", description: "Build it" }, + { name: "debug_app", description: "Debug it" }, + ], + apps: [], + prompts: [], + files: [], + textareaRef, + setText: vi.fn(), + setSelectionStart: vi.fn(), + }), + ); + + expect(result.current.autocompleteMatches.map((item) => item.label)).toEqual([ + "debug_app", + ]); }); }); diff --git a/src/features/composer/hooks/useComposerAutocompleteState.ts b/src/features/composer/hooks/useComposerAutocompleteState.ts index 194f3d1e7..9d8af3a00 100644 --- a/src/features/composer/hooks/useComposerAutocompleteState.ts +++ b/src/features/composer/hooks/useComposerAutocompleteState.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { AutocompleteItem } from "./useComposerAutocomplete"; import { useComposerAutocomplete } from "./useComposerAutocomplete"; import type { AppOption, CustomPromptOption } from "../../../types"; @@ -27,6 +27,7 @@ type UseComposerAutocompleteStateArgs = { const MAX_FILE_SUGGESTIONS = 500; const FILE_TRIGGER_PREFIX = new RegExp("^(?:\\s|[\"'`]|\\(|\\[|\\{)$"); +const SKILL_COMMAND = "/skill"; function isFileTriggerActive(text: string, cursor: number | null) { if (!text || cursor === null) { @@ -65,6 +66,67 @@ function getFileTriggerQuery(text: string, cursor: number | null) { return afterAt; } +type SkillCommandContext = { + query: string; + range: { start: number; end: number }; +}; + +function resolveSkillCommandContext( + text: string, + cursor: number | null, +): SkillCommandContext | null { + if (!text || cursor === null) { + return null; + } + const beforeCursor = text.slice(0, cursor); + const index = beforeCursor.lastIndexOf(SKILL_COMMAND); + if (index < 0) { + return null; + } + const prevChar = index > 0 ? beforeCursor[index - 1] : ""; + if (prevChar && !/\s/.test(prevChar)) { + return null; + } + const afterCommand = beforeCursor.slice(index + SKILL_COMMAND.length); + if (!afterCommand.startsWith(" ")) { + return null; + } + const start = index + SKILL_COMMAND.length + 1; + const query = beforeCursor.slice(start); + if (/\s/.test(query)) { + return null; + } + return { query, range: { start, end: cursor } }; +} + +function rankSkillItems(items: AutocompleteItem[], query: string) { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return items.slice(); + } + const scored = items + .map((item) => { + const label = item.label.toLowerCase(); + const description = item.description?.toLowerCase() ?? ""; + const score = label === normalized + ? 3 + : label.startsWith(normalized) + ? 2 + : label.includes(normalized) || description.includes(normalized) + ? 1 + : 0; + return { item, score }; + }) + .filter((entry) => entry.score > 0) + .sort((a, b) => { + if (a.score !== b.score) { + return b.score - a.score; + } + return a.item.label.localeCompare(b.item.label); + }); + return scored.map((entry) => entry.item); +} + export function useComposerAutocompleteState({ text, selectionStart, @@ -100,6 +162,18 @@ export function useComposerAutocompleteState({ [apps, skills], ); + const skillCommandItems = useMemo( + () => + skills.map((skill) => ({ + id: `skill:${skill.name}`, + label: skill.name, + description: skill.description, + insertText: skill.name, + group: "Skills" as const, + })), + [skills], + ); + const fileTriggerActive = useMemo( () => isFileTriggerActive(text, selectionStart), [selectionStart, text], @@ -184,6 +258,13 @@ export function useComposerAutocompleteState({ insertText: "resume", group: "Slash", }, + { + id: "skill", + label: "skill", + description: "insert a skill command", + insertText: "skill", + group: "Slash", + }, { id: "status", label: "status", @@ -209,6 +290,25 @@ export function useComposerAutocompleteState({ [promptItems, slashCommandItems], ); + const skillCommandContext = useMemo( + () => resolveSkillCommandContext(text, selectionStart), + [selectionStart, text], + ); + const skillCommandMatches = useMemo( + () => + skillCommandContext + ? rankSkillItems(skillCommandItems, skillCommandContext.query) + : [], + [skillCommandContext, skillCommandItems], + ); + const [skillCommandHighlightIndex, setSkillCommandHighlightIndex] = + useState(0); + const [skillCommandDismissed, setSkillCommandDismissed] = useState(false); + useEffect(() => { + setSkillCommandHighlightIndex(0); + setSkillCommandDismissed(false); + }, [skillCommandContext?.query, skillCommandContext?.range.start]); + const triggers = useMemo( () => [ { trigger: "/", items: slashItems }, @@ -219,21 +319,73 @@ export function useComposerAutocompleteState({ ); const { - active: isAutocompleteOpen, - matches: autocompleteMatches, - highlightIndex, - setHighlightIndex, - moveHighlight, - range: autocompleteRange, - close: closeAutocomplete, + active: isBaseAutocompleteOpen, + matches: baseAutocompleteMatches, + highlightIndex: baseHighlightIndex, + setHighlightIndex: setBaseHighlightIndex, + moveHighlight: moveBaseHighlight, + range: baseAutocompleteRange, + close: closeBaseAutocomplete, } = useComposerAutocomplete({ text, selectionStart, triggers, }); - const autocompleteAnchorIndex = autocompleteRange - ? Math.max(0, autocompleteRange.start - 1) - : null; + const isSkillCommandActive = + Boolean(skillCommandContext) && + skillCommandMatches.length > 0 && + !skillCommandDismissed; + const autocompleteMatches = isSkillCommandActive + ? skillCommandMatches + : baseAutocompleteMatches; + const autocompleteRange = isSkillCommandActive + ? skillCommandContext?.range ?? null + : baseAutocompleteRange; + const isAutocompleteOpen = isSkillCommandActive || isBaseAutocompleteOpen; + const highlightIndex = isSkillCommandActive + ? skillCommandHighlightIndex + : baseHighlightIndex; + const setHighlightIndex = useCallback( + (index: number) => { + if (isSkillCommandActive) { + setSkillCommandHighlightIndex(index); + return; + } + setBaseHighlightIndex(index); + }, + [isSkillCommandActive, setBaseHighlightIndex], + ); + const autocompleteAnchorIndex = isSkillCommandActive + ? skillCommandContext?.range.start ?? null + : autocompleteRange + ? Math.max(0, autocompleteRange.start - 1) + : null; + + const closeAutocomplete = useCallback(() => { + if (isSkillCommandActive) { + setSkillCommandDismissed(true); + return; + } + closeBaseAutocomplete(); + }, [closeBaseAutocomplete, isSkillCommandActive]); + + const moveHighlight = useCallback( + (delta: number) => { + if (autocompleteMatches.length === 0) { + return; + } + if (isSkillCommandActive) { + setSkillCommandHighlightIndex((prev) => { + const next = + (prev + delta + autocompleteMatches.length) % autocompleteMatches.length; + return next; + }); + return; + } + moveBaseHighlight(delta); + }, + [autocompleteMatches.length, isSkillCommandActive, moveBaseHighlight], + ); const applyAutocomplete = useCallback( (item: AutocompleteItem) => { diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index a5a0564ac..40692050e 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -142,6 +142,9 @@ const renderDisplaySection = ( onDownloadDictationModel: vi.fn(), onCancelDictationDownload: vi.fn(), onRemoveDictationModel: vi.fn(), + skills: [], + onUseSkill: vi.fn(), + onRefreshSkills: vi.fn(), }; render(); @@ -188,6 +191,9 @@ const renderFeaturesSection = ( onCancelDictationDownload: vi.fn(), onRemoveDictationModel: vi.fn(), initialSection: "features", + skills: [], + onUseSkill: vi.fn(), + onRefreshSkills: vi.fn(), }; render(); @@ -278,6 +284,9 @@ const renderEnvironmentsSection = ( onCancelDictationDownload: vi.fn(), onRemoveDictationModel: vi.fn(), initialSection: "environments", + skills: [], + onUseSkill: vi.fn(), + onRefreshSkills: vi.fn(), }; render(); @@ -566,6 +575,9 @@ describe("SettingsView Codex overrides", () => { onCancelDictationDownload={vi.fn()} onRemoveDictationModel={vi.fn()} initialSection="codex" + skills={[]} + onUseSkill={vi.fn()} + onRefreshSkills={vi.fn()} />, ); @@ -613,6 +625,9 @@ describe("SettingsView Codex overrides", () => { onCancelDictationDownload={vi.fn()} onRemoveDictationModel={vi.fn()} initialSection="codex" + skills={[]} + onUseSkill={vi.fn()} + onRefreshSkills={vi.fn()} />, ); @@ -677,6 +692,9 @@ describe("SettingsView Shortcuts", () => { onDownloadDictationModel={vi.fn()} onCancelDictationDownload={vi.fn()} onRemoveDictationModel={vi.fn()} + skills={[]} + onUseSkill={vi.fn()} + onRefreshSkills={vi.fn()} />, ); @@ -718,6 +736,9 @@ describe("SettingsView Shortcuts", () => { onDownloadDictationModel={vi.fn()} onCancelDictationDownload={vi.fn()} onRemoveDictationModel={vi.fn()} + skills={[]} + onUseSkill={vi.fn()} + onRefreshSkills={vi.fn()} />, ); diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index a58200e2b..6a6b42a44 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -16,6 +16,7 @@ import X from "lucide-react/dist/esm/icons/x"; import FlaskConical from "lucide-react/dist/esm/icons/flask-conical"; import ExternalLink from "lucide-react/dist/esm/icons/external-link"; import Layers from "lucide-react/dist/esm/icons/layers"; +import Wrench from "lucide-react/dist/esm/icons/wrench"; import type { AppSettings, CodexDoctorResult, @@ -24,6 +25,7 @@ import type { OpenAppTarget, WorkspaceGroup, WorkspaceInfo, + SkillOption, } from "../../../types"; import { formatDownloadSize } from "../../../utils/formatting"; import { @@ -48,6 +50,7 @@ import { GENERIC_APP_ICON, getKnownOpenAppIcon } from "../../app/utils/openAppIc import { useGlobalAgentsMd } from "../hooks/useGlobalAgentsMd"; import { useGlobalCodexConfigToml } from "../hooks/useGlobalCodexConfigToml"; import { FileEditorCard } from "../../shared/components/FileEditorCard"; +import { SkillsView } from "./SkillsView"; const DICTATION_MODELS = [ { id: "tiny", label: "Tiny", size: "75 MB", note: "Fastest, least accurate." }, @@ -177,6 +180,9 @@ export type SettingsViewProps = { onCancelDictationDownload?: () => void; onRemoveDictationModel?: () => void; initialSection?: CodexSection; + skills: SkillOption[]; + onUseSkill: (name: string) => void; + onRefreshSkills: () => void; }; type SettingsSection = @@ -187,7 +193,8 @@ type SettingsSection = | "dictation" | "shortcuts" | "open-apps" - | "git"; + | "git" + | "skills"; type CodexSection = SettingsSection | "codex" | "features"; type ShortcutSettingKey = | "composerModelShortcut" @@ -320,6 +327,9 @@ export function SettingsView({ onDownloadDictationModel, onCancelDictationDownload, onRemoveDictationModel, + skills, + onUseSkill, + onRefreshSkills, initialSection, }: SettingsViewProps) { const [activeSection, setActiveSection] = useState("projects"); @@ -1206,6 +1216,14 @@ export function SettingsView({ Git + + + + + +
Installed
+ {!hasSkills ? ( +
+ No skills found for this workspace. Connect a workspace to load skills. +
+ ) : ( +
+ {cards.map((skill) => ( +
+
+
+ +
+
+
{skill.name}
+
+ {skill.description ?? "No description provided."} +
+
+
+
+ + + +
+
+ ))} +
+ )} + + ); +} diff --git a/src/styles/skills.css b/src/styles/skills.css new file mode 100644 index 000000000..6409916c3 --- /dev/null +++ b/src/styles/skills.css @@ -0,0 +1,118 @@ +.skills-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.skills-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.skills-link { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 6px; + font-size: 12px; + color: var(--text-accent); + background: transparent; + border: none; + padding: 0; +} + +.skills-link svg { + width: 12px; + height: 12px; +} + +.skills-subsection-title { + font-size: 12px; + font-weight: 600; + color: var(--text-strong); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-top: 8px; +} + +.skills-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; +} + +.skills-card { + border-radius: 14px; + border: 1px solid var(--border-muted); + background: var(--surface-card); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 110px; +} + +.skills-card-header { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.skills-card-icon { + width: 38px; + height: 38px; + border-radius: 12px; + background: var(--surface-control); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-strong); + flex-shrink: 0; +} + +.skills-card-icon svg { + width: 18px; + height: 18px; +} + +.skills-card-body { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.skills-card-title { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); +} + +.skills-card-description { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; + max-height: 2.8em; + overflow: hidden; + text-overflow: ellipsis; +} + +.skills-card-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.skills-card-actions button { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.skills-card-actions svg { + width: 14px; + height: 14px; +} From 520654a68a1a9782cb061875c3a8bd1f4dcbf498 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 10:25:32 +0200 Subject: [PATCH 2/8] fix: expand skill paths before opening --- .../settings/components/SkillsView.tsx | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/features/settings/components/SkillsView.tsx b/src/features/settings/components/SkillsView.tsx index 8437dd708..5f09f4635 100644 --- a/src/features/settings/components/SkillsView.tsx +++ b/src/features/settings/components/SkillsView.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import type { SkillOption } from "../../../types"; +import { homeDir, join } from "@tauri-apps/api/path"; import { openPath, openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import Wrench from "lucide-react/dist/esm/icons/wrench"; import FolderOpen from "lucide-react/dist/esm/icons/folder-open"; @@ -14,6 +15,20 @@ type SkillPaths = { filePath: string; }; +const expandHomePath = async (value: string) => { + if (!value.startsWith("~")) { + return value; + } + const home = await homeDir(); + if (value === "~") { + return home; + } + const next = value.startsWith("~/") || value.startsWith("~\\") + ? value.slice(2) + : value.slice(1); + return join(home, next); +}; + const normalizeSkillPath = (value: string) => value.replace(/\\/g, "/"); const resolveSkillPaths = (value: string): SkillPaths => { @@ -55,7 +70,9 @@ export function SkillsView({ const handleOpenSkill = (skill: SkillOption) => { const { folderPath } = resolveSkillPaths(skill.path); - void revealItemInDir(folderPath).catch(() => { + void expandHomePath(folderPath) + .then((expanded) => revealItemInDir(expanded)) + .catch(() => { pushErrorToast({ title: "Could not open skill", message: "Failed to reveal the skill folder.", @@ -65,12 +82,14 @@ export function SkillsView({ const handleEditSkill = (skill: SkillOption) => { const { filePath } = resolveSkillPaths(skill.path); - void openPath(filePath).catch(() => { - pushErrorToast({ - title: "Could not edit skill", - message: "Failed to open the skill file.", + void expandHomePath(filePath) + .then((expanded) => openPath(expanded)) + .catch(() => { + pushErrorToast({ + title: "Could not edit skill", + message: "Failed to open the skill file.", + }); }); - }); }; return ( From 64672a604dee5186d87b385db30e3ac63f4247f1 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 10:41:22 +0200 Subject: [PATCH 3/8] fix: resolve skill edit paths --- src-tauri/src/bin/codex_monitor_daemon.rs | 2 +- src-tauri/src/codex/mod.rs | 2 +- src-tauri/src/shared/codex_core.rs | 54 ++++++++++++++++++- .../settings/components/SkillsView.test.tsx | 39 +++++++++++++- .../settings/components/SkillsView.tsx | 48 ++++++++++++++--- 5 files changed, 133 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 7af2b33fe..f25359977 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -627,7 +627,7 @@ impl DaemonState { } async fn skills_list(&self, workspace_id: String) -> Result { - codex_core::skills_list_core(&self.sessions, workspace_id).await + codex_core::skills_list_core(&self.sessions, &self.workspaces, workspace_id).await } async fn apps_list( diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index 86fab50c4..b62dda73d 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -546,7 +546,7 @@ pub(crate) async fn skills_list( .await; } - codex_core::skills_list_core(&state.sessions, workspace_id).await + codex_core::skills_list_core(&state.sessions, &state.workspaces, workspace_id).await } #[tauri::command] diff --git a/src-tauri/src/shared/codex_core.rs b/src-tauri/src/shared/codex_core.rs index a11b18b7b..f8f9d2de6 100644 --- a/src-tauri/src/shared/codex_core.rs +++ b/src-tauri/src/shared/codex_core.rs @@ -62,6 +62,15 @@ async fn resolve_codex_home_for_workspace_core( .ok_or_else(|| "Unable to resolve CODEX_HOME".to_string()) } +fn resolve_skills_cwd(entry: &WorkspaceEntry, parent_entry: Option<&WorkspaceEntry>) -> String { + if entry.kind.is_worktree() { + if let Some(parent) = parent_entry { + return parent.path.clone(); + } + } + entry.path.clone() +} + pub(crate) async fn start_thread_core( sessions: &Mutex>>, workspace_id: String, @@ -435,10 +444,13 @@ pub(crate) async fn codex_login_cancel_core( pub(crate) async fn skills_list_core( sessions: &Mutex>>, + workspaces: &Mutex>, workspace_id: String, ) -> Result { let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "cwd": session.entry.path }); + let (entry, parent_entry) = resolve_workspace_and_parent(workspaces, &workspace_id).await?; + let cwd = resolve_skills_cwd(&entry, parent_entry.as_ref()); + let params = json!({ "cwd": cwd }); session.send_request("skills/list", params).await } @@ -495,3 +507,43 @@ pub(crate) async fn get_config_model_core( let model = codex_config::read_config_model(Some(codex_home))?; Ok(json!({ "model": model })) } + +#[cfg(test)] +mod tests { + use super::resolve_skills_cwd; + use crate::types::{WorkspaceEntry, WorkspaceKind, WorkspaceSettings, WorktreeInfo}; + + fn workspace_entry(kind: WorkspaceKind, path: &str, parent_id: Option<&str>) -> WorkspaceEntry { + WorkspaceEntry { + id: "id".to_string(), + name: "name".to_string(), + path: path.to_string(), + codex_bin: None, + kind, + parent_id: parent_id.map(|value| value.to_string()), + worktree: if kind.is_worktree() { + Some(WorktreeInfo { + branch: "branch".to_string(), + }) + } else { + None + }, + settings: WorkspaceSettings::default(), + } + } + + #[test] + fn uses_entry_path_for_main_workspace_skills() { + let entry = workspace_entry(WorkspaceKind::Main, "/repo", None); + let cwd = resolve_skills_cwd(&entry, None); + assert_eq!(cwd, "/repo"); + } + + #[test] + fn uses_parent_path_for_worktree_skills() { + let parent = workspace_entry(WorkspaceKind::Main, "/repo", None); + let entry = workspace_entry(WorkspaceKind::Worktree, "/repo/wt", Some("parent")); + let cwd = resolve_skills_cwd(&entry, Some(&parent)); + assert_eq!(cwd, "/repo"); + } +} diff --git a/src/features/settings/components/SkillsView.test.tsx b/src/features/settings/components/SkillsView.test.tsx index aade71dd2..8b8d8d384 100644 --- a/src/features/settings/components/SkillsView.test.tsx +++ b/src/features/settings/components/SkillsView.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { SkillOption } from "../../../types"; import { SkillsView } from "./SkillsView"; +import { openPath } from "@tauri-apps/plugin-opener"; vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn().mockResolvedValue(undefined), @@ -46,4 +47,40 @@ describe("SkillsView", () => { expect(screen.getByText(/No skills found for this workspace/i)).toBeTruthy(); }); + + it("tries a nested skill folder when the direct SKILL.md is missing", async () => { + const skills: SkillOption[] = [ + { + name: "skill-installer", + path: "/Users/demo/.codex/skills/.system", + description: "Install curated skills", + }, + ]; + const openPathMock = vi.mocked(openPath); + openPathMock + .mockRejectedValueOnce(new Error("missing")) + .mockResolvedValueOnce(undefined); + + const { container } = render( + , + ); + + fireEvent.click(within(container).getByRole("button", { name: "Edit" })); + + await waitFor(() => { + expect(openPathMock).toHaveBeenCalledTimes(2); + }); + expect(openPathMock).toHaveBeenNthCalledWith( + 1, + "/Users/demo/.codex/skills/.system/SKILL.md", + ); + expect(openPathMock).toHaveBeenNthCalledWith( + 2, + "/Users/demo/.codex/skills/.system/skill-installer/SKILL.md", + ); + }); }); diff --git a/src/features/settings/components/SkillsView.tsx b/src/features/settings/components/SkillsView.tsx index 5f09f4635..c69b683ad 100644 --- a/src/features/settings/components/SkillsView.tsx +++ b/src/features/settings/components/SkillsView.tsx @@ -45,6 +45,40 @@ const resolveSkillPaths = (value: string): SkillPaths => { return { folderPath: trimmed, filePath: `${trimmed}${separator}SKILL.md` }; }; +const buildSkillFileCandidates = (skill: SkillOption) => { + const { filePath } = resolveSkillPaths(skill.path); + const candidates = [filePath]; + const skillName = skill.name?.trim(); + if (!skillName) { + return candidates; + } + const normalizedPath = normalizeSkillPath(skill.path).replace(/[\\/]+$/, ""); + const normalizedLower = normalizedPath.toLowerCase(); + const normalizedName = normalizeSkillPath(skillName).toLowerCase(); + const endsWithName = normalizedLower.endsWith(`/${normalizedName}`); + const endsWithMd = + normalizedLower.endsWith("/skill.md") || normalizedLower.endsWith(".md"); + if (!endsWithName && !endsWithMd) { + const separator = skill.path.includes("\\") ? "\\" : "/"; + candidates.push(`${skill.path}${separator}${skillName}${separator}SKILL.md`); + } + return candidates; +}; + +const openFirstAvailablePath = async (paths: string[]) => { + let lastError: unknown = null; + for (const path of paths) { + try { + const expanded = await expandHomePath(path); + await openPath(expanded); + return; + } catch (error) { + lastError = error; + } + } + throw lastError ?? new Error("No skill file candidates"); +}; + type SkillsViewProps = { skills: SkillOption[]; onUseSkill: (name: string) => void; @@ -81,15 +115,13 @@ export function SkillsView({ }; const handleEditSkill = (skill: SkillOption) => { - const { filePath } = resolveSkillPaths(skill.path); - void expandHomePath(filePath) - .then((expanded) => openPath(expanded)) - .catch(() => { - pushErrorToast({ - title: "Could not edit skill", - message: "Failed to open the skill file.", - }); + const candidates = buildSkillFileCandidates(skill); + void openFirstAvailablePath(candidates).catch(() => { + pushErrorToast({ + title: "Could not edit skill", + message: "Failed to open the skill file.", }); + }); }; return ( From 61c730d8de4f3e00179d4e10bc9b16f97db6b817 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 10:49:50 +0200 Subject: [PATCH 4/8] fix: resolve relative skill paths --- .../settings/components/SkillsView.test.tsx | 11 +++++++- .../settings/components/SkillsView.tsx | 26 ++++++++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/features/settings/components/SkillsView.test.tsx b/src/features/settings/components/SkillsView.test.tsx index 8b8d8d384..541b2b147 100644 --- a/src/features/settings/components/SkillsView.test.tsx +++ b/src/features/settings/components/SkillsView.test.tsx @@ -11,6 +11,15 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@tauri-apps/api/path", () => ({ + homeDir: vi.fn().mockResolvedValue("/Users/demo"), + join: vi.fn((base: string, next: string) => { + const trimmedBase = base.replace(/\/+$/, ""); + const trimmedNext = next.replace(/^\/+/, ""); + return `${trimmedBase}/${trimmedNext}`; + }), +})); + vi.mock("../../../services/toasts", () => ({ pushErrorToast: vi.fn(), })); @@ -52,7 +61,7 @@ describe("SkillsView", () => { const skills: SkillOption[] = [ { name: "skill-installer", - path: "/Users/demo/.codex/skills/.system", + path: ".codex/skills/.system", description: "Install curated skills", }, ]; diff --git a/src/features/settings/components/SkillsView.tsx b/src/features/settings/components/SkillsView.tsx index c69b683ad..eed72e776 100644 --- a/src/features/settings/components/SkillsView.tsx +++ b/src/features/settings/components/SkillsView.tsx @@ -15,18 +15,26 @@ type SkillPaths = { filePath: string; }; +const isAbsolutePath = (value: string) => + value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); + const expandHomePath = async (value: string) => { - if (!value.startsWith("~")) { - return value; + const trimmed = value.trim(); + if (trimmed.startsWith("~")) { + const home = await homeDir(); + if (trimmed === "~") { + return home; + } + const next = trimmed.startsWith("~/") || trimmed.startsWith("~\\") + ? trimmed.slice(2) + : trimmed.slice(1); + return join(home, next); } - const home = await homeDir(); - if (value === "~") { - return home; + if (isAbsolutePath(trimmed)) { + return trimmed; } - const next = value.startsWith("~/") || value.startsWith("~\\") - ? value.slice(2) - : value.slice(1); - return join(home, next); + const home = await homeDir(); + return join(home, trimmed); }; const normalizeSkillPath = (value: string) => value.replace(/\\/g, "/"); From 0d48099a1f968be549b8eb3d42b47ea6688c3ea5 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 10:59:19 +0200 Subject: [PATCH 5/8] fix: resolve skill edit paths --- .../settings/components/SkillsView.test.tsx | 38 ++++- .../settings/components/SkillsView.tsx | 153 +++++++++++++++--- src/features/skills/hooks/useSkills.ts | 2 + src/types.ts | 2 + 4 files changed, 168 insertions(+), 27 deletions(-) diff --git a/src/features/settings/components/SkillsView.test.tsx b/src/features/settings/components/SkillsView.test.tsx index 541b2b147..5fd0f23c5 100644 --- a/src/features/settings/components/SkillsView.test.tsx +++ b/src/features/settings/components/SkillsView.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SkillOption } from "../../../types"; import { SkillsView } from "./SkillsView"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -25,6 +25,12 @@ vi.mock("../../../services/toasts", () => ({ })); describe("SkillsView", () => { + beforeEach(() => { + const openPathMock = vi.mocked(openPath); + openPathMock.mockReset(); + openPathMock.mockResolvedValue(undefined); + }); + it("renders skills and calls onUseSkill", () => { const skills: SkillOption[] = [ { @@ -61,7 +67,7 @@ describe("SkillsView", () => { const skills: SkillOption[] = [ { name: "skill-installer", - path: ".codex/skills/.system", + path: "~/.codex/skills/.system", description: "Install curated skills", }, ]; @@ -92,4 +98,32 @@ describe("SkillsView", () => { "/Users/demo/.codex/skills/.system/skill-installer/SKILL.md", ); }); + + it("resolves skill paths relative to CODEX_HOME", async () => { + const skills: SkillOption[] = [ + { + name: "skill-creator", + path: "skills/skill-creator", + codexHome: "/Users/demo/.codex", + }, + ]; + const openPathMock = vi.mocked(openPath); + + const { container } = render( + , + ); + + fireEvent.click(within(container).getByRole("button", { name: "Edit" })); + + await waitFor(() => { + expect(openPathMock).toHaveBeenCalledTimes(1); + }); + expect(openPathMock).toHaveBeenCalledWith( + "/Users/demo/.codex/skills/skill-creator/SKILL.md", + ); + }); }); diff --git a/src/features/settings/components/SkillsView.tsx b/src/features/settings/components/SkillsView.tsx index eed72e776..7a7ff7045 100644 --- a/src/features/settings/components/SkillsView.tsx +++ b/src/features/settings/components/SkillsView.tsx @@ -15,26 +15,95 @@ type SkillPaths = { filePath: string; }; +type SkillPathContext = { + codexHome?: string | null; + workspacePath?: string | null; +}; + const isAbsolutePath = (value: string) => - value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); + value.startsWith("/") || + /^[A-Za-z]:[\\/]/.test(value) || + value.startsWith("\\\\"); + +const expandTilde = async (value: string) => { + const home = await homeDir(); + if (value === "~") { + return home; + } + const next = value.startsWith("~/") || value.startsWith("~\\") + ? value.slice(2) + : value.slice(1); + return join(home, next); +}; + +const resolveCodexHome = async (context?: SkillPathContext) => { + const raw = context?.codexHome?.trim(); + if (!raw) { + return null; + } + if (raw.startsWith("~")) { + return expandTilde(raw); + } + if (isAbsolutePath(raw)) { + return raw; + } + if (context?.workspacePath) { + return join(context.workspacePath, raw); + } + const home = await homeDir(); + return join(home, raw); +}; + +const expandCodexHomePath = (value: string, codexHome: string | null) => { + if (!codexHome) { + return null; + } + const normalized = value.replace(/^[.\\/]+/, ""); + return join(codexHome, normalized); +}; -const expandHomePath = async (value: string) => { +const expandSkillPathCandidates = async ( + value: string, + context?: SkillPathContext, +) => { const trimmed = value.trim(); + const candidates = new Set(); + const add = (candidate: string | null | undefined) => { + if (candidate) { + candidates.add(candidate); + } + }; + + const codexHome = await resolveCodexHome(context); + if (trimmed.startsWith("$CODEX_HOME/") || trimmed.startsWith("$CODEX_HOME\\")) { + add(expandCodexHomePath(trimmed.replace("$CODEX_HOME", ""), codexHome)); + } else if ( + trimmed.startsWith("${CODEX_HOME}/") || + trimmed.startsWith("${CODEX_HOME}\\") + ) { + add(expandCodexHomePath(trimmed.replace("${CODEX_HOME}", ""), codexHome)); + } + if (trimmed.startsWith("~")) { + add(await expandTilde(trimmed)); + } else if (isAbsolutePath(trimmed)) { + add(trimmed); + } else { + if (codexHome) { + add(join(codexHome, trimmed)); + } + if (context?.workspacePath) { + add(join(context.workspacePath, trimmed)); + } const home = await homeDir(); - if (trimmed === "~") { - return home; + if (trimmed.startsWith(".codex/") || trimmed.startsWith(".codex\\")) { + add(join(home, trimmed)); } - const next = trimmed.startsWith("~/") || trimmed.startsWith("~\\") - ? trimmed.slice(2) - : trimmed.slice(1); - return join(home, next); + const cleaned = trimmed.replace(/^[.\\/]+/, ""); + add(join(home, cleaned)); + add(join(home, ".codex", cleaned)); } - if (isAbsolutePath(trimmed)) { - return trimmed; - } - const home = await homeDir(); - return join(home, trimmed); + return [...candidates]; }; const normalizeSkillPath = (value: string) => value.replace(/\\/g, "/"); @@ -73,17 +142,31 @@ const buildSkillFileCandidates = (skill: SkillOption) => { return candidates; }; -const openFirstAvailablePath = async (paths: string[]) => { +const openFirstAvailablePath = async ( + paths: string[], + context?: SkillPathContext, +) => { + const attempted: string[] = []; let lastError: unknown = null; for (const path of paths) { try { - const expanded = await expandHomePath(path); - await openPath(expanded); - return; + const expandedCandidates = await expandSkillPathCandidates(path, context); + for (const expanded of expandedCandidates) { + try { + attempted.push(expanded); + await openPath(expanded); + return; + } catch (error) { + lastError = error; + } + } } catch (error) { lastError = error; } } + if (attempted.length > 0) { + throw new Error(attempted.join("\n")); + } throw lastError ?? new Error("No skill file candidates"); }; @@ -112,22 +195,42 @@ export function SkillsView({ const handleOpenSkill = (skill: SkillOption) => { const { folderPath } = resolveSkillPaths(skill.path); - void expandHomePath(folderPath) - .then((expanded) => revealItemInDir(expanded)) + void expandSkillPathCandidates(folderPath, { + codexHome: skill.codexHome, + workspacePath: skill.workspacePath, + }) + .then(async (expandedCandidates) => { + for (const expanded of expandedCandidates) { + try { + await revealItemInDir(expanded); + return; + } catch { + // Try next candidate. + } + } + throw new Error("No folder candidates"); + }) .catch(() => { - pushErrorToast({ - title: "Could not open skill", - message: "Failed to reveal the skill folder.", + pushErrorToast({ + title: "Could not open skill", + message: "Failed to reveal the skill folder.", + }); }); - }); }; const handleEditSkill = (skill: SkillOption) => { const candidates = buildSkillFileCandidates(skill); - void openFirstAvailablePath(candidates).catch(() => { + void openFirstAvailablePath(candidates, { + codexHome: skill.codexHome, + workspacePath: skill.workspacePath, + }).catch((error) => { + const details = + error instanceof Error && error.message + ? `\nPath: ${skill.path}\nTried:\n${error.message}` + : ""; pushErrorToast({ title: "Could not edit skill", - message: "Failed to open the skill file.", + message: `Failed to open the skill file.${details}`, }); }); }; diff --git a/src/features/skills/hooks/useSkills.ts b/src/features/skills/hooks/useSkills.ts index 37677b22f..d28de75bd 100644 --- a/src/features/skills/hooks/useSkills.ts +++ b/src/features/skills/hooks/useSkills.ts @@ -50,6 +50,8 @@ export function useSkills({ activeWorkspace, onDebug }: UseSkillsOptions) { name: String(item.name ?? ""), path: String(item.path ?? ""), description: item.description ? String(item.description) : undefined, + codexHome: activeWorkspace?.settings.codexHome ?? null, + workspacePath: activeWorkspace?.path ?? null, })); setSkills(data); lastFetchedWorkspaceId.current = workspaceId; diff --git a/src/types.ts b/src/types.ts index 66e23f697..62d99a3f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -469,6 +469,8 @@ export type SkillOption = { name: string; path: string; description?: string; + codexHome?: string | null; + workspacePath?: string | null; }; export type AppOption = { From 19a60bfa29b5df622d4bffe8ba34069f7ff6e0e8 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 11:04:09 +0200 Subject: [PATCH 6/8] fix: allow opener open_path --- src-tauri/capabilities/default.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b5a2e19df..eca791057 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:webview:allow-set-webview-zoom", "opener:default", + "opener:allow-open-path", "dialog:default", "process:default", "updater:default", From 1204ba0da4b4dcb20bc3e380a6560da0057abccb Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 11:09:48 +0200 Subject: [PATCH 7/8] fix: scope opener paths for skills --- src-tauri/capabilities/default.json | 9 ++++++++- src/features/settings/components/SkillsView.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index eca791057..55db97173 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,7 +7,14 @@ "core:default", "core:webview:allow-set-webview-zoom", "opener:default", - "opener:allow-open-path", + { + "identifier": "opener:allow-open-path", + "allow": [ + { + "path": "$HOME/**" + } + ] + }, "dialog:default", "process:default", "updater:default", diff --git a/src/features/settings/components/SkillsView.tsx b/src/features/settings/components/SkillsView.tsx index 7a7ff7045..af0a3e8b0 100644 --- a/src/features/settings/components/SkillsView.tsx +++ b/src/features/settings/components/SkillsView.tsx @@ -165,7 +165,14 @@ const openFirstAvailablePath = async ( } } if (attempted.length > 0) { - throw new Error(attempted.join("\n")); + const cause = + lastError instanceof Error + ? lastError.message + : lastError + ? String(lastError) + : ""; + const details = [cause, attempted.join("\n")].filter(Boolean).join("\n"); + throw new Error(details); } throw lastError ?? new Error("No skill file candidates"); }; From 895dc8a7d693c881218b50b2ac3d6d1d009ad79e Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 11:14:57 +0200 Subject: [PATCH 8/8] fix: allow opener access to codex skills --- src-tauri/capabilities/default.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 55db97173..535a0069c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,12 @@ "allow": [ { "path": "$HOME/**" + }, + { + "path": "$HOME/.codex/**" + }, + { + "path": "$HOME/**/.codex/**" } ] },