Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 81 additions & 7 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
usePrompt,
ImageAttachmentPart,
AgentPart,
CommandPart,
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
Expand Down Expand Up @@ -210,6 +211,7 @@ export const PromptInput: Component<PromptInputProps> = (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,
Expand Down Expand Up @@ -428,10 +430,17 @@ export const PromptInput: Component<PromptInputProps> = (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()
Expand Down Expand Up @@ -462,12 +471,13 @@ export const PromptInput: Component<PromptInputProps> = (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"
Expand All @@ -493,6 +503,7 @@ export const PromptInput: Component<PromptInputProps> = (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"
})

Expand All @@ -503,7 +514,7 @@ export const PromptInput: Component<PromptInputProps> = (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))
}
}
Expand Down Expand Up @@ -588,6 +599,18 @@ export const PromptInput: Component<PromptInputProps> = (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 ?? ""
Expand All @@ -606,6 +629,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pushAgent(el)
return
}
if (el.dataset.type === "command") {
flushText()
pushCommand(el)
return
}
if (el.tagName === "BR") {
buffer += "\n"
return
Expand Down Expand Up @@ -656,8 +684,49 @@ export const PromptInput: Component<PromptInputProps> = (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])
Expand Down Expand Up @@ -690,7 +759,9 @@ export const PromptInput: Component<PromptInputProps> = (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) {
Expand Down Expand Up @@ -1515,6 +1586,7 @@ export const PromptInput: Component<PromptInputProps> = (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",
}}
/>
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/context/prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
}
Expand All @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ export namespace MessageV2 {
})
export type SubtaskPart = z.infer<typeof SubtaskPart>

export const CommandPart = PartBase.extend({
type: z.literal("command"),
command: z.string(),
}).meta({
ref: "CommandPart",
})
export type CommandPart = z.infer<typeof CommandPart>

export const RetryPart = PartBase.extend({
type: z.literal("retry"),
attempt: z.number(),
Expand Down Expand Up @@ -334,6 +342,7 @@ export namespace MessageV2 {
AgentPart,
RetryPart,
CompactionPart,
CommandPart,
])
.meta({
ref: "Part",
Expand Down
21 changes: 18 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ export namespace SessionPrompt {
.meta({
ref: "SubtaskPartInput",
}),
MessageV2.CommandPart.omit({
messageID: true,
sessionID: true,
})
.partial({
id: true,
})
.meta({
ref: "CommandPartInput",
}),
]),
),
})
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
AuthSetErrors,
AuthSetResponses,
CommandListResponses,
CommandPartInput,
Config as Config2,
ConfigGetResponses,
ConfigProvidersResponses,
Expand Down Expand Up @@ -1314,7 +1315,7 @@ export class Session extends HeyApiClient {
}
system?: string
variant?: string
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput | CommandPartInput>
},
options?: Options<never, ThrowOnError>,
) {
Expand Down Expand Up @@ -1402,7 +1403,7 @@ export class Session extends HeyApiClient {
}
system?: string
variant?: string
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput | CommandPartInput>
},
options?: Options<never, ThrowOnError>,
) {
Expand Down
19 changes: 17 additions & 2 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
| {
Expand All @@ -441,6 +449,7 @@ export type Part =
| AgentPart
| RetryPart
| CompactionPart
| CommandPart

export type EventMessagePartUpdated = {
type: "message.part.updated"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3066,7 +3081,7 @@ export type SessionPromptData = {
}
system?: string
variant?: string
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput | CommandPartInput>
}
path: {
/**
Expand Down Expand Up @@ -3253,7 +3268,7 @@ export type SessionPromptAsyncData = {
}
system?: string
variant?: string
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput | CommandPartInput>
}
path: {
/**
Expand Down
Loading