feat(app): open the slash picker anywhere and insert inline skill chips#1156
Conversation
Inline skill chips need a structured, position-independent message part, mirroring AgentPart: a reference that activates a skill for the whole turn regardless of where it sits in the text. Add the SkillPart schema (name + optional source span) and register it in the Part discriminated union. Resolution (expand the skill template into a synthetic model-visible text part) and the composer UI land in later commits; this commit is the schema foundation only.
Pull the template text transform out of SessionPrompt.command() into a reusable expandCommandTemplate(cmd, args) helper: positional ($1..$n) + $ARGUMENTS substitution, inline shell interpolation, trim. Behavior is unchanged for command(); the helper deliberately excludes agent/model/ subtask selection, plugin hooks, and command events so the upcoming inline-skill resolvePart branch can reuse only the text expansion. No behavior change. Existing command tests pass.
Add SkillPartInput to PromptInput.parts and a skill branch to resolvePart, mirroring the agent branch: a skill part resolves to the persisted structured chip part plus a synthetic, model-visible text part carrying the expanded template (argless — the surrounding prose is the turn body). This makes skill invocation position-independent (activation reads the parts array, not a leading token) without touching the session.command endpoint, command(), or toModelMessages (synthetic text flows through; the structured part is skipped like AgentPart). Unknown skill names keep the chip but inject nothing. Tests: skill part after prose resolves to chip + synthetic template + intact prose; unknown skill keeps chip, injects no synthetic text.
Regenerated src/v2/gen from the opencode server schema (bun run build): adds SkillPart to the Part union and SkillPartInput to PromptInput.parts. Consumer-side only; the checked-in packages/sdk/openapi.json snapshot is untouched (route-inventory reads paths only).
Introduce SkillAttachmentPart in the prompt content union so an inline skill chip can live anywhere in the composer, mirroring the @-agent part. Thread the new member through clonePart, prompt equality, history cloning, and the composer preview helpers.
Round-trip a contenteditable=false skill pill (data-type=skill, data-name, data-source) through createPill / parseEditorToParts / renderPartsToEditor, accept it in isNormalizedEditor so the editor does not rebuild every keystroke, and count it as a non-editable boundary in the caret-placement predicates.
Replace the start-anchored slash regex with a cursor-relative, CJK-aware trigger so "/" opens the command picker mid-sentence without firing inside paths, URLs, or fractions. When the trigger fires mid-text, restrict the picker to inline-eligible entries (skills + simple custom commands), excluding action builtins and agent/arg-heavy commands which keep the leading path.
Selecting a skill or simple command from the picker now replaces the typed "/query" with a position-independent skill chip via addPart, instead of prepending a leading command mark. Arg/agent-heavy commands and action builtins keep their existing leading-pill / trigger paths.
Extract inline skill chips into SkillPartInput on send (mirroring agents), tagging the "/name" span via source so the bubble can render it. Add a toOptimisticPart skill case before the agent fallthrough so the optimistic bubble shows the chip and does not flicker when the server-persisted SkillPart arrives.
When a prompt or followup draft contains a structured skill part, its flattened text can start with "/name" (chip at offset 0). Guard both legacy session.command routing branches (submit + followup) and the canSubmitPrompt command_ready gate so such prompts flow through promptAsync, where the skill part resolves into synthetic model text. Path D (marked leading TextPart) is unaffected: a skill chip carries no command metadata.
Add a skill reference segment to HighlightedText keyed off the skill part's source span, and pass skill parts alongside inline files in the user-message prose branch (not the commandInvocation XOR branch, which suppresses the body). The "/name" token stays inline and is colored with the brand accent, matching the leading command mark.
Bridge the skill glyph from the chrome icon registry into the command-icon registry so both the composer pill and the sent bubble can resolve it. The composer skill pill now renders glyph + /name label (textContent stays /name for caret math); the bubble skill segment renders the glyph before the brand-accent /name token. The persisted SkillPart drops the source kind, so the glyph is uniform across surfaces. Adds a focused message-skill-inline snap target.
… test Move SLASH_TRIGGER + matchSlashTrigger into slash-trigger.ts so it can be unit-tested without importing editor-input (which pulls @solidjs/router and throws in the test env). DRYs the two offset computations in editor-input. Adds a regex matrix covering start/space/newline/CJK triggers and path/URL/fraction/word-char negatives.
…ness editor-serialize: skill pill attributes, '/name' textContent invariant, prose+skill+prose round-trip, position independence in isNormalizedEditor. build-request-parts: SkillPartInput emission + matching optimistic skill part (no agent fallthrough). session-action-readiness: inline skill bypasses the command-hydration gate but still respects submitReady.
Seeds a project-scoped skill (.agents/skills/summarize/SKILL.md), types prose then "/summarize" mid-sentence, asserts the picker opens with the skill and excludes action builtins, clicks it, and verifies a position-independent skill chip lands in the composer.
📝 WalkthroughWalkthroughAdds inline "skill" parts: detect /name triggers, render skill pills in the editor, include skill parts in request/optimistic payloads, resolve templates server-side, render skill highlights and icon in messages, and cover behavior with unit, snapshot, and e2e tests. ChangesInline Skill Attachment Support
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
Suggested priority: P2 (includes user-path files (packages/app/src/components/prompt-input/build-request-parts.test.ts, packages/app/src/components/prompt-input/build-request-parts.ts, packages/app/src/components/prompt-input/command-pill.css, packages/app/src/components/prompt-input/editor-dom.ts, packages/app/src/components/prompt-input/editor-input.ts, packages/app/src/components/prompt-input/editor-serialize.test.ts, packages/app/src/components/prompt-input/editor-serialize.ts, packages/app/src/components/prompt-input/history.ts, packages/app/src/components/prompt-input/popover-controllers.ts, packages/app/src/components/prompt-input/send-followup-draft.ts, packages/app/src/components/prompt-input/slash-popover.tsx, packages/app/src/components/prompt-input/slash-trigger.test.ts, packages/app/src/components/prompt-input/slash-trigger.ts, packages/app/src/components/prompt-input/submit.ts, packages/app/src/context/prompt-equality.ts, packages/app/src/context/prompt.tsx, packages/app/src/pages/session/composer/home-composer-region.tsx, packages/app/src/pages/session/composer/session-composer-region.tsx, packages/app/src/pages/session/session-action-readiness.test.ts, packages/app/src/pages/session/session-action-readiness.ts, packages/app/src/pages/session/use-session-followups.ts)).
P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.
extractPromptFromParts now reconstructs a SkillAttachmentPart from a persisted SkillPart's source span, so fork/undo/revert of a message sent with an inline skill chip brings the chip back instead of a literal "/name" that no longer expands (and, at offset 0, would reroute to the legacy command endpoint). Addresses a /codex review P2.
When a leading marked command owns the turn (Path D / session.command, which carries only image parts), a "/" in its args no longer opens the skill picker — an inserted skill chip would be silently dropped on submit. The slash stays literal command-argument text. Addresses a /codex review P2.
|
Follow-up: the slash picker's icon system (per-function semantic icons, brand accent reserved for the active/in-effect state, drop the source badge) is intentionally out of scope here to keep this PR on the mechanics + the inline chip. Tracked in #1164. |
command-icon.tsx imported { icons } from icon.tsx just to read one
skill glyph, pulling the entire ~140KB registry module into the
command-icon → prompt-input bundle chain. bun's named-export static
analysis chokes on that module on Windows, causing the unit-windows-app
CI job to fail with:
SyntaxError: Export named 'icons' not found in module 'icon.tsx'
Inline the skill SVG content directly and add a sync assertion in
command-icon.test.ts (ui-only, not in the Windows matrix) that compares
the inlined copy against icon.tsx's source of truth.
The skill glyph (✦) already signals "this is a /command"; showing the slash as well produces "✦ /screenshot" instead of "✦ screenshot". Strip the leading "/" when the segment type is "skill" so the chip reads as glyph + name only.
The skill glyph already signals "this is a slash command", so the leading "/" in the pill label is redundant visual noise. Strip it from the label span; pushSkill rebuilds the canonical "/<name>" token from data-name so flattened text and source offsets are unchanged. editor-dom already computes logical pill length as 1 + data-name.length (not textContent), so caret math is unaffected.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/app/src/components/prompt-input/editor-input.ts`:
- Around line 286-289: The trigger matching uses beforeCursor =
rawText.substring(0, cursorPosition) which strips non-text chips, but
replaceStart/setRangeEdge operate on editor logical offsets, causing mismatched
coordinates; fix by performing trigger matching in the same coordinate space as
replacement: either derive beforeCursor from the editor's logical text (not
rawText) or map the match indices back to editor offsets before computing
replaceStart; update usages around beforeCursor, atMatch, matchSlashTrigger,
replaceStart and setRangeEdge so the match start index is converted to the
editor's logical offset consistently.
In `@packages/app/src/components/prompt-input/history.ts`:
- Line 46: isPromptEqual currently doesn't handle parts with type "skill", so
distinct skill-chip prompts can be treated as equal and dropped by
prependHistoryEntry; update isPromptEqual to explicitly handle when partA.type
=== "skill" (and partB.type === "skill") by comparing the skill's unique
identifier or stable property (e.g., skill.id or skillKey/name) rather than
falling through to the generic comparison, and ensure it returns false when
types differ; adjust any equality branches so skill parts are correctly
distinguished and prependHistoryEntry won't dedupe different skills.
In `@packages/app/src/pages/session/session-action-readiness.ts`:
- Around line 15-23: canSendFollowupDraft currently doesn't propagate the
hasSkillPart flag so queued follow-ups that begin with an inline skill token can
be incorrectly blocked by the commandsReady gate; update canSendFollowupDraft to
accept/inspect input.hasSkillPart and forward it when calling canSubmitPrompt
(or mirror the same early-return logic: if input.hasSkillPart return true) so
that follow-ups with inline skill parts bypass the command_ready/commandsReady
check like canSubmitPrompt does.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 6e544f55-6d5f-43cb-847a-1d6db99526db
⛔ Files ignored due to path filters (2)
packages/sdk/js/src/v2/gen/sdk.gen.tsis excluded by!**/gen/**packages/sdk/js/src/v2/gen/types.gen.tsis excluded by!**/gen/**
📒 Files selected for processing (33)
packages/app/e2e/prompt/prompt-slash-open.spec.tspackages/app/e2e/snap/message-skill-inline.snap.tspackages/app/src/components/prompt-input/build-request-parts.test.tspackages/app/src/components/prompt-input/build-request-parts.tspackages/app/src/components/prompt-input/command-pill.csspackages/app/src/components/prompt-input/editor-dom.tspackages/app/src/components/prompt-input/editor-input.tspackages/app/src/components/prompt-input/editor-serialize.test.tspackages/app/src/components/prompt-input/editor-serialize.tspackages/app/src/components/prompt-input/history.tspackages/app/src/components/prompt-input/popover-controllers.tspackages/app/src/components/prompt-input/send-followup-draft.tspackages/app/src/components/prompt-input/slash-popover.tsxpackages/app/src/components/prompt-input/slash-trigger.test.tspackages/app/src/components/prompt-input/slash-trigger.tspackages/app/src/components/prompt-input/submit.tspackages/app/src/context/prompt-equality.tspackages/app/src/context/prompt.tsxpackages/app/src/pages/session/composer/home-composer-region.tsxpackages/app/src/pages/session/composer/session-composer-region.tsxpackages/app/src/pages/session/session-action-readiness.test.tspackages/app/src/pages/session/session-action-readiness.tspackages/app/src/pages/session/use-session-followups.tspackages/app/src/utils/prompt.test.tspackages/app/src/utils/prompt.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/prompt.tspackages/opencode/test/session/prompt.test.tspackages/ui/src/components/command-icon.test.tspackages/ui/src/components/command-icon.tsxpackages/ui/src/components/message-part.csspackages/ui/src/components/message-part/highlighted-text.tsxpackages/ui/src/components/message-part/user-message.tsx
Simple custom commands inserted mid-text were routed as SkillPart, bypassing the command() pipeline's resolvePromptParts (which resolves @file/@agent in templates), plugin hooks (command.execute.before), and Command.Event.Executed. Restrict the inline chip path to source === "skill" entries. Custom/MCP commands keep the leading /name path where the full command pipeline applies.
The skill chip now renders glyph + bare name (no leading "/") in both the composer pill and the sent bubble. Update comments in editor-serialize, message-part.css, highlighted-text.tsx, and the message-skill-inline snap description to match the current behavior.
Three gaps after stripping the leading "/" from the pill label: 1. getNodeLength/getTextLength only checked data-cmd-mark, not data-type="skill". Skill chips reported textContent length (bare name) while the prompt model used "/name" length, causing cursor offsets to drift by one after any skill chip. 2. The copy handler (command-copy.ts) only intercepted data-cmd-mark pills. Copying a selection with a skill chip produced bare "name" on the clipboard instead of "/name", losing the slash on paste. 3. The e2e assertion in prompt-slash-open.spec.ts still expected "/summarize" in the chip's visible text. Fix all three: extend the pill detection in editor-dom and command-copy to include [data-type="skill"], update the e2e assertion.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/app/e2e/prompt/prompt-slash-open.spec.ts`:
- Line 117: The assertion uses a substring check (await
expect(chip).toContainText("summarize")) which allows a leading slash; update
the test to assert the chip's exact text equals "summarize" so "/summarize"
fails — locate the test in prompt-slash-open.spec.ts where the variable chip is
asserted and replace the substring matcher with an exact-text matcher (e.g., use
an equality/exact-text assertion on chip's text content or
toHaveText("summarize") if available) to enforce the no-slash contract.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: d55129bd-44de-49f4-9f7e-53a125b6e76e
📒 Files selected for processing (9)
packages/app/e2e/prompt/prompt-slash-open.spec.tspackages/app/e2e/snap/message-skill-inline.snap.tspackages/app/src/components/prompt-input/command-copy.tspackages/app/src/components/prompt-input/editor-dom.tspackages/app/src/components/prompt-input/editor-serialize.test.tspackages/app/src/components/prompt-input/editor-serialize.tspackages/app/src/components/prompt-input/popover-controllers.tspackages/ui/src/components/message-part.csspackages/ui/src/components/message-part/highlighted-text.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/ui/src/components/message-part/highlighted-text.tsx
- packages/app/e2e/snap/message-skill-inline.snap.ts
- packages/app/src/components/prompt-input/popover-controllers.ts
The server-side resolvePart accepted any command name delivered as a SkillPart, including config-based commands and MCP tools. A crafted SDK call could bypass the full command pipeline (hooks, resolvePromptParts, events) by wrapping an arbitrary command name in a SkillPart. Add a source check: only commands with source === "skill" are expanded inline. Non-skill entries keep the chip (for bubble rendering) but inject nothing, matching the unknown-skill fallback. Test: seed a real SKILL.md via skills.paths instead of a config command, add an explicit non-skill rejection case. Tighten the e2e assertion to check the label sub-element with toHaveText rather than the whole chip with toContainText.
Summary
Type
/anywhere in the composer to open the command picker, and on selection insert a position-independent inline skill chip (like an@-file/@-agent mention) that activates the skill for the whole turn. Previously the slash picker only opened when the text was exactly/queryfrom the start of an empty composer.Architecture mirrors the
@-agent pipeline end to end:SkillPartrides along the prompt viapromptAsync. The server'sresolvePartexpands the skill/command template into asynthetic: true, model-visible text part (bubble-suppressed), exactly likeAgentPart.cmd.source === "skill"). Action builtins (/clear,/new, …), custom commands, and MCP entries keep the leading/name argspath and only appear at an empty/at-start composer.Foundational unlock under the Skill GUI push (#220).
Why
Skills are becoming a first-class product surface, but the only way to invoke one was a leading
/commandtyped from the very start of the composer. That is unlike every other reference affordance (@file,@agent), which can appear anywhere. This makes skills feel position-independent and composable with surrounding prose, which is the foundation the Skill GUI builds on.Related Issue
Relates to #220 (Skill GUI). No dedicated issue for this foundational slice.
Human Review Status
Pending
Review Focus
packages/opencode/src/session/prompt.ts): the extractedexpandCommandTemplatehelper and theresolvePartskill branch. The helper carries ONLY template load +$1..$n/$ARGUMENTS/shell interpolation; all agent/model/subtask/hook/event machinery stays incommand(). Server-sidecmd.source !== "skill"guard rejects non-skill commands delivered asSkillPart, preventing SDK bypass of the full command pipeline.submit.ts,send-followup-draft.ts,session-action-readiness.ts): a skill chip at offset 0 flattens to/name, which previously would route to the legacysession.commandendpoint. Both routing branches and thecanSubmitPromptcommand-ready gate are guarded by "prompt contains atype:skillpart". Path D (leading marked TextPart) is untouched — a skill chip carries no command metadata.slash-trigger.ts): fires after start/whitespace/CJK, never insidefoo/bar,http://,2/3, paths. Covered by a unit matrix.@-agents render as plain text ([Feature] Remove the visible primary-agent mode picker #239), so there was no chip to mirror.HighlightedTextgains a skill reference segment keyed off the skill part'ssourcespan, rendered inline in the prose branch.Risk Notes
renderPartsToEditorreconstructs a structured skill chip from the persistedSkillAttachmentPart(name + source span), so restore rebuilds the interactive pill — it does NOT degrade to plain/nametext.@mentionsstay literal: unlikecommand(), the skill branch does not runresolvePromptParts, so@file/@agentinside a skill template are not resolved. Fine for prose SKILL.md; would matter only if a skill template embeds@path.resolvePartcheckscmd.source === "skill"— a config command or MCP entry with a name collision gets the chip preserved for bubble rendering but no synthetic template injection, preventing bypass of hooks/events/resolvePromptParts.packages/sdk/js/src/v2/genonly; the checked-inpackages/sdk/openapi.jsonroute-inventory snapshot is intentionally untouched.How To Verify
Pre-existing, unrelated: the
home composer shows slash commands after a bare slashe2e fails in this local environment because real global skills (~/.agents/skills/*) leak into the picker and pushfile.opendown — it fails ondevtoo and is a test-sandbox issue, not introduced here.Screenshots or Recordings
Visual check via the added
message-skill-inlinesnap target (bun run snap message-skill-inline, grid underdocs/design/preview/screenshots/): the/summarizetoken renders inline in the user bubble with the skill glyph + brand-accent color, prose unchanged, across leading / mid-sentence / long-prose cases. The composer pill uses the same glyph.Checklist
bug,enhancement,task,documentation.app,ui,harness(assigned by the labeler bot / confirmed).P2(foundational enhancement, not urgent).Pending,Approved by @<reviewer>, orNot required: <reason>(default isPending; "not required" is restricted to bot-authored low-risk PRs).dev, and my PR title and commit messages use Conventional Commits in English.