diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index 27daea09..7a1c3536 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -18,10 +18,10 @@ import {
import { useTranslation } from "react-i18next";
import { AttachmentTile } from "@/components/AttachmentTile";
-import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { SlashCommandText } from "@/components/SlashCommandText";
+import { UserMessageText } from "@/components/UserMessageText";
import {
Tooltip,
TooltipContent,
@@ -37,6 +37,7 @@ import type {
CliAppInfo,
McpPresetInfo,
SlashCommand,
+ SkillSummary,
UICliAppAttachment,
UIMcpPresetAttachment,
UIImage,
@@ -51,6 +52,7 @@ interface MessageBubbleProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
+ skills?: SkillSummary[];
onOpenFilePreview?: (path: string) => void;
onForkFromHere?: () => void;
}
@@ -143,6 +145,7 @@ export function MessageBubble({
cliApps = [],
mcpPresets = [],
slashCommands = [],
+ skills = [],
onOpenFilePreview,
onForkFromHere,
}: MessageBubbleProps) {
@@ -171,15 +174,17 @@ export function MessageBubble({
const messageText = slashCommand ? (
<>
-
>
) : (
-
diff --git a/webui/src/components/UserMessageText.tsx b/webui/src/components/UserMessageText.tsx
new file mode 100644
index 00000000..3a7d38d5
--- /dev/null
+++ b/webui/src/components/UserMessageText.tsx
@@ -0,0 +1,91 @@
+import { CliAppMentionText } from "@/components/CliAppMentionText";
+import {
+ INLINE_TOKEN_HIGHLIGHT_COLOR,
+ InlineTokenHighlight,
+} from "@/components/InlineTokenHighlight";
+import type { CliAppInfo, McpPresetInfo, SkillSummary } from "@/lib/types";
+
+type SkillReferenceSegment =
+ | { kind: "text"; text: string }
+ | { kind: "skill"; text: string; skill: SkillSummary };
+
+function splitSkillReferenceSegments(
+ value: string,
+ skills: SkillSummary[],
+): SkillReferenceSegment[] {
+ if (!value || skills.length === 0) {
+ return value ? [{ kind: "text", text: value }] : [];
+ }
+
+ const skillsByName = new Map(
+ skills
+ .filter((skill) => skill.available)
+ .map((skill) => [skill.name.toLowerCase(), skill]),
+ );
+ if (skillsByName.size === 0) return [{ kind: "text", text: value }];
+
+ const segments: SkillReferenceSegment[] = [];
+ const referenceRe = /\$([A-Za-z0-9_-]+)/g;
+ let cursor = 0;
+ let match: RegExpExecArray | null;
+ while ((match = referenceRe.exec(value)) !== null) {
+ const name = match[1] ?? "";
+ const skill = skillsByName.get(name.toLowerCase());
+ if (!skill) continue;
+
+ if (match.index > cursor) {
+ segments.push({ kind: "text", text: value.slice(cursor, match.index) });
+ }
+ segments.push({
+ kind: "skill",
+ text: value.slice(match.index, referenceRe.lastIndex),
+ skill,
+ });
+ cursor = referenceRe.lastIndex;
+ }
+ if (cursor < value.length) {
+ segments.push({ kind: "text", text: value.slice(cursor) });
+ }
+ return segments.length ? segments : [{ kind: "text", text: value }];
+}
+
+export function UserMessageText({
+ text,
+ skills,
+ cliApps,
+ mcpPresets,
+}: {
+ text: string;
+ skills: SkillSummary[];
+ cliApps: CliAppInfo[];
+ mcpPresets: McpPresetInfo[];
+}) {
+ const segments = splitSkillReferenceSegments(text, skills);
+ return (
+ <>
+ {segments.map((segment, index) => {
+ if (segment.kind === "text") {
+ return (
+
+ );
+ }
+ return (
+
+ {segment.text}
+
+ );
+ })}
+ >
+ );
+}
diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
index 9271fd74..7eaffc9c 100644
--- a/webui/src/components/thread/ThreadMessages.tsx
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -3,7 +3,13 @@ import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
-import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
+import type {
+ CliAppInfo,
+ McpPresetInfo,
+ SlashCommand,
+ SkillSummary,
+ UIMessage,
+} from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@@ -13,6 +19,7 @@ interface ThreadMessagesProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
+ skills?: SkillSummary[];
forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
@@ -53,6 +60,7 @@ export function ThreadMessages({
cliApps = [],
mcpPresets = [],
slashCommands = [],
+ skills = [],
forkBoundaryMessageCount = null,
onOpenFilePreview,
onForkFromMessage,
@@ -115,6 +123,7 @@ export function ThreadMessages({
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
+ skills={skills}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={
onForkFromMessage && forkIndex !== undefined
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx
index 9a52c845..0d0d7505 100644
--- a/webui/src/components/thread/ThreadShell.tsx
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -897,6 +897,7 @@ export function ThreadShell({
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
+ skills={skills}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
index cb7f9d6c..972e9556 100644
--- a/webui/src/components/thread/ThreadViewport.tsx
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -22,7 +22,13 @@ import {
promptTop,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
-import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
+import type {
+ CliAppInfo,
+ McpPresetInfo,
+ SlashCommand,
+ SkillSummary,
+ UIMessage,
+} from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
@@ -41,6 +47,7 @@ interface ThreadViewportProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
+ skills?: SkillSummary[];
forkBoundaryMessageCount?: number | null;
hasMoreBefore?: boolean;
loadingOlder?: boolean;
@@ -113,6 +120,7 @@ export const ThreadViewport = forwardRef {
it("renders user messages as right-aligned pills", () => {
const message: UIMessage = {
@@ -204,6 +225,51 @@ describe("MessageBubble", () => {
expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom");
});
+ it("highlights available skill references while preserving other message tokens", () => {
+ const message: UIMessage = {
+ id: "u-skill-reference",
+ role: "user",
+ content: "Ask $github to review this with @zoom",
+ createdAt: Date.now(),
+ };
+
+ render(
+ ,
+ );
+
+ const skill = screen.getByTestId("message-skill-reference-github");
+ expect(skill).toHaveTextContent("$github");
+ expect(skill).toHaveClass(
+ "font-medium",
+ "transition-[color,text-shadow]",
+ "duration-150",
+ );
+ expect(skill.getAttribute("style")).toContain("var(--inline-token-highlight)");
+ expect(skill.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
+ expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom");
+ expect(skill.parentElement).toHaveTextContent("Ask $github to review this with @zoom");
+ });
+
+ it("keeps unknown and unavailable skill references as plain message text", () => {
+ const message: UIMessage = {
+ id: "u-plain-skill-reference",
+ role: "user",
+ content: "Try $unknown or $blocked-skill",
+ createdAt: Date.now(),
+ };
+
+ render();
+
+ expect(screen.queryByTestId("message-skill-reference-unknown")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("message-skill-reference-blocked-skill"))
+ .not.toBeInTheDocument();
+ expect(screen.getByText("Try $unknown or $blocked-skill")).toBeInTheDocument();
+ });
+
it("renders fork control in completed assistant action rows", () => {
const onForkFromHere = vi.fn();
const message: UIMessage = {
diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx
index 8d4fb374..cc2d56c6 100644
--- a/webui/src/tests/thread-shell.test.tsx
+++ b/webui/src/tests/thread-shell.test.tsx
@@ -484,6 +484,35 @@ describe("ThreadShell", () => {
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
});
+ it("passes skill metadata to sent user messages", async () => {
+ const client = makeClient();
+ render(wrap(
+ client,
+ {}}
+ skills={[{
+ name: "github",
+ description: "Work with pull requests and issues",
+ source: "builtin",
+ available: true,
+ }]}
+ />,
+ ));
+
+ fireEvent.change(screen.getByLabelText("Message input"), {
+ target: { value: "Use $github for this" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ await waitFor(() =>
+ expectSendMessageWithTurn(client, "skill-reference", "Use $github for this"),
+ );
+ expect(screen.getByTestId("message-skill-reference-github"))
+ .toHaveTextContent("$github");
+ });
+
it("clears the old thread when the active session is removed", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");