diff --git a/change-logs/2026/06/11/feature-skill-autocomplete-task-description.md b/change-logs/2026/06/11/feature-skill-autocomplete-task-description.md new file mode 100644 index 00000000..cf746946 --- /dev/null +++ b/change-logs/2026/06/11/feature-skill-autocomplete-task-description.md @@ -0,0 +1 @@ +Typing "/" in the new-task description now opens an autocomplete dropdown with agent skills discovered in the global skill directories (~/.agents/skills, ~/.claude/skills, ~/.codex/skills). Arrow keys navigate, Enter/Tab or click inserts the skill name, Escape dismisses the list without closing the modal. diff --git a/docs/ux/UX_DECISIONS.md b/docs/ux/UX_DECISIONS.md index 0426ce6f..152c8b00 100644 --- a/docs/ux/UX_DECISIONS.md +++ b/docs/ux/UX_DECISIONS.md @@ -61,3 +61,9 @@ Append-only log of UX architecture decisions. Each entry: date, decision, ration - **Decision:** Below a `matchMedia("(max-width: 1600px)")` breakpoint (new `useCompact()` hook), the `GlobalHeader` action cluster and the `TaskInfoPanel` toolbar switch to a compact layout: text labels collapse to icon-only (tooltips kept), and the header's three low-frequency external actions (Website, Report, Change Log) fold into a single "More" (`⋯`) overflow dropdown. Diff badge and status stay labelled. Above the breakpoint the layout is unchanged. - **Rationale:** On a 14" MacBook (≤1512pt) the labelled, `flex-shrink-0` button rows overflowed and overlapped; on 16" (1728pt) they fit. 1600px cleanly separates the two at default scaling and also fires on window resize. Per the action taxonomy, the rare external links are the correct overflow candidates; frequent/destination controls stay visible as icons. Viewport-based v1; a content-aware (ResizeObserver) upgrade is the planned v2 since a long breadcrumb title can still crowd the header near the boundary. No flex-wrap (vertical space is scarce in a terminal-centric app). - **Status:** `Observed` (implemented: `useCompact.ts`, `GlobalHeader.tsx`, `TaskInfoPanel.tsx`, `PreventSleepToggle.tsx`, `GitPullButton.tsx`; keys `header.moreActions`/`header.githubLabel` in en/ru/es). See decision record 063. + +## 2026-06-11 — Slash skill autocomplete in the new-task description + +- **Decision:** Typing `/` at a word boundary in the `CreateTaskModal` description textarea opens an inline suggestion dropdown (Popover-style, anchored under the textarea inside its existing relative wrapper) listing globally installed agent skills, fetched once per modal mount via the new `listAgentSkills` RPC (scans `~/.agents/skills`, `~/.claude/skills`, `~/.codex/skills` for `*/SKILL.md`, dedup by name). ArrowUp/Down navigate, Enter/Tab/click insert `/skill-name `, Escape dismisses only the dropdown (modal Escape handler checks the autocomplete first). No new visible buttons — input-assist only, so no surface budget is consumed. Tokens: `bg-overlay border-edge` container, `bg-accent/15` active row, `text-fg-muted` descriptions. +- **Rationale:** Task descriptions are agent prompts; users invoke skills by `/name` and should not have to remember exact slugs. An inline completer is the zero-chrome placement; a dedicated "insert skill" button would feed the toolbar-creep anti-pattern. Caret-anchored positioning was rejected as needless complexity for a 4-row textarea. +- **Status:** `Observed` (implemented: `src/bun/skills-catalog.ts`, `listAgentSkills` in `app-handlers.ts`, `useSkillAutocomplete.ts`, `SkillAutocompleteDropdown.tsx`, wired in `CreateTaskModal.tsx`). diff --git a/docs/ux/UX_MANIFEST_CHANGELOG.md b/docs/ux/UX_MANIFEST_CHANGELOG.md index 45335632..33672e73 100644 --- a/docs/ux/UX_MANIFEST_CHANGELOG.md +++ b/docs/ux/UX_MANIFEST_CHANGELOG.md @@ -26,3 +26,7 @@ Documented the inspector header as a 2×2 quickbar grid (Context / Session-Agent ## 2026-06-03 — macOS dock-persistence + unified quit-confirmation modal Added a UX decision documenting `exitOnLastWindowClosed: false` (closing the last window keeps the app in the dock, reopened on dock-click) and the React quit-confirmation modal driven by the main-process `before-quit` gate, covering Cmd+Q (via `requestQuit`), menu Quit, and dock Quit. A window-less quit reopens a window that pulls the pending flag on mount to show the dialog reliably. Plus the Cmd+Shift+N New Window shortcut. No new visible buttons or tokens — conforms to the Modal surface and destructive-button-role policy. Decision records 044, 060, 061. + +## 2026-06-11 — Slash skill autocomplete (new-task description) + +Added a UX decision for the inline `/`-triggered skill-name autocomplete in the `CreateTaskModal` description textarea, backed by the `listAgentSkills` RPC over the global agent skill directories. Input-assist pattern: no new visible controls, conforms to Modal surface rules and the token policy. diff --git a/src/bun/__tests__/skills-catalog.test.ts b/src/bun/__tests__/skills-catalog.test.ts new file mode 100644 index 00000000..6404a1f8 --- /dev/null +++ b/src/bun/__tests__/skills-catalog.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listAgentSkills, parseSkillFrontmatter } from "../skills-catalog"; + +describe("parseSkillFrontmatter", () => { + it("parses plain name and description", () => { + const md = "---\nname: dev3\ndescription: Manage dev3 tasks\n---\n\n# Body"; + expect(parseSkillFrontmatter(md)).toEqual({ name: "dev3", description: "Manage dev3 tasks" }); + }); + + it("strips surrounding quotes", () => { + const md = '---\nname: "dev3"\ndescription: \'Quoted desc\'\n---\n'; + expect(parseSkillFrontmatter(md)).toEqual({ name: "dev3", description: "Quoted desc" }); + }); + + it("joins block-scalar descriptions", () => { + const md = "---\nname: foo\ndescription: >-\n Line one\n line two\nallowed-tools: Bash\n---\n"; + expect(parseSkillFrontmatter(md)).toEqual({ name: "foo", description: "Line one line two" }); + }); + + it("returns nulls without frontmatter", () => { + expect(parseSkillFrontmatter("# Just markdown")).toEqual({ name: null, description: null }); + }); + + it("returns nulls for unterminated frontmatter", () => { + expect(parseSkillFrontmatter("---\nname: x\n")).toEqual({ name: null, description: null }); + }); +}); + +describe("listAgentSkills", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "dev3-skills-test-")); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + function addSkill(dir: string, slug: string, frontmatter: string | null) { + const skillDir = join(home, dir, slug); + mkdirSync(skillDir, { recursive: true }); + if (frontmatter !== null) { + writeFileSync(join(skillDir, "SKILL.md"), frontmatter); + } + } + + it("returns empty when no skill directories exist", () => { + expect(listAgentSkills(home)).toEqual([]); + }); + + it("collects skills from all three sources, sorted by name", () => { + addSkill(".agents/skills", "zeta", "---\nname: zeta\ndescription: Z\n---\n"); + addSkill(".claude/skills", "alpha", "---\nname: alpha\ndescription: A\n---\n"); + addSkill(".codex/skills", "mid", "---\nname: mid\ndescription: M\n---\n"); + expect(listAgentSkills(home)).toEqual([ + { name: "alpha", description: "A", source: "claude" }, + { name: "mid", description: "M", source: "codex" }, + { name: "zeta", description: "Z", source: "agents" }, + ]); + }); + + it("dedupes by name with agents dir taking priority", () => { + addSkill(".agents/skills", "dev3", "---\nname: dev3\ndescription: from agents\n---\n"); + addSkill(".claude/skills", "dev3", "---\nname: dev3\ndescription: from claude\n---\n"); + const result = listAgentSkills(home); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ name: "dev3", description: "from agents", source: "agents" }); + }); + + it("falls back to the directory name when frontmatter has no name", () => { + addSkill(".claude/skills", "my-skill", "# No frontmatter here"); + expect(listAgentSkills(home)).toEqual([{ name: "my-skill", description: "", source: "claude" }]); + }); + + it("skips directories without SKILL.md and hidden entries", () => { + addSkill(".claude/skills", "real", "---\nname: real\ndescription: ok\n---\n"); + addSkill(".claude/skills", "empty-dir", null); + addSkill(".claude/skills", ".hidden", "---\nname: hidden\n---\n"); + const result = listAgentSkills(home); + expect(result.map((s) => s.name)).toEqual(["real"]); + }); +}); diff --git a/src/bun/rpc-handlers/app-handlers.ts b/src/bun/rpc-handlers/app-handlers.ts index 088f3451..970b0a53 100644 --- a/src/bun/rpc-handlers/app-handlers.ts +++ b/src/bun/rpc-handlers/app-handlers.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "nod import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import { PATHS, Utils } from "../electrobun-platform"; -import type { ChangelogEntry, ExternalApp, FolderEntry, FolderListing, Project, TipState } from "../../shared/types"; +import type { AgentSkillInfo, ChangelogEntry, ExternalApp, FolderEntry, FolderListing, Project, TipState } from "../../shared/types"; import { DEFAULT_EXTERNAL_APPS, STUCK_PREPARATION_FETCH_THRESHOLD_MS, extractRepoName } from "../../shared/types"; import * as data from "../data"; import * as git from "../git"; @@ -12,6 +12,7 @@ import { consumeQuitDialogPending, markQuitConfirmed } from "../quit-manager"; import { BUNDLED_CHANGELOG } from "../changelog-bundled"; import * as repoConfig from "../repo-config"; import { DEV3_HOME } from "../paths"; +import { listAgentSkills as scanAgentSkills } from "../skills-catalog"; import { spawn } from "../spawn"; import { writeSystemClipboard } from "../system-clipboard"; import { getUploadedImageExtension, hideAppNative, log, logRendererError, logRendererEvent, setAppForeground } from "./shared"; @@ -186,6 +187,18 @@ async function listDirectory(params?: { path?: string | null; includeFiles?: boo } } +/** List skills found in the global agent skill directories for autocomplete. */ +async function listAgentSkills(): Promise { + try { + const skills = scanAgentSkills(); + log.info("← listAgentSkills", { count: skills.length }); + return skills; + } catch (err) { + log.error("listAgentSkills failed", { error: String(err) }); + return []; + } +} + async function addProjectImpl(params: { path: string; name: string }): Promise<{ ok: true; project: Project } | { ok: false; error: string }> { log.info("→ addProject", params); try { @@ -774,6 +787,7 @@ export const appHandlers = { getProjects, reorderProjects, listDirectory, + listAgentSkills, addProject: addProjectImpl, cloneAndAddProject, createDirectory, diff --git a/src/bun/skills-catalog.ts b/src/bun/skills-catalog.ts new file mode 100644 index 00000000..00c4882d --- /dev/null +++ b/src/bun/skills-catalog.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { AgentSkillInfo } from "../shared/types"; + +const SOURCE_DIRS: Array<{ dir: string; source: AgentSkillInfo["source"] }> = [ + { dir: ".agents/skills", source: "agents" }, + { dir: ".claude/skills", source: "claude" }, + { dir: ".codex/skills", source: "codex" }, +]; + +/** + * Extract `name` and `description` from a SKILL.md YAML frontmatter block. + * Deliberately not a full YAML parser — handles plain scalars, quoted + * scalars, and block scalars (`|`, `>`, with optional chomping) which cover + * every SKILL.md in the wild. Returns nulls for absent fields. + */ +export function parseSkillFrontmatter(content: string): { name: string | null; description: string | null } { + const result: { name: string | null; description: string | null } = { name: null, description: null }; + if (!content.startsWith("---")) return result; + const end = content.indexOf("\n---", 3); + if (end === -1) return result; + const lines = content.slice(content.indexOf("\n") + 1, end).split("\n"); + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^(name|description):\s*(.*)$/); + if (!match) continue; + const key = match[1] as "name" | "description"; + let value = match[2].trim(); + if (/^[|>][+-]?$/.test(value)) { + // Block scalar: collect the following more-indented lines. + const block: string[] = []; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].trim() === "") continue; + if (!/^\s/.test(lines[j])) break; + block.push(lines[j].trim()); + } + value = block.join(" "); + } else if ( + (value.startsWith('"') && value.endsWith('"') && value.length >= 2) + || (value.startsWith("'") && value.endsWith("'") && value.length >= 2) + ) { + value = value.slice(1, -1); + } + result[key] = value || null; + } + return result; +} + +/** + * Scan the global agent skill directories (`~/.agents/skills`, + * `~/.claude/skills`, `~/.codex/skills`) for `/SKILL.md` entries. + * Skills with the same name are deduplicated; the first source in the + * priority order above wins. Result is sorted by name. + */ +export function listAgentSkills(home: string = homedir()): AgentSkillInfo[] { + const byName = new Map(); + + for (const { dir, source } of SOURCE_DIRS) { + const root = join(home, dir); + if (!existsSync(root)) continue; + let entries: string[]; + try { + entries = readdirSync(root); + } catch { + continue; + } + for (const entry of entries) { + if (entry.startsWith(".")) continue; + const skillFile = join(root, entry, "SKILL.md"); + try { + if (!statSync(join(root, entry)).isDirectory()) continue; + if (!existsSync(skillFile)) continue; + const parsed = parseSkillFrontmatter(readFileSync(skillFile, "utf8")); + const name = parsed.name ?? entry; + if (byName.has(name)) continue; + byName.set(name, { name, description: parsed.description ?? "", source }); + } catch { + // Unreadable skill dir/file (permissions, broken symlink) — skip it. + continue; + } + } + } + + return [...byName.values()].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); +} diff --git a/src/mainview/__tests__/App.test.tsx b/src/mainview/__tests__/App.test.tsx index 9c23b61e..284c531a 100644 --- a/src/mainview/__tests__/App.test.tsx +++ b/src/mainview/__tests__/App.test.tsx @@ -29,6 +29,7 @@ vi.mock("../rpc", () => ({ }), moveTask: vi.fn().mockResolvedValue({}), dismissMergeCompletionPrompt: vi.fn().mockResolvedValue(undefined), + listAgentSkills: vi.fn().mockResolvedValue([]), }, }, })); diff --git a/src/mainview/components/CreateTaskModal.tsx b/src/mainview/components/CreateTaskModal.tsx index 23745864..cb591a01 100644 --- a/src/mainview/components/CreateTaskModal.tsx +++ b/src/mainview/components/CreateTaskModal.tsx @@ -10,8 +10,10 @@ import LabelChip from "./LabelChip"; import { ImageAttachmentsStrip } from "./ImageAttachmentsStrip"; import { useImagePaste } from "../hooks/useImagePaste"; import { useFileDrop } from "../hooks/useFileDrop"; +import { useSkillAutocomplete } from "../hooks/useSkillAutocomplete"; import { removeImagePath } from "../utils/imageAttachments"; import BranchSelector from "./BranchSelector"; +import SkillAutocompleteDropdown from "./SkillAutocompleteDropdown"; interface ProjectCurrentBranchInfo { branch: string | null; @@ -83,6 +85,8 @@ function CreateTaskModal({ project, dispatch, onClose, onCreateAndRun }: CreateT }); }, []); + const skillAutocomplete = useSkillAutocomplete(textareaRef, description, setDescription); + const { handlePaste, isPasting } = useImagePaste(project.id, insertPathAtCursor); const { handleDragOver, handleDragEnter, handleDragLeave, handleDrop, isDragging } = useFileDrop(project.id, insertPathAtCursor); @@ -150,7 +154,9 @@ function CreateTaskModal({ project, dispatch, onClose, onCreateAndRun }: CreateT function handleKey(e: KeyboardEvent) { if (e.key === "Escape") { e.stopImmediatePropagation(); - if (pendingBranchChoice) { + if (skillAutocomplete.open) { + skillAutocomplete.close(); + } else if (pendingBranchChoice) { setPendingBranchChoice(null); setPendingSubmitMode(null); } else if (confirmDiscard) { @@ -164,7 +170,7 @@ function CreateTaskModal({ project, dispatch, onClose, onCreateAndRun }: CreateT window.addEventListener("keydown", handleKey, true); return () => window.removeEventListener("keydown", handleKey, true); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [description, onClose, confirmDiscard, pendingBranchChoice]); + }, [description, onClose, confirmDiscard, pendingBranchChoice, skillAutocomplete.open, skillAutocomplete.close]); async function createTaskWithBranch(branch: string | null, mode: "save" | "run" | "scratch") { const trimmed = description.trim(); @@ -325,8 +331,13 @@ function CreateTaskModal({ project, dispatch, onClose, onCreateAndRun }: CreateT