Skip to content
Open
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
161 changes: 136 additions & 25 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
usePrompt,
ImageAttachmentPart,
AgentPart,
CommandPart,
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
Expand Down Expand Up @@ -198,6 +199,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 @@ -415,10 +417,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 @@ -471,6 +480,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"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
Expand Down Expand Up @@ -503,6 +513,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
} else if (part.type === "command") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "command")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
}
})

Expand Down Expand Up @@ -550,6 +569,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 @@ -568,6 +599,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 @@ -617,8 +653,52 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const shellMode = store.mode === "shell"

if (!shellMode) {
const commandPartIndex = rawParts.findIndex((p) => p.type === "command")
const commandPillNotAtStart =
commandPartIndex > 0 ||
(commandPartIndex === 0 && rawParts[0].type === "command" && (rawParts[0] as CommandPart).start > 0)
if (commandPillNotAtStart) {
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 @@ -651,7 +731,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 @@ -781,7 +863,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isPill =
node.nodeType === Node.ELEMENT_NODE &&
((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 All @@ -790,7 +876,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}

if ((isFile || isBreak) && remaining <= length) {
if ((isPill || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
Expand Down Expand Up @@ -1348,25 +1434,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}

if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
client.session
.command({
const commandPart = currentPrompt.find((p) => p.type === "command") as CommandPart | undefined
if (commandPart) {
const argsText = currentPrompt
.filter((p) => p.type === "text")
.map((p) => (p as { content: string }).content)
.join("")
.trim()

const messageID = Identifier.ascending("message")

sync.session.addOptimisticMessage({
sessionID: existing.id,
messageID,
parts: [
{
id: Identifier.ascending("part"),
sessionID: existing.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
})
.catch((e) => {
console.error("Failed to send command", e)
})
return
}
messageID,
type: "command" as const,
command: text,
prompt: "",
},
],
agent,
model,
})

sdk.client.session
.command({
sessionID: existing.id,
command: commandPart.name,
arguments: argsText,
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
messageID,
})
.catch((e) => {
console.error("Failed to send command", e)
})
return
}

const messageID = Identifier.ascending("message")
Expand Down Expand Up @@ -1668,6 +1776,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 @@ -1867,7 +1976,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
10 changes: 10 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ export namespace MessageV2 {
})
export type SubtaskPart = z.infer<typeof SubtaskPart>

export const CommandPart = PartBase.extend({
type: z.literal("command"),
command: z.string(),
prompt: 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 @@ -326,6 +335,7 @@ export namespace MessageV2 {
AgentPart,
RetryPart,
CompactionPart,
CommandPart,
])
.meta({
ref: "Part",
Expand Down
46 changes: 32 additions & 14 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 @@ -1048,8 +1058,8 @@ export namespace SessionPrompt {

return [
{
id: Identifier.ascending("part"),
...part,
id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
},
Expand Down Expand Up @@ -1444,19 +1454,27 @@ export namespace SessionPrompt {
throw error
}

const parts =
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
? [
{
type: "subtask" as const,
agent: agent.name,
description: command.description ?? "",
command: input.command,
// TODO: how can we make task tool accept a more complex input?
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
},
]
: await resolvePromptParts(template)
const commandText = `/${input.command}${input.arguments ? " " + input.arguments : ""}`
const commandPart = {
type: "command" as const,
command: commandText,
prompt: template,
}

const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
const parts = isSubtask
? [
commandPart,
{
type: "subtask" as const,
agent: agent.name,
description: command.description ?? "",
command: input.command,
// TODO: how can we make task tool accept a more complex input?
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
},
]
: [commandPart, ...(await resolvePromptParts(template))]

const result = (await prompt({
sessionID: input.sessionID,
Expand Down
Loading