Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/inline-model-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Reference a model inline in the prompt with `@`, opening a model picker that inserts an `@provider/model` mention for Agent Manager or subagent instructions.
32 changes: 30 additions & 2 deletions packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
TERMINAL_RESULT,
GIT_CHANGES_RESULT,
WORKTREES_RESULT,
MODEL_RESULT,
modelReferenceToken,
filePickerNamed,
defaultMentionIndex,
} from "../../webview-ui/src/hooks/file-mention-utils"
Expand Down Expand Up @@ -75,19 +77,31 @@ describe("buildMentionResults", () => {
it("includes special mentions for empty mention query", () => {
const result = buildMentionResults("", [])
expect(result[0]).toEqual({
type: "model",
value: "model",
label: "Model",
description: "Reference a model for subagents",
})
expect(result[1]).toEqual({
type: "terminal",
value: "terminal",
label: "Terminal",
description: "Active terminal output",
})
expect(result[1]).toEqual({
expect(result[2]).toEqual({
type: "git-changes",
value: "git-changes",
label: "Git changes",
description: "Current session/worktree changes",
})
})

it("offers the model reference entry for its label and aliases", () => {
expect(buildMentionResults("model", [])).toContainEqual(MODEL_RESULT)
expect(buildMentionResults("models", [])).toContainEqual(MODEL_RESULT)
expect(buildMentionResults("llm", [])).toContainEqual(MODEL_RESULT)
})

it("ranks terminal above a file the query fits less well", () => {
const result = buildMentionResults("term", ["src/terminal-view-model.ts"])
expect(result.map((item) => item.type)).toEqual(["terminal", "file", "file-picker"])
Expand Down Expand Up @@ -121,6 +135,7 @@ describe("buildMentionResults", () => {
it("keeps the menu order for a bare @, entries above the files", () => {
const result = buildMentionResults("", ["src/index.ts"])
expect(result).toEqual([
MODEL_RESULT,
TERMINAL_RESULT,
GIT_CHANGES_RESULT,
PAST_CHATS_RESULT,
Expand Down Expand Up @@ -743,7 +758,7 @@ describe("session mentions", () => {
describe("buildMentionResults", () => {
it("offers the past-chats picker alongside the other special mentions", () => {
const result = buildMentionResults("", [])
expect(result[0]).toEqual(TERMINAL_RESULT)
expect(result[0]).toEqual(MODEL_RESULT)
expect(result).toContainEqual(PAST_CHATS_RESULT)
expect(result).toContainEqual(FILE_PICKER_RESULT)
})
Expand Down Expand Up @@ -899,3 +914,16 @@ describe("session mentions", () => {
})
})
})

describe("modelReferenceToken", () => {
it("builds the provider/model inline token", () => {
expect(modelReferenceToken("anthropic", "claude-sonnet-4")).toBe("anthropic/claude-sonnet-4")
expect(modelReferenceToken("openrouter", "anthropic/claude-sonnet-4")).toBe("openrouter/anthropic/claude-sonnet-4")
})

it("is rediscovered by syncMentionedPaths as a mention token", () => {
const token = modelReferenceToken("anthropic", "claude-sonnet-4")
const kept = syncMentionedPaths(new Set([token]), `use @${token} for the subagent`)
expect(kept.has(token)).toBe(true)
})
})
116 changes: 115 additions & 1 deletion packages/kilo-vscode/tests/unit/use-file-mention.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
import { FILE_PICKER_RESULT, TERMINAL_RESULT } from "../../webview-ui/src/hooks/file-mention-utils"
import { FILE_PICKER_RESULT, MODEL_RESULT, TERMINAL_RESULT } from "../../webview-ui/src/hooks/file-mention-utils"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"

declare global {
Expand Down Expand Up @@ -339,6 +339,117 @@ describe("useFileMention", () => {
dispose.fn?.()
})

it("opens the model picker from the @ menu and inserts an inline model reference", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}

const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})

const input = editor("@mod")
mockDocument(input)
try {
mention.selectMention(MODEL_RESULT, input, () => {})
expect(mention.modelPicker()).toBe(true)
expect(mention.showMention()).toBe(false)

mention.selectModelReference("anthropic", "claude-sonnet-4", () => {})
} finally {
restoreDocument()
}

expect(mention.modelPicker()).toBe(false)
expect(input.value).toBe("@anthropic/claude-sonnet-4 ")
expect(mention.mentionedModels().has("anthropic/claude-sonnet-4")).toBe(true)
// Model references are inline text, never file attachments.
expect(mention.mentionedPaths().has("anthropic/claude-sonnet-4")).toBe(false)
expect(mention.parseFileAttachments(input.value)).toEqual([])

dispose.fn?.()
})

it("seeds known model references from restored text without treating them as files", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
const modelKeys = () => new Set(["anthropic/claude-sonnet-4"])
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false, undefined, modelKeys)
})

mention.seedFromText("use @anthropic/claude-sonnet-4 for the subagent")
expect(mention.mentionedModels().has("anthropic/claude-sonnet-4")).toBe(true)
expect(mention.mentionedPaths().has("anthropic/claude-sonnet-4")).toBe(false)
expect(mention.parseFileAttachments("use @anthropic/claude-sonnet-4 for the subagent")).toEqual([])

dispose.fn?.()
})

it("reclassifies a restored model reference once the catalog loads after seeding", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
// The catalog is empty while the draft is restored, so the seed cannot yet
// tell the token is a model reference.
let catalog = new Set<string>()
const modelKeys = () => catalog
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false, undefined, modelKeys)
})

const text = "use @anthropic/claude-sonnet-4 for the subagent"
mention.seedFromText(text)
expect(mention.mentionedPaths().has("anthropic/claude-sonnet-4")).toBe(true)

catalog = new Set(["anthropic/claude-sonnet-4"])
mention.seedFromText(text)
expect(mention.mentionedModels().has("anthropic/claude-sonnet-4")).toBe(true)
expect(mention.mentionedPaths().has("anthropic/claude-sonnet-4")).toBe(false)
expect(mention.parseFileAttachments(text)).toEqual([])

dispose.fn?.()
})

it("never turns a catalog model reference into a file attachment", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
// Simulate a path that was seeded before the catalog was available.
let catalog = new Set<string>()
const modelKeys = () => catalog
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false, undefined, modelKeys)
})

const text = "use @anthropic/claude-sonnet-4 for the subagent"
mention.seedFromText(text)
mention.addPaths(["anthropic/claude-sonnet-4"], "/workspace")
catalog = new Set(["anthropic/claude-sonnet-4"])

expect(mention.parseFileAttachments(text)).toEqual([])

dispose.fn?.()
})

it("waits for past chats before treating a spaced query as prose", async () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
Expand Down Expand Up @@ -1515,6 +1626,7 @@ describe("useFileMention", () => {
}

expect(mention.mentionResults()).toEqual([
MODEL_RESULT,
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
FILE_PICKER_RESULT,
Expand All @@ -1528,6 +1640,7 @@ describe("useFileMention", () => {

mention.onInput("@", 1)
expect(mention.mentionResults()).toEqual([
MODEL_RESULT,
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
FILE_PICKER_RESULT,
Expand Down Expand Up @@ -1675,6 +1788,7 @@ describe("useFileMention", () => {
state.mention.onInput("@", 1)

expect(state.mention.mentionResults()).toEqual([
MODEL_RESULT,
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
FILE_PICKER_RESULT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import { useConfig } from "../../context/config"
import { useProvider } from "../../context/provider"
import { ModelSelector } from "../shared/ModelSelector"
import { ModelSelector, ModelSelectorBase } from "../shared/ModelSelector"
import { ModeSwitcher } from "../shared/ModeSwitcher"
import { SandboxButtonBase, SandboxTooltipContent } from "../shared/SandboxButton"
import { SpeechToTextButton } from "../speech-to-text/SpeechToTextButton"
Expand Down Expand Up @@ -150,6 +150,11 @@ interface PromptInputProps {
resolveEmbeddedTerminal?: (context?: string) => Promise<string | undefined>
}

// The `@` model entry reopens the shared model selector through its
// programmatic-open event, keyed to this prompt scope so the chat model
// selector and slash-command opens are unaffected.
const MENTION_MODEL_TRIGGER = "mention-model"

function MentionItemContent(props: { item: MentionResult }) {
const item = props.item
const language = useLanguage()
Expand Down Expand Up @@ -181,6 +186,14 @@ function MentionItemContent(props: { item: MentionResult }) {
<span class="file-mention-dir">{item.description}</span>
</>
)
if (item.type === "model")
return (
<>
<Icon name="models" class="file-mention-icon" />
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
)
if (item.type === "session")
return (
<>
Expand Down Expand Up @@ -231,7 +244,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return rest === "unassigned" ? undefined : rest
}
const hasGit = () => server.gitInstalled()
const mention = useFileMention(vscode, sid, hasGit, props.worktrees)
const modelKeys = () => new Set(provider.models().map((model) => `${model.providerID}/${model.id}`))
const mention = useFileMention(vscode, sid, hasGit, props.worktrees, modelKeys)
// Picking the `@` model entry reuses the shared model selector: it is
// mounted hidden and opened through its programmatic-open event. The mention
// latch resets immediately because the selector owns its own open state, so
// dismissing it by clicking outside cannot leave the latch stuck open.
createEffect(() => {
if (!mention.modelPicker()) return
mention.closeMention()
window.dispatchEvent(new CustomEvent("openModelPicker", { detail: { source: MENTION_MODEL_TRIGGER } }))
})
const terminal = useTerminalContext(props.resolveEmbeddedTerminal)
const git = useGitChangesContext(vscode, ctx, hasGit)
const imageAttach = useImageAttachments()
Expand Down Expand Up @@ -705,10 +728,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const highlightMentions = () => {
const paths = new Set(mention.mentionedPaths())
for (const token of mention.mentionedSessions().keys()) paths.add(token)
for (const token of mention.mentionedModels()) paths.add(token)
if (hasTerminalMention(text())) paths.add("terminal")
if (hasGit() && hasGitChangesMention(text())) paths.add("git-changes")
return paths
}
// Model references are inline text tokens, not files, so they must not be
// styled as or behave like clickable path mentions.
const isModelMention = (text: string) => mention.mentionedModels().has(text.replace(/^@/, ""))
const placeholder = () => {
switch (server.connectionState()) {
case "connecting":
Expand Down Expand Up @@ -1653,6 +1680,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
</div>
</Show>
<div class="mention-model-anchor" aria-hidden="true">
<ModelSelectorBase
value={null}
trigger={MENTION_MODEL_TRIGGER}
collapsed
onSelect={(providerID, modelID) => {
if (providerID && modelID) mention.selectModelReference(providerID, modelID, adjustHeight)
}}
onCancel={() => {
mention.closeMention()
textareaRef?.focus()
}}
/>
</div>
<Show when={mention.showMention()}>
<div class="file-mention-dropdown" ref={dropdownRef}>
<Show
Expand Down Expand Up @@ -1813,9 +1854,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={seg().highlight} fallback={<span>{seg().text}</span>}>
<span
class="prompt-input-file-mention"
classList={{ "prompt-input-file-mention--file": isPathMention(seg().text) }}
classList={{
"prompt-input-file-mention--file": isPathMention(seg().text) && !isModelMention(seg().text),
}}
onClick={(e) => {
if (!isPathMention(seg().text)) return
if (isModelMention(seg().text)) return
if (mention.mentionedSessions().has(seg().text.replace(/^@/, ""))) return
e.preventDefault()
e.stopPropagation()
Expand Down
Loading
Loading