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
144 changes: 132 additions & 12 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)

const [store, setStore] = createStore<{
popover: "at" | "slash" | null
popover: "at" | "slash" | "at:model" | null
historyIndex: number
savedPrompt: PromptHistoryEntry | null
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
agentForModel: string | undefined
}>({
popover: null,
historyIndex: -1,
Expand All @@ -252,6 +253,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null,
mode: "normal",
applyingHistory: false,
agentForModel: undefined,
})

const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
Expand Down Expand Up @@ -509,18 +511,50 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))

const modelList = createMemo(() => {
if (!store.agentForModel) return []
return providers.connected().flatMap((provider) =>
Object.entries(provider.models)
.filter(([_, info]) => info.status !== "deprecated")
.map(
([modelId, info]): AtOption => ({
type: "model",
providerID: provider.id,
modelID: modelId,
modelName: info.name ?? modelId,
providerName: provider.name,
display: `${provider.id}/${modelId}`,
}),
),
)
})

const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
if (option.type === "agent") {
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
} else {
} else if (option.type === "model" && store.agentForModel) {
const agentName = store.agentForModel
const content = `@${agentName}:${option.providerID}/${option.modelID}`
addPart({
type: "agent",
name: agentName,
model: { providerID: option.providerID, modelID: option.modelID },
content,
start: 0,
end: 0,
})
setStore("agentForModel", undefined)
} else if (option.type === "file") {
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
}
}

const atKey = (x: AtOption | undefined) => {
if (!x) return ""
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
if (x.type === "agent") return `agent:${x.name}`
if (x.type === "model") return `model:${x.providerID}/${x.modelID}`
return `file:${x.path}`
}

const {
Expand All @@ -531,6 +565,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
items: async (query) => {
// When in model selection mode, return models instead of agents/files
if (store.agentForModel) {
return modelList()
}
const agents = agentList()
const open = recent()
const seen = new Set(open)
Expand All @@ -545,7 +583,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
filterKeys: ["display"],
groupBy: (item) => {
if (item.type === "agent") return "agent"
if (item.recent) return "recent"
if (item.type === "model") return "model"
if (item.type === "file" && item.recent) return "recent"
return "file"
},
sortGroupsBy: (a, b) => {
Expand Down Expand Up @@ -618,7 +657,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
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 === "agent") {
pill.setAttribute("data-name", part.name)
if (part.model) {
pill.setAttribute("data-model-provider", part.model.providerID)
pill.setAttribute("data-model-id", part.model.modelID)
}
}
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
Expand Down Expand Up @@ -674,7 +719,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})

const selectPopoverActive = () => {
if (store.popover === "at") {
if (store.popover === "at" || store.popover === "at:model") {
const items = atFlat()
if (items.length === 0) return
const active = atActive()
Expand Down Expand Up @@ -746,9 +791,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {

const pushAgent = (agent: HTMLElement) => {
const content = agent.textContent ?? ""
const providerID = agent.dataset.modelProvider
const modelID = agent.dataset.modelId
const model = providerID && modelID ? { providerID, modelID } : undefined
parts.push({
type: "agent",
name: agent.dataset.name!,
model,
content,
start: position,
end: position + content.length,
Expand Down Expand Up @@ -824,20 +873,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const shellMode = store.mode === "shell"

if (!shellMode) {
// Check for @agentname : pattern (space before colon) and collapse it
const spaceColonMatch = rawText.substring(0, cursorPosition).match(/@(\S+) :$/)
if (spaceColonMatch) {
const agentName = spaceColonMatch[1]
const validAgent = sync.data.agent.find(
(a) => !a.hidden && a.mode !== "primary" && a.name.toLowerCase() === agentName.toLowerCase(),
)
if (validAgent) {
// Remove the space before the colon by manipulating the DOM
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// Move back 2 positions (past the colon and space), then delete the space
const spacePos = cursorPosition - 2
setRangeEdge(editorRef, range, "start", spacePos)
setRangeEdge(editorRef, range, "end", spacePos + 1)
range.deleteContents()
// Position cursor after the colon (which is now at spacePos)
const newCursorPos = spacePos + 1
setRangeEdge(editorRef, range, "start", newCursorPos)
setRangeEdge(editorRef, range, "end", newCursorPos)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
// Re-parse to get updated parts
const updatedParts = parseFromDOM()
// Trigger model selection mode
setStore("agentForModel", validAgent.name)
setStore("popover", "at:model")
atOnInput("")
// Update prompt with new cursor position
mirror.input = true
prompt.set([...updatedParts, ...images], newCursorPos)
queueScroll()
return
}
}
}

const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
const slashMatch = rawText.match(/^\/(\S*)$/)

if (atMatch) {
atOnInput(atMatch[1])
setStore("popover", "at")
const afterAt = atMatch[1]
// Check if the user typed @agent: to enter model selection mode
// Match against known agent names followed by colon
const validAgent = sync.data.agent.find(
(a) => !a.hidden && a.mode !== "primary" && afterAt.toLowerCase().startsWith(a.name.toLowerCase() + ":"),
)
if (validAgent) {
// Extract the part after agent: for filtering models
const colonIndex =
afterAt.toLowerCase().indexOf(validAgent.name.toLowerCase() + ":") + validAgent.name.length + 1
const modelFilter = afterAt.slice(colonIndex)
setStore("agentForModel", validAgent.name)
setStore("popover", "at:model")
atOnInput(modelFilter)
} else if (store.agentForModel) {
// Check if user deleted the colon
if (!afterAt.includes(":")) {
setStore("agentForModel", undefined)
setStore("popover", "at")
atOnInput(afterAt)
} else {
// Still in model mode, extract model filter
const colonIdx = afterAt.indexOf(":")
const modelFilter = afterAt.slice(colonIdx + 1)
atOnInput(modelFilter)
}
} else {
setStore("popover", "at")
atOnInput(afterAt)
}
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
} else {
closePopover()
setStore("popover", null)
setStore("agentForModel", undefined)
}
} else {
closePopover()
setStore("popover", null)
setStore("agentForModel", undefined)
}

resetHistoryNavigation()
Expand Down Expand Up @@ -1091,7 +1209,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
if (nav || ctrlNav) {
if (store.popover === "at") {
if (store.popover === "at" || store.popover === "at:model") {
atOnKeyDown(event)
event.preventDefault()
return
Expand All @@ -1106,7 +1224,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {

if (ctrl && event.code === "KeyG") {
if (store.popover) {
closePopover()
setStore("popover", null)
setStore("agentForModel", undefined)
event.preventDefault()
return
}
Expand Down Expand Up @@ -1157,6 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSlashSelect={handleSlashSelect}
commandKeybind={command.keybind}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
agentForModel={store.agentForModel}
/>
<DockShellForm
onSubmit={handleSubmit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
id: Identifier.ascending("part"),
type: "agent",
name: attachment.name,
model: attachment.model,
source: {
value: attachment.content,
start: attachment.start,
Expand Down
44 changes: 43 additions & 1 deletion packages/app/src/components/prompt-input/slash-popover.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Component, For, Match, Show, Switch } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { getDirectory, getFilename } from "@opencode-ai/util/path"

export type AtOption =
| { type: "agent"; name: string; display: string }
| { type: "file"; path: string; display: string; recent?: boolean }
| { type: "model"; providerID: string; modelID: string; modelName: string; providerName: string; display: string }

export interface SlashCommand {
id: string
Expand All @@ -18,7 +21,7 @@ export interface SlashCommand {
}

type PromptPopoverProps = {
popover: "at" | "slash" | null
popover: "at" | "slash" | "at:model" | null
setSlashPopoverRef: (el: HTMLDivElement) => void
atFlat: AtOption[]
atActive?: string
Expand All @@ -31,6 +34,7 @@ type PromptPopoverProps = {
onSlashSelect: (item: SlashCommand) => void
commandKeybind: (id: string) => string | undefined
t: (key: string) => string
agentForModel?: string
}

export const PromptPopover: Component<PromptPopoverProps> = (props) => {
Expand Down Expand Up @@ -69,6 +73,8 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
)
}

if (item.type === "model") return null

const isDirectory = item.path.endsWith("/")
const directory = isDirectory ? item.path : getDirectory(item.path)
const filename = isDirectory ? "" : getFilename(item.path)
Expand All @@ -93,6 +99,42 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
</For>
</Show>
</Match>
<Match when={props.popover === "at:model"}>
<Show
when={props.atFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
>
<For each={props.atFlat.slice(0, 10)}>
{(item) => {
const model = item as {
type: "model"
providerID: string
modelID: string
modelName: string
providerName: string
display: string
}
return (
<button
classList={{
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
}}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))}
>
<ProviderIcon id={model.providerID as IconName} class="size-4 shrink-0" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap truncate">
@{props.agentForModel}:{model.display}
</span>
</div>
</button>
)
}}
</For>
</Show>
</Match>
<Match when={props.popover === "slash"}>
<Show
when={props.slashFlat.length > 0}
Expand Down
13 changes: 11 additions & 2 deletions packages/app/src/context/prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export interface FileAttachmentPart extends PartBase {
export interface AgentPart extends PartBase {
type: "agent"
name: string
model?: {
providerID: string
modelID: string
}
}

export interface ImageAttachmentPart {
Expand Down Expand Up @@ -67,7 +71,12 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) {
case "file":
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
case "agent":
return partB.type === "agent" && partA.name === partB.name
return (
partB.type === "agent" &&
partA.name === partB.name &&
partA.model?.providerID === partB.model?.providerID &&
partA.model?.modelID === partB.model?.modelID
)
case "image":
return partB.type === "image" && partA.id === partB.id
}
Expand All @@ -89,7 +98,7 @@ function cloneSelection(selection?: FileSelection) {
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 === "agent") return { ...part, model: part.model ? { ...part.model } : undefined }
return {
...part,
selection: cloneSelection(part.selection),
Expand Down
Loading
Loading