Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions docs/ux/UX_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
4 changes: 4 additions & 0 deletions docs/ux/UX_MANIFEST_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
86 changes: 86 additions & 0 deletions src/bun/__tests__/skills-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
16 changes: 15 additions & 1 deletion src/bun/rpc-handlers/app-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<AgentSkillInfo[]> {
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 {
Expand Down Expand Up @@ -774,6 +787,7 @@ export const appHandlers = {
getProjects,
reorderProjects,
listDirectory,
listAgentSkills,
addProject: addProjectImpl,
cloneAndAddProject,
createDirectory,
Expand Down
88 changes: 88 additions & 0 deletions src/bun/skills-catalog.ts
Original file line number Diff line number Diff line change
@@ -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>/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<string, AgentSkillInfo>();

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" }),
);
}
1 change: 1 addition & 0 deletions src/mainview/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ vi.mock("../rpc", () => ({
}),
moveTask: vi.fn().mockResolvedValue({}),
dismissMergeCompletionPrompt: vi.fn().mockResolvedValue(undefined),
listAgentSkills: vi.fn().mockResolvedValue([]),
},
},
}));
Expand Down
25 changes: 22 additions & 3 deletions src/mainview/components/CreateTaskModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -325,8 +331,13 @@ function CreateTaskModal({ project, dispatch, onClose, onCreateAndRun }: CreateT
<textarea
ref={textareaRef}
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={(e) => {
setDescription(e.target.value);
skillAutocomplete.sync();
}}
onSelect={skillAutocomplete.sync}
onKeyDown={(e) => {
if (skillAutocomplete.handleKeyDown(e)) return;
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey && onCreateAndRun) {
handleCreateAndRun();
Expand All @@ -340,6 +351,14 @@ function CreateTaskModal({ project, dispatch, onClose, onCreateAndRun }: CreateT
rows={4}
className="w-full px-3 py-2.5 bg-elevated border border-edge-active rounded-xl text-fg text-sm placeholder-fg-muted outline-none focus:border-accent/50 transition-colors resize-y min-h-[5rem] max-h-[18.75rem]"
/>
{skillAutocomplete.open && (
<SkillAutocompleteDropdown
items={skillAutocomplete.items}
activeIndex={skillAutocomplete.activeIndex}
onHover={skillAutocomplete.setActiveIndex}
onSelect={skillAutocomplete.accept}
/>
)}
</div>
{isPasting && (
<span className="text-[0.6875rem] text-accent animate-pulse">{t("images.pasting")}</span>
Expand Down
45 changes: 45 additions & 0 deletions src/mainview/components/SkillAutocompleteDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { AgentSkillInfo } from "../../shared/types";

interface SkillAutocompleteDropdownProps {
items: AgentSkillInfo[];
activeIndex: number;
onHover: (index: number) => void;
onSelect: (skill: AgentSkillInfo) => void;
}

/** Suggestion list for the "/" skill autocomplete, anchored under a textarea. */
function SkillAutocompleteDropdown({ items, activeIndex, onHover, onSelect }: SkillAutocompleteDropdownProps) {
return (
<div
role="listbox"
aria-label="Skill suggestions"
data-skill-autocomplete="true"
className="absolute top-full left-0 right-0 mt-1 z-20 max-h-56 overflow-y-auto bg-overlay border border-edge rounded-xl shadow-2xl py-1"
>
{items.map((skill, index) => (
<button
key={`${skill.source}:${skill.name}`}
type="button"
role="option"
aria-selected={index === activeIndex}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
// preventDefault keeps focus in the textarea while selecting.
e.preventDefault();
onSelect(skill);
}}
className={`w-full flex items-baseline gap-2 px-3 py-1.5 text-left text-sm transition-colors ${
index === activeIndex ? "bg-accent/15 text-fg" : "text-fg-2 hover:bg-elevated-hover"
}`}
>
<span className="font-medium text-fg shrink-0">/{skill.name}</span>
{skill.description && (
<span className="text-fg-muted text-xs truncate">{skill.description}</span>
)}
</button>
))}
</div>
);
}

export default SkillAutocompleteDropdown;
Loading
Loading