diff --git a/.changeset/inline-model-references.md b/.changeset/inline-model-references.md new file mode 100644 index 00000000000..66a8a5699c7 --- /dev/null +++ b/.changeset/inline-model-references.md @@ -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. diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 6b66713a985..1e883601847 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -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" @@ -75,12 +77,18 @@ 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", @@ -88,6 +96,12 @@ describe("buildMentionResults", () => { }) }) + 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"]) @@ -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, @@ -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) }) @@ -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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts index a849e51e1f4..6b3e24f3782 100644 --- a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts @@ -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 { @@ -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() + 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() + 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>() @@ -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, @@ -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, @@ -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, diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 30e3730edce..b385a2db17b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -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" @@ -150,6 +150,11 @@ interface PromptInputProps { resolveEmbeddedTerminal?: (context?: string) => Promise } +// 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() @@ -181,6 +186,14 @@ function MentionItemContent(props: { item: MentionResult }) { {item.description} ) + if (item.type === "model") + return ( + <> + + {item.label} + {item.description} + + ) if (item.type === "session") return ( <> @@ -231,7 +244,17 @@ export const PromptInput: Component = (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() @@ -705,10 +728,14 @@ export const PromptInput: Component = (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": @@ -1653,6 +1680,20 @@ export const PromptInput: Component = (props) => { /> +
= (props) => { {seg().text}}> { if (!isPathMention(seg().text)) return + if (isModelMention(seg().text)) return if (mention.mentionedSessions().has(seg().text.replace(/^@/, ""))) return e.preventDefault() e.stopPropagation() diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 7c01f53a53e..ca2c620f2c2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -140,6 +140,12 @@ export interface ModelSelectorBaseProps { trigger?: string /** Disable this prompt-scoped selector while a permission owns the prompt. */ blocked?: boolean + /** + * Force the compact list layout. Used by inline `@` model references, where + * there is no current model for the preview pane and picking is a one-click, + * insert-only action. The persisted chat-selector preference is not changed. + */ + collapsed?: boolean } export const ModelSelectorBase: Component = (props) => { @@ -162,8 +168,14 @@ export const ModelSelectorBase: Component = (props) => { const [open, setOpen] = createSignal(false) // Shared, host-persisted expand/collapse preference (see VSCodeProvider). - const expanded = vscode.getModelSelectorExpanded - const setExpanded = vscode.setModelSelectorExpanded + // Inline `@` model references force the compact layout and must not read or + // write that preference. + const preferExpanded = vscode.getModelSelectorExpanded + const expanded = () => !props.collapsed && preferExpanded() + const setExpanded = (value: boolean) => { + if (props.collapsed) return + vscode.setModelSelectorExpanded(value) + } const [search, setSearch] = createSignal("") const hasSearch = () => search().trim().length > 0 const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY) @@ -914,30 +926,34 @@ export const ModelSelectorBase: Component = (props) => { } }} /> - - { - if (expanded()) { - setPreActiveKey(null) - setPreviewKey(null) + + + { - searchRef?.focus() - scrollRow(preActiveKey() ?? selectedKey(), "nearest") - }) - }} - /> - + aria-expanded={expanded()} + aria-controls={previewID} + onClick={() => { + if (expanded()) { + setPreActiveKey(null) + setPreviewKey(null) + } + setExpanded(!expanded()) + requestAnimationFrame(() => { + searchRef?.focus() + scrollRow(preActiveKey() ?? selectedKey(), "nearest") + }) + }} + /> + +
> /** Mentioned past chats, keyed by their `@title` token in the text. */ mentionedSessions: Accessor> + /** Mentioned model references, keyed by their `@providerID/modelID` token. */ + mentionedModels: Accessor> /** Whether the past-chat session picker (AM-style search) is open. */ sessionPicker: Accessor /** Directory-scoped past chats shown in the session picker. */ sessionCandidates: Accessor + /** Whether the inline model reference picker is open. */ + modelPicker: Accessor worktreePicker: Accessor worktreeCandidates: Accessor selectWorktree: ( @@ -138,6 +143,8 @@ export interface FileMention { setText: (text: string) => void, onSelect?: () => void, ) => void + /** Insert a model reference picked from the model picker as an @-mention. */ + selectModelReference: (providerID: string, modelID: string, onSelect?: () => void) => void } export function useFileMention( @@ -145,14 +152,17 @@ export function useFileMention( sessionID?: Accessor, git?: Accessor, worktrees?: Accessor, + modelKeys?: Accessor>, ): FileMention { const [mentionedPaths, setMentionedPaths] = createSignal>(new Set()) const [mentionedSessions, setMentionedSessions] = createSignal>(new Map()) + const [mentionedModels, setMentionedModels] = createSignal>(new Set()) const [mentionQuery, setMentionQuery] = createSignal(null) const [mentionResults, setMentionResults] = createSignal([]) const [mentionIndex, setMentionIndex] = createSignal(0) const [sessionPicker, setSessionPicker] = createSignal(false) const [sessionCandidates, setSessionCandidates] = createSignal([]) + const [modelPicker, setModelPicker] = createSignal(false) const [worktreePicker, setWorktreePicker] = createSignal(false) const worktreeCandidates = () => worktrees?.().filter((worktree) => !worktree.disabled) ?? [] let workspaceDir = "" @@ -164,6 +174,9 @@ export function useFileMention( // Same accumulation for past-chat mentions, keyed by their exact visible // token. Duplicate titles receive a numeric suffix so they cannot overwrite. const knownSessions = new Map() + // Model references are kept apart from knownPaths: they are inline text + // tokens, not files, so they must never turn into file attachments. + const knownModels = new Set() const knownWorktrees = new Map() const references = () => { for (const worktree of worktrees?.() ?? []) { @@ -216,6 +229,8 @@ export function useFileMention( setText: (text: string) => void onSelect?: () => void } | null = null + // The `@query` range that the open model picker will replace on selection. + let modelPickerState: { textarea: HTMLTextAreaElement; atStart: number; atEnd: number } | null = null let pendingArrowSnap: { timer: ReturnType; prevValue: string; prevPosition: number } | undefined // Offset of the "@" that opened the current query, the mention inserted at // each "@" offset, and the last spaced query the file search resolved to @@ -254,6 +269,8 @@ export function useFileMention( sessionTimer = undefined setSessionCandidates([]) setWorktreePicker(false) + setModelPicker(false) + modelPickerState = null setMentionResults([]) setMentionIndex(0) return value @@ -461,6 +478,7 @@ export function useFileMention( setMentionResults([]) setSessionPicker(false) setWorktreePicker(false) + setModelPicker(false) } const closeSessionPicker = () => { @@ -469,8 +487,24 @@ export function useFileMention( const syncMentionedPaths = (text: string) => { references() + reclassifyModels() setMentionedPaths(() => _syncMentionedPaths(knownPaths, text)) setMentionedSessions(() => _syncMentionedSessions(knownSessions, text)) + setMentionedModels(() => _syncMentionedPaths(knownModels, text)) + } + + // A restored draft can be seeded before the model catalog has loaded, so the + // seed-time split between files and models is not final. Re-run it against the + // live catalog so a model reference that was momentarily treated as a file + // moves to the model set instead of becoming a bogus attachment. + const reclassifyModels = () => { + const keys = modelKeys?.() + if (!keys?.size) return + for (const key of keys) { + if (!knownPaths.has(key)) continue + knownPaths.delete(key) + knownModels.add(key) + } } // Past chats are searched client-side (fuzzysort, same as the Agent Manager @@ -557,6 +591,18 @@ export function useFileMention( return } + if (result.type === "model") { + // Switch the dropdown into the model picker; the actual insertion + // happens when a model is picked there. + const match = before.match(AT_PATTERN)! + const prefix = /^\s/.test(match[0]) ? 1 : 0 + const atPos = match.index! + prefix + modelPickerState = { textarea, atStart: atPos, atEnd: cursor } + closeMention() + setModelPicker(true) + return + } + // Past chats resolve their token again here: inline results are built from // a shared candidate list, so two chats with the same title would otherwise // insert the same token and overwrite each other in knownSessions. @@ -620,6 +666,35 @@ export function useFileMention( onSelect, ) + const selectModelReference = (providerID: string, modelID: string, onSelect?: () => void) => { + const state = modelPickerState + modelPickerState = null + setModelPicker(false) + if (!state) return + const textarea = state.textarea + if (!textarea.isConnected) return + const token = modelReferenceToken(providerID, modelID) + const after = textarea.value.substring(state.atEnd) + const suffix = /^\s/.test(after) ? "" : " " + // Add to knownModels BEFORE execCommand so syncMentionedPaths (triggered by + // the input event) can discover the new reference. Model tokens stay out of + // knownPaths so they are never turned into file attachments. + knownModels.add(token) + remember(state.atStart, token) + // Restore focus before execCommand: the picker's search field owns focus, + // which makes execCommand silently no-op. + textarea.focus() + suppress = true + try { + textarea.setSelectionRange(state.atStart, state.atEnd) + document.execCommand("insertText", false, `@${token}${suffix}`) + } finally { + suppress = false + } + setMentionedModels((prev) => new Set([...prev, token])) + onSelect?.() + } + // When true, onInput skips dropdown logic (used during execCommand changes) let suppress = false @@ -629,6 +704,7 @@ export function useFileMention( if (suppress) return closeSessionPicker() setWorktreePicker(false) + setModelPicker(false) const before = val.substring(0, cursor) const match = before.match(AT_PATTERN) if (!match) { @@ -723,12 +799,17 @@ export function useFileMention( } // Mention tokens that count as atomic units for cursor movement, deletion - // and selection snapping: file paths plus past-chat title tokens. - const mentionTokens = () => new Set([...mentionedPaths(), ...mentionedSessions().keys()]) + // and selection snapping: file paths, past-chat title tokens and model + // references. + const mentionTokens = () => new Set([...mentionedPaths(), ...mentionedSessions().keys(), ...mentionedModels()]) const parseFileAttachments = (text: string): FileAttachment[] => { const worktrees = references() - const paths = new Set([..._syncMentionedPaths(knownPaths, text)].filter((path) => !knownWorktrees.has(path))) + reclassifyModels() + const keys = modelKeys?.() + const paths = new Set( + [..._syncMentionedPaths(knownPaths, text)].filter((path) => !knownWorktrees.has(path) && !keys?.has(path)), + ) return [ ...buildFileAttachments(text, paths, workspaceDir), ...buildSessionAttachments(text, mentionedSessions()), @@ -858,7 +939,11 @@ export function useFileMention( const re = /@((?:[A-Za-z]:)?(?:[\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+))/g let m: RegExpExecArray | null while ((m = re.exec(text))) { - knownPaths.add(m[1]) + const token = m[1]! + // A known model reference is inline text, not a path, so it must not be + // seeded into knownPaths where it would become a file attachment. + if (modelKeys?.().has(token)) knownModels.add(token) + else knownPaths.add(token) } syncMentionedPaths(text) } @@ -926,8 +1011,10 @@ export function useFileMention( return { mentionedPaths, mentionedSessions, + mentionedModels, sessionPicker, sessionCandidates, + modelPicker, worktreePicker, worktreeCandidates, selectWorktree, @@ -950,5 +1037,6 @@ export function useFileMention( seedFromParts, seedSessions, selectSession, + selectModelReference, } } diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css index 581074de74d..634d8169564 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css @@ -72,6 +72,20 @@ opacity: 0.5; } +/* Inline model reference reuses the shared ModelSelectorBase. The component + renders its own trigger button, so the wrapper collapses it to a zero-size, + non-focusable anchor that only positions the popover above the prompt. */ +.mention-model-anchor { + width: 0; + height: 0; + overflow: visible; + pointer-events: none; +} + +.mention-model-anchor button { + visibility: hidden; +} + /* ============================================ Session Mention Picker (past chats) ============================================ */