diff --git a/packages/agents/opencode/src/__tests__/opencode-agent.test.ts b/packages/agents/opencode/src/__tests__/opencode-agent.test.ts index 071eb5a..6e2b80c 100644 --- a/packages/agents/opencode/src/__tests__/opencode-agent.test.ts +++ b/packages/agents/opencode/src/__tests__/opencode-agent.test.ts @@ -46,6 +46,7 @@ function createMockSdkClient() { }, app: { agents: vi.fn().mockResolvedValue({ data: [] }), + skills: vi.fn().mockResolvedValue({ data: [] }), }, mcp: { status: vi.fn().mockResolvedValue({ data: {} }), @@ -366,6 +367,17 @@ describe("OpenCodeAgent", () => { expect(call.parts[1]).toEqual({ type: "agent", name: "code-reviewer" }); }); + it("should prepend synthetic skill command when skill is provided", async () => { + await agent.connect(); + + await agent.sendMessage("sess-1", "Hello", { skill: "coding-guidelines" } as never); + + const call = mockClient.session.promptAsync.mock.calls[0][0]; + expect(call.parts).toHaveLength(2); + expect(call.parts[0]).toEqual({ type: "text", text: "/coding-guidelines", synthetic: true }); + expect(call.parts[1]).toEqual({ type: "text", text: "Hello" }); + }); + it("should include all parts when files and agent are provided", async () => { await agent.connect(); agent.workspaceFolder = "/ws"; @@ -684,6 +696,22 @@ describe("OpenCodeAgent", () => { }); }); + describe("getSkills()", () => { + it("should call app.skills() and return mapped skills", async () => { + mockClient.app.skills.mockResolvedValue({ + data: [{ name: "coding-guidelines", description: "desc", location: "/skills/coding-guidelines" }], + }); + await agent.connect(); + + const result = await agent.getSkills(); + + expect(mockClient.app.skills).toHaveBeenCalled(); + expect(result).toEqual([ + { name: "coding-guidelines", description: "desc", location: "/skills/coding-guidelines" }, + ]); + }); + }); + // ============================================================ // Config API // ============================================================ diff --git a/packages/agents/opencode/src/mappers.ts b/packages/agents/opencode/src/mappers.ts index b395dcd..750dd29 100644 --- a/packages/agents/opencode/src/mappers.ts +++ b/packages/agents/opencode/src/mappers.ts @@ -32,6 +32,7 @@ import type { McpStatus, MessagePart, ProviderInfo, + SkillInfo, TodoItem, ToolListItem, } from "@opencodegui/core"; @@ -129,6 +130,14 @@ export function mapAgents(agents: Agent[]): AgentInfo[] { return agents.map(mapAgent); } +export function mapSkills(skills: Array<{ name: string; description: string; location: string }>): SkillInfo[] { + return skills.map((skill) => ({ + name: skill.name, + description: skill.description, + location: skill.location, + })); +} + // ============================================================ // Config // ============================================================ diff --git a/packages/agents/opencode/src/opencode-agent.ts b/packages/agents/opencode/src/opencode-agent.ts index fcf61ac..9740369 100644 --- a/packages/agents/opencode/src/opencode-agent.ts +++ b/packages/agents/opencode/src/opencode-agent.ts @@ -29,6 +29,7 @@ import type { ProviderInfo, QuestionAnswer, SendMessageOptions, + SkillInfo, TodoItem, ToolListItem, } from "@opencodegui/core"; @@ -44,6 +45,7 @@ import { mapProviders, mapSession, mapSessions, + mapSkills, mapTodos, mapToolIds, } from "./mappers"; @@ -239,10 +241,16 @@ export class OpenCodeAgent implements IAgent { async sendMessage(sessionId: string, text: string, options?: SendMessageOptions): Promise { const client = this.requireClient(); const parts: Array< - | { type: "text"; text: string } + | { type: "text"; text: string; synthetic?: boolean } | { type: "file"; mime: string; url: string; filename: string } | { type: "agent"; name: string } - > = [{ type: "text", text }]; + > = []; + + if (options?.skill) { + parts.push({ type: "text", text: `/${options.skill}`, synthetic: true }); + } + + parts.push({ type: "text", text }); if (options?.files) { for (const file of options.files) { @@ -320,6 +328,12 @@ export class OpenCodeAgent implements IAgent { return mapAgents(response.data!); } + async getSkills(): Promise { + const client = this.requireClient(); + const response = await client.app.skills(); + return mapSkills(response.data!); + } + async getChildSessions(sessionId: string): Promise { const client = this.requireClient(); const response = await client.session.children({ diff --git a/packages/core/src/agent.interface.ts b/packages/core/src/agent.interface.ts index ba89c9f..f8e841e 100644 --- a/packages/core/src/agent.interface.ts +++ b/packages/core/src/agent.interface.ts @@ -22,6 +22,7 @@ import type { ProviderInfo, QuestionAnswer, SendMessageOptions, + SkillInfo, TodoItem, ToolListItem, } from "./domain"; @@ -100,6 +101,7 @@ export interface IAgent { // --- Agent list (capabilities.subAgent) --- getAgents(): Promise; + getSkills(): Promise; getChildSessions(sessionId: string): Promise; // --- Permissions (capabilities.permission) --- diff --git a/packages/core/src/domain.ts b/packages/core/src/domain.ts index 690e878..d825bb6 100644 --- a/packages/core/src/domain.ts +++ b/packages/core/src/domain.ts @@ -438,6 +438,12 @@ export type AgentInfo = { color?: string; }; +export type SkillInfo = { + name: string; + description?: string; + location?: string; +}; + // ============================================================ // App Config & Paths // ============================================================ @@ -460,6 +466,7 @@ export type SendMessageOptions = { files?: FileAttachment[]; agent?: string; primaryAgent?: string; + skill?: string; }; // ============================================================ diff --git a/packages/core/src/protocol.ts b/packages/core/src/protocol.ts index 141e29c..cd80c6b 100644 --- a/packages/core/src/protocol.ts +++ b/packages/core/src/protocol.ts @@ -20,6 +20,7 @@ import type { PermissionResponse, ProviderInfo, QuestionAnswer, + SkillInfo, TodoItem, } from "./domain"; @@ -59,6 +60,7 @@ export type UIToHostMessage = files?: FileAttachment[]; agent?: string; primaryAgent?: string; + skill?: string; } | { type: "editAndResend"; @@ -105,6 +107,7 @@ export type UIToHostMessage = | { type: "getSessionTodos"; sessionId: string } | { type: "getChildSessions"; sessionId: string } | { type: "getAgents" } + | { type: "getSkills" } // --- Model config --- | { type: "setModel"; model: string } @@ -193,6 +196,7 @@ export type HostToUIMessage = // --- Agent list --- | { type: "agents"; agents: AgentInfo[] } + | { type: "skills"; skills: SkillInfo[] } // --- Platform data --- | { type: "openEditors"; files: FileAttachment[] } diff --git a/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts b/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts index 27eb26d..de2bd9a 100644 --- a/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts +++ b/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts @@ -58,6 +58,7 @@ function createMockAgent(): { getProviders: vi.fn().mockResolvedValue({ providers: [], default: {} }), listAllProviders: vi.fn().mockResolvedValue({ all: [], default: {}, connected: [] }), getAgents: vi.fn().mockResolvedValue([]), + getSkills: vi.fn().mockResolvedValue([]), getChildSessions: vi.fn().mockResolvedValue([]), replyPermission: vi.fn().mockResolvedValue(undefined), getSessionDiff: vi.fn().mockResolvedValue([]), @@ -499,12 +500,14 @@ describe("ChatViewProvider", () => { model: { providerID: "anthropic", modelID: "claude-4" }, files: [{ filePath: "a.ts", fileName: "a.ts" }], agent: "reviewer", + skill: "coding-guidelines", }); expect(mockAgent.sendMessage).toHaveBeenCalledWith("sess-1", "Hello", { model: { providerID: "anthropic", modelID: "claude-4" }, files: [{ filePath: "a.ts", fileName: "a.ts" }], agent: "reviewer", + skill: "coding-guidelines", }); }); }); @@ -909,6 +912,18 @@ describe("ChatViewProvider", () => { }); }); + describe("getSkills", () => { + it("should send skills message", async () => { + const skills = [{ name: "coding-guidelines" }]; + mockAgent.getSkills.mockResolvedValue(skills as never); + + const { postMessage, sendMessage } = setupProvider(mockAgent); + await sendMessage({ type: "getSkills" }); + + expect(postMessage).toHaveBeenCalledWith({ type: "skills", skills }); + }); + }); + // ============================================================ // shareSession // ============================================================ diff --git a/packages/platforms/vscode/src/chat-view-provider.ts b/packages/platforms/vscode/src/chat-view-provider.ts index 6184516..0dc4f21 100644 --- a/packages/platforms/vscode/src/chat-view-provider.ts +++ b/packages/platforms/vscode/src/chat-view-provider.ts @@ -103,6 +103,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { files: message.files, agent: message.agent, primaryAgent: message.primaryAgent, + skill: message.skill, }); break; } @@ -266,6 +267,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: "agents", agents }); break; } + case "getSkills": { + const skills = await this.agent.getSkills(); + this.postMessage({ type: "skills", skills }); + break; + } case "shareSession": { const session = await this.agent.shareSession(message.sessionId); this.activeSession = session; diff --git a/packages/platforms/vscode/webview/App.tsx b/packages/platforms/vscode/webview/App.tsx index 7fc7671..1effc02 100644 --- a/packages/platforms/vscode/webview/App.tsx +++ b/packages/platforms/vscode/webview/App.tsx @@ -1,4 +1,4 @@ -import type { AgentEvent, AgentInfo, ChatSession, TodoItem } from "@opencodegui/core"; +import type { AgentEvent, AgentInfo, ChatSession, SkillInfo, TodoItem } from "@opencodegui/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { EmptyState } from "./components/molecules/EmptyState"; import { FileChangesHeader } from "./components/molecules/FileChangesHeader"; @@ -47,6 +47,7 @@ export function App() { const [todos, setTodos] = useState([]); const [childSessions, setChildSessions] = useState([]); const [agents, setAgents] = useState([]); + const [skills, setSkills] = useState([]); const [selectedPrimaryAgent, setSelectedPrimaryAgent] = useState(null); const [difitAvailable, setDifitAvailable] = useState(false); const [openCodePaths, setOpenCodePaths] = useState<{ @@ -208,6 +209,10 @@ export function App() { }); break; } + case "skills": { + setSkills(data.skills); + break; + } case "difitAvailable": { setDifitAvailable(data.available); break; @@ -218,6 +223,7 @@ export function App() { postMessage({ type: "ready" }); postMessage({ type: "getOpenEditors" }); postMessage({ type: "getAgents" }); + postMessage({ type: "getSkills" }); return () => window.removeEventListener("message", handler); }, [ session.activeSession?.id, @@ -236,7 +242,7 @@ export function App() { // Cross-cutting action handlers (span multiple hooks) const handleSend = useCallback( - (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string) => { + (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string, skill?: string) => { if (!session.activeSession) return; postMessage({ type: "sendMessage", @@ -246,6 +252,7 @@ export function App() { files: files.length > 0 ? files : undefined, agent, primaryAgent, + skill, }); }, [session.activeSession, prov.selectedModel], @@ -538,6 +545,7 @@ export function App() { soundSettings={sound.soundSettings} onSoundSettingChange={sound.handleSoundSettingChange} agents={agents} + skills={skills} /> )} diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx index d659e83..1aa2815 100644 --- a/packages/platforms/vscode/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx @@ -30,6 +30,10 @@ const defaultProps = { selectedAgent: null, onSelectAgent: vi.fn(), onClearAgent: vi.fn(), + skills: [] as any[], + selectedSkill: null, + onSelectSkill: vi.fn(), + onClearSkill: vi.fn(), isShellMode: false, onToggleShellMode: vi.fn(), onDisableShellMode: vi.fn(), @@ -105,13 +109,20 @@ describe("FileAttachmentBar", () => { expect(container.querySelector(".pickerDropdown")).toBeInTheDocument(); }); - // renders three sections: Files, Agents, Shell Mode - it("3 つのセクション(Files, Agents, Shell Mode)を表示すること", () => { + // renders four sections: Files, Agents, Skills, Shell Mode + it("4 つのセクション(Files, Agents, Skills, Shell Mode)を表示すること", () => { render( - , + , ); expect(screen.getByText("Files")).toBeInTheDocument(); expect(screen.getByText("Sub-agents")).toBeInTheDocument(); + expect(screen.getByText("Skills")).toBeInTheDocument(); expect(screen.getByText("Shell Mode")).toBeInTheDocument(); }); }); @@ -161,7 +172,7 @@ describe("FileAttachmentBar", () => { />, ); const disabledSections = container.querySelectorAll(".sectionDisabled"); - expect(disabledSections.length).toBe(2); + expect(disabledSections.length).toBe(3); }); // toggle track has toggleOn class diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/SkillPopup.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/SkillPopup.test.tsx new file mode 100644 index 0000000..e1c13b2 --- /dev/null +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/SkillPopup.test.tsx @@ -0,0 +1,51 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SkillPopup } from "../../../components/molecules/SkillPopup"; + +function createSkill(name: string, description?: string) { + return { + name, + description, + location: `/skills/${name}`, + }; +} + +describe("SkillPopup", () => { + context("スキル一覧がある場合", () => { + const skills = [createSkill("coding-guidelines", "Coding skill"), createSkill("manage-task-plan", "Task plan")]; + + it("スキル名を表示すること", () => { + const { container } = render( + , + ); + const titles = container.querySelectorAll(".title"); + expect(titles[0]?.textContent).toBe("coding-guidelines"); + expect(titles[1]?.textContent).toBe("manage-task-plan"); + }); + + it("クリックで onSelectSkill を呼ぶこと", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + , + ); + const items = container.querySelectorAll(".root > div"); + await user.click(items[0]!); + expect(onSelect).toHaveBeenCalledWith(skills[0]); + }); + }); + + context("focusedIndex が指定された場合", () => { + const skills = [createSkill("coding-guidelines", "Coding skill"), createSkill("manage-task-plan", "Task plan")]; + + it("対応するアイテムに data-focused 属性が付与されること", () => { + const { container } = render( + , + ); + const items = container.querySelectorAll("[data-focused]"); + expect(items).toHaveLength(1); + expect(items[0]?.getAttribute("data-focused")).toBe("true"); + }); + }); +}); diff --git a/packages/platforms/vscode/webview/__tests__/factories.ts b/packages/platforms/vscode/webview/__tests__/factories.ts index c58d106..50b9d5b 100644 --- a/packages/platforms/vscode/webview/__tests__/factories.ts +++ b/packages/platforms/vscode/webview/__tests__/factories.ts @@ -152,7 +152,7 @@ export function createAllProvidersData( connected: string[] = [], all: Array<{ id: string; name: string; models: Record }> = [], defaultModel: Record = {}, -) { +): any { return { connected, all: all.map((p) => ({ diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/01-initialization.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/01-initialization.test.tsx index 0c4e6ad..0158795 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/01-initialization.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/01-initialization.test.tsx @@ -1,6 +1,6 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { postMessage } from "../../vscode-api"; import { createAllProvidersData, createProvider, createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; @@ -22,6 +22,12 @@ describe("初期化", () => { expect(postMessage).toHaveBeenCalledWith({ type: "getOpenEditors" }); }); + + it("getSkills を送信すること", () => { + renderApp(); + + expect(postMessage).toHaveBeenCalledWith({ type: "getSkills" }); + }); }); // When sessions message is received diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/21-popup-tab-select.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/21-popup-tab-select.test.tsx index af3e5a5..33475aa 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/21-popup-tab-select.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/21-popup-tab-select.test.tsx @@ -27,6 +27,19 @@ const testAgents = [ }, ] as any; +const testSkills = [ + { + name: "coding-guidelines", + description: "Coding skill", + location: "/skills/coding-guidelines", + }, + { + name: "manage-task-plan", + description: "Task plan skill", + location: "/skills/manage-task-plan", + }, +] as any; + /** ファイル候補付きセットアップ */ async function setupWithFiles() { renderApp(); @@ -47,6 +60,7 @@ async function setupWithFiles() { ], }); await sendExtMessage({ type: "agents", agents: testAgents }); + await sendExtMessage({ type: "skills", skills: testSkills }); vi.mocked(postMessage).mockClear(); } @@ -259,4 +273,28 @@ describe("ポップアップの Tab 選択", () => { expect(popup?.querySelectorAll(":scope > div")[0]?.getAttribute("data-focused")).toBe("true"); }); }); + + context("/ ポップアップで Tab を押した場合", () => { + it("先頭のスキルにフォーカスが当たること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "/"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='skill-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[0]?.getAttribute("data-focused")).toBe("true"); + }); + }); + + context("/ ポップアップで Enter を押した場合", () => { + it("スキルが選択されポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "/"); + await user.keyboard("{Tab}"); + await user.keyboard("{Enter}"); + expect(screen.queryByTestId("skill-popup")).not.toBeInTheDocument(); + expect(screen.getByText("/coding-guidelines")).toBeInTheDocument(); + }); + }); }); diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/23-clip-context-menu.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/23-clip-context-menu.test.tsx index e023f01..da86304 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/23-clip-context-menu.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/23-clip-context-menu.test.tsx @@ -27,11 +27,20 @@ const testAgents = [ }, ] as any; +const testSkills = [ + { + name: "coding-guidelines", + description: "Code with project guidelines", + location: "/skills/coding-guidelines", + }, +] as any; + /** エージェント付きセットアップ */ async function setupWithAgents() { renderApp(); await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); await sendExtMessage({ type: "agents", agents: testAgents }); + await sendExtMessage({ type: "skills", skills: testSkills }); vi.mocked(postMessage).mockClear(); } @@ -40,6 +49,7 @@ async function setupWithFiles() { renderApp(); await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); await sendExtMessage({ type: "agents", agents: testAgents }); + await sendExtMessage({ type: "skills", skills: testSkills }); // openEditors はファイルピッカー表示時に使う await sendExtMessage({ type: "openEditors", @@ -64,13 +74,14 @@ describe("統合コンテキストメニュー", () => { await setupWithAgents(); }); - // shows file, agent, and shell sections - it("ファイル・エージェント・シェルモードの 3 セクションが表示されること", async () => { + // shows file, agent, skill, and shell sections + it("ファイル・エージェント・スキル・シェルモードの 4 セクションが表示されること", async () => { const user = userEvent.setup(); const clipButton = screen.getByTitle("Add context"); await user.click(clipButton); expect(screen.getByText("Files")).toBeInTheDocument(); expect(screen.getByText("Sub-agents")).toBeInTheDocument(); + expect(screen.getByText("Skills")).toBeInTheDocument(); expect(screen.getByText("Shell Mode")).toBeInTheDocument(); }); }); @@ -106,6 +117,34 @@ describe("統合コンテキストメニュー", () => { }); }); + context("統合メニューからスキルを選択した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + it("スキルチップが contextBar に表示されること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Add context")); + await user.click(screen.getByText("coding-guidelines")); + expect(screen.getByText("/coding-guidelines")).toBeInTheDocument(); + }); + + it("送信時に skill が含まれること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Add context")); + await user.click(screen.getByText("coding-guidelines")); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "Fix the bug{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "Fix the bug", + skill: "coding-guidelines", + }), + ); + }); + }); + // Toggling shell mode from the menu context("統合メニューでシェルモードを ON にした場合", () => { beforeEach(async () => { diff --git a/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css b/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css index e6ad51d..4985a73 100644 --- a/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css +++ b/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css @@ -268,3 +268,38 @@ .agentChipClear:hover { opacity: 1; } + +.skillChip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 6px; + font-size: 11px; + line-height: 1; + color: var(--vscode-terminal-ansiGreen, #98c379); + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-terminal-ansiGreen, #98c379); + border-radius: 4px; +} + +.skillChipName { + font-weight: 600; +} + +.skillChipClear { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: inherit; + opacity: 0.7; +} + +.skillChipClear:hover { + opacity: 1; +} diff --git a/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx b/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx index 62b0d47..8daf58c 100644 --- a/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx +++ b/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx @@ -1,9 +1,9 @@ -import type { AgentInfo } from "@opencodegui/core"; +import type { AgentInfo, SkillInfo } from "@opencodegui/core"; import { useLocale } from "../../../locales"; import { getFileIcon } from "../../../utils/file-icons"; import type { FileAttachment } from "../../../vscode-api"; import { IconButton } from "../../atoms/IconButton"; -import { AgentIcon, ClipIcon, CloseIcon, PlusIcon, TerminalIcon } from "../../atoms/icons"; +import { AgentIcon, ClipIcon, CloseIcon, GearIcon, PlusIcon, TerminalIcon } from "../../atoms/icons"; import { ListItem } from "../../atoms/ListItem"; import styles from "./FileAttachmentBar.module.css"; @@ -23,6 +23,10 @@ type Props = { selectedAgent: AgentInfo | null; onSelectAgent: (agent: AgentInfo) => void; onClearAgent: () => void; + skills: SkillInfo[]; + selectedSkill: SkillInfo | null; + onSelectSkill: (skill: SkillInfo) => void; + onClearSkill: () => void; isShellMode: boolean; onToggleShellMode: () => void; onDisableShellMode: () => void; @@ -44,6 +48,10 @@ export function FileAttachmentBar({ selectedAgent, onSelectAgent, onClearAgent, + skills, + selectedSkill, + onSelectSkill, + onClearSkill, isShellMode, onToggleShellMode, onDisableShellMode, @@ -116,6 +124,28 @@ export function FileAttachmentBar({ + {/* スキルセクション */} +
+
+
{t["input.section.skills"]}
+
+ {skills.length > 0 ? ( + skills.map((skill) => ( + } + onClick={() => onSelectSkill(skill)} + focused={selectedSkill?.name === skill.name} + /> + )) + ) : ( +
{t["input.noSkills"]}
+ )} +
+
+ {/* シェルモードセクション */}
{t["input.section.shell"]}
@@ -149,6 +179,15 @@ export function FileAttachmentBar({
)} + {selectedSkill && ( +
+ + /{selectedSkill.name} + +
+ )} {/* 添付されたファイルチップ (インライン) */} {attachedFiles.map((file) => { const ChipIcon = getFileIcon(file.fileName); diff --git a/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.module.css b/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.module.css new file mode 100644 index 0000000..f8ba5c0 --- /dev/null +++ b/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.module.css @@ -0,0 +1,21 @@ +.root { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + min-width: 240px; + max-width: 360px; + max-height: 200px; + overflow-y: auto; + background-color: var(--vscode-quickInput-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.empty { + padding: 8px 10px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} diff --git a/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.tsx b/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.tsx new file mode 100644 index 0000000..4b0efb8 --- /dev/null +++ b/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.tsx @@ -0,0 +1,35 @@ +import type { SkillInfo } from "@opencodegui/core"; +import { useLocale } from "../../../locales"; +import { GearIcon } from "../../atoms/icons"; +import { ListItem } from "../../atoms/ListItem"; +import styles from "./SkillPopup.module.css"; + +type Props = { + skills: SkillInfo[]; + onSelectSkill: (skill: SkillInfo) => void; + skillPopupRef: React.RefObject; + focusedIndex: number; +}; + +export function SkillPopup({ skills, onSelectSkill, skillPopupRef, focusedIndex }: Props) { + const t = useLocale(); + + return ( +
+ {skills.length > 0 ? ( + skills.map((skill, i) => ( + } + onClick={() => onSelectSkill(skill)} + focused={i === focusedIndex} + /> + )) + ) : ( +
{t["input.noSkills"]}
+ )} +
+ ); +} diff --git a/packages/platforms/vscode/webview/components/molecules/SkillPopup/index.ts b/packages/platforms/vscode/webview/components/molecules/SkillPopup/index.ts new file mode 100644 index 0000000..4a6f4b1 --- /dev/null +++ b/packages/platforms/vscode/webview/components/molecules/SkillPopup/index.ts @@ -0,0 +1 @@ +export { SkillPopup } from "./SkillPopup"; diff --git a/packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx b/packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx index b46ce04..3607753 100644 --- a/packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx +++ b/packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx @@ -1,4 +1,11 @@ -import type { AgentInfo, ProviderInfo, SoundEventSetting, SoundEventType, SoundSettings } from "@opencodegui/core"; +import type { + AgentInfo, + ProviderInfo, + SkillInfo, + SoundEventSetting, + SoundEventType, + SoundSettings, +} from "@opencodegui/core"; import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; import { useClickOutside } from "../../../hooks/useClickOutside"; import { useInputHistory } from "../../../hooks/useInputHistory"; @@ -14,11 +21,12 @@ import { AgentSelector } from "../../molecules/AgentSelector"; import { FileAttachmentBar } from "../../molecules/FileAttachmentBar"; import { HashFilePopup } from "../../molecules/HashFilePopup"; import { ModelSelector } from "../../molecules/ModelSelector"; +import { SkillPopup } from "../../molecules/SkillPopup"; import { ToolConfigPanel } from "../../organisms/ToolConfigPanel"; import styles from "./InputArea.module.css"; type Props = { - onSend: (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string) => void; + onSend: (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string, skill?: string) => void; onShellExecute: (command: string) => void; onAbort: () => void; isBusy: boolean; @@ -41,6 +49,7 @@ type Props = { soundSettings: SoundSettings; onSoundSettingChange: (eventType: SoundEventType, setting: Partial) => void; agents: AgentInfo[]; + skills: SkillInfo[]; }; export function InputArea({ @@ -67,6 +76,7 @@ export function InputArea({ soundSettings, onSoundSettingChange, agents, + skills, }: Props) { const t = useLocale(); const [text, setText] = useState(""); @@ -85,16 +95,24 @@ export function InputArea({ startIndex: -1, }); const [atQuery, setAtQuery] = useState(""); + const [slashTrigger, setSlashTrigger] = useState<{ active: boolean; startIndex: number }>({ + active: false, + startIndex: -1, + }); + const [slashQuery, setSlashQuery] = useState(""); const [selectedAgent, setSelectedAgent] = useState(null); + const [selectedSkill, setSelectedSkill] = useState(null); const [isShellMode, setIsShellMode] = useState(false); // ポップアップ内のフォーカス位置(-1 = フォーカスなし) const [hashFocusedIndex, setHashFocusedIndex] = useState(-1); const [atFocusedIndex, setAtFocusedIndex] = useState(-1); + const [slashFocusedIndex, setSlashFocusedIndex] = useState(-1); const textareaRef = useRef(null); const composingRef = useRef(false); const filePickerRef = useRef(null); const hashPopupRef = useRef(null); const agentPopupRef = useRef(null); + const skillPopupRef = useRef(null); const inputHistory = useInputHistory(); // 履歴テキスト適用時は onChange が走るが resetNavigation を呼ばないようにするフラグ const applyingHistoryRef = useRef(false); @@ -183,6 +201,16 @@ export function InputArea({ atTrigger.active, ); + useClickOutside( + [skillPopupRef, textareaRef], + () => { + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + }, + slashTrigger.active, + ); + // # トリガー: ワークスペースファイルを検索する useEffect(() => { if (hashTrigger.active) { @@ -195,6 +223,7 @@ export function InputArea({ setIsShellMode(true); setAttachedFiles([]); setSelectedAgent(null); + setSelectedSkill(null); }, []); // シェルモード OFF @@ -237,6 +266,29 @@ export function InputArea({ setSelectedAgent(null); }, []); + const selectSkill = useCallback( + (skill: SkillInfo) => { + setSelectedSkill(skill); + setIsShellMode(false); + if (slashTrigger.active) { + setText((prev) => { + const before = prev.slice(0, slashTrigger.startIndex); + const after = prev.slice(slashTrigger.startIndex + 1 + slashQuery.length); + return before + after; + }); + } + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + textareaRef.current?.focus(); + }, + [slashQuery, slashTrigger], + ); + + const clearSkill = useCallback(() => { + setSelectedSkill(null); + }, []); + const handleSend = useCallback(() => { const trimmed = text.trim(); if (!trimmed) return; @@ -245,11 +297,12 @@ export function InputArea({ if (isShellMode) { onShellExecute(trimmed); } else { - onSend(trimmed, attachedFiles, selectedAgent?.name, selectedPrimaryAgent ?? undefined); + onSend(trimmed, attachedFiles, selectedAgent?.name, selectedPrimaryAgent ?? undefined, selectedSkill?.name); } setText(""); setAttachedFiles([]); setSelectedAgent(null); + setSelectedSkill(null); setIsShellMode(false); if (textareaRef.current) { textareaRef.current.style.height = "auto"; @@ -260,6 +313,7 @@ export function InputArea({ onSend, onShellExecute, selectedAgent?.name, + selectedSkill?.name, isShellMode, inputHistory, selectedPrimaryAgent, @@ -284,6 +338,9 @@ export function InputArea({ const filteredAgents = atQuery ? subagents.filter((a) => a.name.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 10) : subagents.slice(0, 10); + const filteredSkills = slashQuery + ? skills.filter((skill) => skill.name.toLowerCase().includes(slashQuery.toLowerCase())).slice(0, 10) + : skills.slice(0, 10); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -301,6 +358,12 @@ export function InputArea({ setAtFocusedIndex(-1); return; } + if (e.key === "Escape" && slashTrigger.active) { + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + return; + } // # ポップアップ表示中の Tab / ↑ / ↓ / Enter ナビゲーション if (hashTrigger.active && hashFiles.length > 0) { @@ -346,6 +409,24 @@ export function InputArea({ } } + if (slashTrigger.active && filteredSkills.length > 0) { + if (e.key === "Tab" || e.key === "ArrowDown") { + e.preventDefault(); + setSlashFocusedIndex((prev) => (prev + 1) % filteredSkills.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSlashFocusedIndex((prev) => (prev <= 0 ? filteredSkills.length - 1 : prev - 1)); + return; + } + if (e.key === "Enter" && !e.shiftKey && !composingRef.current && slashFocusedIndex >= 0) { + e.preventDefault(); + selectSkill(filteredSkills[slashFocusedIndex]); + return; + } + } + // ArrowUp: カーソルが先頭行にあるとき履歴を遡る if (e.key === "ArrowUp" && !composingRef.current) { const el = textareaRef.current; @@ -417,6 +498,11 @@ export function InputArea({ setAtQuery(""); setAtFocusedIndex(-1); } + if (slashTrigger.active) { + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + } handleSend(); } }, @@ -427,10 +513,14 @@ export function InputArea({ atTrigger.active, hashFocusedIndex, atFocusedIndex, + slashTrigger.active, + slashFocusedIndex, hashFiles, filteredAgents, + filteredSkills, addFile, selectAgent, + selectSkill, text, inputHistory, ], @@ -478,6 +568,14 @@ export function InputArea({ setAtQuery(""); return; } + if ( + addedChar === "/" && + (cursorPos === 1 || newText[cursorPos - 2] === " " || newText[cursorPos - 2] === "\n") + ) { + setSlashTrigger({ active: true, startIndex: cursorPos - 1 }); + setSlashQuery(""); + return; + } } // # トリガーがアクティブなら、クエリを更新する @@ -508,8 +606,20 @@ export function InputArea({ setAtFocusedIndex(-1); } } + + if (slashTrigger.active) { + const queryPart = newText.slice(slashTrigger.startIndex + 1, cursorPos); + if (/\s/.test(queryPart) || cursorPos <= slashTrigger.startIndex) { + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + } else { + setSlashQuery(queryPart); + setSlashFocusedIndex(-1); + } + } }, - [text, hashTrigger, atTrigger, isShellMode, enableShellMode, inputHistory], + [text, hashTrigger, atTrigger, slashTrigger, isShellMode, enableShellMode, inputHistory], ); const handleInput = useCallback(() => { @@ -552,6 +662,10 @@ export function InputArea({ selectedAgent={selectedAgent} onSelectAgent={selectAgent} onClearAgent={clearAgent} + skills={skills} + selectedSkill={selectedSkill} + onSelectSkill={selectSkill} + onClearSkill={clearSkill} isShellMode={isShellMode} onToggleShellMode={toggleShellMode} onDisableShellMode={disableShellMode} @@ -595,6 +709,14 @@ export function InputArea({ focusedIndex={atFocusedIndex} /> )} + {slashTrigger.active && ( + + )}
diff --git a/packages/platforms/vscode/webview/locales/en.ts b/packages/platforms/vscode/webview/locales/en.ts index 7803f9c..d9499c0 100644 --- a/packages/platforms/vscode/webview/locales/en.ts +++ b/packages/platforms/vscode/webview/locales/en.ts @@ -143,10 +143,12 @@ export const en = { // Context menu sections "input.section.files": "Files", "input.section.agents": "Sub-agents", + "input.section.skills": "Skills", "input.section.shell": "Shell Mode", // AgentMention "input.noAgents": "No sub-agents available", + "input.noSkills": "No skills available", } as const; export type LocaleSchema = { diff --git a/packages/platforms/vscode/webview/locales/ja.ts b/packages/platforms/vscode/webview/locales/ja.ts index 772e384..51399e3 100644 --- a/packages/platforms/vscode/webview/locales/ja.ts +++ b/packages/platforms/vscode/webview/locales/ja.ts @@ -145,8 +145,10 @@ export const ja: LocaleSchema = { // Context menu sections "input.section.files": "ファイル", "input.section.agents": "サブエージェント", + "input.section.skills": "スキル", "input.section.shell": "シェルモード", // AgentMention "input.noAgents": "利用可能なサブエージェントがありません", + "input.noSkills": "利用可能なスキルがありません", };