From b3ef15319edd94b481f50821fc350a467f28e327 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 17 Sep 2026 15:37:54 +0200 Subject: [PATCH 01/10] feat(agent-manager): add @model, @past-chats and @worktrees mentions to New Worktree prompt --- .changeset/new-worktree-mentions.md | 5 + .../tests/unit/worktree-mention.test.ts | 202 +++++++++ .../agent-manager/AgentManagerApp.tsx | 1 + .../agent-manager/NewWorktreeDialog.tsx | 160 +++++++- .../agent-manager/agent-manager.css | 5 + .../agent-manager/worktree-mention.ts | 385 ++++++++++++++++++ 6 files changed, 755 insertions(+), 3 deletions(-) create mode 100644 .changeset/new-worktree-mentions.md create mode 100644 packages/kilo-vscode/tests/unit/worktree-mention.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts diff --git a/.changeset/new-worktree-mentions.md b/.changeset/new-worktree-mentions.md new file mode 100644 index 000000000000..e6101887eadc --- /dev/null +++ b/.changeset/new-worktree-mentions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add `@model`, `@past-chats`, and `@worktrees` mentions to the Agent Manager New Worktree prompt. Past chats and worktrees are sent as attachments to the new session, and model references stay inline text. diff --git a/packages/kilo-vscode/tests/unit/worktree-mention.test.ts b/packages/kilo-vscode/tests/unit/worktree-mention.test.ts new file mode 100644 index 000000000000..fabc95e40ec1 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-mention.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { + buildWorktreeMentionFiles, + buildWorktreeMentionResults, + useWorktreeMention, + WORKTREE_MENTION_ENTRIES, + type WorktreeMention, +} from "../../webview-ui/agent-manager/worktree-mention" +import type { WorktreeReference } from "../../webview-ui/src/hooks/file-mention-utils" +import type { ExtensionMessage, SessionSearchItem, WebviewMessage } from "../../webview-ui/src/types/messages" + +const CHAT: SessionSearchItem = { id: "ses_chat", title: "Fix auth bug", updated: 10 } +const WORKTREE: WorktreeReference = { + id: "wt-other", + name: "other-worktree", + branch: "other-branch", + path: "/repo/.kilo/worktrees/other", + base: "main", + sessions: [{ id: "ses_chat", title: "Fix auth bug" }], + disabled: false, +} + +/** Textarea stub whose value and selection mutate like a real prompt box. */ +function area(initial: string, cursor = initial.length) { + const state = { value: initial, start: cursor, end: cursor } + return { + get value() { + return state.value + }, + set value(value: string) { + state.value = value + }, + get selectionStart() { + return state.start + }, + get selectionEnd() { + return state.end + }, + isConnected: true, + focus: () => {}, + setSelectionRange: (start: number, end = start) => { + state.start = start + state.end = end + }, + } as unknown as HTMLTextAreaElement +} + +function harness(worktrees: WorktreeReference[] = [WORKTREE]) { + 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 useWorktreeMention(ctx, () => worktrees) + }) + const sessions = (list: SessionSearchItem[]) => { + for (const handler of handlers) { + handler({ type: "sessionSearchResult", sessions: list, requestId: "worktree-mention-session-1" }) + } + } + return { mention, posted, sessions, dispose } +} + +function pick(mention: WorktreeMention, type: string) { + return mention.mentionResults().find((item) => item.type === type) +} + +describe("buildWorktreeMentionResults", () => { + it("lists only the three worktree-independent entries for a bare @", () => { + expect(WORKTREE_MENTION_ENTRIES.map((entry) => entry.type)).toEqual(["model", "past-chats", "worktrees"]) + expect(buildWorktreeMentionResults("").map((entry) => entry.type)).toEqual(["model", "past-chats", "worktrees"]) + }) + + it("never offers file, folder, terminal or git changes", () => { + const types = buildWorktreeMentionResults("src").map((entry) => entry.type) + expect(types).not.toContain("file") + expect(types).not.toContain("folder") + expect(types).not.toContain("terminal") + expect(types).not.toContain("git-changes") + }) + + it("ranks a matching past chat next to the built-in entries", () => { + const session = { type: "session", value: CHAT.title, session: CHAT } as const + const results = buildWorktreeMentionResults("Fix", [session]) + expect(results.some((entry) => entry.type === "session")).toBe(true) + }) +}) + +describe("buildWorktreeMentionFiles", () => { + it("builds a session: attachment for a mentioned past chat", () => { + const files = buildWorktreeMentionFiles("@Fix auth bug ", new Map([[CHAT.title, CHAT]]), []) + expect(files).toHaveLength(1) + expect(files[0]!.url).toBe("session:ses_chat") + expect(files[0]!.source?.text.value).toBe("@Fix auth bug") + }) + + it("builds a data: metadata attachment for a mentioned worktree", () => { + const files = buildWorktreeMentionFiles(`@${WORKTREE.path}`, new Map(), [WORKTREE]) + expect(files).toHaveLength(1) + expect(files[0]!.url.startsWith("data:text/plain")).toBe(true) + expect(files[0]!.source?.path).toBe(WORKTREE.path) + }) + + it("produces no attachment for a model reference or unmatched text", () => { + expect(buildWorktreeMentionFiles("@provider/model", new Map(), [WORKTREE])).toEqual([]) + expect(buildWorktreeMentionFiles("plain prompt", new Map([[CHAT.title, CHAT]]), [WORKTREE])).toEqual([]) + }) +}) + +describe("useWorktreeMention", () => { + it("opens on a bare @ with the three entries and requests past chats once", () => { + const { mention, posted, dispose } = harness() + mention.onInput("@", 1) + expect(mention.showMention()).toBe(true) + expect(mention.mentionResults().map((entry) => entry.type)).toEqual(["model", "past-chats", "worktrees"]) + expect(posted.filter((message) => message.type === "requestSessionSearch")).toHaveLength(1) + + mention.onInput("@m", 2) + expect(posted.filter((message) => message.type === "requestSessionSearch")).toHaveLength(1) + dispose.fn?.() + }) + + it("inserts a past chat as @title and attaches its session id", () => { + const { mention, sessions, dispose } = harness() + const input = area("@Fix", 4) + mention.onInput("@Fix", 4) + sessions([CHAT]) + + const result = pick(mention, "session") + expect(result).toBeDefined() + mention.selectMention(result!, input, () => {}) + + expect(input.value).toBe("@Fix auth bug ") + expect(mention.mentionedSessions().get("Fix auth bug")?.id).toBe("ses_chat") + expect(mention.parseAttachments(input.value).map((file) => file.url)).toEqual(["session:ses_chat"]) + dispose.fn?.() + }) + + it("inserts a worktree path and attaches metadata only", () => { + const { mention, dispose } = harness() + const input = area("@", 1) + mention.onInput("@", 1) + mention.selectWorktree(WORKTREE, input, () => {}) + + expect(input.value).toBe(`@${WORKTREE.path} `) + const files = mention.parseAttachments(input.value) + expect(files).toHaveLength(1) + expect(files[0]!.url.startsWith("data:text/plain")).toBe(true) + dispose.fn?.() + }) + + it("inserts a model token as plain text with no attachment", () => { + const { mention, dispose } = harness() + const input = area("@", 1) + mention.onInput("@", 1) + const model = pick(mention, "model")! + mention.selectMention(model, input, () => {}) + expect(mention.modelPicker()).toBe(true) + + mention.selectModelReference("provider", "model-x") + expect(input.value).toBe("@provider/model-x ") + expect(mention.parseAttachments(input.value)).toEqual([]) + dispose.fn?.() + }) + + it("closes the menu once prose follows a completed mention", () => { + const { mention, sessions, dispose } = harness() + const input = area("@Fix", 4) + mention.onInput("@Fix", 4) + sessions([CHAT]) + mention.selectMention(pick(mention, "session")!, input, () => {}) + + mention.onInput("@Fix auth bug and then", 21) + expect(mention.showMention()).toBe(false) + dispose.fn?.() + }) + + it("keeps the menu closed after Escape until the query is edited", () => { + const { mention, dispose } = harness() + const event = { + key: "Escape", + preventDefault: () => {}, + stopPropagation: () => {}, + isComposing: false, + } as unknown as KeyboardEvent + + mention.onInput("@Fix", 4) + expect(mention.onKeyDown(event, area("@Fix", 4), () => {})).toBe(true) + expect(mention.showMention()).toBe(false) + mention.onInput("@Fix", 4) + expect(mention.showMention()).toBe(false) + dispose.fn?.() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index f47ba9a41906..0cce44a2593c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1815,6 +1815,7 @@ const AgentManagerContent: Component = () => { activeProjectId={activeProjectId()} defaultBase={defaultBase} onCreate={creation.schedule} + worktrees={references} /> )) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index fab84df593b6..1be019fbff54 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -46,6 +46,11 @@ import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut" import { convertToMentionPath, insertPathMentions } from "../src/utils/path-mentions" import { insertSpacedText, undoKey } from "../src/components/chat/prompt-input-utils" import { useSlashCommand } from "../src/hooks/useSlashCommand" +import type { MentionResult, WorktreeReference } from "../src/hooks/file-mention-utils" +import { SessionMentionPicker } from "../src/components/chat/SessionMentionPicker" +import { WorktreeMentionPicker } from "../src/components/chat/WorktreeMentionPicker" +import { formatRelativeDate } from "../src/utils/date" +import { useWorktreeMention } from "./worktree-mention" import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect" import { tracker } from "./telemetry" import { cycleAgent } from "../src/context/session-agent" @@ -58,6 +63,10 @@ type VersionCount = 1 | 2 | 3 | 4 const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4] const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox", "project"]) const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt" +// The `@model` entry opens the shared model selector through its programmatic +// open event, keyed to this prompt scope so the footer model selector and slash +// commands are unaffected. +const WORKTREE_MENTION_MODEL_TRIGGER = "agent-manager-worktree-mention-model" type DialogTab = "new" | "import" type Model = { providerID: string; modelID: string } @@ -94,6 +103,50 @@ function restoreAgent(value: string | undefined, list: Array<{ name: string }>, const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) +/** + * One row of the dialog's `@` menu. Only the three worktree-independent + * references plus past-chat matches can appear here. + */ +function MentionRow(props: { item: MentionResult }) { + const { t } = useLanguage() + const item = props.item + if (item.type === "model") + return ( + <> + + {item.label} + {item.description} + + ) + if (item.type === "past-chats") + return ( + <> + + {item.label} + {item.description} + + ) + if (item.type === "worktrees") + return ( + <> + + {t("prompt.worktrees.title")} + {t("prompt.worktrees.search")} + + ) + if (item.type === "session") + return ( + <> + + {item.session.title} + + {item.session.worktreeName ?? formatRelativeDate(new Date(item.session.updated).toISOString())} + + + ) + return null +} + export const NewWorktreeDialog: Component<{ onClose: () => void defaultBase?: (projectId: string) => string | undefined @@ -101,6 +154,7 @@ export const NewWorktreeDialog: Component<{ projects?: () => AgentProjectSnapshot[] activeProjectId?: string onCreate?: (projectId: string) => void + worktrees?: () => WorktreeReference[] mode: ModeRouter }> = (props) => { const { t } = useLanguage() @@ -317,6 +371,16 @@ export const NewWorktreeDialog: Component<{ window.addEventListener("focusPrompt", onFocusPrompt) onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt)) + const mention = useWorktreeMention(vscode, () => props.worktrees?.() ?? []) + // Picking the `@` model entry opens the shared model selector, mounted hidden + // and keyed to its own trigger. The mention latch resets first because the + // selector owns its open state afterwards. + createEffect(() => { + if (!mention.modelPicker()) return + mention.closeMention() + window.dispatchEvent(new CustomEvent("openModelPicker", { detail: { source: WORKTREE_MENTION_MODEL_TRIGGER } })) + }) + onMount(() => { // Resize textarea if restoring a cached prompt if (prompt()) adjustHeight() @@ -363,6 +427,18 @@ export const NewWorktreeDialog: Component<{ const total = () => (compareMode() ? totalAllocations(modelAllocations()) : versions()) const mode = () => (compareMode() ? "compare_models" : versions() > 1 ? "multiple_versions" : "single") + /** + * Attachments for the new sessions. Mentions are resolved here, at creation + * time: the new worktree does not exist yet, so nothing is read from it. Past + * chats and worktrees travel as attachments; model references stay inline. + */ + const resolveFiles = (text: string | undefined) => { + const mentionFiles = text ? mention.parseAttachments(text) : [] + const imgFiles = imageAttach.images().map((img) => ({ mime: img.mime, url: img.dataUrl })) + const files = [...mentionFiles, ...imgFiles] + return files.length > 0 ? files : undefined + } + const handleSubmit = () => { if (!canSubmit()) return const advanced = showAdvanced() @@ -380,8 +456,6 @@ export const NewWorktreeDialog: Component<{ const text = prompt().trim() || undefined const defaultAgent = session.agents()[0]?.name const selectedAgent = agent() !== defaultAgent ? agent() : undefined - const imgs = imageAttach.images() - const imgFiles = imgs.length > 0 ? imgs.map((img) => ({ mime: img.mime, url: img.dataUrl })) : undefined const isCompare = compareMode() const allocations = isCompare ? allocationsToArray(modelAllocations()) : undefined @@ -404,7 +478,7 @@ export const NewWorktreeDialog: Component<{ branchName: customBranch, modelAllocations: allocations, sandbox: sandboxVisible() ? sandboxOverride() : undefined, - files: imgFiles, + files: resolveFiles(text), }) persistPrompt("") @@ -450,6 +524,11 @@ export const NewWorktreeDialog: Component<{ return } + if (mention.onKeyDown(e, textareaRef, setPromptValue, restorePrompt)) { + e.stopPropagation() + return + } + // Shift+Tab cycles reasoning effort variants (setting: chat.shiftTabCyclesVariant). // When disabled or no variants exist, fall through to default focus navigation. if (e.key === "Tab" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { @@ -704,6 +783,80 @@ export const NewWorktreeDialog: Component<{ onDragLeave={imageAttach.handleDragLeave} onDrop={imageAttach.handleDrop} > + + +
+ { + if (textareaRef) mention.selectSession(picked, textareaRef, setPromptValue, restorePrompt) + }} + onClose={() => { + mention.closeMention() + restorePrompt() + }} + /> + } + > + { + if (textareaRef) mention.selectWorktree(picked, textareaRef, setPromptValue, restorePrompt) + }} + onClose={() => { + mention.closeMention() + restorePrompt() + }} + /> + } + > + 0} + fallback={
No mentions found
} + > + + {(item, index) => ( +
{ + e.preventDefault() + if (textareaRef) mention.selectMention(item, textareaRef, setPromptValue, restorePrompt) + }} + onMouseEnter={() => mention.setMentionIndex(index())} + > + +
+ )} +
+
+
+
+
+
void + onMessage: (handler: (message: ExtensionMessage) => void) => () => void +} + +/** + * The three entries the New Worktree dialog can mention. @file and friends are + * deliberately absent: the worktree does not exist yet when the dialog runs, so + * resolving file paths against it would be unsafe. + */ +export const WORKTREE_MENTION_ENTRIES: MentionResult[] = [MODEL_RESULT, PAST_CHATS_RESULT, WORKTREES_RESULT] + +/** + * Rank the dialog's mentions for a query. A bare `@` lists the three entries in + * order; a query ranks those entries together with any matching past chats, so + * the best answer comes first whatever kind of reference it is. + */ +export function buildWorktreeMentionResults(query: string, sessions: MentionResult[] = []): MentionResult[] { + if (!query) return [...WORKTREE_MENTION_ENTRIES] + return rankMentionResults(query, [...WORKTREE_MENTION_ENTRIES, ...sessions]) +} + +/** + * Build the mention attachments for a prompt. Sessions travel as `session:` + * URLs the backend resolves into transcript context; worktrees travel as + * metadata-only data URLs. Model references are inline text and never produce + * an attachment, so they stay out of this list. + */ +export function buildWorktreeMentionFiles( + text: string, + sessions: Map, + worktrees: WorktreeReference[], +): FileAttachment[] { + return [...buildSessionAttachments(text, sessions), ...buildWorktreeAttachments(text, worktrees)] +} + +export interface WorktreeMention { + /** Mentioned past chats, keyed by their `@title` token in the text. */ + mentionedSessions: Accessor> + sessionPicker: Accessor + sessionCandidates: Accessor + modelPicker: Accessor + worktreePicker: Accessor + worktreeCandidates: Accessor + mentionResults: Accessor + mentionIndex: Accessor + showMention: Accessor + onInput: (text: string, cursor: number) => void + onKeyDown: ( + e: KeyboardEvent, + textarea: HTMLTextAreaElement | undefined, + setText: (text: string) => void, + onSelect?: () => void, + ) => boolean + selectMention: ( + result: MentionResult, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => void + selectSession: ( + session: SessionSearchItem, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => void + selectWorktree: ( + worktree: WorktreeReference, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => void + selectModelReference: (providerID: string, modelID: string) => void + setMentionIndex: (index: number) => void + closeMention: () => void + /** Attachments for the prompt text the user is about to send. */ + parseAttachments: (text: string) => FileAttachment[] +} + +/** + * `@`-mention state for the New Worktree dialog. Worktree-independent by + * design: it offers inline model references, past chats and Agent Manager + * worktrees, and builds only attachments that are safe before the new worktree + * exists. The shared file/terminal/git entries are intentionally not offered. + */ +export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor): WorktreeMention { + const [query, setQuery] = createSignal(null) + const [results, setResults] = createSignal([]) + const [index, setIndex] = createSignal(0) + const [candidates, setCandidates] = createSignal([]) + const [sessionPicker, setSessionPicker] = createSignal(false) + const [worktreePicker, setWorktreePicker] = createSignal(false) + const [modelPicker, setModelPicker] = createSignal(false) + const [mentioned, setMentioned] = createSignal>(new Map()) + // Model references are inline text, not files, so they are tracked apart and + // never become attachments. They still count as completed mentions, so prose + // typed after one closes the dropdown instead of reopening it. + const models = new Set() + // The `@` offset the open query belongs to, and the dead query at that offset + // that a dismissal or a completed mention closed on. + let at = 0 + let dead: { at: number; query: string } | undefined + let requested = false + let ready = false + let counter = 0 + let modelState: { + textarea: HTMLTextAreaElement + start: number + end: number + setText: (text: string) => void + onSelect?: () => void + } | null = null + + const worktreeCandidates = () => worktrees() + const showMention = () => query() !== null + + /** Every token the query could stand for, for prose detection. */ + const tokens = () => + new Set([ + MODEL_RESULT.value, + PAST_CHATS_RESULT.value, + WORKTREES_RESULT.value, + ...mentioned().keys(), + ...models, + ...worktreeCandidates().map((worktree) => worktree.path), + ]) + + const sessionResults = (value: string): MentionResult[] => { + if (!value) return [] + return filterSessions(candidates(), value) + .slice(0, SESSION_RESULT_LIMIT) + .map((session) => ({ type: "session", value: sessionMentionToken(session, mentioned()), session })) + } + + const close = () => { + setQuery(null) + setResults([]) + setIndex(0) + setSessionPicker(false) + setWorktreePicker(false) + setModelPicker(false) + } + + // The past-chat list is directory-scoped server-side; one fetch per dialog is + // enough, since a session title is what a spaced query is matched against. + const loadSessions = () => { + if (requested) return + requested = true + vscode.postMessage({ type: "requestSessionSearch", requestId: `worktree-mention-session-${++counter}` }) + } + + const unsubscribe = vscode.onMessage((message) => { + if (message.type !== "sessionSearchResult") return + if (message.requestId !== `worktree-mention-session-${counter}`) return + ready = true + setCandidates( + message.sessions + .map((session) => ({ ...session, title: sessionMentionText(session.title) })) + .filter((session) => session.title) + .sort((a, b) => b.updated - a.updated), + ) + const open = query() + if (open === null) return + const next = buildWorktreeMentionResults(open, sessionResults(open)) + if (next.length === 0) { + close() + return + } + setResults(next) + }) + onCleanup(unsubscribe) + + const onInput = (text: string, cursor: number) => { + setWorktreePicker(false) + setModelPicker(false) + const before = text.substring(0, cursor) + const match = before.match(AT_PATTERN) + if (!match) { + dead = undefined + close() + return + } + at = (match.index ?? 0) + (/^\s/.test(match[0]) ? 1 : 0) + const value = match[1] ?? "" + if (dead && dead.at === at && value.startsWith(dead.query)) { + close() + return + } + // Prose past a completed mention is not a longer query: close on it and + // remember the dead query so the next keystroke does not reopen the menu. + if (mentionSettled(value, tokens())) { + dead = { at, query: value } + close() + return + } + dead = undefined + loadSessions() + const next = buildWorktreeMentionResults(value, sessionResults(value)) + if (next.length === 0) { + // Past chats are the only non-entry match a query can have. Until their + // list has arrived, a query that matches nothing yet may still match a + // chat title, so the menu waits instead of closing. + if (!ready) { + setQuery(value) + setResults([]) + setIndex(0) + return + } + close() + return + } + setQuery(value) + setResults(next) + setIndex(0) + } + + /** Replace the open `@query` with `@token`, keeping a trailing space. */ + const insertToken = ( + token: string, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => { + const cursor = textarea.selectionStart ?? textarea.value.length + const before = textarea.value.substring(0, cursor) + const match = before.match(AT_PATTERN) + if (!match) return + const start = (match.index ?? 0) + (/^\s/.test(match[0]) ? 1 : 0) + const after = textarea.value.substring(cursor) + const suffix = /^\s/.test(after) ? "" : " " + const next = `${textarea.value.substring(0, start)}@${token}${suffix}${after}` + const pos = start + token.length + 1 + suffix.length + textarea.value = next + textarea.setSelectionRange(pos, pos) + textarea.focus() + setText(next) + close() + onSelect?.() + } + + const selectMention = ( + result: MentionResult, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => { + if (result.type === "worktrees") { + setWorktreePicker(true) + return + } + if (result.type === "past-chats") { + setSessionPicker(true) + return + } + if (result.type === "model") { + const cursor = textarea.selectionStart ?? textarea.value.length + const match = textarea.value.substring(0, cursor).match(AT_PATTERN) + if (!match) return + const start = (match.index ?? 0) + (/^\s/.test(match[0]) ? 1 : 0) + modelState = { textarea, start, end: cursor, setText, onSelect } + close() + setModelPicker(true) + return + } + const token = result.type === "session" ? sessionMentionToken(result.session, mentioned()) : result.value + if (result.type === "session") setMentioned((prev) => new Map(prev).set(token, result.session)) + insertToken(token, textarea, setText, onSelect) + } + + const selectSession = ( + session: SessionSearchItem, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => { + const token = sessionMentionToken(session, mentioned()) + setMentioned((prev) => new Map(prev).set(token, session)) + insertToken(token, textarea, setText, onSelect) + } + + const selectWorktree = ( + worktree: WorktreeReference, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => insertToken(worktree.path, textarea, setText, onSelect) + + const selectModelReference = (providerID: string, modelID: string) => { + const state = modelState + modelState = null + setModelPicker(false) + if (!state) return + const textarea = state.textarea + if (!textarea.isConnected) return + const token = `${providerID}/${modelID}` + const after = textarea.value.substring(state.end) + const suffix = /^\s/.test(after) ? "" : " " + const next = `${textarea.value.substring(0, state.start)}@${token}${suffix}${after}` + const pos = state.start + token.length + 1 + suffix.length + models.add(token) + textarea.value = next + textarea.setSelectionRange(pos, pos) + textarea.focus() + state.setText(next) + state.onSelect?.() + } + + const onKeyDown = ( + e: KeyboardEvent, + textarea: HTMLTextAreaElement | undefined, + setText: (text: string) => void, + onSelect?: () => void, + ): boolean => { + if (!showMention()) return false + if (e.isComposing) return false + + if (e.key === "ArrowDown") { + e.preventDefault() + setIndex((i) => Math.min(i + 1, Math.max(results().length - 1, 0))) + return true + } + if (e.key === "ArrowUp") { + e.preventDefault() + setIndex((i) => Math.max(i - 1, 0)) + return true + } + if (e.key === "Enter" || (e.key === "Tab" && !e.shiftKey)) { + const result = results()[index()] + if (!result) return false + e.preventDefault() + if (textarea) selectMention(result, textarea, setText, onSelect) + return true + } + if (e.key === "Escape") { + e.preventDefault() + e.stopPropagation() + dead = { at, query: query() ?? "" } + close() + return true + } + return false + } + + return { + mentionedSessions: mentioned, + sessionPicker, + sessionCandidates: candidates, + modelPicker, + worktreePicker, + worktreeCandidates, + mentionResults: results, + mentionIndex: index, + showMention, + onInput, + onKeyDown, + selectMention, + selectSession, + selectWorktree, + selectModelReference, + setMentionIndex: setIndex, + closeMention: close, + parseAttachments: (text) => buildWorktreeMentionFiles(text, mentioned(), worktreeCandidates()), + } +} From 9bdd549bdfa9277f348877d96ea7702181f48fec Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 17 Sep 2026 15:49:09 +0200 Subject: [PATCH 02/10] fix(agent-manager): reset past-chat picker on prompt input --- .../kilo-vscode/tests/unit/worktree-mention.test.ts | 12 ++++++++++++ .../webview-ui/agent-manager/worktree-mention.ts | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/packages/kilo-vscode/tests/unit/worktree-mention.test.ts b/packages/kilo-vscode/tests/unit/worktree-mention.test.ts index fabc95e40ec1..d2cbe99ddfc7 100644 --- a/packages/kilo-vscode/tests/unit/worktree-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-mention.test.ts @@ -183,6 +183,18 @@ describe("useWorktreeMention", () => { dispose.fn?.() }) + it("closes the past-chat picker when the prompt text changes", () => { + const { mention, dispose } = harness() + const input = area("@", 1) + mention.onInput("@", 1) + mention.selectMention(pick(mention, "past-chats")!, input, () => {}) + expect(mention.sessionPicker()).toBe(true) + + mention.onInput("@q", 2) + expect(mention.sessionPicker()).toBe(false) + dispose.fn?.() + }) + it("keeps the menu closed after Escape until the query is edited", () => { const { mention, dispose } = harness() const event = { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts index a3aac0cbf6cb..23297f7d85c4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts @@ -132,6 +132,10 @@ export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor void } | null = null + // Unlike chat, the dialog does not hide `disabled` worktrees. In chat the + // flag marks the worktree the user is currently in, but the dialog has no + // current worktree and the selected one is the most useful reference. The + // mention is metadata only, so a stale path has no read-time side effect. const worktreeCandidates = () => worktrees() const showMention = () => query() !== null @@ -192,6 +196,7 @@ export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor { + setSessionPicker(false) setWorktreePicker(false) setModelPicker(false) const before = text.substring(0, cursor) From 1f4180d5990b9e214d3901ba0028f90031da865e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 17 Sep 2026 15:59:10 +0200 Subject: [PATCH 03/10] fix(agent-manager): insert mention tokens without clearing native undo --- .../agent-manager/worktree-mention.ts | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts index 23297f7d85c4..694b48703935 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts @@ -240,6 +240,35 @@ export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor void, + ) => { + const pos = start + inserted.length + const canExec = + typeof document !== "undefined" && typeof document.execCommand === "function" && textarea.isConnected + if (canExec) { + textarea.focus() + textarea.setSelectionRange(start, end) + document.execCommand("insertText", false, inserted) + textarea.setSelectionRange(pos, pos) + return + } + textarea.value = `${textarea.value.substring(0, start)}${inserted}${textarea.value.substring(end)}` + textarea.setSelectionRange(pos, pos) + textarea.focus() + setText(textarea.value) + } + /** Replace the open `@query` with `@token`, keeping a trailing space. */ const insertToken = ( token: string, @@ -252,14 +281,8 @@ export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor Date: Thu, 17 Sep 2026 16:07:51 +0200 Subject: [PATCH 04/10] fix(agent-manager): sync prompt if mention insert skips input event --- .../webview-ui/agent-manager/worktree-mention.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts index 694b48703935..c968468a4133 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts @@ -121,6 +121,9 @@ export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor { + sawInput = true setSessionPicker(false) setWorktreePicker(false) setModelPicker(false) @@ -259,7 +263,11 @@ export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor Date: Thu, 17 Sep 2026 16:21:33 +0200 Subject: [PATCH 05/10] fix(agent-manager): align new worktree mentions with chat reference rules --- .../tests/unit/worktree-mention.test.ts | 19 +++++++++ .../agent-manager/AgentManagerApp.tsx | 6 +-- .../agent-manager/worktree-mention.ts | 40 +++++++++---------- .../agent-manager/worktree-references.ts | 18 +++++++++ 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/worktree-mention.test.ts b/packages/kilo-vscode/tests/unit/worktree-mention.test.ts index d2cbe99ddfc7..2e4d152b0b0b 100644 --- a/packages/kilo-vscode/tests/unit/worktree-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-mention.test.ts @@ -157,6 +157,25 @@ describe("useWorktreeMention", () => { dispose.fn?.() }) + it("excludes disabled worktrees from the picker and from selection", () => { + const stale: WorktreeReference = { + ...WORKTREE, + id: "wt-stale", + name: "stale", + path: "/repo/.kilo/worktrees/stale", + disabled: true, + } + const { mention, dispose } = harness([WORKTREE, stale]) + mention.onInput("@", 1) + mention.selectMention(pick(mention, "worktrees")!, area("@", 1), () => {}) + expect(mention.worktreeCandidates().map((worktree) => worktree.id)).toEqual(["wt-other"]) + + const input = area("@", 1) + mention.selectWorktree(stale, input, () => {}) + expect(input.value).toBe("@") + dispose.fn?.() + }) + it("inserts a model token as plain text with no attachment", () => { const { mention, dispose } = harness() const input = area("@", 1) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 0cce44a2593c..a2a1297e1b03 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -186,7 +186,7 @@ import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" import { createWorktreeDiffs, diffDataKey, wireDiffId } from "./worktree-diffs" -import { createWorktreeReferences } from "./worktree-references" +import { createWorktreeMentionReferences as createMentionRefs } from "./worktree-references" import type { ReviewComment } from "../diff-viewer/review-comments" import { createReviewComposers } from "./review-composers" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" @@ -831,7 +831,7 @@ const AgentManagerContent: Component = () => { managed: managedSessions, locals: localSet, }) - const references = createWorktreeReferences(vscode, registry.active, projectSessionsLive.current, selection) + const { references, dialogRefs } = createMentionRefs(vscode, registry.active, projectSessionsLive.current, selection) /** Session ids shown in the project-scoped history view (every session of the project). */ const historySessionIds = createMemo(() => { @@ -1815,7 +1815,7 @@ const AgentManagerContent: Component = () => { activeProjectId={activeProjectId()} defaultBase={defaultBase} onCreate={creation.schedule} - worktrees={references} + worktrees={dialogRefs} /> )) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts index c968468a4133..5c53b00bd39b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-mention.ts @@ -135,23 +135,15 @@ export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor void } | null = null - // Unlike chat, the dialog does not hide `disabled` worktrees. In chat the - // flag marks the worktree the user is currently in, but the dialog has no - // current worktree and the selected one is the most useful reference. The - // mention is metadata only, so a stale path has no read-time side effect. - const worktreeCandidates = () => worktrees() + // Stale and busy worktrees stay out of the picker, like chat. The dialog + // receives a reference list built without a selection, so the worktree the + // user is currently in is still offered (see AgentManagerApp's dialog list). + const worktreeCandidates = () => worktrees().filter((worktree) => !worktree.disabled) const showMention = () => query() !== null /** Every token the query could stand for, for prose detection. */ const tokens = () => - new Set([ - MODEL_RESULT.value, - PAST_CHATS_RESULT.value, - WORKTREES_RESULT.value, - ...mentioned().keys(), - ...models, - ...worktreeCandidates().map((worktree) => worktree.path), - ]) + new Set([...mentioned().keys(), ...models, ...worktreeCandidates().map((worktree) => worktree.path)]) const sessionResults = (value: string): MentionResult[] => { if (!value) return [] @@ -261,17 +253,22 @@ export function useWorktreeMention(vscode: VSCodeContext, worktrees: Accessor void, onSelect?: () => void, - ) => insertToken(worktree.path, textarea, setText, onSelect) + ) => { + if (worktree.disabled) return + insertToken(worktree.path, textarea, setText, onSelect) + } const selectModelReference = (providerID: string, modelID: string) => { const state = modelState diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts index c2917cd8bd82..bcf30811ff09 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts @@ -92,3 +92,21 @@ export function createWorktreeReferences( }) return createMemo(() => worktreeReferences(state(), sessions(), selection(), recency.recent())) } + +/** + * The chat list disables the current, stale, and busy worktrees. The New + * Worktree dialog has no current worktree, so it builds a second list with no + * selection: the worktree the user is in stays selectable while stale and busy + * ones stay disabled and are filtered out by the mention picker. + */ +export function createWorktreeMentionReferences( + vscode: Pick, "getState" | "setState">, + state: Accessor, + sessions: Accessor, + selection: Accessor, +) { + return { + references: createWorktreeReferences(vscode, state, sessions, selection), + dialogRefs: createWorktreeReferences(vscode, state, sessions, () => null), + } +} From b434feb619d7ac68cddb6dcb9d048ce63af2cf5b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 17 Sep 2026 16:29:01 +0200 Subject: [PATCH 06/10] fix(agent-manager): share worktree recency with the new worktree dialog list --- .../agent-manager-worktree-reference.test.ts | 27 ++++++++++++++++++- .../agent-manager/worktree-references.ts | 19 ++++++++----- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-reference.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-reference.test.ts index 27aa709ca4f8..3994a81cf75c 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-reference.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-reference.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test" import { createRoot } from "solid-js" -import { worktreeDropReference, worktreeReferences } from "../../webview-ui/agent-manager/worktree-references" +import { + worktreeDropReference, + worktreeReferences, + createWorktreeMentionReferences, +} from "../../webview-ui/agent-manager/worktree-references" import { createProjectStore } from "../../webview-ui/agent-manager/project/store" import { buildMentionResults, @@ -130,6 +134,27 @@ describe("Agent Manager worktree references", () => { scope.dispose() }) + it("keeps the selected worktree in the dialog list but leaves stale and busy disabled", () => { + const state = createProjectStore("project") + state.setWorktrees([tree("current"), tree("stale"), tree("busy"), tree("other")]) + state.setStaleWorktreeIds(new Set(["stale"])) + state.setBusy(new Map([["busy", { reason: "deleting" }]])) + let stored: Record | undefined + createRoot((dispose) => { + const { dialogRefs } = createWorktreeMentionReferences( + { getState: () => stored, setState: (value) => (stored = value) } as never, + () => state, + () => [], + () => "current", + ) + const dialog = dialogRefs() + expect(dialog.find((ref) => ref.id === "current")?.disabled).toBe(false) + expect(dialog.find((ref) => ref.id === "stale")?.disabled).toBe(true) + expect(dialog.find((ref) => ref.id === "busy")?.disabled).toBe(true) + dispose() + }) + }) + it("keeps project inventories separate even when worktree IDs match", () => { const first = createProjectStore("first") const second = createProjectStore("second") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts index bcf30811ff09..8d6f8a1060f3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts @@ -95,9 +95,10 @@ export function createWorktreeReferences( /** * The chat list disables the current, stale, and busy worktrees. The New - * Worktree dialog has no current worktree, so it builds a second list with no - * selection: the worktree the user is in stays selectable while stale and busy - * ones stay disabled and are filtered out by the mention picker. + * Worktree dialog has no current worktree, so it re-enables the selected + * worktree when that worktree is neither stale nor busy. The list derives from + * the chat list so both share one recency signal; stale and busy worktrees stay + * disabled and are filtered out by the mention picker. */ export function createWorktreeMentionReferences( vscode: Pick, "getState" | "setState">, @@ -105,8 +106,12 @@ export function createWorktreeMentionReferences( sessions: Accessor, selection: Accessor, ) { - return { - references: createWorktreeReferences(vscode, state, sessions, selection), - dialogRefs: createWorktreeReferences(vscode, state, sessions, () => null), - } + const references = createWorktreeReferences(vscode, state, sessions, selection) + const dialogRefs = createMemo(() => { + const store = state() + const id = selection() + if (!id || store.staleWorktreeIds().has(id) || store.busy().has(id)) return references() + return references().map((ref) => (ref.id === id ? { ...ref, disabled: false } : ref)) + }) + return { references, dialogRefs } } From 0e2f1a5c020c6125a327f32716ae637343c4dbf6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 18 Sep 2026 08:55:19 +0200 Subject: [PATCH 07/10] feat(agent-manager): highlight mentions in the new worktree prompt --- .../tests/unit/file-mention-utils.test.ts | 35 +++++++++++++++++++ .../tests/unit/worktree-mention.test.ts | 3 ++ .../agent-manager/NewWorktreeDialog.tsx | 22 ++++++++++++ .../agent-manager/agent-manager.css | 5 ++- .../agent-manager/worktree-mention.ts | 3 ++ .../src/hooks/file-mention-utils.ts | 28 +++++++++++++++ 6 files changed, 95 insertions(+), 1 deletion(-) 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 59b7fbd17104..2d361ed03a82 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -12,6 +12,7 @@ import { isCursorAtMentionEnd, findMentionRange, mentionSettled, + segmentMentionText, sessionMentionFilename, sessionMentionText, sessionMentionToken, @@ -921,3 +922,37 @@ describe("modelReferenceToken", () => { expect(kept.has(token)).toBe(true) }) }) + +describe("segmentMentionText", () => { + it("marks inserted mention tokens and keeps plain text as-is", () => { + const tokens = new Set(["kilo/kilo-auto/free", "Fix auth bug", "/repo/.kilo/worktrees/feature"]) + const segments = segmentMentionText( + "see @Fix auth bug then @/repo/.kilo/worktrees/feature use @kilo/kilo-auto/free", + tokens, + ) + expect(segments.map((segment) => [segment.mention, segment.text])).toEqual([ + [false, "see "], + [true, "@Fix auth bug"], + [false, " then "], + [true, "@/repo/.kilo/worktrees/feature"], + [false, " use "], + [true, "@kilo/kilo-auto/free"], + ]) + }) + + it("keeps multiple spaces and newlines intact", () => { + const segments = segmentMentionText("a b\n @Fix auth bug", new Set(["Fix auth bug"])) + expect(segments.map((segment) => segment.text).join("")).toBe("a b\n @Fix auth bug") + expect(segments.filter((segment) => segment.mention).map((segment) => segment.text)).toEqual(["@Fix auth bug"]) + }) + + it("matches the longest token first", () => { + const segments = segmentMentionText("@Fix auth bug", new Set(["Fix", "Fix auth bug"])) + expect(segments.filter((segment) => segment.mention).map((segment) => segment.text)).toEqual(["@Fix auth bug"]) + }) + + it("returns one plain segment when there are no tokens or no text", () => { + expect(segmentMentionText("plain", new Set())).toEqual([{ text: "plain", mention: false }]) + expect(segmentMentionText("", new Set(["model"]))).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-mention.test.ts b/packages/kilo-vscode/tests/unit/worktree-mention.test.ts index 2e4d152b0b0b..fbd101a95e0f 100644 --- a/packages/kilo-vscode/tests/unit/worktree-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-mention.test.ts @@ -140,6 +140,7 @@ describe("useWorktreeMention", () => { expect(input.value).toBe("@Fix auth bug ") expect(mention.mentionedSessions().get("Fix auth bug")?.id).toBe("ses_chat") + expect(mention.highlightTokens().has("Fix auth bug")).toBe(true) expect(mention.parseAttachments(input.value).map((file) => file.url)).toEqual(["session:ses_chat"]) dispose.fn?.() }) @@ -151,6 +152,7 @@ describe("useWorktreeMention", () => { mention.selectWorktree(WORKTREE, input, () => {}) expect(input.value).toBe(`@${WORKTREE.path} `) + expect(mention.highlightTokens().has(WORKTREE.path)).toBe(true) const files = mention.parseAttachments(input.value) expect(files).toHaveLength(1) expect(files[0]!.url.startsWith("data:text/plain")).toBe(true) @@ -186,6 +188,7 @@ describe("useWorktreeMention", () => { mention.selectModelReference("provider", "model-x") expect(input.value).toBe("@provider/model-x ") + expect(mention.highlightTokens().has("provider/model-x")).toBe(true) expect(mention.parseAttachments(input.value)).toEqual([]) dispose.fn?.() }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 1be019fbff54..1434b041305b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -47,6 +47,7 @@ import { convertToMentionPath, insertPathMentions } from "../src/utils/path-ment import { insertSpacedText, undoKey } from "../src/components/chat/prompt-input-utils" import { useSlashCommand } from "../src/hooks/useSlashCommand" import type { MentionResult, WorktreeReference } from "../src/hooks/file-mention-utils" +import { segmentMentionText } from "../src/hooks/file-mention-utils" import { SessionMentionPicker } from "../src/components/chat/SessionMentionPicker" import { WorktreeMentionPicker } from "../src/components/chat/WorktreeMentionPicker" import { formatRelativeDate } from "../src/utils/date" @@ -372,6 +373,13 @@ export const NewWorktreeDialog: Component<{ onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt)) const mention = useWorktreeMention(vscode, () => props.worktrees?.() ?? []) + let highlightRef: HTMLDivElement | undefined + const mentionSegments = createMemo(() => segmentMentionText(prompt(), mention.highlightTokens())) + const syncHighlight = () => { + if (!highlightRef || !textareaRef) return + highlightRef.scrollTop = textareaRef.scrollTop + highlightRef.scrollLeft = textareaRef.scrollLeft + } // Picking the `@` model entry opens the shared model selector, mounted hidden // and keyed to its own trigger. The mention latch resets first because the // selector owns its open state afterwards. @@ -554,6 +562,7 @@ export const NewWorktreeDialog: Component<{ box.style.height = "auto" const chrome = box.offsetHeight - area.offsetHeight box.style.height = `${Math.min(area.scrollHeight, 200) + chrome}px` + syncHighlight() } const insertSpeechText = (value: string) => { @@ -912,6 +921,18 @@ export const NewWorktreeDialog: Component<{
+