Compare commits

...

1 Commits

Author SHA1 Message Date
neriousy 5e5e3d09cb feat(tui): support multiple inline skills 2026-08-19 17:28:41 +00:00
5 changed files with 120 additions and 24 deletions
+30 -5
View File
@@ -1353,6 +1353,28 @@ const layer = Layer.effect(
return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready)
})
const chainedSkills = Effect.fnUntraced(function* (cmd: Command.Info, argumentsText: string) {
if (cmd.source !== "skill") return { commands: [cmd], arguments: argumentsText }
const available = new Map(
(yield* commands.list()).filter((item) => item.source === "skill").map((item) => [item.name, item]),
)
const selected = [cmd]
const seen = new Set([cmd.name])
let matched = false
const remaining = argumentsText.replace(/(^|\s)\/([^\s/]+)(?=\s|$)\s?/g, (token, prefix, name) => {
const skill = available.get(name)
if (!skill) return token
matched = true
if (!seen.has(name)) {
seen.add(name)
selected.push(skill)
}
return prefix
})
return { commands: selected, arguments: matched ? remaining.trim() : argumentsText }
})
const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
yield* Effect.logInfo("command", {
"session.id": input.sessionID,
@@ -1369,9 +1391,12 @@ const layer = Layer.effect(
}
const agentName = cmd.agent ?? input.agent
const raw = input.arguments.match(argsRegex) ?? []
const chained = yield* chainedSkills(cmd, input.arguments)
const raw = chained.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
const templateCommand = yield* Effect.promise(async () => cmd.template)
const templateCommand = yield* Effect.promise(async () =>
(await Promise.all(chained.commands.map(async (item) => item.template))).join("\n\n"),
)
const placeholders = templateCommand.match(placeholderRegex) ?? []
let last = 0
@@ -1388,10 +1413,10 @@ const layer = Layer.effect(
return args[argIndex]
})
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
let template = withArgs.replaceAll("$ARGUMENTS", chained.arguments)
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
template = template + "\n\n" + input.arguments
if (placeholders.length === 0 && !usesArgumentsPlaceholder && chained.arguments.trim()) {
template = template + "\n\n" + chained.arguments
}
const shellMatches = ConfigMarkdown.shell(template)
@@ -1816,6 +1816,41 @@ unix(
30_000,
)
it.instance(
"command expands multiple skill invocations into one prompt",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
yield* writeText(
path.join(directory, ".opencode", "skills", "skill-alpha", "SKILL.md"),
"---\nname: skill-alpha\ndescription: Alpha instructions\n---\nALPHA_INSTRUCTIONS",
)
yield* writeText(
path.join(directory, ".opencode", "skills", "skill-beta", "SKILL.md"),
"---\nname: skill-beta\ndescription: Beta instructions\n---\nBETA_INSTRUCTIONS",
)
const { llm } = yield* useServerConfig(providerCfg)
const { prompt, chat } = yield* boot()
yield* llm.text("done")
yield* prompt.command({
sessionID: chat.id,
command: "skill-alpha",
arguments: "/skill-beta /skill-beta inspect src/foo/bar.ts and https://example.com/a/b /unknown /skill-alpha",
})
const messages = JSON.stringify((yield* llm.inputs).at(-1)?.messages)
expect(messages).toContain("ALPHA_INSTRUCTIONS")
expect(messages).toContain("BETA_INSTRUCTIONS")
expect(messages).toContain("inspect src/foo/bar.ts and https://example.com/a/b /unknown")
expect(messages.match(/ALPHA_INSTRUCTIONS/g)).toHaveLength(1)
expect(messages.match(/BETA_INSTRUCTIONS/g)).toHaveLength(1)
expect(messages.match(/inspect src\/foo\/bar\.ts/g)).toHaveLength(1)
}),
{ git: true },
30_000,
)
unixNoLLMServer(
"cancel interrupts shell and resolves cleanly",
() =>
@@ -21,7 +21,7 @@ import { Locale } from "../../util/locale"
import type { PromptInfo } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import { displayCharAt, mentionTriggerIndex, slashTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/sdk/v2"
function removeLineRange(input: string) {
@@ -63,6 +63,7 @@ export type AutocompleteRef = {
export type AutocompleteOption = {
display: string
kind?: "skill"
value?: string
aliases?: string[]
disabled?: boolean
@@ -448,17 +449,24 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = [...slashes()]
for (const serverCommand of sync.data.command) {
if (serverCommand.source === "skill") continue
const label = serverCommand.source === "mcp" ? ":mcp" : ""
results.push({
display: "/" + serverCommand.name + label,
kind: serverCommand.source === "skill" ? "skill" : undefined,
description: serverCommand.description,
onSelect: () => {
const input = props.input()
const newText = "/" + serverCommand.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
const currentCursorOffset = input.cursorOffset
input.cursorOffset = store.index
const startCursor = input.logicalCursor
input.cursorOffset = currentCursorOffset
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText(newText)
props.setPrompt((draft) => {
draft.input = input.plainText
})
},
})
}
@@ -489,7 +497,11 @@ export function Autocomplete(props: {
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
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]
@@ -553,7 +565,7 @@ export function Autocomplete(props: {
function select() {
const selected = options()[store.selected]
if (!selected) return
hide()
setStore("visible", false)
selected.onSelect?.()
}
@@ -649,12 +661,17 @@ 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)
if (store.visible === "/" && !text.endsWith(" ")) {
const input = props.input()
const currentCursorOffset = input.cursorOffset
input.cursorOffset = store.index
const startCursor = input.logicalCursor
input.cursorOffset = currentCursorOffset
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
// Sync the prompt store immediately since onContentChange is async
props.setPrompt((draft) => {
draft.input = props.input().plainText
draft.input = input.plainText
})
}
setStore("visible", false)
@@ -679,9 +696,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()
}
@@ -692,10 +707,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
}
+11
View File
@@ -46,3 +46,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))
}
}
+11 -1
View File
@@ -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 commands 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()
})
})