diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a5655902a47..67fa6ea82ec 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -24,6 +24,7 @@ import { usePrompt, ImageAttachmentPart, AgentPart, + CommandPart, FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" @@ -210,6 +211,7 @@ export const PromptInput: Component = (props) => { if (part.type === "text") return { ...part } if (part.type === "image") return { ...part } if (part.type === "agent") return { ...part } + if (part.type === "command") return { ...part } return { ...part, selection: part.selection ? { ...part.selection } : undefined, @@ -428,10 +430,17 @@ export const PromptInput: Component = (props) => { setStore("popover", null) if (cmd.type === "custom") { - const text = `/${cmd.trigger} ` + const content = `/${cmd.trigger}` + const commandPart: CommandPart = { + type: "command", + name: cmd.trigger, + content, + start: 0, + end: content.length, + } + const textPart = { type: "text" as const, content: " ", start: content.length, end: content.length + 1 } editorRef.innerHTML = "" - editorRef.textContent = text - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + prompt.set([commandPart, textPart], content.length + 1) requestAnimationFrame(() => { editorRef.focus() const range = document.createRange() @@ -462,12 +471,13 @@ export const PromptInput: Component = (props) => { onSelect: handleSlashSelect, }) - const createPill = (part: FileAttachmentPart | AgentPart) => { + const createPill = (part: FileAttachmentPart | AgentPart | CommandPart) => { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", part.type) if (part.type === "file") pill.setAttribute("data-path", part.path) if (part.type === "agent") pill.setAttribute("data-name", part.name) + if (part.type === "command") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" pill.style.cursor = "default" @@ -493,6 +503,7 @@ export const PromptInput: Component = (props) => { const el = node as HTMLElement if (el.dataset.type === "file") return true if (el.dataset.type === "agent") return true + if (el.dataset.type === "command") return true return el.tagName === "BR" }) @@ -503,7 +514,7 @@ export const PromptInput: Component = (props) => { editorRef.appendChild(createTextFragment(part.content)) continue } - if (part.type === "file" || part.type === "agent") { + if (part.type === "file" || part.type === "agent" || part.type === "command") { editorRef.appendChild(createPill(part)) } } @@ -588,6 +599,18 @@ export const PromptInput: Component = (props) => { position += content.length } + const pushCommand = (cmd: HTMLElement) => { + const content = cmd.textContent ?? "" + parts.push({ + type: "command", + name: cmd.dataset.name!, + content, + start: position, + end: position + content.length, + }) + position += content.length + } + const visit = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { buffer += node.textContent ?? "" @@ -606,6 +629,11 @@ export const PromptInput: Component = (props) => { pushAgent(el) return } + if (el.dataset.type === "command") { + flushText() + pushCommand(el) + return + } if (el.tagName === "BR") { buffer += "\n" return @@ -656,8 +684,49 @@ export const PromptInput: Component = (props) => { const shellMode = store.mode === "shell" if (!shellMode) { + const commandPartIndex = rawParts.findIndex((p) => p.type === "command") + if (commandPartIndex > 0) { + const textPart = { + type: "text" as const, + content: rawText, + start: 0, + end: rawText.length, + } + setStore("popover", null) + prompt.set([textPart], cursorPosition) + queueScroll() + return + } + const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) const slashMatch = rawText.match(/^\/(\S*)$/) + const slashWithSpaceMatch = rawText.match(/^\/(\S+)\s/) + + if (slashWithSpaceMatch && !rawParts.some((p) => p.type === "command")) { + const cmdName = slashWithSpaceMatch[1] + const customCmd = sync.data.command.find((c) => c.name === cmdName) + if (customCmd) { + const content = `/${cmdName}` + const commandPart: CommandPart = { + type: "command", + name: cmdName, + content, + start: 0, + end: content.length, + } + const afterCommand = rawText.slice(content.length) + const textPart = { + type: "text" as const, + content: afterCommand, + start: content.length, + end: content.length + afterCommand.length, + } + setStore("popover", null) + prompt.set([commandPart, textPart], cursorPosition) + queueScroll() + return + } + } if (atMatch) { atOnInput(atMatch[1]) @@ -690,7 +759,9 @@ export const PromptInput: Component = (props) => { const isText = node.nodeType === Node.TEXT_NODE const isPill = node.nodeType === Node.ELEMENT_NODE && - ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + ((node as HTMLElement).dataset.type === "file" || + (node as HTMLElement).dataset.type === "agent" || + (node as HTMLElement).dataset.type === "command") const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { @@ -1515,6 +1586,7 @@ export const PromptInput: Component = (props) => { "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, + "[&_[data-type=command]]:text-syntax-string": true, "font-mono!": store.mode === "shell", }} /> @@ -1714,7 +1786,9 @@ function setCursorPosition(parent: HTMLElement, position: number) { const isText = node.nodeType === Node.TEXT_NODE const isPill = node.nodeType === Node.ELEMENT_NODE && - ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + ((node as HTMLElement).dataset.type === "file" || + (node as HTMLElement).dataset.type === "agent" || + (node as HTMLElement).dataset.type === "command") const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 2fa4571e890..a189b089eb2 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -26,6 +26,11 @@ export interface AgentPart extends PartBase { name: string } +export interface CommandPart extends PartBase { + type: "command" + name: string +} + export interface ImageAttachmentPart { type: "image" id: string @@ -34,7 +39,7 @@ export interface ImageAttachmentPart { dataUrl: string } -export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | CommandPart | ImageAttachmentPart export type Prompt = ContentPart[] export type FileContextItem = { @@ -73,6 +78,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) { return false } + if (partA.type === "command" && partA.name !== (partB as CommandPart).name) { + return false + } if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { return false } @@ -89,6 +97,7 @@ function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } if (part.type === "image") return { ...part } if (part.type === "agent") return { ...part } + if (part.type === "command") return { ...part } return { ...part, selection: cloneSelection(part.selection), diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2dff17a5efa..28af8c43bc5 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -172,6 +172,14 @@ export namespace MessageV2 { }) export type SubtaskPart = z.infer + export const CommandPart = PartBase.extend({ + type: z.literal("command"), + command: z.string(), + }).meta({ + ref: "CommandPart", + }) + export type CommandPart = z.infer + export const RetryPart = PartBase.extend({ type: z.literal("retry"), attempt: z.number(), @@ -334,6 +342,7 @@ export namespace MessageV2 { AgentPart, RetryPart, CompactionPart, + CommandPart, ]) .meta({ ref: "Part", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f891612272c..839bfc06a12 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -142,6 +142,16 @@ export namespace SessionPrompt { .meta({ ref: "SubtaskPartInput", }), + MessageV2.CommandPart.omit({ + messageID: true, + sessionID: true, + }) + .partial({ + id: true, + }) + .meta({ + ref: "CommandPartInput", + }), ]), ), }) @@ -1545,8 +1555,12 @@ export namespace SessionPrompt { } const templateParts = await resolvePromptParts(template) - const parts = - (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true + const parts = [ + { + type: "command" as const, + command: `/${input.command}${input.arguments ? " " + input.arguments : ""}`, + }, + ...((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true ? [ { type: "subtask" as const, @@ -1557,7 +1571,8 @@ export namespace SessionPrompt { prompt: templateParts.find((y) => y.type === "text")?.text ?? "", }, ] - : [...templateParts, ...(input.parts ?? [])] + : [...templateParts, ...(input.parts ?? [])]), + ] const result = (await prompt({ sessionID: input.sessionID, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a26cefb176f..ac8ffaaba64 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -11,6 +11,7 @@ import type { AuthSetErrors, AuthSetResponses, CommandListResponses, + CommandPartInput, Config as Config2, ConfigGetResponses, ConfigProvidersResponses, @@ -1314,7 +1315,7 @@ export class Session extends HeyApiClient { } system?: string variant?: string - parts?: Array + parts?: Array }, options?: Options, ) { @@ -1402,7 +1403,7 @@ export class Session extends HeyApiClient { } system?: string variant?: string - parts?: Array + parts?: Array }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 97a695162ed..0b216fd83fc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -419,6 +419,14 @@ export type CompactionPart = { auto: boolean } +export type CommandPart = { + id: string + sessionID: string + messageID: string + type: "command" + command: string +} + export type Part = | TextPart | { @@ -441,6 +449,7 @@ export type Part = | AgentPart | RetryPart | CompactionPart + | CommandPart export type EventMessagePartUpdated = { type: "message.part.updated" @@ -1763,6 +1772,12 @@ export type SubtaskPartInput = { command?: string } +export type CommandPartInput = { + id?: string + type: "command" + command: string +} + export type Command = { name: string description?: string @@ -3066,7 +3081,7 @@ export type SessionPromptData = { } system?: string variant?: string - parts: Array + parts: Array } path: { /** @@ -3253,7 +3268,7 @@ export type SessionPromptAsyncData = { } system?: string variant?: string - parts: Array + parts: Array } path: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f697f6e3f3e..a2ff9a3effc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2182,6 +2182,9 @@ }, { "$ref": "#/components/schemas/SubtaskPartInput" + }, + { + "$ref": "#/components/schemas/CommandPartInput" } ] } @@ -2558,6 +2561,9 @@ }, { "$ref": "#/components/schemas/SubtaskPartInput" + }, + { + "$ref": "#/components/schemas/CommandPartInput" } ] } @@ -6620,6 +6626,28 @@ }, "required": ["id", "sessionID", "messageID", "type", "auto"] }, + "CommandPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "command" + }, + "command": { + "type": "string" + } + }, + "required": ["id", "sessionID", "messageID", "type", "command"] + }, "Part": { "anyOf": [ { @@ -6685,6 +6713,9 @@ }, { "$ref": "#/components/schemas/CompactionPart" + }, + { + "$ref": "#/components/schemas/CommandPart" } ] }, @@ -9427,6 +9458,22 @@ }, "required": ["type", "prompt", "description", "agent"] }, + "CommandPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "command" + }, + "command": { + "type": "string" + } + }, + "required": ["type", "command"] + }, "Command": { "type": "object", "properties": { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 8102c2ce715..f8a30ff3099 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -14,6 +14,7 @@ import { Dynamic } from "solid-js/web" import { AgentPart, AssistantMessage, + CommandPart, FilePart, Message as MessageType, Part as PartType, @@ -276,9 +277,26 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part return {(part) => } } +function CommandDisplay(props: { command: string }) { + const parts = createMemo(() => { + const match = props.command.match(/^(\/\S+)(.*)$/) + if (!match) return { command: props.command, args: "" } + return { command: match[1], args: match[2] } + }) + + return ( + + {parts().command} + {parts().args} + + ) +} + export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { const dialog = useDialog() + const commandPart = createMemo(() => props.parts?.find((p) => p.type === "command") as CommandPart | undefined) + const textPart = createMemo( () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, ) @@ -338,6 +356,13 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp + + {(cmd) => ( +
+ +
+ )} +