mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Support skill mentions in composer input
This commit is contained in:
parent
d08a46becf
commit
79bc1b458b
11 changed files with 192 additions and 18 deletions
|
|
@ -248,10 +248,10 @@ export class AppServerClient {
|
|||
});
|
||||
}
|
||||
|
||||
listSkills(cwd: string): Promise<SkillsListResponse> {
|
||||
listSkills(cwd: string, forceReload = false): Promise<SkillsListResponse> {
|
||||
return this.request("skills/list", {
|
||||
cwds: [cwd],
|
||||
forceReload: false,
|
||||
forceReload,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { SkillMetadata } from "../generated/app-server/v2/SkillMetadata";
|
||||
import type { UserInput } from "../generated/app-server/v2/UserInput";
|
||||
|
||||
export interface ParsedWikiLink {
|
||||
|
|
@ -10,6 +11,7 @@ export interface ParsedWikiLink {
|
|||
export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null;
|
||||
|
||||
const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g;
|
||||
const SKILL_REFERENCE_PATTERN = /(^|[\s([{])\$([^\s\])}.,;!?]{1,120})(?=$|[\s\])}.,;!?])/g;
|
||||
|
||||
export function parsedWikiLinks(text: string): ParsedWikiLink[] {
|
||||
const links: ParsedWikiLink[] = [];
|
||||
|
|
@ -27,6 +29,14 @@ export function parsedWikiLinks(text: string): ParsedWikiLink[] {
|
|||
}
|
||||
|
||||
export function userInputWithWikiLinkMentions(text: string, resolveMention: WikiLinkMentionResolver): UserInput[] {
|
||||
return userInputWithWikiLinkMentionsAndSkills(text, resolveMention, []);
|
||||
}
|
||||
|
||||
export function userInputWithWikiLinkMentionsAndSkills(
|
||||
text: string,
|
||||
resolveMention: WikiLinkMentionResolver,
|
||||
skills: SkillMetadata[],
|
||||
): UserInput[] {
|
||||
const input: UserInput[] = [{ type: "text", text, text_elements: [] }];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
|
|
@ -37,9 +47,31 @@ export function userInputWithWikiLinkMentions(text: string, resolveMention: Wiki
|
|||
input.push({ type: "mention", name: mention.name, path: mention.path });
|
||||
}
|
||||
|
||||
const skillByName = firstEnabledSkillByName(skills);
|
||||
const seenSkillPaths = new Set<string>();
|
||||
for (const reference of parsedSkillReferences(text)) {
|
||||
const skill = skillByName.get(reference.toLowerCase());
|
||||
if (!skill || seenSkillPaths.has(skill.path)) continue;
|
||||
seenSkillPaths.add(skill.path);
|
||||
input.push({ type: "skill", name: skill.name, path: skill.path });
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export function parsedSkillReferences(text: string): string[] {
|
||||
const references: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) {
|
||||
const name = match[2] ?? "";
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
references.push(name);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function parseWikiLink(raw: string): ParsedWikiLink | null {
|
||||
const separator = raw.indexOf("|");
|
||||
const linkPart = (separator === -1 ? raw : raw.slice(0, separator)).trim();
|
||||
|
|
@ -60,3 +92,13 @@ function firstSubpathIndex(linkPart: string): number {
|
|||
if (blockIndex === -1) return headingIndex;
|
||||
return Math.min(headingIndex, blockIndex);
|
||||
}
|
||||
|
||||
function firstEnabledSkillByName(skills: SkillMetadata[]): Map<string, SkillMetadata> {
|
||||
const byName = new Map<string, SkillMetadata>();
|
||||
for (const skill of skills) {
|
||||
if (!skill.enabled) continue;
|
||||
const key = skill.name.toLowerCase();
|
||||
if (!byName.has(key)) byName.set(key, skill);
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type ComposerSuggestion,
|
||||
type NoteCandidate,
|
||||
} from "../composer/suggestions";
|
||||
import { userInputWithWikiLinkMentions } from "../composer/wikilink-context";
|
||||
import { userInputWithWikiLinkMentionsAndSkills } from "../composer/wikilink-context";
|
||||
import type { UserInput } from "../generated/app-server/v2/UserInput";
|
||||
import type { SendShortcut } from "../settings/model";
|
||||
import { renderComposerShell, renderComposerSuggestions, syncComposerControls, syncComposerHeight } from "../ui/composer";
|
||||
|
|
@ -102,7 +102,11 @@ export class PanelComposerController {
|
|||
}
|
||||
|
||||
codexInput(text: string): UserInput[] {
|
||||
return userInputWithWikiLinkMentions(text, (target) => resolveAppWikiLinkMention(this.options.app, target));
|
||||
return userInputWithWikiLinkMentionsAndSkills(
|
||||
text,
|
||||
(target) => resolveAppWikiLinkMention(this.options.app, target),
|
||||
this.options.state.availableSkills,
|
||||
);
|
||||
}
|
||||
|
||||
private handleSuggestionKeydown(event: KeyboardEvent): boolean {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { clearUserInputDrafts, createApprovalResultItem, createUserInputResultIt
|
|||
|
||||
export interface PanelControllerActions {
|
||||
refreshThreads: () => void;
|
||||
refreshSkills: (forceReload?: boolean) => void;
|
||||
maybeNameThread: (threadId: string, turn: Turn) => void;
|
||||
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
|
||||
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
|
||||
|
|
@ -111,6 +112,8 @@ export class PanelController {
|
|||
this.state.tokenUsage = params.tokenUsage ?? null;
|
||||
} else if (method === "account/rateLimits/updated") {
|
||||
this.state.rateLimit = params.rateLimits ?? null;
|
||||
} else if (method === "skills/changed") {
|
||||
this.actions.refreshSkills(true);
|
||||
} else if (method === "hook/started") {
|
||||
this.upsertHookRun(params.run, params.turnId, "running");
|
||||
} else if (method === "hook/completed") {
|
||||
|
|
|
|||
|
|
@ -68,11 +68,11 @@ export class PanelSessionController {
|
|||
}
|
||||
}
|
||||
|
||||
async refreshSkills(): Promise<void> {
|
||||
async refreshSkills(forceReload = false): Promise<void> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
const response = await client.listSkills(this.host.vaultPath);
|
||||
const response = await client.listSkills(this.host.vaultPath, forceReload);
|
||||
this.host.state.availableSkills = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled);
|
||||
} catch (error) {
|
||||
this.host.state.availableSkills = [];
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ export class CodexPanelView extends ItemView {
|
|||
});
|
||||
this.controller = new PanelController(this.state, {
|
||||
refreshThreads: () => void this.refreshThreads(),
|
||||
refreshSkills: (forceReload) => void this.refreshSkills(forceReload),
|
||||
maybeNameThread: (threadId, turn) => this.threadRename.maybeAutoNameThread(threadId, turn),
|
||||
respondToServerRequest: (requestId, result) => this.respondToServerRequest(requestId, result),
|
||||
rejectServerRequest: (requestId, code, message) => this.rejectServerRequest(requestId, code, message),
|
||||
|
|
@ -274,6 +275,13 @@ export class CodexPanelView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
private async refreshSkills(forceReload = false): Promise<void> {
|
||||
this.client = this.connection.currentClient();
|
||||
if (!this.client) return;
|
||||
await this.session.refreshSkills(forceReload);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async resumeThread(threadId: string): Promise<void> {
|
||||
if (this.state.busy && threadId !== this.state.activeThreadId) {
|
||||
this.addSystemMessage("Finish or interrupt the current turn before switching threads.");
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function inputToText(content: UserInput[]): string {
|
|||
if (item.type === "localImage") return `[local image] ${item.path}`;
|
||||
if (item.type === "image") return `[image] ${item.url}`;
|
||||
if (item.type === "mention") return hasText ? "" : `[@${item.name}] ${item.path}`;
|
||||
if (item.type === "skill") return `[$${item.name}] ${item.path}`;
|
||||
if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
|
|
|||
|
|
@ -351,31 +351,40 @@ describe("AppServerClient", () => {
|
|||
transport.emitLine({ id: 5, result: { data: [] } });
|
||||
await skills;
|
||||
|
||||
const turns = client.threadTurnsList("thread-1", "cursor-1", 10);
|
||||
const reloadedSkills = client.listSkills("/vault", true);
|
||||
expect(transport.sent[6]).toMatchObject({
|
||||
id: 6,
|
||||
method: "thread/turns/list",
|
||||
params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" },
|
||||
method: "skills/list",
|
||||
params: { cwds: ["/vault"], forceReload: true },
|
||||
});
|
||||
transport.emitLine({ id: 6, result: { data: [], nextCursor: null } });
|
||||
await turns;
|
||||
transport.emitLine({ id: 6, result: { data: [] } });
|
||||
await reloadedSkills;
|
||||
|
||||
const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc");
|
||||
const turns = client.threadTurnsList("thread-1", "cursor-1", 10);
|
||||
expect(transport.sent[7]).toMatchObject({
|
||||
id: 7,
|
||||
method: "thread/turns/list",
|
||||
params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" },
|
||||
params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" },
|
||||
});
|
||||
transport.emitLine({ id: 7, result: { data: [], nextCursor: null } });
|
||||
await turns;
|
||||
|
||||
const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc");
|
||||
expect(transport.sent[8]).toMatchObject({
|
||||
id: 8,
|
||||
method: "thread/turns/list",
|
||||
params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" },
|
||||
});
|
||||
transport.emitLine({ id: 8, result: { data: [], nextCursor: null } });
|
||||
await firstTurn;
|
||||
|
||||
const rollback = client.rollbackThread("thread-1");
|
||||
expect(transport.sent[8]).toMatchObject({
|
||||
id: 8,
|
||||
expect(transport.sent[9]).toMatchObject({
|
||||
id: 9,
|
||||
method: "thread/rollback",
|
||||
params: { threadId: "thread-1", numTurns: 1 },
|
||||
});
|
||||
transport.emitLine({ id: 8, result: { thread: { id: "thread-1", turns: [] } } });
|
||||
transport.emitLine({ id: 9, result: { thread: { id: "thread-1", turns: [] } } });
|
||||
await rollback;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,22 @@ describe("display model", () => {
|
|||
expect(displayItemFromThreadItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world", markdown: true });
|
||||
});
|
||||
|
||||
it("keeps structured skill metadata out of displayed user text when a prompt body exists", () => {
|
||||
const item: ThreadItem = {
|
||||
type: "userMessage",
|
||||
id: "u1",
|
||||
content: [
|
||||
{ type: "text", text: "Use $obsidian-codex-panel-maintain.", text_elements: [] },
|
||||
{ type: "skill", name: "obsidian-codex-panel-maintain", path: "/skills/obsidian-codex-panel-maintain/SKILL.md" },
|
||||
],
|
||||
};
|
||||
|
||||
expect(displayItemFromThreadItem(item)).toMatchObject({
|
||||
text: "Use $obsidian-codex-panel-maintain.",
|
||||
copyText: "Use $obsidian-codex-panel-maintain.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves reasoning text", () => {
|
||||
const item: ThreadItem = { type: "reasoning", id: "r1", summary: ["summary"], content: ["detail"] };
|
||||
expect(displayItemFromThreadItem(item)?.text).toBe("summary\n\ndetail");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ function controllerForState(
|
|||
): PanelController {
|
||||
return new PanelController(state, {
|
||||
refreshThreads: vi.fn(),
|
||||
refreshSkills: vi.fn(),
|
||||
maybeNameThread: vi.fn(),
|
||||
respondToServerRequest: vi.fn(() => true),
|
||||
rejectServerRequest: vi.fn(() => true),
|
||||
|
|
@ -125,6 +126,18 @@ describe("PanelController", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("refreshes skills from disk when app-server reports skill changes", () => {
|
||||
const refreshSkills = vi.fn();
|
||||
const controller = controllerForState(createPanelState(), { refreshSkills });
|
||||
|
||||
controller.handleNotification({
|
||||
method: "skills/changed",
|
||||
params: {},
|
||||
} satisfies Extract<ServerNotification, { method: "skills/changed" }>);
|
||||
|
||||
expect(refreshSkills).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("stores the latest aggregated turn diff for the active turn", () => {
|
||||
const state = createPanelState();
|
||||
state.activeThreadId = "thread-active";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parsedWikiLinks, userInputWithWikiLinkMentions } from "../src/composer/wikilink-context";
|
||||
import {
|
||||
parsedSkillReferences,
|
||||
parsedWikiLinks,
|
||||
userInputWithWikiLinkMentions,
|
||||
userInputWithWikiLinkMentionsAndSkills,
|
||||
} from "../src/composer/wikilink-context";
|
||||
|
||||
describe("wikilink context", () => {
|
||||
it("parses aliases, subpaths, and duplicate links", () => {
|
||||
|
|
@ -34,4 +39,78 @@ describe("wikilink context", () => {
|
|||
{ type: "mention", name: "Alpha", path: "thoughts/Alpha.md" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses complete skill references", () => {
|
||||
expect(parsedSkillReferences("Use $obsidian-codex-panel-maintain and ($github:yeet), but not $missing.")).toEqual([
|
||||
"obsidian-codex-panel-maintain",
|
||||
"github:yeet",
|
||||
"missing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds resolved skill input without changing the visible prompt body", () => {
|
||||
const text = "Please use $obsidian-codex-panel-maintain with [[Alpha]].";
|
||||
const input = userInputWithWikiLinkMentionsAndSkills(
|
||||
text,
|
||||
(target) => (target === "Alpha" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null),
|
||||
[
|
||||
{
|
||||
name: "obsidian-codex-panel-maintain",
|
||||
description: "Maintain Codex Panel",
|
||||
path: "/vault/___/skills/obsidian-codex-panel-maintain/SKILL.md",
|
||||
scope: "repo",
|
||||
enabled: true,
|
||||
} as never,
|
||||
],
|
||||
);
|
||||
|
||||
expect(input).toEqual([
|
||||
{ type: "text", text, text_elements: [] },
|
||||
{ type: "mention", name: "Alpha", path: "thoughts/Alpha.md" },
|
||||
{
|
||||
type: "skill",
|
||||
name: "obsidian-codex-panel-maintain",
|
||||
path: "/vault/___/skills/obsidian-codex-panel-maintain/SKILL.md",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores unresolved skills and deduplicates resolved skills by path", () => {
|
||||
const text = "Use $First, $missing, $first, and $Alias.";
|
||||
const input = userInputWithWikiLinkMentionsAndSkills(text, () => null, [
|
||||
{
|
||||
name: "First",
|
||||
description: "First skill",
|
||||
path: "/skills/first/SKILL.md",
|
||||
scope: "user",
|
||||
enabled: true,
|
||||
} as never,
|
||||
{
|
||||
name: "first",
|
||||
description: "Duplicate name should not win",
|
||||
path: "/skills/duplicate/SKILL.md",
|
||||
scope: "user",
|
||||
enabled: true,
|
||||
} as never,
|
||||
{
|
||||
name: "Alias",
|
||||
description: "Same path alias",
|
||||
path: "/skills/first/SKILL.md",
|
||||
scope: "user",
|
||||
enabled: true,
|
||||
} as never,
|
||||
{
|
||||
name: "missing",
|
||||
description: "Disabled skill",
|
||||
path: "/skills/missing/SKILL.md",
|
||||
scope: "user",
|
||||
enabled: false,
|
||||
} as never,
|
||||
]);
|
||||
|
||||
expect(input).toEqual([
|
||||
{ type: "text", text, text_elements: [] },
|
||||
{ type: "skill", name: "First", path: "/skills/first/SKILL.md" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue