diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b5a2e19df..535a0069c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,20 @@ "core:default", "core:webview:allow-set-webview-zoom", "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { + "path": "$HOME/**" + }, + { + "path": "$HOME/.codex/**" + }, + { + "path": "$HOME/**/.codex/**" + } + ] + }, "dialog:default", "process:default", "updater:default", 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/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/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/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; +} 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 = {