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
41 changes: 40 additions & 1 deletion packages/cli/src/patches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* See patches/README.md for details.
*/
import { describe, test, expect } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
Expand Down Expand Up @@ -341,6 +341,45 @@ describe("pi-coding-agent patched runtime behavior", () => {
expect(api.replaceQueuedMessages).toBeUndefined();
});

test("_expandSkillCommand expands every /skill: token in the message", async () => {
const { AgentSession } = await import(
piCodingAgentPath("dist/core/agent-session.js")
);
const skillDir = mkdtempSync(resolve(tmpdir(), "pizzapi-skills-"));
try {
const makeSkill = (name: string) => {
const filePath = resolve(skillDir, `${name}.md`);
writeFileSync(filePath, `# ${name}\n\nBody of ${name}\n`);
return { name, filePath, baseDir: skillDir };
};
const alpha = makeSkill("alpha");
const beta = makeSkill("beta");
const ctx = {
resourceLoader: { getSkills: () => ({ skills: [alpha, beta] }) },
_extensionRunner: { emitError() {} },
};
const expand = (text: string) =>
(AgentSession as any).prototype._expandSkillCommand.call(ctx, text);

// Leading skill keeps upstream semantics: trailing text becomes args.
const leading = expand("/skill:alpha do the thing @file.txt");
expect(leading).toContain('<skill name="alpha"');
expect(leading.endsWith("do the thing @file.txt")).toBe(true);

// Multiple inline skills all expand; @mentions pass through untouched.
const multi = expand("Use /skill:alpha then /skill:beta and read @src/x.ts");
expect(multi).toContain('<skill name="alpha"');
expect(multi).toContain('<skill name="beta"');
expect(multi).toContain("@src/x.ts");
expect(multi.includes("/skill:")).toBe(false);

// Unknown skills pass through untouched.
expect(expand("try /skill:missing now")).toBe("try /skill:missing now");
} finally {
rmSync(skillDir, { recursive: true, force: true });
}
});

});

// ---------------------------------------------------------------------------
Expand Down
180 changes: 132 additions & 48 deletions packages/ui/src/components/SessionViewer.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,12 @@ const { act, cleanup, fireEvent, render, waitFor } = await import(
);
const React = (await import("react")).default;
const { TooltipProvider } = await import("@/components/ui/tooltip");
const atMention = await import("../session-viewer/at-mention-handlers");
const { SessionViewer } = await import("../SessionViewer");

afterEach(cleanup);

function setup(options: { onSendInput?: any }) {
function setup(options: { onSendInput?: any; runnerId?: string; runnerInfo?: any }) {
const view = render(
React.createElement(
TooltipProvider,
Expand All @@ -56,6 +57,8 @@ function setup(options: { onSendInput?: any }) {
messages: [],
viewerStatus: "Connected",
onSendInput: options.onSendInput,
runnerId: options.runnerId,
runnerInfo: options.runnerInfo,
} as any),
),
);
Expand Down Expand Up @@ -136,3 +139,31 @@ describe("SessionViewer composer clear-on-send", () => {
await waitFor(() => expect(textarea.value).toBe("world"));
});
});

describe("scanAtMentionTrigger (composer @-mention alongside slash commands)", () => {
const scan = (text: string) => atMention.scanAtMentionTrigger(text, text.length);

test("detects @ after a skill command", () => {
expect(scan("/skill:demo @")).toEqual({ triggerOffset: 12, query: "" });
});

test("detects @ query mid-message after a skill command", () => {
expect(scan("/skill:demo @src")).toEqual({ triggerOffset: 12, query: "src" });
});

test("inactive when mention already completed with a space", () => {
expect(scan("/skill:demo @src/ foo")).toEqual({ triggerOffset: null, query: "" });
});

test("plain slash command without @ is inactive", () => {
expect(scan("/skill:demo some args")).toEqual({ triggerOffset: null, query: "" });
});

test("@ mid-word does not trigger", () => {
expect(scan("email me@test.com")).toEqual({ triggerOffset: null, query: "" });
});

test("@ at start of message triggers", () => {
expect(scan("@")).toEqual({ triggerOffset: 0, query: "" });
});
});
25 changes: 25 additions & 0 deletions packages/ui/src/components/session-viewer/at-mention-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,31 @@ export interface AtMentionHandlers {

export interface AtMentionResult extends AtMentionState, AtMentionHandlers {}

export interface AtMentionScan {
/** Offset of the triggering "@" character, or null when no active mention query sits at the cursor. */
triggerOffset: number | null;
/** Text between the "@" and the cursor ("" immediately after typing "@"). */
query: string;
}

/**
* Scan composer text backwards from the cursor for an active @-mention trigger:
* an "@" at the start of the text or preceded by whitespace, with no whitespace
* between it and the cursor. Runs regardless of whether the text starts with
* "/" so skills and @-mentions can be combined in one message.
*/
export function scanAtMentionTrigger(text: string, cursorPos: number): AtMentionScan {
const end = Math.min(cursorPos, text.length);
for (let i = end - 1; i >= 0; i--) {
if (text[i] !== "@") continue;
if (i !== 0 && text[i - 1] !== " " && text[i - 1] !== "\n" && text[i - 1] !== "\t") continue;
const query = text.slice(i + 1, end);
if (/\s/.test(query)) return { triggerOffset: null, query: "" };
return { triggerOffset: i, query };
}
return { triggerOffset: null, query: "" };
}

/**
* Owns all @-mention popover state and the action handlers for file/agent selection,
* directory drill-in, back navigation, and popover close.
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/src/components/session-viewer/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseToolInputArgs,
extToMime,
resolveCommandPopoverState,
scanSlashCommandToken,
} from "./utils";
import type { RelayMessage } from "./types";

Expand Down Expand Up @@ -465,6 +466,37 @@ describe("extToMime", () => {

// ── resolveCommandPopoverState ──────────────────────────────────────────────

describe("scanSlashCommandToken", () => {
const scan = (text: string) => scanSlashCommandToken(text, text.length);

test("finds a mid-message /skill token after the first skill", () => {
const text = "/skill:alpha do it /skill:be";
expect(scan(text)).toEqual({ offset: text.lastIndexOf("/"), token: "skill:be" });
});

test("finds bare slash after whitespace", () => {
const text = "/skill:a stuff /";
expect(scan(text)).toEqual({ offset: text.length - 1, token: "" });
});

test("finds leading slash", () => {
expect(scan("/sk")).toEqual({ offset: 0, token: "sk" });
});

test("finds partial leading token at cursor", () => {
expect(scanSlashCommandToken("/skill:a rest", 5)).toEqual({ offset: 0, token: "skil" });
});

test("no trigger without a slash token at the cursor", () => {
expect(scan("plain text")).toBeNull();
expect(scanSlashCommandToken("no slash here", 13)).toBeNull();
});

test("no trigger mid-word", () => {
expect(scan("see foo/bar")).toBeNull();
});
});

describe("resolveCommandPopoverState", () => {
const known = new Set(["compact", "new", "resume", "skill:beads-ccpm", "skill:double-check"]);
const keepOpen = new Set(["resume"]);
Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/components/session-viewer/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,27 @@ export function extToMime(path: string): string {
* "/resume my-session" → open, query="resume my-session"
* "/unknown-thing args" → open, query="unknown-thing args"
*/
export interface SlashTokenScan {
/** Offset of the triggering "/" character. */
offset: number;
/** Token text after the "/" up to the cursor (no whitespace). */
token: string;
}

/**
* Find a "/"-prefixed token ending at the cursor (start of text or preceded by
* whitespace, no whitespace inside). Returns null when no such token sits at
* the cursor. Used to surface mid-message skill suggestions, mirroring how
* @-mentions work anywhere in the message.
*/
export function scanSlashCommandToken(text: string, cursorPos: number): SlashTokenScan | null {
const end = Math.min(cursorPos, text.length);
let start = end;
while (start > 0 && !/\s/.test(text[start - 1])) start--;
if (start === end || text[start] !== "/") return null;
return { offset: start, token: text.slice(start + 1, end) };
}

export function resolveCommandPopoverState(
afterSlash: string,
knownNames: Set<string>,
Expand Down
Loading
Loading