Procházet zdrojové kódy

feat: attach skills to prompts (#41188)

Kit Langton před 1 dnem
rodič
revize
c2ba0bef6b
36 změnil soubory, kde provedl 732 přidání a 102 odebrání
  1. 2 0
      packages/client/src/effect/api/api.ts
  2. 2 0
      packages/client/src/effect/generated/client.ts
  3. 2 0
      packages/client/src/promise/generated/client.ts
  4. 131 0
      packages/client/src/promise/generated/types.ts
  5. 3 0
      packages/core/src/plugin/promise.ts
  6. 33 5
      packages/core/src/session.ts
  7. 2 1
      packages/core/src/session/compaction.ts
  8. 1 0
      packages/core/src/session/projector.ts
  9. 1 0
      packages/core/src/session/runner/to-llm-message.ts
  10. 9 0
      packages/core/src/session/transfer.ts
  11. 19 0
      packages/core/src/skill.ts
  12. 2 19
      packages/core/src/tool/plugin/skill.ts
  13. 36 1
      packages/core/test/session-runner-message.test.ts
  14. 36 0
      packages/core/test/session-skill.test.ts
  15. 5 5
      packages/core/test/tool-skill.test.ts
  16. 1 0
      packages/protocol/src/groups/session.ts
  17. 8 0
      packages/schema/src/prompt-input.ts
  18. 12 1
      packages/schema/src/prompt.ts
  19. 1 0
      packages/schema/src/session-message.ts
  20. 8 0
      packages/server/src/handlers/session.ts
  21. 1 0
      packages/theme/src/tui/syntax.ts
  22. 53 20
      packages/tui/src/component/prompt/autocomplete.tsx
  23. 46 5
      packages/tui/src/component/prompt/index.tsx
  24. 74 23
      packages/tui/src/mini/footer.prompt.tsx
  25. 5 5
      packages/tui/src/mini/prompt.editor.ts
  26. 1 1
      packages/tui/src/mini/prompt.shared.ts
  27. 24 3
      packages/tui/src/mini/stream-v2.transport.ts
  28. 1 0
      packages/tui/src/mini/types.ts
  29. 10 1
      packages/tui/src/prompt/codec.ts
  30. 11 0
      packages/tui/src/prompt/display.ts
  31. 4 4
      packages/tui/src/prompt/history.tsx
  32. 8 5
      packages/tui/src/prompt/mention.ts
  33. 24 1
      packages/tui/src/routes/session/index.tsx
  34. 69 0
      packages/tui/test/mini/footer.view.test.tsx
  35. 76 1
      packages/tui/test/mini/stream-v2.transport.test.ts
  36. 11 1
      packages/tui/test/prompt/display.test.ts

+ 2 - 0
packages/client/src/effect/api/api.ts

@@ -180,6 +180,7 @@ export type Endpoint5_12Input = {
   readonly text: string
   readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
   readonly agents?: ReadonlyArray<AgentAttachment> | undefined
+  readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
   readonly metadata?: { readonly [x: string]: unknown } | undefined
   readonly delivery?: "steer" | "queue" | undefined
   readonly resume?: boolean | undefined
@@ -196,6 +197,7 @@ export type Endpoint5_13Input = {
   readonly model?: Model.Ref | undefined
   readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
   readonly agents?: ReadonlyArray<AgentAttachment> | undefined
+  readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
   readonly delivery?: "steer" | "queue" | undefined
   readonly resume?: boolean | undefined
 }

+ 2 - 0
packages/client/src/effect/generated/client.ts

@@ -412,6 +412,7 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
         text: input["text"],
         files: input["files"],
         agents: input["agents"],
+        skills: input["skills"],
         metadata: input["metadata"],
         delivery: input["delivery"],
         resume: input["resume"],
@@ -434,6 +435,7 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
         model: input["model"],
         files: input["files"],
         agents: input["agents"],
+        skills: input["skills"],
         delivery: input["delivery"],
         resume: input["resume"],
       },

+ 2 - 0
packages/client/src/promise/generated/client.ts

@@ -615,6 +615,7 @@ export function make(options: ClientOptions) {
               text: input["text"],
               files: input["files"],
               agents: input["agents"],
+              skills: input["skills"],
               metadata: input["metadata"],
               delivery: input["delivery"],
               resume: input["resume"],
@@ -638,6 +639,7 @@ export function make(options: ClientOptions) {
               model: input["model"],
               files: input["files"],
               agents: input["agents"],
+              skills: input["skills"],
               delivery: input["delivery"],
               resume: input["resume"],
             },

+ 131 - 0
packages/client/src/promise/generated/types.ts

@@ -1080,6 +1080,8 @@ export type PromptFileAttachment = {
 
 export type PromptAgentAttachment = { name: string; mention?: PromptMention }
 
+export type PromptSkillAttachment = { id: string; name: string; text: string; mention?: PromptMention }
+
 export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
 
 export type SessionMessageAssistantReasoning = {
@@ -1565,6 +1567,7 @@ export type SessionMessageUser = {
   text: string
   files?: Array<PromptFileAttachment>
   agents?: Array<PromptAgentAttachment>
+  skills?: Array<PromptSkillAttachment>
   type: "user"
 }
 
@@ -1572,6 +1575,7 @@ export type SessionPendingUserData = {
   text: string
   files?: Array<PromptFileAttachment>
   agents?: Array<PromptAgentAttachment>
+  skills?: Array<PromptSkillAttachment>
   metadata?: { [x: string]: JsonValue }
 }
 
@@ -1579,6 +1583,7 @@ export type SessionPendingUserData1 = {
   text: string
   files?: Array<PromptFileAttachment>
   agents?: Array<PromptAgentAttachment>
+  skills?: Array<PromptSkillAttachment>
   metadata?: { [x: string]: any }
 }
 
@@ -2579,6 +2584,12 @@ export type SessionImportInput = {
             readonly name: string
             readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
           }>
+          readonly skills?: ReadonlyArray<{
+            readonly id: string
+            readonly name: string
+            readonly text: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
           readonly type: "user"
         }
       | {
@@ -2824,6 +2835,12 @@ export type SessionImportInput = {
             readonly name: string
             readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
           }>
+          readonly skills?: ReadonlyArray<{
+            readonly id: string
+            readonly name: string
+            readonly text: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
           readonly type: "user"
         }
       | {
@@ -3069,6 +3086,12 @@ export type SessionImportInput = {
             readonly name: string
             readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
           }>
+          readonly skills?: ReadonlyArray<{
+            readonly id: string
+            readonly name: string
+            readonly text: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
           readonly type: "user"
         }
       | {
@@ -3320,6 +3343,10 @@ export type SessionPromptInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly metadata?: { readonly [x: string]: JsonValue }
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
@@ -3337,6 +3364,10 @@ export type SessionPromptInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly metadata?: { readonly [x: string]: JsonValue }
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
@@ -3354,6 +3385,10 @@ export type SessionPromptInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly metadata?: { readonly [x: string]: JsonValue }
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
@@ -3371,10 +3406,35 @@ export type SessionPromptInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly metadata?: { readonly [x: string]: JsonValue }
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["agents"]
+  readonly skills?: {
+    readonly id?: string | null
+    readonly text: string
+    readonly files?: ReadonlyArray<{
+      readonly uri: string
+      readonly name?: string
+      readonly description?: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
+    readonly agents?: ReadonlyArray<{
+      readonly name: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
+    readonly metadata?: { readonly [x: string]: JsonValue }
+    readonly delivery?: "steer" | "queue" | null
+    readonly resume?: boolean | null
+  }["skills"]
   readonly metadata?: {
     readonly id?: string | null
     readonly text: string
@@ -3388,6 +3448,10 @@ export type SessionPromptInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly metadata?: { readonly [x: string]: JsonValue }
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
@@ -3405,6 +3469,10 @@ export type SessionPromptInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly metadata?: { readonly [x: string]: JsonValue }
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
@@ -3422,6 +3490,10 @@ export type SessionPromptInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly metadata?: { readonly [x: string]: JsonValue }
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
@@ -3448,6 +3520,10 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["id"]
@@ -3467,6 +3543,10 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["command"]
@@ -3486,6 +3566,10 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["arguments"]
@@ -3505,6 +3589,10 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["agent"]
@@ -3524,6 +3612,10 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["model"]
@@ -3543,6 +3635,10 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["files"]
@@ -3562,9 +3658,36 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["agents"]
+  readonly skills?: {
+    readonly id?: string | null
+    readonly command: string
+    readonly arguments?: string | null
+    readonly agent?: string | null
+    readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
+    readonly files?: ReadonlyArray<{
+      readonly uri: string
+      readonly name?: string
+      readonly description?: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
+    readonly agents?: ReadonlyArray<{
+      readonly name: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
+    readonly delivery?: "steer" | "queue" | null
+    readonly resume?: boolean | null
+  }["skills"]
   readonly delivery?: {
     readonly id?: string | null
     readonly command: string
@@ -3581,6 +3704,10 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["delivery"]
@@ -3600,6 +3727,10 @@ export type SessionCommandInput = {
       readonly name: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
+    readonly skills?: ReadonlyArray<{
+      readonly id: string
+      readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+    }>
     readonly delivery?: "steer" | "queue" | null
     readonly resume?: boolean | null
   }["resume"]

+ 3 - 0
packages/core/src/plugin/promise.ts

@@ -11,6 +11,7 @@ import { Provider } from "@opencode-ai/schema/provider"
 import { AbsolutePath } from "@opencode-ai/schema/schema"
 import { Session } from "@opencode-ai/schema/session"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
+import { Skill } from "@opencode-ai/schema/skill"
 import { Workspace } from "@opencode-ai/schema/workspace"
 import { WebSearch } from "@opencode-ai/schema/websearch"
 import { DateTime, Effect, Scope, Stream } from "effect"
@@ -296,6 +297,7 @@ export function fromPromise(plugin: Plugin) {
                   ...input,
                   sessionID: Session.ID.make(input.sessionID),
                   id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
+                  skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
                   delivery: input.delivery ?? undefined,
                   resume: input.resume ?? undefined,
                 }),
@@ -310,6 +312,7 @@ export function fromPromise(plugin: Plugin) {
                   id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
                   agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
                   model: input.model == null ? undefined : model(input.model),
+                  skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
                   arguments: input.arguments ?? undefined,
                   delivery: input.delivery ?? undefined,
                   resume: input.resume ?? undefined,

+ 33 - 5
packages/core/src/session.ts

@@ -218,10 +218,11 @@ export interface Interface {
     text: string
     files?: PromptInput.Prompt["files"]
     agents?: PromptInput.Prompt["agents"]
+    skills?: PromptInput.Prompt["skills"]
     metadata?: Record<string, unknown>
     delivery?: SessionPending.Delivery
     resume?: boolean
-  }) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
+  }) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
   /** Generates text from current Session context without admitting input or mutating history. */
   readonly generate: (input: {
     sessionID: SessionSchema.ID
@@ -236,11 +237,17 @@ export interface Interface {
     model?: Model.Ref
     files?: PromptInput.Prompt["files"]
     agents?: PromptInput.Prompt["agents"]
+    skills?: PromptInput.Prompt["skills"]
     delivery?: SessionPending.Delivery
     resume?: boolean
   }) => Effect.Effect<
     SessionPending.User,
-    NotFoundError | PromptConflictError | AttachmentError | Command.NotFoundError | Command.EvaluationError
+    | NotFoundError
+    | PromptConflictError
+    | AttachmentError
+    | SkillNotFoundError
+    | Command.NotFoundError
+    | Command.EvaluationError
   >
   readonly shell: (input: {
     id?: Event.ID
@@ -345,7 +352,9 @@ const layer = Layer.effect(
         Effect.gen(function* () {
           yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
             Effect.catchDefect((defect) =>
-              defect instanceof SessionPending.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
+              defect instanceof SessionPending.LifecycleConflict
+                ? pendingConflict(input)
+                : Effect.die(defect),
             ),
           )
           if (wake) yield* execution.wake(input.sessionID)
@@ -565,9 +574,11 @@ const layer = Layer.effect(
             // Resolved lazily so prompt admission only boots location services when an
             // image attachment actually needs the resizer.
             const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
+            const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
             const prompt = yield* resolvePrompt(
-              { text: input.text, files: input.files, agents: input.agents },
+              { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
               image,
+              skills,
             ).pipe(Effect.provideService(FSUtil.Service, fs))
             const messageID = input.id ?? SessionMessage.ID.create()
             const admittedInput = SessionPending.Message.make({
@@ -633,6 +644,7 @@ const layer = Layer.effect(
           text: evaluated.text,
           files: input.files,
           agents: input.agents,
+          skills: input.skills,
           delivery: input.delivery,
           resume: input.resume,
         })
@@ -895,12 +907,28 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
 const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
   input: PromptInput.Prompt,
   image: Effect.Effect<Image.Interface>,
+  skills: Effect.Effect<Skill.Interface>,
 ) {
   const fs = yield* FSUtil.Service
   const files = input.files
     ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 })
     : undefined
-  return Prompt.make({ text: input.text, agents: input.agents, files })
+  const requested = input.skills
+  const selected = yield* Effect.gen(function* () {
+    if (!requested?.length) return undefined
+    const available = yield* (yield* skills).list()
+    return yield* Effect.forEach(requested, (attachment) => {
+      const skill = available.find((item) => item.id === attachment.id)
+      if (!skill) return Effect.fail(new SkillNotFoundError({ skill: attachment.id }))
+      return Effect.succeed({
+        id: skill.id,
+        name: skill.name,
+        text: Skill.toModelOutput(skill, []),
+        mention: attachment.mention,
+      })
+    })
+  })
+  return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
 })
 
 const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024

+ 2 - 1
packages/core/src/session/compaction.ts

@@ -124,7 +124,8 @@ const serialize = (message: SessionMessage.Info) => {
         (file) =>
           `[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
       ) ?? []
-    return [`[User]: ${message.text}`, ...files].join("\n")
+    const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
+    return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
   }
   if (message.type === "assistant") {
     return message.content

+ 1 - 0
packages/core/src/session/projector.ts

@@ -445,6 +445,7 @@ const layer = Layer.effectDiscard(
                 text: input.data.text,
                 files: input.data.files,
                 agents: input.data.agents,
+                skills: input.data.skills,
                 time: { created: event.created },
               }
             : {

+ 1 - 0
packages/core/src/session/runner/to-llm-message.ts

@@ -184,6 +184,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
       return []
     case "user":
       const content = [
+        ...(message.skills ?? []).map((skill) => Message.text(skill.text)),
         ...(message.text === "" ? [] : [Message.text(message.text)]),
         ...(message.files ?? []).flatMap(attachmentContent),
       ]

+ 9 - 0
packages/core/src/session/transfer.ts

@@ -2,6 +2,7 @@ export * as SessionTransfer from "./transfer"
 
 import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
 import { Tool } from "@opencode-ai/schema/tool"
+import { Skill } from "@opencode-ai/schema/skill"
 import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
 import { Context, DateTime, Effect, Layer, Schema } from "effect"
 import path from "path"
@@ -218,6 +219,14 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
           ? { ...agent.mention, text: redact("agent-mention", String(index), agent.mention.text) }
           : undefined,
       })),
+      skills: message.skills?.map((skill, index) => ({
+        ...skill,
+        name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
+        text: redact("skill", String(index), skill.text),
+        mention: skill.mention
+          ? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
+          : undefined,
+      })),
     }
   if (message.type === "synthetic")
     return {

+ 19 - 0
packages/core/src/skill.ts

@@ -38,6 +38,25 @@ export { Event } from "@opencode-ai/schema/skill"
 export const available = (skills: ReadonlyArray<Info>, agent: Agent.Info) =>
   skills.filter((skill) => Permission.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
 
+export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
+  const directory = path.dirname(skill.location)
+  return [
+    `<skill_content name="${skill.name}">`,
+    `# Skill: ${skill.name}`,
+    "",
+    skill.content.trim(),
+    "",
+    `Base directory for this skill: ${directory}`,
+    "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
+    "Note: file list is sampled.",
+    "",
+    "<skill_files>",
+    ...files.map((file) => `<file>${file}</file>`),
+    "</skill_files>",
+    "</skill_content>",
+  ].join("\n")
+}
+
 const Frontmatter = Schema.Struct({
   name: Schema.String.pipe(Schema.optional),
   description: Schema.String.pipe(Schema.optional),

+ 2 - 19
packages/core/src/tool/plugin/skill.ts

@@ -26,24 +26,7 @@ export const description = [
   "The skill ID must match one of the available skills in the instructions.",
 ].join("\n")
 
-export const toModelOutput = (skill: Skill.Info, files: ReadonlyArray<string>) => {
-  const directory = path.dirname(skill.location)
-  return [
-    `<skill_content name="${skill.name}">`,
-    `# Skill: ${skill.name}`,
-    "",
-    skill.content.trim(),
-    "",
-    `Base directory for this skill: ${directory}`,
-    "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
-    "Note: file list is sampled.",
-    "",
-    "<skill_files>",
-    ...files.map((file) => `<file>${file}</file>`),
-    "</skill_files>",
-    "</skill_content>",
-  ].join("\n")
-}
+export const toModelOutput = Skill.toModelOutput
 
 const unableToLoad = (name: string, error?: unknown) =>
   new ToolFailure({ message: `Unable to load skill ${name}`, error })
@@ -87,7 +70,7 @@ export const Plugin = {
                 return {
                   name: skill.name,
                   directory,
-                  output: toModelOutput(skill, files),
+                  output: Skill.toModelOutput(skill, files),
                 }
               }).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
             }).pipe(

+ 36 - 1
packages/core/test/session-runner-message.test.ts

@@ -3,7 +3,8 @@ import { Message } from "@opencode-ai/ai"
 import { Model } from "@opencode-ai/core/model"
 import { Provider } from "@opencode-ai/core/provider"
 import { SessionMessage } from "@opencode-ai/core/session/message"
-import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
+import { AgentAttachment, Base64, FileAttachment, SkillAttachment } from "@opencode-ai/schema/prompt"
+import { Skill } from "@opencode-ai/schema/skill"
 import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
 import { Agent } from "@opencode-ai/core/agent"
 import { Shell } from "@opencode-ai/schema/shell"
@@ -184,6 +185,40 @@ Recent work
     })
   })
 
+  test("lowers selected skill instructions with the original user prompt", () => {
+    const messages = toLLMMessages(
+      [
+        SessionMessage.User.make({
+          id: id("user-skill"),
+          type: "user",
+          text: "Design this API",
+          skills: [
+            SkillAttachment.make({
+              id: Skill.ID.make("api-design"),
+              name: Skill.Name.make("API design"),
+              text: "Start from the ideal call site.",
+            }),
+          ],
+          time: { created },
+        }),
+      ],
+      model,
+    )
+
+    expect(messages).toHaveLength(1)
+    expect(messages[0]).toMatchObject({
+      id: id("user-skill"),
+      role: "user",
+      content: [
+        {
+          type: "text",
+          text: "Start from the ideal call site.",
+        },
+        { type: "text", text: "Design this API" },
+      ],
+    })
+  })
+
   test("decodes inline text attachment content", () => {
     const messages = toLLMMessages(
       [

+ 36 - 0
packages/core/test/session-skill.test.ts

@@ -15,6 +15,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionStore } from "@opencode-ai/core/session/store"
+import { SessionPending } from "@opencode-ai/core/session/pending"
 import { Skill } from "@opencode-ai/core/skill"
 import { testEffect } from "./lib/effect"
 
@@ -55,6 +56,41 @@ const it = testEffect(
 )
 
 describe("Session.skill", () => {
+  it.effect("attaches a resolved skill snapshot to a normal prompt", () =>
+    Effect.gen(function* () {
+      const sessions = yield* Session.Service
+      const database = yield* Database.Service
+      const bus = yield* Bus.Service
+      const session = yield* sessions.create({ location })
+      const id = SessionMessage.ID.make("msg_skill_attachment")
+
+      yield* sessions.prompt({
+        id,
+        sessionID: session.id,
+        text: "Apply this guidance",
+        skills: [{ id: Skill.ID.make("effect"), mention: { start: 20, end: 27, text: "/effect" } }],
+        resume: false,
+      })
+      yield* SessionPending.promote(database.db, bus, session.id, "steer")
+
+      expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
+        expect.objectContaining({
+          id,
+          type: "user",
+          text: "Apply this guidance",
+          skills: [
+            {
+              id: "effect",
+              name: "Effect",
+              text: expect.stringContaining("Use Effect"),
+              mention: { start: 20, end: 27, text: "/effect" },
+            },
+          ],
+        }),
+      )
+    }),
+  )
+
   it.effect("projects the caller-supplied message ID", () =>
     Effect.gen(function* () {
       const sessions = yield* Session.Service

+ 5 - 5
packages/core/test/tool-skill.test.ts

@@ -105,9 +105,9 @@ describe("SkillTool", () => {
               }),
             ).toMatchObject({
               status: "completed",
-              content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
+              content: [{ type: "text", text: Skill.toModelOutput(info, [reference]) }],
             })
-            expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`)
+            expect(Skill.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`)
             expect(
               yield* executeTool(registry, {
                 sessionID,
@@ -116,8 +116,8 @@ describe("SkillTool", () => {
               }),
             ).toEqual({
               status: "completed",
-              output: { name: "Effect", directory, output: SkillTool.toModelOutput(info, [reference]) },
-              content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
+              output: { name: "Effect", directory, output: Skill.toModelOutput(info, [reference]) },
+              content: [{ type: "text", text: Skill.toModelOutput(info, [reference]) }],
               metadata: { name: "Effect", directory },
             })
             expect(assertions).toMatchObject([
@@ -168,7 +168,7 @@ describe("SkillTool", () => {
               }),
             ).toMatchObject({
               status: "completed",
-              content: [{ type: "text", text: SkillTool.toModelOutput(flat, []) }],
+              content: [{ type: "text", text: Skill.toModelOutput(flat, []) }],
             })
           }).pipe(Effect.provide(skillToolLayer))
         }),

+ 1 - 0
packages/protocol/src/groups/session.ts

@@ -345,6 +345,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
           model: Model.Ref.pipe(Schema.optional),
           files: PromptInput.Prompt.fields.files,
           agents: PromptInput.Prompt.fields.agents,
+          skills: PromptInput.Prompt.fields.skills,
           delivery: SessionPending.Delivery.pipe(Schema.optional),
           resume: Schema.Boolean.pipe(Schema.optional),
         }),

+ 8 - 0
packages/schema/src/prompt-input.ts

@@ -3,6 +3,7 @@ export * as PromptInput from "./prompt-input.js"
 import { Schema } from "effect"
 import { AgentAttachment, PromptMention } from "./prompt.js"
 import { optional, statics } from "./schema.js"
+import { Skill } from "./skill.js"
 
 export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
 export const FileAttachment = Schema.Struct({
@@ -19,8 +20,15 @@ export const FileAttachment = Schema.Struct({
   )
 
 export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
+export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachment> {}
+export const SkillAttachment = Schema.Struct({
+  id: Skill.ID,
+  mention: PromptMention.pipe(optional),
+}).annotate({ identifier: "PromptInput.SkillAttachment" })
+
 export const Prompt = Schema.Struct({
   text: Schema.String,
   files: Schema.Array(FileAttachment).pipe(optional),
   agents: Schema.Array(AgentAttachment).pipe(optional),
+  skills: Schema.Array(SkillAttachment).pipe(optional),
 }).annotate({ identifier: "PromptInput" })

+ 12 - 1
packages/schema/src/prompt.ts

@@ -1,6 +1,7 @@
 import { Schema } from "effect"
 import { optional } from "./schema.js"
 import { statics } from "./schema.js"
+import { Skill } from "./skill.js"
 
 export interface PromptMention extends Schema.Schema.Type<typeof PromptMention> {}
 export const PromptMention = Schema.Struct({
@@ -52,21 +53,31 @@ export const AgentAttachment = Schema.Struct({
   mention: PromptMention.pipe(optional),
 }).annotate({ identifier: "Prompt.AgentAttachment" })
 
+export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachment> {}
+export const SkillAttachment = Schema.Struct({
+  id: Skill.ID,
+  name: Skill.Name,
+  text: Schema.String,
+  mention: PromptMention.pipe(optional),
+}).annotate({ identifier: "Prompt.SkillAttachment" })
+
 export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
 export const Prompt = Schema.Struct({
   text: Schema.String,
   files: Schema.Array(FileAttachment).pipe(optional),
   agents: Schema.Array(AgentAttachment).pipe(optional),
+  skills: Schema.Array(SkillAttachment).pipe(optional),
 })
   .annotate({ identifier: "Prompt" })
   .pipe(
     statics((schema) => ({
       equivalence: Schema.toEquivalence(schema),
-      fromUserMessage: (input: Pick<Prompt, "text" | "files" | "agents">) =>
+      fromUserMessage: (input: Pick<Prompt, "text" | "files" | "agents" | "skills">) =>
         schema.make({
           text: input.text,
           ...(input.files === undefined ? {} : { files: input.files }),
           ...(input.agents === undefined ? {} : { agents: input.agents }),
+          ...(input.skills === undefined ? {} : { skills: input.skills }),
         }),
     })),
   )

+ 1 - 0
packages/schema/src/session-message.ts

@@ -58,6 +58,7 @@ export const User = Schema.Struct({
   text: Prompt.fields.text,
   files: Prompt.fields.files,
   agents: Prompt.fields.agents,
+  skills: Prompt.fields.skills,
   type: Schema.tag("user"),
 }).annotate({ identifier: "Session.Message.User" })
 

+ 8 - 0
packages/server/src/handlers/session.ts

@@ -313,6 +313,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
                 text: ctx.payload.text,
                 files: ctx.payload.files,
                 agents: ctx.payload.agents,
+                skills: ctx.payload.skills,
                 metadata: ctx.payload.metadata,
                 delivery: ctx.payload.delivery,
                 resume: ctx.payload.resume,
@@ -337,6 +338,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
                 Effect.catchTag("Session.AttachmentError", (error) =>
                   Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })),
                 ),
+                Effect.catchTag("Session.SkillNotFoundError", (error) =>
+                  Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })),
+                ),
               ),
           }
         }),
@@ -355,6 +359,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
                 model: ctx.payload.model,
                 files: ctx.payload.files,
                 agents: ctx.payload.agents,
+                skills: ctx.payload.skills,
                 delivery: ctx.payload.delivery,
                 resume: ctx.payload.resume,
               })
@@ -394,6 +399,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
                 Effect.catchTag("Session.AttachmentError", (error) =>
                   Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })),
                 ),
+                Effect.catchTag("Session.SkillNotFoundError", (error) =>
+                  Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })),
+                ),
               ),
           }
         }),

+ 1 - 0
packages/theme/src/tui/syntax.ts

@@ -12,6 +12,7 @@ export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
     rule(["prompt"], theme.hue.accent[step]),
     rule(["extmark.file"], feedback.warning.default, { bold: true }),
     rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
+    rule(["extmark.skill"], theme.categorical[1][step], { bold: true }),
     // V1 migration preserves its selected/inverse foreground in this action state.
     rule(["extmark.paste"], theme.text.action.primary.focused, {
       background: feedback.warning.default,

+ 53 - 20
packages/tui/src/component/prompt/autocomplete.tsx

@@ -19,8 +19,9 @@ import { Locale } from "../../util/locale"
 import type { PromptInfo, PromptPartRef } from "../../prompt/history"
 import { useFrecency } from "../../prompt/frecency"
 import { Keymap } from "../../context/keymap"
-import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
+import { displayCharAt, mentionTriggerIndex, slashTriggerIndex } from "../../prompt/display"
 import type { FileSystemEntry } from "@opencode-ai/client"
+import { Skill } from "@opencode-ai/schema/skill"
 import { stringWidth } from "../../util/string-width"
 import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
 import { moveSelection, revealSelectionOffset } from "../../ui/select-controller"
@@ -39,6 +40,7 @@ export type AutocompleteOption = {
   isDirectory?: boolean
   onSelect?: () => void
   path?: string
+  kind?: "skill"
 }
 
 export function Autocomplete(props: {
@@ -51,6 +53,8 @@ export function Autocomplete(props: {
   ref: (ref: AutocompleteRef) => void
   fileStyleId: number
   agentStyleId: number
+  skillStyleId: number
+  hasSkill: (id: string) => boolean
   promptPartTypeId: () => number
 }) {
   const editor = useEditorContext()
@@ -140,14 +144,17 @@ export function Autocomplete(props: {
     text: string,
     part:
       | { type: "file"; value: NonNullable<PromptInfo["files"]>[number]; path?: string }
-      | { type: "agent"; value: NonNullable<PromptInfo["agents"]>[number] },
+      | { type: "agent"; value: NonNullable<PromptInfo["agents"]>[number] }
+      | { type: "skill"; value: NonNullable<PromptInfo["skills"]>[number] },
   ) {
+    if (part.type === "skill" && props.hasSkill(part.value.id)) return
     const input = props.input()
     const currentCursorOffset = input.cursorOffset
 
     const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
     const needsSpace = charAfterCursor !== " "
-    const append = "@" + text + (needsSpace ? " " : "")
+    const prefix = part.type === "skill" ? "/" : "@"
+    const append = prefix + text + (needsSpace ? " " : "")
 
     input.cursorOffset = store.index
     const startCursor = input.logicalCursor
@@ -157,11 +164,12 @@ export function Autocomplete(props: {
     input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
     input.insertText(append)
 
-    const virtualText = "@" + text
+    const virtualText = prefix + text
     const extmarkStart = store.index
     const extmarkEnd = extmarkStart + stringWidth(virtualText)
 
-    const styleId = part.type === "file" ? props.fileStyleId : props.agentStyleId
+    const styleId =
+      part.type === "file" ? props.fileStyleId : part.type === "skill" ? props.skillStyleId : props.agentStyleId
 
     const extmarkId = input.extmarks.create({
       start: extmarkStart,
@@ -195,6 +203,20 @@ export function Autocomplete(props: {
         return
       }
 
+      if (part.type === "skill") {
+        const skills = (draft.skills ??= [])
+        if (skills.some((skill) => skill.id === part.value.id)) return
+        if (part.value.mention) {
+          part.value.mention.start = extmarkStart
+          part.value.mention.end = extmarkEnd
+          part.value.mention.text = virtualText
+        }
+        const index = skills.length
+        skills.push(part.value)
+        props.setExtmark({ type: "skill", index }, extmarkId)
+        return
+      }
+
       const agents = (draft.agents ??= [])
       if (part.value.mention) {
         part.value.mention.start = extmarkStart
@@ -433,7 +455,12 @@ export function Autocomplete(props: {
       results.push({
         display: "/" + skill.id,
         description: skill.description,
-        onSelect: () => insertSlash(skill.id),
+        kind: "skill",
+        onSelect: () =>
+          insertPart(skill.id, {
+            type: "skill",
+            value: { id: Skill.ID.make(skill.id), mention: { start: 0, end: 0, text: "" } },
+          }),
       })
     }
 
@@ -463,7 +490,11 @@ export function Autocomplete(props: {
     // it shouldn't be additionally sorted by fuzzysort as it will loose the results
     const fileOptions: AutocompleteOption[] = store.visible === "@" ? fileSearch.options : []
     const nonFileOptions: AutocompleteOption[] =
-      store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
+      store.visible === "@"
+        ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()]
+        : store.index === 0
+          ? [...commandsValue]
+          : commandsValue.filter((item) => item.kind === "skill")
 
     if (!searchValue) {
       return [...nonFileOptions, ...fileOptions]
@@ -520,7 +551,7 @@ export function Autocomplete(props: {
   function select() {
     const selected = options()[store.selected]
     if (!selected) return
-    hide()
+    hide(true)
     selected.onSelect?.()
   }
 
@@ -608,14 +639,18 @@ export function Autocomplete(props: {
     })
   }
 
-  function hide() {
-    const text = props.input().plainText
-    if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
-      const cursor = props.input().logicalCursor
-      props.input().deleteRange(0, 0, cursor.row, cursor.col)
+  function hide(removeToken = false) {
+    if (removeToken && store.visible === "/") {
+      const input = props.input()
+      const cursorOffset = input.cursorOffset
+      input.cursorOffset = store.index
+      const start = input.logicalCursor
+      input.cursorOffset = cursorOffset
+      const end = input.logicalCursor
+      input.deleteRange(start.row, start.col, end.row, end.col)
       // Sync the prompt store immediately since onContentChange is async
       props.setPrompt((draft) => {
-        draft.text = props.input().plainText
+        draft.text = input.plainText
       })
     }
     setStore("visible", false)
@@ -640,9 +675,7 @@ export function Autocomplete(props: {
             // Typed text before the trigger
             props.input().cursorOffset <= store.index ||
             // There is a space between the trigger and the cursor
-            props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
-            // "/<command>" is not the sole content
-            (store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
+            props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
           ) {
             hide()
           }
@@ -653,10 +686,10 @@ export function Autocomplete(props: {
         const offset = props.input().cursorOffset
         if (offset === 0) return
 
-        // Check for "/" at position 0 - reopen slash commands
-        if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
+        const slash = slashTriggerIndex(value, offset)
+        if (slash !== undefined) {
           show("/")
-          setStore("index", 0)
+          setStore("index", slash)
           return
         }
 

+ 46 - 5
packages/tui/src/component/prompt/index.tsx

@@ -29,6 +29,7 @@ import { parseSlashHead } from "../../prompt/parse"
 import { stringWidth } from "../../util/string-width"
 import { createStore, produce, unwrap } from "solid-js/store"
 import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
+import { Skill } from "@opencode-ai/schema/skill"
 import { computePromptTraits } from "../../prompt/traits"
 import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
 import { usePromptStash } from "../../prompt/stash"
@@ -273,6 +274,7 @@ export function Prompt(props: PromptProps) {
   }
   const fileStyleId = syntax().getStyleId("extmark.file")!
   const agentStyleId = syntax().getStyleId("extmark.agent")!
+  const skillStyleId = syntax().getStyleId("extmark.skill")!
   const pasteStyleId = syntax().getStyleId("extmark.paste")!
   let promptPartTypeId = 0
   const event = useEvent()
@@ -493,12 +495,29 @@ export function Prompt(props: PromptProps) {
             <DialogSkill
               location={currentLocation.current}
               onSelect={(skill) => {
-                input.setText(`/${skill} `)
-                setStore("prompt", {
-                  ...emptyPrompt(),
-                  text: `/${skill} `,
+                if (store.prompt.skills?.some((item) => item.id === skill)) return
+                const text = `/${skill}`
+                const start = input.cursorOffset
+                input.insertText(text + " ")
+                const extmarkId = input.extmarks.create({
+                  start,
+                  end: start + promptOffsetWidth(text),
+                  virtual: true,
+                  styleId: skillStyleId,
+                  typeId: promptPartTypeId,
                 })
-                input.gotoBufferEnd()
+                setStore(
+                  produce((draft) => {
+                    draft.prompt.text = input.plainText
+                    const skills = (draft.prompt.skills ??= [])
+                    const index = skills.length
+                    skills.push({
+                      id: Skill.ID.make(skill),
+                      mention: { start, end: start + promptOffsetWidth(text), text },
+                    })
+                    draft.extmarkToPart.set(extmarkId, { type: "skill", index })
+                  }),
+                )
               }}
             />
           ))
@@ -639,6 +658,11 @@ export function Prompt(props: PromptProps) {
         ref: { type: "agent" as const, index },
         styleId: agentStyleId,
       })),
+      ...(prompt.skills ?? []).map((part, index) => ({
+        mention: part.mention,
+        ref: { type: "skill" as const, index },
+        styleId: skillStyleId,
+      })),
       ...prompt.pasted.map((part, index) => ({
         mention: part.source,
         ref: { type: "pasted" as const, index },
@@ -671,6 +695,7 @@ export function Prompt(props: PromptProps) {
         const newMap = new Map<number, PromptPartRef>()
         const files: NonNullable<PromptInfo["files"]> = []
         const agents: NonNullable<PromptInfo["agents"]> = []
+        const skills: NonNullable<PromptInfo["skills"]> = []
         const pasted: PromptInfo["pasted"] = []
 
         for (const extmark of allExtmarks) {
@@ -696,6 +721,16 @@ export function Prompt(props: PromptProps) {
             newMap.set(extmark.id, { type: "agent", index })
             continue
           }
+          if (ref.type === "skill") {
+            const part = draft.prompt.skills?.[ref.index]
+            if (!part?.mention) continue
+            part.mention.start = extmark.start
+            part.mention.end = extmark.end
+            const index = skills.length
+            skills.push(part)
+            newMap.set(extmark.id, { type: "skill", index })
+            continue
+          }
           const part = draft.prompt.pasted[ref.index]
           if (!part) continue
           part.source.start = extmark.start
@@ -708,6 +743,7 @@ export function Prompt(props: PromptProps) {
         draft.extmarkToPart = newMap
         draft.prompt.files = files
         draft.prompt.agents = agents
+        draft.prompt.skills = skills
         draft.prompt.pasted = pasted
       }),
     )
@@ -983,6 +1019,7 @@ export function Prompt(props: PromptProps) {
     )
     const slashHead = parseSlashHead(inputText, /\s/)
     const isSkill =
+      !(store.prompt.skills?.length ?? 0) &&
       slashHead !== undefined &&
       (data.location.skill.list(currentLocation.ref) ?? []).some(
         (skill) => skill.slash === true && skill.id === slashHead.name,
@@ -1080,6 +1117,7 @@ export function Prompt(props: PromptProps) {
           model,
           files: store.prompt.files,
           agents: store.prompt.agents,
+          skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
           delivery,
         })
         .catch((error) => {
@@ -1146,6 +1184,7 @@ export function Prompt(props: PromptProps) {
           text: inputText,
           files: store.prompt.files,
           agents: store.prompt.agents,
+          skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
           delivery,
         })
         .then(
@@ -1685,6 +1724,8 @@ export function Prompt(props: PromptProps) {
         value={store.prompt.text}
         fileStyleId={fileStyleId}
         agentStyleId={agentStyleId}
+        skillStyleId={skillStyleId}
+        hasSkill={(id) => store.prompt.skills?.some((skill) => skill.id === id) ?? false}
         promptPartTypeId={() => promptPartTypeId}
       />
     </>

+ 74 - 23
packages/tui/src/mini/footer.prompt.tsx

@@ -21,6 +21,7 @@ import {
   isExitCommand,
   isCompactCommand,
   mentionTriggerIndex,
+  slashTriggerIndex,
   isNewCommand,
   movePromptHistory,
   promptCopy,
@@ -50,7 +51,7 @@ export const TEXTAREA_MIN_ROWS = 1
 const TEXTAREA_MAX_ROWS = 6
 export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
 
-type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
+type Mention = Extract<RunPromptPart, { type: "file" | "agent" | "skill" }>
 
 type Auto = RunFooterMenuItem & {
   kind: "mention"
@@ -65,7 +66,12 @@ type SlashOption = RunFooterMenuItem & {
   action?: "skill-menu" | "editor" | "settings"
 }
 
-type PromptOption = Auto | SlashOption
+type SkillOption = RunFooterMenuItem & {
+  kind: "skill"
+  id: string
+}
+
+type PromptOption = Auto | SlashOption | SkillOption
 
 type MenuMode = false | "mention" | "slash"
 
@@ -124,12 +130,9 @@ function emptyPrompt(shell: boolean): RunPrompt {
 }
 
 function slashQuery(text: string, cursor: number) {
-  const head = parseSlashHead(text.slice(0, cursor))
-  if (!head || head.end !== cursor) {
-    return
-  }
-
-  return head.name
+  const at = slashTriggerIndex(text, cursor)
+  if (at === undefined) return
+  return { at, value: displaySlice(text, at + 1, cursor) }
 }
 
 function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
@@ -382,10 +385,18 @@ export function createPromptState(input: PromptInput): PromptState {
   )
   const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
   const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
+  const skillOptions = createMemo<SkillOption[]>(() =>
+    skillCommands().map((item) => ({
+      kind: "skill",
+      id: item.name,
+      display: `/${item.name}`,
+      description: item.description,
+    })),
+  )
   const hasSkillsCommand = createMemo(() =>
     (input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
   )
-  const slashOptions = createMemo<SlashOption[]>(() => {
+  const slashOptions = createMemo<Array<SlashOption | SkillOption>>(() => {
     const builtins = [
       {
         kind: "slash",
@@ -417,6 +428,7 @@ export function createPromptState(input: PromptInput): PromptState {
     }
 
     return [
+      ...skillOptions(),
       ...(showSkillMenu
         ? [
             {
@@ -443,7 +455,7 @@ export function createPromptState(input: PromptInput): PromptState {
     ].sort((a, b) => a.display.localeCompare(b.display))
   })
   const options = createMemo<PromptOption[]>(() => {
-    const mixed: PromptOption[] = mode() === "slash" ? slashOptions() : mentionOptions()
+    const mixed: PromptOption[] = mode() === "slash" ? (at() === 0 ? slashOptions() : skillOptions()) : mentionOptions()
     if (!query()) {
       return mixed
     }
@@ -459,7 +471,11 @@ export function createPromptState(input: PromptInput): PromptState {
 
     return fuzzysort
       .go(next, mixed, {
-        keys: [(item) => (item.kind === "mention" ? item.value : item.name).trimEnd(), "display", "description"],
+        keys: [
+          (item) => (item.kind === "mention" ? item.value : item.kind === "skill" ? item.id : item.name).trimEnd(),
+          "display",
+          "description",
+        ],
       })
       .map((item) => item.obj)
   })
@@ -512,17 +528,19 @@ export function createPromptState(input: PromptInput): PromptState {
         continue
       }
 
-      const text = area.plainText.slice(item.start, item.end)
+      const text = displaySlice(area.plainText, item.start, item.end)
       const prev =
         part.type === "agent"
           ? (part.source?.value ?? "@" + part.name)
-          : (part.source?.text.value ?? "@" + (part.filename ?? ""))
+          : part.type === "skill"
+            ? (part.source?.value ?? "/" + part.id)
+            : (part.source?.text.value ?? "@" + (part.filename ?? ""))
       if (text !== prev) {
         continue
       }
 
       const copy = structuredClone(part)
-      if (copy.type === "agent") {
+      if (copy.type === "agent" || copy.type === "skill") {
         copy.source = {
           start: item.start,
           end: item.end,
@@ -558,7 +576,7 @@ export function createPromptState(input: PromptInput): PromptState {
   const restoreParts = (value: RunPromptPart[]) => {
     clearParts()
     parts = value
-      .filter((item): item is Mention => item.type === "file" || item.type === "agent")
+      .filter((item): item is Mention => item.type === "file" || item.type === "agent" || item.type === "skill")
       .map((item) => structuredClone(item))
     if (!area || area.isDestroyed || type === 0) {
       return
@@ -566,8 +584,8 @@ export function createPromptState(input: PromptInput): PromptState {
 
     const box = area
     parts.forEach((item, idx) => {
-      const start = item.type === "agent" ? item.source?.start : item.source?.text.start
-      const end = item.type === "agent" ? item.source?.end : item.source?.text.end
+      const start = item.type === "file" ? item.source?.text.start : item.source?.start
+      const end = item.type === "file" ? item.source?.text.end : item.source?.end
       if (start === undefined || end === undefined) {
         return
       }
@@ -627,16 +645,16 @@ export function createPromptState(input: PromptInput): PromptState {
         return
       }
 
-      setAt(0)
-      setQuery(slash)
+      setAt(slash.at)
+      setQuery(slash.value)
       return
     }
 
     if (slash !== undefined) {
-      setAt(0)
+      setAt(slash.at)
       menu.reset()
       setMode("slash")
-      setQuery(slash)
+      setQuery(slash.value)
       return
     }
 
@@ -782,7 +800,7 @@ export function createPromptState(input: PromptInput): PromptState {
     }
 
     const cursor = area.cursorOffset
-    const startOffset = mode() === "slash" ? 0 : at()
+    const startOffset = at()
     area.cursorOffset = startOffset
     const start = area.logicalCursor
     area.cursorOffset = cursor
@@ -828,6 +846,39 @@ export function createPromptState(input: PromptInput): PromptState {
       return
     }
 
+    if (next.kind === "skill") {
+      if (parts.some((part) => part.type === "skill" && part.id === next.id)) {
+        cancelAutocomplete()
+        return
+      }
+      const cursor = area.cursorOffset
+      const tail = displayCharAt(area.plainText, cursor)
+      const append = `/${next.id}${tail === " " ? "" : " "}`
+      area.cursorOffset = at()
+      const start = area.logicalCursor
+      area.cursorOffset = cursor
+      const end = area.logicalCursor
+      area.deleteRange(start.row, start.col, end.row, end.col)
+      area.insertText(append)
+
+      const text = `/${next.id}`
+      const startOffset = at()
+      const endOffset = startOffset + stringWidth(text)
+      const part: Extract<RunPromptPart, { type: "skill" }> = {
+        type: "skill",
+        id: next.id,
+        source: { start: startOffset, end: endOffset, value: text },
+      }
+      const id = area.extmarks.create({ start: startOffset, end: endOffset, virtual: true, typeId: type })
+      marks.set(id, parts.length)
+      parts.push(part)
+      hide()
+      syncDraft()
+      scheduleRows()
+      area.focus()
+      return
+    }
+
     if (next.kind === "slash") {
       if (next.action === "editor") {
         void openEditor({
@@ -1193,7 +1244,7 @@ export function createPromptState(input: PromptInput): PromptState {
     }
 
     const parsed =
-      command || next.mode === "shell" || isNewCommand(next.text)
+      command || next.parts.some((part) => part.type === "skill") || next.mode === "shell" || isNewCommand(next.text)
         ? undefined
         : parseSlashCommand(next.text, input.commands())
     if (parsed?.type === "pending") {

+ 5 - 5
packages/tui/src/mini/prompt.editor.ts

@@ -2,7 +2,7 @@ import type { RunPromptPart } from "./types"
 import { realignPromptMentions } from "../prompt/mention"
 import { parseSlashHead } from "../prompt/parse"
 
-type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
+type Mention = Extract<RunPromptPart, { type: "file" | "agent" | "skill" }>
 
 export function resolveEditorSlashValue(text: string) {
   const head = parseSlashHead(text)
@@ -17,13 +17,13 @@ export function realignEditorPromptParts(content: string, parts: RunPromptPart[]
   const matches = realignPromptMentions(
     content,
     parts.map((part) => {
-      if (part.type !== "file" && part.type !== "agent") return
+      if (part.type !== "file" && part.type !== "agent" && part.type !== "skill") return
       return promptPartMention(part)
     }),
   )
 
   return parts.flatMap((part, index) => {
-    if (part.type !== "file" && part.type !== "agent") return [part]
+    if (part.type !== "file" && part.type !== "agent" && part.type !== "skill") return [part]
     const mention = promptPartMention(part)
     if (!mention?.text) return [part]
     const match = matches[index]
@@ -32,13 +32,13 @@ export function realignEditorPromptParts(content: string, parts: RunPromptPart[]
 }
 
 function promptPartMention(part: Mention) {
-  const source = part.type === "agent" ? part.source : part.source?.text
+  const source = part.type === "file" ? part.source?.text : part.source
   if (!source) return
   return { start: source.start, end: source.end, text: source.value }
 }
 
 function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
-  if (part.type === "agent") {
+  if (part.type === "agent" || part.type === "skill") {
     return {
       ...part,
       source: {

+ 1 - 1
packages/tui/src/mini/prompt.shared.ts

@@ -7,7 +7,7 @@
 // the current browse position. When the user arrows up at cursor offset 0,
 // the current draft is saved and history begins. Arrowing past the end
 // restores the draft.
-export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt/display"
+export { displayCharAt, displaySlice, mentionTriggerIndex, slashTriggerIndex } from "../prompt/display"
 import { stringWidth } from "../util/string-width"
 import type { RunPrompt } from "./types"
 

+ 24 - 3
packages/tui/src/mini/stream-v2.transport.ts

@@ -288,6 +288,21 @@ function promptAgents(next: SessionTurnInput) {
   )
 }
 
+function promptSkills(next: SessionTurnInput) {
+  return next.prompt.parts.flatMap((part) =>
+    part.type === "skill"
+      ? [
+          {
+            id: part.id,
+            mention: part.source
+              ? { start: part.source.start, end: part.source.end, text: part.source.value }
+              : undefined,
+          },
+        ]
+      : [],
+  )
+}
+
 function streamPartKey(messageID: string, partID: string) {
   return `${messageID}\u0000${partID}`
 }
@@ -358,12 +373,12 @@ const catalogEvents = new Set([
 // briefly so the output commit renders inside it.
 const SHELL_OUTPUT_GRACE_MS = 1500
 
-function skillCommit(messageID: string, name: string): StreamCommit {
+function skillCommit(messageID: string, name: string, skillID = messageID): StreamCommit {
   return {
     kind: "system",
     source: "system",
     messageID,
-    partID: `skill:${messageID}`,
+    partID: `skill:${skillID}`,
     text: `→ Skill "${name}"`,
     phase: "start",
   }
@@ -637,7 +652,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       state.messageIDs.add(message.id)
       if (!render) return
       if (reuseVisibleWait && waiting) return
-      write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
+      write([
+        ...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
+        { kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
+      ])
       return
     }
     if (message.type === "skill") {
@@ -1615,6 +1633,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     const command = next.prompt.command
     const attachments = await prepareAttachments(next, command ? "command" : "prompt", input.readTextFile)
     const agents = promptAgents(next)
+    const skills = promptSkills(next)
     if (!command) {
       input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID, delivery })
       return client.session.prompt(
@@ -1624,6 +1643,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
           text: [next.prompt.text, ...attachments.text].join("\n\n"),
           files: attachments.files.length ? attachments.files : undefined,
           agents: agents.length ? agents : undefined,
+          skills: skills.length ? skills : undefined,
           delivery,
         },
         { signal: next.signal },
@@ -1643,6 +1663,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         model: selected,
         files: attachments.files.length ? attachments.files : undefined,
         agents: agents.length ? agents : undefined,
+        skills: skills.length ? skills : undefined,
         delivery,
       },
       { signal: next.signal },

+ 1 - 0
packages/tui/src/mini/types.ts

@@ -47,6 +47,7 @@ export type RunPromptPart =
       }
     }
   | { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
+  | { type: "skill"; id: string; source?: { start: number; end: number; value: string } }
 
 export type RunCommand = {
   name: string

+ 10 - 1
packages/tui/src/prompt/codec.ts

@@ -1,9 +1,14 @@
 import type { Prompt, PromptInput } from "@opencode-ai/schema"
+import { Skill } from "@opencode-ai/schema/skill"
 import type { Types } from "effect"
 
 export type EditablePromptInput = Types.DeepMutable<PromptInput.Prompt>
 
-export function projectedPromptInput(input: Pick<Prompt, "text" | "files" | "agents">): EditablePromptInput {
+type ProjectedPrompt = Pick<Prompt, "text" | "files" | "agents"> & {
+  readonly skills?: ReadonlyArray<{ readonly id: string; readonly mention?: PromptInput.SkillAttachment["mention"] }>
+}
+
+export function projectedPromptInput(input: ProjectedPrompt): EditablePromptInput {
   return {
     text: input.text,
     files: input.files?.map((file) => ({
@@ -16,5 +21,9 @@ export function projectedPromptInput(input: Pick<Prompt, "text" | "files" | "age
       name: agent.name,
       mention: agent.mention ? { ...agent.mention } : undefined,
     })),
+    skills: input.skills?.map((skill) => ({
+      id: Skill.ID.make(skill.id),
+      mention: skill.mention ? { ...skill.mention } : undefined,
+    })),
   }
 }

+ 11 - 0
packages/tui/src/prompt/display.ts

@@ -48,3 +48,14 @@ export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(va
     return promptOffsetWidth(text.slice(0, index))
   }
 }
+
+export function slashTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
+  const text = displaySlice(value, 0, offset)
+  for (let index = text.lastIndexOf("/"); index >= 0; index = text.lastIndexOf("/", index - 1)) {
+    const before = index === 0 ? undefined : text[index - 1]
+    const query = text.slice(index)
+    if (before !== undefined && !/\s/.test(before)) continue
+    if (/\s/.test(query) || query.slice(1).includes("/")) return
+    return promptOffsetWidth(text.slice(0, index))
+  }
+}

+ 4 - 4
packages/tui/src/prompt/history.tsx

@@ -1,7 +1,7 @@
 import path from "path"
 import { onMount } from "solid-js"
 import { createStore, produce, unwrap } from "solid-js/store"
-import type { SessionPromptInput } from "@opencode-ai/client"
+import type { PromptInput } from "@opencode-ai/schema"
 import type { Types } from "effect"
 import { createSimpleContext } from "../context/helper"
 import { useTuiPaths } from "../context/runtime"
@@ -16,17 +16,17 @@ export type PastedText = {
   }
 }
 
-export type PromptInfo = Types.DeepMutable<Pick<SessionPromptInput, "text" | "files" | "agents">> & {
+export type PromptInfo = Types.DeepMutable<Pick<PromptInput.Prompt, "text" | "files" | "agents" | "skills">> & {
   pasted: PastedText[]
   mode?: "normal" | "shell"
 }
 
 export type PromptPartRef = {
-  type: "file" | "agent" | "pasted"
+  type: "file" | "agent" | "skill" | "pasted"
   index: number
 }
 
-export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], pasted: [] })
+export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], skills: [], pasted: [] })
 
 export const MAX_HISTORY_ENTRIES = 50
 

+ 8 - 5
packages/tui/src/prompt/mention.ts

@@ -35,7 +35,9 @@ export function realignPromptMentions(
     ([left], [right]) => right.length - left.length || left.localeCompare(right),
   )) {
     const candidates = mentionRanges(content, text)
-    const available = candidates.filter((candidate) => !protectedRanges.some((range) => overlaps(candidate, range)))
+    const available = candidates.filter(
+      (candidate) => !protectedRanges.some((range) => overlaps(candidate, range)),
+    )
     for (const [item, candidate] of assignMentions(items, available)) {
       aligned[item.index] = {
         text,
@@ -52,9 +54,11 @@ export function realignPromptMentions(
 export function realignPromptInputMentions(content: string, input: PromptInput.Prompt): EditablePromptInput {
   const files = input.files ?? []
   const agents = input.agents ?? []
+  const skills = input.skills ?? []
   const mentions = realignPromptMentions(content, [
     ...files.map((file) => file.mention),
     ...agents.map((agent) => agent.mention),
+    ...skills.map((skill) => skill.mention),
   ])
   const align = <T extends { mention?: PromptMention }>(items: readonly T[] | undefined, offset = 0) =>
     items?.flatMap((item, index) => {
@@ -67,6 +71,7 @@ export function realignPromptInputMentions(content: string, input: PromptInput.P
     text: content,
     files: align(input.files),
     agents: align(input.agents, files.length),
+    skills: align(input.skills, files.length + agents.length),
   }
 }
 
@@ -89,6 +94,7 @@ export function expandPromptInputPastedText(
     text: expandTrackedPastedText(input.text, ranges),
     files: input.files?.map((file) => ({ ...file, mention: shift(file.mention) })),
     agents: input.agents?.map((agent) => ({ ...agent, mention: shift(agent.mention) })),
+    skills: input.skills?.map((skill) => ({ ...skill, mention: shift(skill.mention) })),
   }
 }
 
@@ -107,10 +113,7 @@ function assignMentions(items: MentionItem[], candidates: CandidateRange[]) {
   const ordered = items.toSorted((left, right) => left.mention.start - right.mention.start || left.index - right.index)
   const memo = new Map<string, { matches: number; cost: number; pairs: Array<[MentionItem, CandidateRange]> }>()
 
-  function solve(
-    item: number,
-    candidate: number,
-  ): { matches: number; cost: number; pairs: Array<[MentionItem, CandidateRange]> } {
+  function solve(item: number, candidate: number): { matches: number; cost: number; pairs: Array<[MentionItem, CandidateRange]> } {
     if (item >= ordered.length || candidate >= candidates.length) return { matches: 0, cost: 0, pairs: [] }
     const key = `${item}:${candidate}`
     const cached = memo.get(key)

+ 24 - 1
packages/tui/src/routes/session/index.tsx

@@ -1893,6 +1893,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
   const data = useData()
   const local = useLocal()
   const files = createMemo(() => props.message.files ?? [])
+  const skills = createMemo(() => props.message.skills ?? [])
   const themes = useThemes()
   const theme = useTheme("elevated")
   const mode = themes.mode
@@ -1908,7 +1909,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
   }
 
   return (
-    <Show when={props.message.text.trim() || files().length}>
+    <Show when={props.message.text.trim() || files().length || skills().length}>
       <box
         border={["left"]}
         borderColor={delivery() ? theme.border.default : color()}
@@ -1953,6 +1954,28 @@ function UserMessage(props: { message: SessionMessageUser }) {
           flexShrink={0}
         >
           <text fg={theme.text.default}>{props.message.text}</text>
+          <Show when={skills().length}>
+            <box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
+              <For each={skills()}>
+                {(skill) => (
+                  <text fg={theme.text.default}>
+                    <span
+                      style={{
+                        bg: theme.hue.accent[mode() === "light" ? 700 : 200],
+                        fg: theme.background.default,
+                        bold: true,
+                      }}
+                    >
+                      {" skill "}
+                    </span>
+                    <span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
+                      {` ${skill.name} `}
+                    </span>
+                  </text>
+                )}
+              </For>
+            </box>
+          </Show>
           <Show when={files().length}>
             <box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
               <For each={files()}>

+ 69 - 0
packages/tui/test/mini/footer.view.test.tsx

@@ -1262,6 +1262,75 @@ test("direct footer tags skill slash submissions with their catalog source", asy
   }
 })
 
+test("direct footer submits a selected leading skill as a prompt attachment", async () => {
+  const submits: RunPrompt[] = []
+  const app = await renderFooter({
+    commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
+    onSubmit(prompt) {
+      submits.push(prompt)
+      return true
+    },
+  })
+
+  try {
+    await app.renderOnce()
+    "/forma".split("").forEach((key) => app.mockInput.pressKey(key))
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+    "src".split("").forEach((key) => app.mockInput.pressKey(key))
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+
+    expect(submits).toEqual([
+      {
+        text: "/formatter src",
+        parts: [
+          {
+            type: "skill",
+            id: "formatter",
+            source: { start: 0, end: 10, value: "/formatter" },
+          },
+        ],
+        delivery: "steer",
+      },
+    ])
+  } finally {
+    app.cleanup()
+  }
+})
+
+test("direct footer preserves a selected skill after wide text", async () => {
+  const submits: RunPrompt[] = []
+  const app = await renderFooter({
+    commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
+    onSubmit(prompt) {
+      submits.push(prompt)
+      return true
+    },
+  })
+
+  try {
+    await app.renderOnce()
+    "中 /forma".split("").forEach((key) => app.mockInput.pressKey(key))
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+
+    expect(submits[0]?.parts).toEqual([
+      {
+        type: "skill",
+        id: "formatter",
+        source: { start: 3, end: 13, value: "/formatter" },
+      },
+    ])
+  } finally {
+    app.cleanup()
+  }
+})
+
 // OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition.
 // Re-enable after the upstream renderer teardown fix lands.
 test.skip("direct footer skill picker inserts an editable bound skill command", async () => {

+ 76 - 1
packages/tui/test/mini/stream-v2.transport.test.ts

@@ -2755,7 +2755,7 @@ describe("V2 mini transport", () => {
       variant: undefined,
       prompt: {
         messageID: "msg_cmd",
-        text: "/deploy prod",
+        text: "/deploy prod /api-design",
         parts: [
           {
             type: "file",
@@ -2763,6 +2763,11 @@ describe("V2 mini transport", () => {
             filename: "mentioned.txt",
             source: { type: "file", text: { start: 8, end: 12, value: "prod" } },
           },
+          {
+            type: "skill",
+            id: "api-design",
+            source: { start: 13, end: 24, value: "/api-design" },
+          },
         ],
         command: { name: "deploy", arguments: "prod" },
       },
@@ -2785,6 +2790,7 @@ describe("V2 mini transport", () => {
           mention: { start: 8, end: 12, text: "prod" },
         },
       ],
+      skills: [{ id: "api-design", mention: { start: 13, end: 24, text: "/api-design" } }],
       delivery: "steer",
     })
     // Selection rides the command payload; no separate client-side switch.
@@ -2857,6 +2863,75 @@ describe("V2 mini transport", () => {
     await transport.close()
   })
 
+  test("sends inline skill attachments with a normal prompt", async () => {
+    const events = feed()
+    events.push(connected())
+    const client = sdk({ streams: [events] })
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: client,
+      sessionID: "ses_1",
+      thinking: false,
+      footer: ui.api,
+    })
+    let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
+    spyOn(client.session, "prompt").mockImplementation((input) => {
+      request = input
+      queueMicrotask(() => {
+        events.push({
+          id: "evt_prompted",
+          created: 0,
+          type: "session.input.promoted",
+          durable: durable("ses_1"),
+          data: { sessionID: "ses_1", inputID: "msg_skill_attachment" },
+        })
+        events.push({
+          id: "evt_settled",
+          created: 0,
+          type: "session.execution.succeeded",
+          durable: durable("ses_1"),
+          data: { sessionID: "ses_1" },
+        })
+      })
+      return ok({
+        id: input.id ?? "msg_skill_attachment",
+        sessionID: "ses_1",
+        type: "user" as const,
+        data: { text: input.text },
+        delivery: "steer" as const,
+        timeCreated: 2,
+      })
+    })
+
+    await transport.runPromptTurn({
+      agent: undefined,
+      model: undefined,
+      variant: undefined,
+      prompt: {
+        messageID: "msg_skill_attachment",
+        text: "Review this /api-design",
+        parts: [
+          {
+            type: "skill",
+            id: "api-design",
+            source: { start: 12, end: 23, value: "/api-design" },
+          },
+        ],
+      },
+      files: [],
+      includeFiles: false,
+    })
+
+    expect(request).toMatchObject({
+      sessionID: "ses_1",
+      id: "msg_skill_attachment",
+      text: "Review this /api-design",
+      skills: [{ id: "api-design", mention: { start: 12, end: 23, text: "/api-design" } }],
+      delivery: "steer",
+    })
+    await transport.close()
+  })
+
   test("refreshes catalogs on connection and location-scoped invalidations", async () => {
     const events = feed()
     events.push(connected())

+ 11 - 1
packages/tui/test/prompt/display.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { displayCharAt, displaySlice, mentionTriggerIndex } from "../../src/prompt/display"
+import { displayCharAt, displaySlice, mentionTriggerIndex, slashTriggerIndex } from "../../src/prompt/display"
 
 describe("prompt display", () => {
   test("uses display-width offsets for mentions", () => {
@@ -30,4 +30,14 @@ describe("prompt display", () => {
     expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
     expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
   })
+
+  test("finds slash attachments at token boundaries", () => {
+    expect(slashTriggerIndex("/")).toBe(0)
+    expect(slashTriggerIndex("Review this /api-design")).toBe(12)
+    expect(slashTriggerIndex("中文 /api-design")).toBe(5)
+    expect(slashTriggerIndex("Review /api design")).toBeUndefined()
+    expect(slashTriggerIndex("Review /tmp/file.ts")).toBeUndefined()
+    expect(slashTriggerIndex("https://opencode.ai/docs")).toBeUndefined()
+    expect(slashTriggerIndex("src/prompt/index.ts")).toBeUndefined()
+  })
 })