From cb02cb42fc4eb65b56cf2b8d338985c4841d10ac Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Fri, 10 Jul 2026 13:10:27 +0530 Subject: [PATCH 01/28] fix(provider): enable reasoning variants for Grok models --- packages/opencode/src/provider/transform.ts | 1 - packages/opencode/test/provider/transform.test.ts | 10 ++++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 18b94c5adf7..609d6027018 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -725,7 +725,6 @@ export function variants(model: Provider.Model): Record { }) describe("@ai-sdk/xai", () => { - test("grok-3 returns empty object", () => { + test("grok-4.5 uses standard reasoning efforts", () => { const model = createMockModel({ - id: "xai/grok-3", + id: "xai/grok-4.5", providerID: "xai", api: { - id: "grok-3", + id: "grok-4.5", url: "https://api.x.ai", npm: "@ai-sdk/xai", }, }) + const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoningEffort: "medium" }) }) test("grok-3-mini returns low and high with reasoningEffort", () => { From c654f1e3d1efae339a20a44b6cd7e2f78deab4eb Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Fri, 10 Jul 2026 13:52:35 +0530 Subject: [PATCH 02/28] fix(provider): enable reasoning variants for Grok 4.5 --- .changeset/grok-4.5-reasoning-variants.md | 5 ++++ packages/opencode/src/provider/transform.ts | 3 +++ .../opencode/test/provider/transform.test.ts | 27 ++++++++++++++----- 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 .changeset/grok-4.5-reasoning-variants.md diff --git a/.changeset/grok-4.5-reasoning-variants.md b/.changeset/grok-4.5-reasoning-variants.md new file mode 100644 index 00000000000..d77ad6e9d0b --- /dev/null +++ b/.changeset/grok-4.5-reasoning-variants.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix Grok 4.5 reasoning variants not showing up in the model picker. diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 609d6027018..7f299cdeb0b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -725,6 +725,9 @@ export function variants(model: Provider.Model): Record { }) describe("@ai-sdk/xai", () => { - test("grok-4.5 uses standard reasoning efforts", () => { + test("grok-3 returns empty object", () => { const model = createMockModel({ - id: "xai/grok-4.5", + id: "xai/grok-3", providerID: "xai", api: { - id: "grok-4.5", + id: "grok-3", url: "https://api.x.ai", npm: "@ai-sdk/xai", }, }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) - expect(result.medium).toEqual({ reasoningEffort: "medium" }) + expect(result).toEqual({}) }) test("grok-3-mini returns low and high with reasoningEffort", () => { @@ -3371,6 +3369,23 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) + + test("grok-4.5 uses standard reasoning efforts", () => { + const model = createMockModel({ + id: "xai/grok-4.5", + providerID: "xai", + api: { + id: "grok-4.5", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + }) + + const result = ProviderTransform.variants(model) + + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoningEffort: "medium" }) + }) }) describe("@ai-sdk/deepinfra", () => { From 46fe0a91d91ec42931a3af78e409334f05de91ea Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Tue, 14 Jul 2026 03:38:59 +0200 Subject: [PATCH 03/28] feat(vscode): toggle chat search from Command Palette, jump focus on close, and auto-expand matched blocks --- .changeset/chat-search-toggle-and-jump.md | 5 + .../kilo-ui/src/components/message-part.tsx | 28 +++ packages/kilo-vscode/package.json | 5 + packages/kilo-vscode/src/extension.ts | 2 +- .../code-actions/register-code-actions.ts | 17 +- packages/kilo-vscode/webview-ui/src/App.tsx | 4 + .../src/components/chat/AssistantMessage.tsx | 24 +- .../src/components/chat/MessageList.tsx | 218 ++++++++++++++++-- .../src/components/chat/TaskHeader.tsx | 30 ++- .../src/components/chat/TranscriptRow.tsx | 4 + .../chat/transcript-search-highlight.ts | 43 +++- .../src/context/transcript-search.tsx | 5 +- 12 files changed, 361 insertions(+), 24 deletions(-) create mode 100644 .changeset/chat-search-toggle-and-jump.md diff --git a/.changeset/chat-search-toggle-and-jump.md b/.changeset/chat-search-toggle-and-jump.md new file mode 100644 index 00000000000..d4a5563bf90 --- /dev/null +++ b/.changeset/chat-search-toggle-and-jump.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Toggle chat search from the Command Palette, jump focus back to the chat input when it closes, and auto-expand the collapsed tool call or reasoning block containing the current search match. diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ce5205552f8..2a0868a25dd 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -142,6 +142,10 @@ export interface MessagePartProps { message: MessageType hideDetails?: boolean defaultOpen?: boolean + /** True when this part contains the transcript search's current match — + * forces a collapsed tool/reasoning block open so the user can see the + * highlighted match without manually expanding it first. */ + forceOpen?: boolean reasoningAutoCollapse?: boolean showAssistantCopyPartID?: string | null showTurnDiffSummary?: boolean @@ -974,6 +978,7 @@ export function Part(props: MessagePartProps) { message={props.message} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + forceOpen={props.forceOpen} reasoningAutoCollapse={props.reasoningAutoCollapse} showAssistantCopyPartID={props.showAssistantCopyPartID} showTurnDiffSummary={props.showTurnDiffSummary} @@ -1198,6 +1203,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { status={part.state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + forceOpen={props.forceOpen} animate reveal={props.animate} /> @@ -1265,6 +1271,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { attachments={part.state.attachments} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + forceOpen={props.forceOpen} animate reveal={props.animate} /> @@ -1519,6 +1526,18 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp setOpen(value) } + // Reasoning has no built-in "force open" hook (unlike BasicTool's forceOpen + // ratchet) — mirror that one-way-open behavior here so jumping a chat + // search match to a collapsed reasoning block reveals it, the same as it + // does for tool calls. Recorded into userOpened/userCollapsed the same way + // a manual open would be, so it stays open across remounts/re-renders. + createEffect(() => { + if (!props.forceOpen || open()) return + if (props.reasoningAutoCollapse) rememberReasoningState(userOpened, id) + else userCollapsed.delete(id) + setOpen(true) + }) + createEffect(() => { if (!props.reasoningAutoCollapse) return // Skip auto-collapse for blocks the user explicitly opened. @@ -2588,6 +2607,15 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) + // Deleted files start collapsed above; a chat search match could be + // inside one, and there's no per-file tracking of which file a match + // falls in, so force-opening this tool expands every file's accordion + // rather than guessing — better to over-reveal than leave the match + // hidden behind a still-collapsed file. + createEffect(() => { + if (!props.forceOpen) return + setExpanded(files().map((f) => f.filePath)) + }) const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 354178db8e0..d6879026ddc 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -408,6 +408,11 @@ "title": "Focus Chat Input", "category": "Kilo Code" }, + { + "command": "kilo-code.new.toggleChatSearch", + "title": "Toggle Chat Search", + "category": "Kilo Code" + }, { "command": "kilo-code.new.cycleAgentMode", "title": "Cycle Agent Mode", diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 3e3aebd125c..558961a6e2e 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -557,7 +557,7 @@ export function activate(context: vscode.ExtensionContext) { ) // Register code actions (editor context menus, terminal context menus, keyboard shortcuts) - registerCodeActions(context, provider, agentManagerProvider) + registerCodeActions(context, provider, agentManagerProvider, activeTabProvider) registerTerminalActions(context, provider, agentManagerProvider) // Register CodeActionProvider (lightbulb quick fixes) diff --git a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts index 79114ac578a..6511c5faa4a 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts @@ -8,8 +8,9 @@ export function registerCodeActions( context: vscode.ExtensionContext, provider: KiloProvider, agentManager?: AgentManagerProvider, + activeTabProvider?: () => KiloProvider | undefined, ): void { - const target = () => (agentManager?.isActive() ? agentManager : provider) + const target = () => (agentManager?.isActive() ? agentManager : (activeTabProvider?.() ?? provider)) const reveal = async () => { await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") await provider.waitForReady() @@ -82,5 +83,19 @@ export function registerCodeActions( } view.postMessage({ type: "action", action: "focusInput" }) }), + + // Command Palette only — no keybinding. A keybinding would need to + // route through VS Code's keybinding-to-focused-webview forwarding, + // which doesn't reliably reach a webview whose own input already has + // focus; invoking straight from the palette sidesteps that path + // entirely, the same way terminalAddToContext etc. do. Toggles: the + // webview closes the search bar itself if it's already open. + vscode.commands.registerCommand("kilo-code.new.toggleChatSearch", async () => { + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "action", action: "focusSearch" }) + }), ) } diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index e396fd9061c..4516d0c190e 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -267,6 +267,10 @@ const AppContent: Component = () => { case "cyclePreviousAgentMode": if (document.hasFocus()) cycleAgent(-1) break + case "focusSearch": + setCurrentView("newTask") + window.dispatchEvent(new CustomEvent("focusTranscriptSearch")) + break } } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index b87ce38294b..edc2255be7d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -125,6 +125,10 @@ interface AssistantMessageProps { parts?: SDKPart[] showAssistantCopyPartID?: string | null feedback?: MessageFeedbackControls + /** id of the part containing the current chat-search match, if any — forces + * that part's collapsed tool/reasoning content open so the user can see + * the highlighted match without manually expanding it first. */ + forceOpenPartID?: string } type ToolStateProps = { @@ -136,7 +140,7 @@ type ToolStateProps = { type MemoryItem = MemoryMarkerMeta.Decoded -function TodoToolCard(props: { part: ToolPart }) { +function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) { const render = ToolRegistry.render(props.part.tool) const state = () => props.part.state as ToolStateProps return ( @@ -152,6 +156,7 @@ function TodoToolCard(props: { part: ToolPart }) { output={state()?.output} status={state()?.status} defaultOpen + forceOpen={props.forceOpen} reveal={false} /> )} @@ -159,7 +164,7 @@ function TodoToolCard(props: { part: ToolPart }) { ) } -function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) { +function BashToolCard(props: { part: ToolPart; defaultOpen: boolean; forceOpen?: boolean }) { const render = ToolRegistry.render(props.part.tool) const state = () => props.part.state as ToolStateProps return ( @@ -176,6 +181,7 @@ function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) { output={state()?.output} status={state()?.status} defaultOpen={props.defaultOpen} + forceOpen={props.forceOpen} animate reveal={state()?.status === "pending" || state()?.status === "running"} /> @@ -279,6 +285,7 @@ export const AssistantMessage: Component = (props) => { if (!planExitInfo(part)) return return part as unknown as ToolPart }) + const forceOpen = createMemo(() => !!props.forceOpenPartID && part.id === props.forceOpenPartID) return ( = (props) => { PART_MAPPING[part.type] } > -
+
= (props) => { message={props.message as SDKMessage} showAssistantCopyPartID={props.showAssistantCopyPartID} defaultOpen={editOpen(part, edit())} + forceOpen={forceOpen()} reasoningAutoCollapse={display.reasoningAutoCollapse()} feedback={props.feedback} animate={ @@ -322,11 +330,17 @@ export const AssistantMessage: Component = (props) => { /> } > - + } > - {(tool) => } + {(tool) => ( + + )} } > diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 54443b6fc6e..b5a375f92f8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -27,6 +27,7 @@ import { createAutoScroll } from "@kilocode/kilo-ui/hooks" import { useSession } from "../../context/session" import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" +import { useI18n } from "@kilocode/kilo-ui/context/i18n" import { useProvider } from "../../context/provider" import { WelcomeEmptyState } from "./WelcomeEmptyState" import { TranscriptRowView } from "./TranscriptRow" @@ -96,6 +97,7 @@ export const MessageList: Component = (props) => { const server = useServer() const language = useLanguage() const provider = useProvider() + const i18n = useI18n() const autoScroll = createAutoScroll({ working: () => session.status() !== "idle", @@ -163,9 +165,21 @@ export const MessageList: Component = (props) => { const search = useTranscriptSearch() - function rowText(row: TranscriptRow): string { - if (row.type === "error") return errorText(row.error) - if (row.type === "diff") return "" + interface RowTextRange { + start: number + end: number + partId: string + } + + // Returns the row's full searchable text plus, for every chunk that came + // from a specific part (tool call/reasoning/text/file), the character + // range it occupies within that text. Lets a match's character index be + // attributed back to the part it came from, so navigation can force that + // exact collapsed tool/reasoning block open instead of just scrolling to + // the row. + function rowText(row: TranscriptRow): { text: string; ranges: RowTextRange[] } { + if (row.type === "error") return { text: errorText(row.error), ranges: [] } + if (row.type === "diff") return { text: "", ranges: [] } // User message text is rendered by UserMessageDisplay/HighlightedText // (message-part.tsx), which never parses markdown at all — [label](url) // always shows literally, brackets and all, unlike assistant text/ @@ -174,13 +188,22 @@ export const MessageList: Component = (props) => { // visible occurrences (the literal label and the literal URL) into one. const markdown = row.type !== "user" const chunks: string[] = [] + const ranges: RowTextRange[] = [] + let pos = 0 + const push = (text: string, partId: string) => { + if (!text) return + if (chunks.length > 0) pos += 1 // account for the "\n" chunk joiner below + chunks.push(text) + ranges.push({ start: pos, end: pos + text.length, partId }) + pos += text.length + } for (const part of row.parts) { switch (part.type) { case "text": - if (!part.synthetic) chunks.push(markdown ? stripMarkdownLinkUrls(part.text) : part.text) + if (!part.synthetic) push(markdown ? stripMarkdownLinkUrls(part.text) : part.text, part.id) break case "reasoning": - chunks.push(stripMarkdownLinkUrls(part.text)) + push(stripMarkdownLinkUrls(part.text), part.id) break case "tool": // Bash output is rendered via escapeHtml + syntax highlighting @@ -190,14 +213,18 @@ export const MessageList: Component = (props) => { // Stripping it here would search text that no longer matches the // literal characters on screen, the same class of mismatch this // rewrite fixes elsewhere. - chunks.push(...toolText(part)) + for (const chunk of toolText(part)) push(chunk, part.id) break case "file": - if (part.filename) chunks.push(part.filename) + if (part.filename) push(part.filename, part.id) break } } - return chunks.join("\n") + return { text: chunks.join("\n"), ranges } + } + + function partIdAt(ranges: RowTextRange[], index: number): string | undefined { + return ranges.find((r) => index >= r.start && index < r.end)?.partId } // Markdown link/image URLs are part of the raw source text but are never @@ -273,13 +300,51 @@ export const MessageList: Component = (props) => { // the same class of bug this rewrite fixes for every other tool. const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) + // edit/write/apply_patch render their actual diff content through + // @pierre/diffs inside a shadow-DOM (packages/ui/src/ + // pierre/file-runtime.ts's getViewerRoot()), which a light-DOM text scan + // can never reach — and diff-mode rendering is virtualized by default, so + // even piercing the shadow root wouldn't guarantee off-screen lines are + // mounted. state.input/state.metadata also duplicate the full before/ + // after file content and the path itself several times over (a unified + // patch string with the path repeated in its `---`/`+++` headers, a + // separate raw `metadata.diff` copy, write's extra top-level + // `metadata.filepath`), none of which corresponds 1:1 with what's on + // screen. Rather than collect+dedupe those redundant fields, mirror the + // renderer's fixed, known layout directly: edit/write always show one + // file's path in exactly two places (the BasicTool trigger's + // ToolMetaLine and that file's own accordion header); apply_patch's + // trigger only adds a third, single-file ToolMetaLine when there's + // exactly one file (message-part.tsx's `single()`) — for a multi-file + // patch each file's name only appears once, in its own accordion header. + const DIFF_TOOLS = new Set(["edit", "write"]) + // todowrite/todoread's renderer resolves the shown list from a fallback + // chain (metadata.view.todos, else metadata.todos, else input.todos) — + // packages/opencode/src/tool/todo.ts sets metadata.todos to the exact + // same array as input.todos, and metadata.view.todos to the exact same + // content again whenever the view is in "full" mode (the common case, + // see packages/opencode/src/kilocode/todo-view.ts). Recursively collecting + // both input and metadata would count every todo's text 2-3x even though + // shown() only ever renders it once. + const TODO_TOOLS = new Set(["todowrite", "todoread"]) + function toolText(part: Part & { type: "tool" }): string[] { const state = part.state - if (state.status === "running") return state.title ? [state.title] : [] if (state.status === "error") return state.error ? [state.error] : [] - if (state.status !== "completed") return [] + // task's trigger (title "{type} Agent" + input.description subtitle) and + // question's dock (question text + full option list) render the same + // way whether the call is still pending/running or already completed — + // unlike every other tool, where only a bare title shows until + // completion — so both need handling before the completed-only gate + // below, or an in-progress task/question would index nothing at all. + if (part.tool === "task") return taskText(part, state) + if (part.tool === "question") return questionText(state) + if (state.status !== "completed") return "title" in state && state.title ? [state.title] : [] if (CONTEXT_GROUP_TOOLS.has(part.tool)) return state.title ? [state.title] : [] if (part.tool === "bash") return bashText(state) + if (part.tool === "apply_patch") return applyPatchText(state) + if (DIFF_TOOLS.has(part.tool)) return editWriteText(state) + if (TODO_TOOLS.has(part.tool)) return todoText(state) const chunks: string[] = [] if (state.title) chunks.push(state.title) collectStrings(state.input, chunks) @@ -288,6 +353,98 @@ export const MessageList: Component = (props) => { return chunks } + function todoText(state: Extract): string[] { + const metadata = state.metadata as { todos?: unknown; view?: unknown } | undefined + const input = state.input as { todos?: unknown } | undefined + const view = metadata?.view + const viewTodos = isTodoView(view) ? view.todos : undefined + const todos = + viewTodos ?? + (Array.isArray(metadata?.todos) ? metadata.todos : undefined) ?? + (Array.isArray(input?.todos) ? input.todos : undefined) ?? + [] + return (todos as { content?: unknown }[]) + .map((todo) => todo?.content) + .filter((content): content is string => typeof content === "string" && content.length > 0) + } + + function isTodoView(value: unknown): value is { todos?: { content?: unknown }[] } { + return !!value && typeof value === "object" && Array.isArray((value as { todos?: unknown }).todos) + } + + // Matches TaskToolExpanded.tsx (the renderer this webview actually + // registers for "task", overriding kilo-ui's default) exactly: title is + // always `i18n.t("ui.tool.agent", { type })` regardless of status — the + // "capitalize" CSS class only changes how it *looks*, the DOM text node + // itself is the raw, lowercase subagent_type. The "(N)" child-tool-count + // suffix shown there is a live value from session.getSessionToolCount(), + // not stored on the part at all, so it can't be indexed from a snapshot — + // searching for that count isn't meaningful content anyway. + function taskText(part: Part & { type: "tool" }, state: ToolState): string[] { + const input = state.input as { subagent_type?: string; description?: string } | undefined + const type = input?.subagent_type || part.tool + const chunks = [i18n.t("ui.tool.agent", { type })] + if (input?.description) chunks.push(input.description) + return chunks + } + + // QuestionDock renders very different content depending on whether the + // question is still awaiting an answer or already resolved: while + // pending/running it shows the full clickable option list (label + + // description per option); once completed it only shows the question + // text plus whichever answer was actually given (dismissed questions show + // neither the options nor a real answer, just a static "dismissed" + // label that isn't meaningful content to index). + function questionText(state: ToolState): string[] { + const input = state.input as + | { questions?: { question?: string; options?: { label?: string; description?: string }[] }[] } + | undefined + const questions = input?.questions ?? [] + const done = state.status === "completed" + const metadata = done ? (state.metadata as { answers?: unknown; dismissed?: unknown } | undefined) : undefined + const answers = Array.isArray(metadata?.answers) ? (metadata!.answers as unknown[][]) : undefined + const dismissed = metadata?.dismissed === true + const chunks: string[] = [] + questions.forEach((q, i) => { + if (q.question) chunks.push(q.question) + if (!done) { + for (const option of q.options ?? []) { + if (option.label) chunks.push(option.label) + if (option.description) chunks.push(option.description) + } + return + } + if (dismissed) return + for (const value of answers?.[i] ?? []) { + if (typeof value === "string" && value) chunks.push(value) + } + }) + return chunks + } + + function editWriteText(state: Extract): string[] { + const input = state.input as { filePath?: string } | undefined + const metadata = state.metadata as { filepath?: string; filediff?: { file?: string } } | undefined + const path = input?.filePath ?? metadata?.filediff?.file ?? metadata?.filepath + if (!path) return [] + return [path, path] + } + + function applyPatchText(state: Extract): string[] { + const files = ((state.metadata as { files?: { filePath?: string; relativePath?: string }[] } | undefined)?.files ?? + []) as { filePath?: string; relativePath?: string }[] + // Only when there's exactly one file does the trigger also show a + // ToolMetaLine for it, on top of that file's own accordion header. + const perFile = files.length === 1 ? 2 : 1 + const chunks: string[] = [] + for (const file of files) { + const path = file.relativePath ?? file.filePath + if (!path) continue + for (let i = 0; i < perFile; i += 1) chunks.push(path) + } + return chunks + } + // Mirrors McpTool's formattedOutput(): if `output` parses as JSON, the // renderer pretty-prints it inside a fenced ```json block, so it's shown // literally, same as bash. If it isn't valid JSON — the common case for a @@ -409,7 +566,7 @@ export const MessageList: Component = (props) => { const list = rows() const result: SearchMatch[] = [] for (const row of list) { - const text = rowText(row) + const { text, ranges } = rowText(row) p.lastIndex = 0 let occurrence = 0 let hit = p.exec(text) @@ -419,7 +576,7 @@ export const MessageList: Component = (props) => { hit = p.exec(text) continue } - result.push({ key: row.key, messageId: row.message.id, occurrence }) + result.push({ key: row.key, occurrence, partId: partIdAt(ranges, hit.index) }) occurrence += 1 hit = p.exec(text) } @@ -482,6 +639,26 @@ export const MessageList: Component = (props) => { const activeMatch = createMemo(() => matches()[search.index()]) + // Maps a row's key to the part ids the data model could attribute matches + // to there. A row with NO entry here has zero data-level matches, so the + // highlighter must scan nothing in it at all — otherwise unindexed text (a + // static button label, a sibling part that didn't match) could get + // highlighted despite never being counted. An entry always exists for + // every row that has at least one match, even with an empty part-id set + // (a match that couldn't be attributed to a specific part, e.g. error/diff + // rows) — the highlighter treats "entry exists" as "this row has a real + // match" and falls back to scanning the whole row whenever the part-id + // lookup doesn't resolve to a mounted element. + const matchedPartsByRow = createMemo(() => { + const map = new Map>() + for (const match of matches()) { + const set = map.get(match.key) ?? new Set() + if (match.partId) set.add(match.partId) + map.set(match.key, set) + } + return map + }) + // Highlights every rendered occurrence of the query (not just matching // rows) via the CSS Custom Highlight API, and returns the precise Range of // the current occurrence so navigation can judge whether it needs to @@ -496,7 +673,12 @@ export const MessageList: Component = (props) => { return } const active = activeMatch() - const range = applyTranscriptHighlights(el, pattern(), active && { key: active.key, occurrence: active.occurrence }) + const range = applyTranscriptHighlights( + el, + pattern(), + active && { key: active.key, occurrence: active.occurrence }, + matchedPartsByRow(), + ) if (!pendingCenter) return pendingCenter = false if (!range) return @@ -820,6 +1002,7 @@ export const MessageList: Component = (props) => { index={index()} onForkMessage={props.onForkMessage} activeSearch={activeKey() === row.key} + activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined} /> )} @@ -830,6 +1013,7 @@ export const MessageList: Component = (props) => { row={lookup().get(key)!} onForkMessage={props.onForkMessage} activeSearch={activeKey() === key} + activeSearchPartID={activeKey() === key ? activeMatch()?.partId : undefined} /> )} @@ -839,7 +1023,13 @@ export const MessageList: Component = (props) => { - {(row) => } + {(row) => ( + + )} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index e35195bc3f6..538009dff63 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -8,7 +8,7 @@ * session activity) and a context window progress bar. */ -import { Component, For, Show, createMemo, createSignal, createEffect, onMount, onCleanup } from "solid-js" +import { Component, For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from "solid-js" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { Icon } from "@kilocode/kilo-ui/icon" @@ -111,6 +111,34 @@ export const TaskHeader: Component = (props) => { window.addEventListener("message", handler) onCleanup(() => window.removeEventListener("message", handler)) + // "Kilo Code: Search Current Chat" (Command Palette) toggles the search + // bar from here rather than TranscriptSearch.tsx itself: that component + // only mounts once search.active() is already true (it's behind a + // ), so it can never be what turns search on in the first place — + // and it also wouldn't exist anymore to react to a request to close it. + // TaskHeader is mounted the whole time there's an active chat, so it's + // the right place to react to the external toggle request. + const toggleSearch = () => search.setActive(!search.active()) + window.addEventListener("focusTranscriptSearch", toggleSearch) + onCleanup(() => window.removeEventListener("focusTranscriptSearch", toggleSearch)) + + // Whenever search closes — the header toggle button, the command palette + // toggle above, the search bar's own "X", or Escape — send focus back to + // the chat input rather than leaving it stranded on whatever control was + // just clicked/removed. `defer: true` skips the initial run so mounting + // with search already inactive doesn't steal focus from wherever it + // already was. + createEffect( + on( + () => search.active(), + (active) => { + if (active) return + window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } })) + }, + { defer: true }, + ), + ) + const toggle = () => { const next = !expanded() setExpanded(next) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx index 92c65de914b..f54e82e29c3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx @@ -18,6 +18,9 @@ interface TranscriptRowViewProps { index?: number onForkMessage?: (sessionId: string, messageId: string) => void activeSearch?: boolean + /** id of the part (tool call/reasoning block) containing the current chat + * search match within this row, if any. */ + activeSearchPartID?: string } export const TranscriptRowView: Component = (props) => { @@ -78,6 +81,7 @@ export const TranscriptRowView: Component = (props) => { message={row().message as unknown as SDKAssistantMessage} parts={row().parts as unknown as SDKPart[]} showAssistantCopyPartID={row().copy} + forceOpenPartID={props.activeSearchPartID} feedback={{ enabled: feedback.telemetryEnabled(), rating: feedback.getRating(row().message.id), diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-highlight.ts b/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-highlight.ts index 8f0d4be4206..c90359fc9be 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-highlight.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-highlight.ts @@ -90,11 +90,24 @@ export function scanScope(scope: HTMLElement, pattern: RegExp): Range[] { * actually found in the DOM, so a data/DOM count mismatch (e.g. content the * renderer collapses or reformats) still always highlights *something* in * the active row rather than silently highlighting nothing. + * + * `matchedParts` maps a row key to the part ids MessageList's data model + * could attribute a match to there. A row with **no entry** has zero + * data-level matches, so nothing in it is scanned at all — otherwise some + * unindexed text (a static button label, a different non-matching part in + * the same message) could get highlighted despite never being counted. A + * row *with* an entry (even one with an empty part-id set, e.g. an + * error/diff row with no per-part attribution) always has SOME genuine + * match, so scanning falls back to the whole row whenever the part-scoped + * DOM lookup doesn't resolve to anything mounted (a part id with no + * `[data-part-id]` marker at all — e.g. user messages — or not yet + * expanded) — that's a lookup failure, not a signal to scan nothing. */ export function applyTranscriptHighlights( root: HTMLElement, pattern: RegExp | undefined, active: { key: string; occurrence: number } | undefined, + matchedParts?: Map>, ): Range | undefined { const api = highlightApi() if (!api) return undefined @@ -107,7 +120,13 @@ export function applyTranscriptHighlights( const current: Range[] = [] let currentRange: Range | undefined for (const scope of scopes) { - const ranges = scanScope(scope, pattern) + // Every search scope within a row contributes to ONE combined range + // list before the active-occurrence index is resolved — clamping it + // per search-scope instead would treat `active.occurrence` as local to + // whichever part happened to be scanned first, misattributing which + // occurrence is "current" for any row with more than one contributing + // part (e.g. a reasoning block followed by a tool call). + const ranges = resolveSearchScopes(scope, matchedParts).flatMap((searchScope) => scanScope(searchScope, pattern)) if (ranges.length === 0) continue const isActiveRow = !!active && scope.dataset.rowKey === active.key const activeIdx = isActiveRow ? Math.min(active!.occurrence, ranges.length - 1) : -1 @@ -125,6 +144,28 @@ export function applyTranscriptHighlights( return currentRange } +/** + * Decides which element(s) within a row to actually scan for text. A row + * with no entry in `matchedParts` has zero data-level matches, so it's + * skipped entirely. A row with an entry scans just its known parts' DOM + * subtrees when they're mounted, and falls back to the whole row whenever + * that lookup comes up empty — whether because a match couldn't be + * attributed to a specific part at all, or because the part it WAS + * attributed to has no `[data-part-id]` marker (or isn't mounted yet) — a + * row with a real match should never end up scanning nothing. + */ +function resolveSearchScopes(scope: HTMLElement, matchedParts: Map> | undefined): HTMLElement[] { + const rowKey = scope.dataset.rowKey + const partIds = rowKey ? matchedParts?.get(rowKey) : undefined + if (matchedParts && !partIds) return [] + const partScopes = partIds + ? Array.from(partIds) + .map((id) => scope.querySelector(`[data-part-id="${CSS.escape(id)}"]`)) + .filter((el): el is HTMLElement => !!el) + : [] + return partScopes.length > 0 ? partScopes : [scope] +} + export function clearTranscriptHighlights(): void { const api = highlightApi() if (!api) return diff --git a/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx b/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx index eabc0e55f76..e7e1f37f6f2 100644 --- a/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx @@ -2,9 +2,12 @@ import { createContext, useContext, createSignal, type Accessor, type ParentComp export interface SearchMatch { key: string - messageId: string /** Index (0-based) of this occurrence among all matches within the same row. */ occurrence: number + /** id of the part (tool call/reasoning block/text) this occurrence falls + * within, if it could be attributed to one — lets navigation force a + * collapsed part open instead of just scrolling to the row. */ + partId?: string } interface TranscriptSearchContextValue { From 9cc63d08cbada24443f0a1b65210767d40413780 Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Tue, 14 Jul 2026 11:18:23 +0530 Subject: [PATCH 04/28] chore(provider): annotate grok-4.5 kilocode_change markers --- packages/opencode/src/provider/transform.ts | 2 ++ packages/opencode/test/provider/transform.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7f299cdeb0b..a5e6324b649 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -725,9 +725,11 @@ export function variants(model: Provider.Model): Record { expect(result.high).toEqual({ reasoningEffort: "high" }) }) + // kilocode_change start test("grok-4.5 uses standard reasoning efforts", () => { const model = createMockModel({ id: "xai/grok-4.5", @@ -3386,6 +3387,7 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high"]) expect(result.medium).toEqual({ reasoningEffort: "medium" }) }) + // kilocode_change end }) describe("@ai-sdk/deepinfra", () => { From 57c46044c21ce3fecee78c0c17fed921f0239c89 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Tue, 14 Jul 2026 11:12:32 +0200 Subject: [PATCH 05/28] fix(vscode): address chat-search review feedback - Await selected webview readiness before posting code-action commands - Route toggleChatSearch to Agent Manager's active chat - Fix transcript search indexing for task results, todoread, diagnostics, multi-page questions, translated question text, and edit/write/apply_patch path rendering - Include todo title/subtitle in indexed text to match DOM scan scope - Fix forceOpen not mounting Bash output after virtualized remount - Force-open only the matched file in a multi-file apply_patch instead of all - Distinguish explicit search close from session-change reset to stop it stealing focus from tab navigation --- .../kilo-ui/src/components/message-part.tsx | 32 ++- .../src/agent-manager/AgentManagerProvider.ts | 7 + .../code-actions/register-code-actions.ts | 29 ++- .../agent-manager/AgentManagerApp.tsx | 1 + .../src/components/chat/AssistantMessage.tsx | 4 + .../src/components/chat/MessageList.tsx | 201 +++++++++++++++--- .../src/components/chat/TaskHeader.tsx | 26 +-- .../src/components/chat/TranscriptRow.tsx | 3 + .../src/components/chat/TranscriptSearch.tsx | 2 +- .../src/context/transcript-search.tsx | 21 ++ 10 files changed, 265 insertions(+), 61 deletions(-) diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 2a0868a25dd..ffcaaab637c 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -146,6 +146,10 @@ export interface MessagePartProps { * forces a collapsed tool/reasoning block open so the user can see the * highlighted match without manually expanding it first. */ forceOpen?: boolean + /** For a multi-file apply_patch part, the specific file path (matching + * that file's `filePath`) whose accordion contains the current match — + * lets that one nested item open instead of every file in the patch. */ + forceOpenFile?: string reasoningAutoCollapse?: boolean showAssistantCopyPartID?: string | null showTurnDiffSummary?: boolean @@ -979,6 +983,7 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} forceOpen={props.forceOpen} + forceOpenFile={props.forceOpenFile} reasoningAutoCollapse={props.reasoningAutoCollapse} showAssistantCopyPartID={props.showAssistantCopyPartID} showTurnDiffSummary={props.showTurnDiffSummary} @@ -1004,6 +1009,9 @@ export interface ToolProps { hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean + /** For a multi-file apply_patch part, the specific file path whose + * accordion contains the current transcript search match. */ + forceOpenFile?: string locked?: boolean animate?: boolean reveal?: boolean @@ -1272,6 +1280,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} forceOpen={props.forceOpen} + forceOpenFile={props.forceOpenFile} animate reveal={props.animate} /> @@ -2257,8 +2266,15 @@ ToolRegistry.register({ const [open, setOpen] = createSignal(readToolOpen(key(), props.defaultOpen ?? true) ?? true) const [mounted, setMounted] = createSignal(open()) + // BasicTool's `initialOpen()` forces its own open state to true whenever + // forceOpen is set, but that's an initial value, not a transition — if + // it's already mounted open (e.g. after a virtualized remount), there's + // no open/close change for `onOpenChange={setOpen}` below to fire, so + // this local `open`/`mounted` pair (seeded independently from + // readToolOpen) can stay stale and out of sync, leaving the accordion + // visibly expanded with no output mounted inside it. createEffect(() => { - if (open() || pending()) setMounted(true) + if (open() || pending() || props.forceOpen) setMounted(true) }) // also apply processCarriageReturns for Windows CLI tools @@ -2608,11 +2624,17 @@ ToolRegistry.register({ setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) // Deleted files start collapsed above; a chat search match could be - // inside one, and there's no per-file tracking of which file a match - // falls in, so force-opening this tool expands every file's accordion - // rather than guessing — better to over-reveal than leave the match - // hidden behind a still-collapsed file. + // inside one. `forceOpenFile` (from MessageList's per-chunk file + // attribution) names exactly which file's accordion to open — falling + // back to expanding every file only when forceOpen fires without a + // known file (defensive; MessageList always attributes apply_patch + // matches to a specific file today). createEffect(() => { + if (props.forceOpenFile) { + const path = props.forceOpenFile + setExpanded((prev) => (prev.includes(path) ? prev : [...prev, path])) + return + } if (!props.forceOpen) return setExpanded(files().map((f) => f.filePath)) }) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index a40840eea60..f39698a3b0e 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1847,6 +1847,13 @@ export class AgentManagerProvider implements Disposable { return this.waitForPanel(panel, panel.waitForActive()) } + /** Wait for the current panel's webview to be ready before posting to it. False if there is no panel or it closed while waiting. */ + public waitForReady(): Promise { + const panel = this.panel + if (!panel) return Promise.resolve(false) + return this.waitForPanelReady(panel) + } + public async showMemory(): Promise { const panel = this.panel const sid = this.activeSessionId diff --git a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts index 6511c5faa4a..ef941ab4bbd 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts @@ -15,6 +15,23 @@ export function registerCodeActions( await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") await provider.waitForReady() } + // Only the sidebar `provider` branch used to await readiness before + // posting. An editor-tab webview or the Agent Manager panel can still be + // opening/restoring when one of these commands fires, and postMessage() + // does not queue — it silently drops the message if the webview hasn't + // installed its listener yet. Wait for the selected target's own + // readiness too before posting to it. + const revealTarget = async (view: KiloProvider | AgentManagerProvider) => { + if (view === provider) { + await reveal() + return + } + if (view === agentManager) { + await agentManager.waitForReady() + return + } + await view.waitForReady() + } context.subscriptions.push( vscode.commands.registerCommand("kilo-code.new.explainCode", async () => { @@ -70,17 +87,13 @@ export function registerCodeActions( selectedText: ctx.selectedText, }) const view = target() - if (view === provider) { - await reveal() - } + await revealTarget(view) view.postMessage({ type: "appendChatBoxMessage", text: prompt }) }), vscode.commands.registerCommand("kilo-code.new.focusChatInput", async () => { const view = target() - if (view === provider) { - await reveal() - } + await revealTarget(view) view.postMessage({ type: "action", action: "focusInput" }) }), @@ -92,9 +105,7 @@ export function registerCodeActions( // webview closes the search bar itself if it's already open. vscode.commands.registerCommand("kilo-code.new.toggleChatSearch", async () => { const view = target() - if (view === provider) { - await reveal() - } + await revealTarget(view) view.postMessage({ type: "action", action: "focusSearch" }) }), ) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 467cb4fe3e7..3b8decfe03e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1088,6 +1088,7 @@ const AgentManagerContent: Component = () => { else if (msg.action === "closeWorktree") closeSelectedWorktree() else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts() else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt")) + else if (msg.action === "focusSearch") window.dispatchEvent(new CustomEvent("focusTranscriptSearch")) else if (msg.action === "newTerminal") termHandlers.requestNew() else if (msg.action === "cycleAgentMode" && document.hasFocus()) cycleAgent(1) else if (msg.action === "cyclePreviousAgentMode" && document.hasFocus()) cycleAgent(-1) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index edc2255be7d..44d5ca39a93 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -129,6 +129,9 @@ interface AssistantMessageProps { * that part's collapsed tool/reasoning content open so the user can see * the highlighted match without manually expanding it first. */ forceOpenPartID?: string + /** For a multi-file apply_patch match, the specific file within that part — + * lets that one nested item open instead of every file in the patch. */ + forceOpenFile?: string } type ToolStateProps = { @@ -320,6 +323,7 @@ export const AssistantMessage: Component = (props) => { showAssistantCopyPartID={props.showAssistantCopyPartID} defaultOpen={editOpen(part, edit())} forceOpen={forceOpen()} + forceOpenFile={forceOpen() ? props.forceOpenFile : undefined} reasoningAutoCollapse={display.reasoningAutoCollapse()} feedback={props.feedback} animate={ diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index b5a375f92f8..1881ad2f004 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -56,6 +56,11 @@ import { stableMessageTurns, type MessageTurn, } from "../../context/session-queue" +import { childID } from "../../context/session-utils" +import { taskResult } from "./task-tool-state" +import { tr } from "./question-dock-utils" +import { useData } from "@kilocode/kilo-ui/context/data" +import { getDirectory as getRawDirectory, getFilename } from "@opencode-ai/core/util/path" import { partitionRows, retainTurn, @@ -98,6 +103,26 @@ export const MessageList: Component = (props) => { const language = useLanguage() const provider = useProvider() const i18n = useI18n() + const data = useData() + + // Mirrors message-part.tsx's own (unexported) relativizeProjectPath/ + // getDirectory exactly, so the directory text indexed here matches what + // ToolMetaLine/ToolFileAccordion actually put on screen. + function relativizeProjectPath(path: string, directory?: string) { + if (!path) return "" + if (!directory) return path + if (directory === "/") return path + if (directory === "\\") return path + if (path === directory) return "" + const separator = directory.includes("\\") ? "\\" : "/" + const prefix = directory.endsWith(separator) ? directory : directory + separator + if (!path.startsWith(prefix)) return path + return path.slice(directory.length) + } + + function getDirectory(path: string | undefined) { + return relativizeProjectPath(getRawDirectory(path), data.directory) + } const autoScroll = createAutoScroll({ working: () => session.status() !== "idle", @@ -169,8 +194,14 @@ export const MessageList: Component = (props) => { start: number end: number partId: string + /** For a multi-file apply_patch chunk, the file this range belongs to. */ + file?: string } + /** A tool-text chunk, optionally attributed to a specific file within a + * multi-file tool part (currently only apply_patch's per-file chunks). */ + type ToolChunk = string | { text: string; file: string } + // Returns the row's full searchable text plus, for every chunk that came // from a specific part (tool call/reasoning/text/file), the character // range it occupies within that text. Lets a match's character index be @@ -190,11 +221,11 @@ export const MessageList: Component = (props) => { const chunks: string[] = [] const ranges: RowTextRange[] = [] let pos = 0 - const push = (text: string, partId: string) => { + const push = (text: string, partId: string, file?: string) => { if (!text) return if (chunks.length > 0) pos += 1 // account for the "\n" chunk joiner below chunks.push(text) - ranges.push({ start: pos, end: pos + text.length, partId }) + ranges.push({ start: pos, end: pos + text.length, partId, file }) pos += text.length } for (const part of row.parts) { @@ -213,7 +244,10 @@ export const MessageList: Component = (props) => { // Stripping it here would search text that no longer matches the // literal characters on screen, the same class of mismatch this // rewrite fixes elsewhere. - for (const chunk of toolText(part)) push(chunk, part.id) + for (const chunk of toolText(part)) { + if (typeof chunk === "string") push(chunk, part.id) + else push(chunk.text, part.id, chunk.file) + } break case "file": if (part.filename) push(part.filename, part.id) @@ -223,8 +257,8 @@ export const MessageList: Component = (props) => { return { text: chunks.join("\n"), ranges } } - function partIdAt(ranges: RowTextRange[], index: number): string | undefined { - return ranges.find((r) => index >= r.start && index < r.end)?.partId + function rangeAt(ranges: RowTextRange[], index: number): RowTextRange | undefined { + return ranges.find((r) => index >= r.start && index < r.end) } // Markdown link/image URLs are part of the raw source text but are never @@ -318,17 +352,22 @@ export const MessageList: Component = (props) => { // exactly one file (message-part.tsx's `single()`) — for a multi-file // patch each file's name only appears once, in its own accordion header. const DIFF_TOOLS = new Set(["edit", "write"]) - // todowrite/todoread's renderer resolves the shown list from a fallback - // chain (metadata.view.todos, else metadata.todos, else input.todos) — + // todowrite's renderer resolves the shown list from a fallback chain + // (metadata.view.todos, else metadata.todos, else input.todos) — // packages/opencode/src/tool/todo.ts sets metadata.todos to the exact // same array as input.todos, and metadata.view.todos to the exact same // content again whenever the view is in "full" mode (the common case, // see packages/opencode/src/kilocode/todo-view.ts). Recursively collecting // both input and metadata would count every todo's text 2-3x even though // shown() only ever renders it once. - const TODO_TOOLS = new Set(["todowrite", "todoread"]) + // todoread is deliberately excluded: AssistantMessage.tsx's isRenderable() + // only shows a completed part from UPSTREAM_SUPPRESSED_TOOLS when + // ToolRegistry has a renderer for it, and only "todowrite" is registered — + // a completed todoread never reaches the DOM, so indexing it would count + // matches with nothing to highlight or navigate to. + const TODO_TOOLS = new Set(["todowrite"]) - function toolText(part: Part & { type: "tool" }): string[] { + function toolText(part: Part & { type: "tool" }): ToolChunk[] { const state = part.state if (state.status === "error") return state.error ? [state.error] : [] // task's trigger (title "{type} Agent" + input.description subtitle) and @@ -343,7 +382,7 @@ export const MessageList: Component = (props) => { if (CONTEXT_GROUP_TOOLS.has(part.tool)) return state.title ? [state.title] : [] if (part.tool === "bash") return bashText(state) if (part.tool === "apply_patch") return applyPatchText(state) - if (DIFF_TOOLS.has(part.tool)) return editWriteText(state) + if (DIFF_TOOLS.has(part.tool)) return editWriteText(part.tool, state) if (TODO_TOOLS.has(part.tool)) return todoText(state) const chunks: string[] = [] if (state.title) chunks.push(state.title) @@ -353,19 +392,33 @@ export const MessageList: Component = (props) => { return chunks } + // transcript-search-highlight.ts's scanScope() walks the whole [data-part- + // id] subtree for this tool, which includes the ToolTriggerRow's title + // ("To-dos") and "completed/total" subtitle alongside each item's content + // — indexing only item content undercounts what's actually scanned there, + // shifting which range resolveSearchScopes/scanScope picks as "active". function todoText(state: Extract): string[] { const metadata = state.metadata as { todos?: unknown; view?: unknown } | undefined const input = state.input as { todos?: unknown } | undefined const view = metadata?.view const viewTodos = isTodoView(view) ? view.todos : undefined - const todos = - viewTodos ?? + // Matches the renderer's own `todos()` memo (used for the "N/M" + // subtitle): metadata.todos, else input.todos — never the (possibly + // view-truncated) compact list. + const full = (Array.isArray(metadata?.todos) ? metadata.todos : undefined) ?? (Array.isArray(input?.todos) ? input.todos : undefined) ?? [] - return (todos as { content?: unknown }[]) - .map((todo) => todo?.content) - .filter((content): content is string => typeof content === "string" && content.length > 0) + const shown = viewTodos ?? full + const chunks: string[] = [i18n.t("ui.tool.todos")] + if (full.length > 0) { + const completed = (full as { status?: unknown }[]).filter((t) => t?.status === "completed").length + chunks.push(`${completed}/${full.length}`) + } + for (const content of (shown as { content?: unknown }[]).map((todo) => todo?.content)) { + if (typeof content === "string" && content.length > 0) chunks.push(content) + } + return chunks } function isTodoView(value: unknown): value is { todos?: { content?: unknown }[] } { @@ -385,6 +438,16 @@ export const MessageList: Component = (props) => { const type = input?.subagent_type || part.tool const chunks = [i18n.t("ui.tool.agent", { type })] if (input?.description) chunks.push(input.description) + // TaskToolExpanded.tsx only shows the raw body when there's + // no live child session to display instead (result() there resolves to + // undefined once a child session exists) — mirror that exactly so a + // completed task with no child session stays searchable, without + // indexing text that's actually replaced by the child tool list. + if (state.status === "completed") { + const child = childID({ type: "tool", tool: part.tool, state: { metadata: state.metadata } }) + const result = taskResult(state.output, child) + if (result) chunks.push(result) + } return chunks } @@ -395,10 +458,11 @@ export const MessageList: Component = (props) => { // text plus whichever answer was actually given (dismissed questions show // neither the options nor a real answer, just a static "dismissed" // label that isn't meaningful content to index). + type QuestionOption = { label?: string; description?: string; labelKey?: string; descriptionKey?: string } + type QuestionInfo = { question?: string; questionKey?: string; options?: QuestionOption[] } + function questionText(state: ToolState): string[] { - const input = state.input as - | { questions?: { question?: string; options?: { label?: string; description?: string }[] }[] } - | undefined + const input = state.input as { questions?: QuestionInfo[] } | undefined const questions = input?.questions ?? [] const done = state.status === "completed" const metadata = done ? (state.metadata as { answers?: unknown; dismissed?: unknown } | undefined) : undefined @@ -406,11 +470,24 @@ export const MessageList: Component = (props) => { const dismissed = metadata?.dismissed === true const chunks: string[] = [] questions.forEach((q, i) => { - if (q.question) chunks.push(q.question) + // QuestionDock renders localized text via questionKey/labelKey/ + // descriptionKey (see question-dock-utils.ts's tr()), with the raw + // wire strings kept only as untranslated fallbacks/reply values — + // index what's actually displayed, not the canonical fallback. + const questionLabel = tr(language.t, q.questionKey, q.question ?? "") + if (questionLabel) chunks.push(questionLabel) if (!done) { + // QuestionDock only ever mounts one page (store.tab) of a pending + // multi-question at a time and there's no external signal exposing + // which page that is — indexing every question's full option list + // produces matches for pages that aren't in the DOM. Limit to the + // first question, which is always the page mounted on first render. + if (i > 0) return for (const option of q.options ?? []) { - if (option.label) chunks.push(option.label) - if (option.description) chunks.push(option.description) + const label = tr(language.t, option.labelKey, option.label ?? "") + if (label) chunks.push(label) + const description = option.description ? tr(language.t, option.descriptionKey, option.description) : "" + if (description) chunks.push(description) } return } @@ -422,25 +499,77 @@ export const MessageList: Component = (props) => { return chunks } - function editWriteText(state: Extract): string[] { - const input = state.input as { filePath?: string } | undefined - const metadata = state.metadata as { filepath?: string; filediff?: { file?: string } } | undefined - const path = input?.filePath ?? metadata?.filediff?.file ?? metadata?.filepath - if (!path) return [] - return [path, path] + type Diagnostic = { severity: number; range: { start: { line: number; character: number } }; message: string } + + // Mirrors getDiagnostics()/DiagnosticsDisplay in message-part.tsx: up to 3 + // severity-1 diagnostics for the file, each shown as an "Error" label, a + // "[line:char]" location, and the diagnostic message. + function editWriteDiagnostics(metadata: unknown, filePath: string | undefined): string[] { + const byFile = (metadata as { diagnostics?: Record } | undefined)?.diagnostics + if (!byFile || !filePath) return [] + const diagnostics = (byFile[filePath] ?? []).filter((d) => d.severity === 1).slice(0, 3) + const chunks: string[] = [] + for (const d of diagnostics) { + chunks.push(i18n.t("ui.messagePart.diagnostic.error")) + chunks.push(`[${d.range.start.line + 1}:${d.range.start.character + 1}]`) + if (d.message) chunks.push(d.message) + } + return chunks } - function applyPatchText(state: Extract): string[] { + // edit/write show a file's path in two places with different layouts: + // the BasicTool trigger's ToolMetaLine (filename, then — only when the raw + // input path has a directory — the directory, bidi-isolated) and that + // file's own accordion header (directory-then-filename, same bidi + // wrapper). edit's accordion can source a different path than the + // trigger (metadata.filediff.file falls back to input.filePath); write + // uses input.filePath for both. Mirror both occurrences exactly rather + // than indexing the raw path string, which never appears in the DOM as a + // single contiguous run. + function editWriteText(tool: string, state: Extract): string[] { + const input = state.input as { filePath?: string } | undefined + const metadata = state.metadata as + | { filepath?: string; filediff?: { file?: string }; diagnostics?: Record } + | undefined + const triggerPath = input?.filePath + const accordionPath = tool === "edit" ? (metadata?.filediff?.file ?? triggerPath) : triggerPath + const chunks: string[] = [] + if (triggerPath) { + chunks.push(getFilename(triggerPath)) + if (triggerPath.includes("/")) chunks.push(`\u2066${getDirectory(triggerPath)}\u2069`) + } + if (accordionPath) { + if (accordionPath.includes("/")) chunks.push(`\u2066${getDirectory(accordionPath)}\u2069`) + chunks.push(getFilename(accordionPath)) + } + chunks.push(...editWriteDiagnostics(metadata, triggerPath)) + return chunks + } + + function applyPatchText(state: Extract): ToolChunk[] { const files = ((state.metadata as { files?: { filePath?: string; relativePath?: string }[] } | undefined)?.files ?? []) as { filePath?: string; relativePath?: string }[] // Only when there's exactly one file does the trigger also show a - // ToolMetaLine for it, on top of that file's own accordion header. - const perFile = files.length === 1 ? 2 : 1 - const chunks: string[] = [] + // ToolMetaLine for it (filename, then bidi-isolated directory), on top + // of that file's own accordion header (bidi-isolated directory, then + // filename) — for a multi-file patch each file's name only appears + // once, in its own accordion header. Multi-file chunks are tagged with + // the file's `filePath` (matching the accordion's item key) so a match + // can force just that one nested item open instead of every file. + const single = files.length === 1 + const chunks: ToolChunk[] = [] for (const file of files) { const path = file.relativePath ?? file.filePath - if (!path) continue - for (let i = 0; i < perFile; i += 1) chunks.push(path) + if (!path || !file.filePath) continue + const dir = path.includes("/") ? `\u2066${getDirectory(path)}\u2069` : undefined + if (single) { + chunks.push(getFilename(path)) + if (dir) chunks.push(dir) + continue + } + const tag = (text: string): ToolChunk => ({ text, file: file.filePath! }) + if (dir) chunks.push(tag(dir)) + chunks.push(tag(getFilename(path))) } return chunks } @@ -576,7 +705,8 @@ export const MessageList: Component = (props) => { hit = p.exec(text) continue } - result.push({ key: row.key, occurrence, partId: partIdAt(ranges, hit.index) }) + const range = rangeAt(ranges, hit.index) + result.push({ key: row.key, occurrence, partId: range?.partId, partFile: range?.file }) occurrence += 1 hit = p.exec(text) } @@ -1003,6 +1133,7 @@ export const MessageList: Component = (props) => { onForkMessage={props.onForkMessage} activeSearch={activeKey() === row.key} activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined} + activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined} /> )} @@ -1014,6 +1145,7 @@ export const MessageList: Component = (props) => { onForkMessage={props.onForkMessage} activeSearch={activeKey() === key} activeSearchPartID={activeKey() === key ? activeMatch()?.partId : undefined} + activeSearchPartFile={activeKey() === key ? activeMatch()?.partFile : undefined} /> )} @@ -1028,6 +1160,7 @@ export const MessageList: Component = (props) => { row={row} activeSearch={activeKey() === row.key} activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined} + activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined} /> )} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index 538009dff63..0ed14b06202 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -111,28 +111,30 @@ export const TaskHeader: Component = (props) => { window.addEventListener("message", handler) onCleanup(() => window.removeEventListener("message", handler)) - // "Kilo Code: Search Current Chat" (Command Palette) toggles the search + // "Kilo Code: Toggle Chat Search" (Command Palette) toggles the search // bar from here rather than TranscriptSearch.tsx itself: that component // only mounts once search.active() is already true (it's behind a // ), so it can never be what turns search on in the first place — // and it also wouldn't exist anymore to react to a request to close it. // TaskHeader is mounted the whole time there's an active chat, so it's // the right place to react to the external toggle request. - const toggleSearch = () => search.setActive(!search.active()) + const toggleSearch = () => (search.active() ? search.closeSearch() : search.setActive(true)) window.addEventListener("focusTranscriptSearch", toggleSearch) onCleanup(() => window.removeEventListener("focusTranscriptSearch", toggleSearch)) - // Whenever search closes — the header toggle button, the command palette - // toggle above, the search bar's own "X", or Escape — send focus back to - // the chat input rather than leaving it stranded on whatever control was - // just clicked/removed. `defer: true` skips the initial run so mounting - // with search already inactive doesn't steal focus from wherever it - // already was. + // Whenever search closes via an explicit user action — the header toggle + // button, the command palette toggle above, the search bar's own "X", or + // Escape — send focus back to the chat input rather than leaving it + // stranded on whatever control was just clicked/removed. Watches + // `closeSignal` rather than `active()` transitions so that MessageList + // silently resetting the widget on a session/tab change (which also + // flips `active()` false) can't trigger this same aggressive restore and + // steal focus back from the tab strip's own focus handling. `defer: true` + // skips the initial run so mounting doesn't immediately steal focus. createEffect( on( - () => search.active(), - (active) => { - if (active) return + () => search.closeSignal(), + () => { window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } })) }, { defer: true }, @@ -266,7 +268,7 @@ export const TaskHeader: Component = (props) => { variant="ghost" class="task-header-search-toggle" data-active={search.active() ? "" : undefined} - onClick={() => search.setActive(!search.active())} + onClick={toggleSearch} aria-label={language.t("chat.search.toggle")} aria-pressed={search.active()} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx index f54e82e29c3..0b797becfdc 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx @@ -21,6 +21,8 @@ interface TranscriptRowViewProps { /** id of the part (tool call/reasoning block) containing the current chat * search match within this row, if any. */ activeSearchPartID?: string + /** For a multi-file apply_patch match, the specific file within that part. */ + activeSearchPartFile?: string } export const TranscriptRowView: Component = (props) => { @@ -82,6 +84,7 @@ export const TranscriptRowView: Component = (props) => { parts={row().parts as unknown as SDKPart[]} showAssistantCopyPartID={row().copy} forceOpenPartID={props.activeSearchPartID} + forceOpenFile={props.activeSearchPartFile} feedback={{ enabled: feedback.telemetryEnabled(), rating: feedback.getRating(row().message.id), diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptSearch.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptSearch.tsx index 9e1e0fc2e15..6673489689a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptSearch.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptSearch.tsx @@ -24,7 +24,7 @@ export const TranscriptSearch: Component = () => { } const close = () => { - search.setActive(false) + search.closeSearch() search.setQuery("") search.setCount(0) search.setIndex(0) diff --git a/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx b/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx index e7e1f37f6f2..1edb13bdc11 100644 --- a/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx @@ -8,6 +8,10 @@ export interface SearchMatch { * within, if it could be attributed to one — lets navigation force a * collapsed part open instead of just scrolling to the row. */ partId?: string + /** For a multi-file apply_patch part, the specific file path this + * occurrence falls within — lets navigation open just that file's nested + * accordion instead of every file in the patch. */ + partFile?: string } interface TranscriptSearchContextValue { @@ -21,6 +25,16 @@ interface TranscriptSearchContextValue { setRegex: (value: boolean) => void active: Accessor setActive: (value: boolean) => void + /** Bumped only when search closes via an explicit user action (the header + * toggle button, the Command Palette toggle, or the search bar's own "X"/ + * Escape) — never when `setActive(false)` is called to silently reset the + * widget because the current session changed. TaskHeader watches this + * instead of `active()` transitions so a session/tab switch can't trigger + * the same aggressive focus-restore sequence as a real close. */ + closeSignal: Accessor + /** Closes search and bumps `closeSignal` — use this for explicit + * user-initiated closes; use `setActive(false)` for a silent reset. */ + closeSearch: () => void index: Accessor setIndex: (value: number) => void count: Accessor @@ -51,6 +65,11 @@ export const TranscriptSearchProvider: ParentComponent = (props) => { const [wholeWord, setWholeWord] = createSignal(false) const [regex, setRegex] = createSignal(false) const [active, setActive] = createSignal(false) + const [closeSignal, setCloseSignal] = createSignal(0) + const closeSearch = () => { + setActive(false) + setCloseSignal((n) => n + 1) + } const [index, setIndex] = createSignal(0) const [count, setCount] = createSignal(0) const [jump, setJump] = createSignal(0) @@ -70,6 +89,8 @@ export const TranscriptSearchProvider: ParentComponent = (props) => { setRegex, active, setActive, + closeSignal, + closeSearch, index, setIndex, count, From fae8eba117e8c1780065df19a9de3b8babfb7172 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Tue, 14 Jul 2026 14:49:59 +0200 Subject: [PATCH 06/28] fix(vscode): update unit tests for waitForReady and forceOpen mount guard --- packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts | 2 +- .../kilo-vscode/tests/unit/register-code-actions.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index c3fa1547bbb..fad676c7a4e 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -388,7 +388,7 @@ describe("Collapsed deferred tool details contract (source)", () => { const block = message.match(/ToolRegistry\.register\(\{\s*name:\s*"bash"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? "" expect(block).toContain("const [mounted, setMounted] = createSignal(open())") - expect(block).toMatch(/if \(open\(\) \|\| pending\(\)\) setMounted\(true\)/) + expect(block).toMatch(/if \(open\(\) \|\| pending\(\) \|\| props\.forceOpen\) setMounted\(true\)/) expect(block).toContain("hasDetails") expect(block).toMatch(/[\s\S]*? { + events.push("wait") + waits.push("agent") + }, } api.commands.registerCommand = (command, callback) => { @@ -103,9 +107,9 @@ describe("registerCodeActions", () => { await state.commands.get("kilo-code.new.addToContext")?.() - expect(state.events).toEqual(["post"]) + expect(state.events).toEqual(["wait", "post"]) expect(state.executed).toEqual([]) - expect(state.waits).toEqual([]) + expect(state.waits).toEqual(["agent"]) expect(state.posts).toEqual([ { type: "appendChatBoxMessage", From 8e8ce2b36ac843fa8c6336151908956935c77c7b Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Tue, 14 Jul 2026 15:56:04 +0200 Subject: [PATCH 07/28] fix(vscode): address chat-search review feedback --- .../kilo-ui/src/components/message-part.tsx | 40 +++++++--- .../code-actions/register-code-actions.ts | 18 +++-- .../tests/unit/kilo-ui-contract.test.ts | 5 +- .../tests/unit/register-code-actions.test.ts | 12 ++- .../agent-manager/AgentManagerApp.tsx | 4 +- .../webview-ui/agent-manager/navigate.ts | 12 +++ .../src/components/chat/MessageList.tsx | 76 +++++++++++++------ .../src/components/chat/QuestionDock.tsx | 10 ++- .../src/components/chat/TaskToolExpanded.tsx | 7 ++ .../components/chat/question-dock-utils.ts | 27 +++++++ .../webview-ui/src/types/messages/parts.ts | 2 + 11 files changed, 171 insertions(+), 42 deletions(-) diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ffcaaab637c..ecbc45b2fc9 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -2311,7 +2311,12 @@ ToolRegistry.register({ } > - + @@ -2625,19 +2630,29 @@ ToolRegistry.register({ }) // Deleted files start collapsed above; a chat search match could be // inside one. `forceOpenFile` (from MessageList's per-chunk file - // attribution) names exactly which file's accordion to open — falling - // back to expanding every file only when forceOpen fires without a - // known file (defensive; MessageList always attributes apply_patch - // matches to a specific file today). + // attribution) names exactly which file's accordion to open. This is + // tracked separately from the user's own manual toggles: replacing it + // on every navigation (rather than appending to `expanded`, which never + // shrinks) closes the previously force-opened file again, so its Pierre + // diff instance unmounts instead of accumulating one per visited match. + const [searchOpenFile, setSearchOpenFile] = createSignal() createEffect(() => { if (props.forceOpenFile) { - const path = props.forceOpenFile - setExpanded((prev) => (prev.includes(path) ? prev : [...prev, path])) + setSearchOpenFile(props.forceOpenFile) return } + // Defensive fallback for forceOpen without a known file (MessageList + // always attributes apply_patch matches to a specific file today): + // expand everything rather than nothing. + setSearchOpenFile(undefined) if (!props.forceOpen) return setExpanded(files().map((f) => f.filePath)) }) + const allExpanded = createMemo(() => { + const search = searchOpenFile() + if (!search) return expanded() + return expanded().includes(search) ? expanded() : [...expanded(), search] + }) const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -2690,8 +2705,15 @@ ToolRegistry.register({ multiple data-scope="apply-patch" style={{ "--sticky-accordion-offset": "37px" }} - value={expanded()} - onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + value={allExpanded()} + onChange={(value) => { + const next = Array.isArray(value) ? value : value ? [value] : [] + // The user explicitly closed the search-forced file — + // stop treating it as force-open so it doesn't reopen + // itself out of `allExpanded()` on the next render. + if (searchOpenFile() && !next.includes(searchOpenFile()!)) setSearchOpenFile(undefined) + setExpanded(next.filter((path) => path !== searchOpenFile())) + }} > {(file) => { diff --git a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts index ef941ab4bbd..e7417e11169 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts @@ -21,16 +21,22 @@ export function registerCodeActions( // does not queue — it silently drops the message if the webview hasn't // installed its listener yet. Wait for the selected target's own // readiness too before posting to it. - const revealTarget = async (view: KiloProvider | AgentManagerProvider) => { + // + // AgentManagerProvider.waitForReady() resolves `false` instead of hanging + // forever when the selected panel closes or is replaced while waiting. + // Propagate that so callers skip posting instead of delivering the + // message to whatever panel happens to be active by the time the wait + // settles. + const revealTarget = async (view: KiloProvider | AgentManagerProvider): Promise => { if (view === provider) { await reveal() - return + return true } if (view === agentManager) { - await agentManager.waitForReady() - return + return agentManager.waitForReady() } await view.waitForReady() + return true } context.subscriptions.push( @@ -87,13 +93,13 @@ export function registerCodeActions( selectedText: ctx.selectedText, }) const view = target() - await revealTarget(view) + if (!(await revealTarget(view))) return view.postMessage({ type: "appendChatBoxMessage", text: prompt }) }), vscode.commands.registerCommand("kilo-code.new.focusChatInput", async () => { const view = target() - await revealTarget(view) + if (!(await revealTarget(view))) return view.postMessage({ type: "action", action: "focusInput" }) }), diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index fad676c7a4e..fbc8aa42555 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -225,7 +225,10 @@ describe("Bash tool static terminal preview (source)", () => { it("BashHighlightedOutput highlights only while expanded", () => { expect(src).toContain("if (!props.active) return") - expect(block).toContain("active={open()}") + // Also active when forceOpen fires from a virtualized remount that + // starts already open — `open()` alone only reflects the toggle + // transition, not that initial-mount case. + expect(block).toContain("active={open() || !!props.forceOpen}") }) it("BashHighlightedOutput keeps command and output in separate terminal containers", () => { diff --git a/packages/kilo-vscode/tests/unit/register-code-actions.test.ts b/packages/kilo-vscode/tests/unit/register-code-actions.test.ts index 14e618d14f5..cc5c812daa5 100644 --- a/packages/kilo-vscode/tests/unit/register-code-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/register-code-actions.test.ts @@ -23,7 +23,7 @@ const original = { diagnostics: api.languages.getDiagnostics, } -function setup(active = false) { +function setup(active = false, agentReady = true) { const commands = new Map() const executed: unknown[][] = [] const events: string[] = [] @@ -49,6 +49,7 @@ function setup(active = false) { waitForReady: async () => { events.push("wait") waits.push("agent") + return agentReady }, } @@ -117,4 +118,13 @@ describe("registerCodeActions", () => { }, ]) }) + + it("does not post to the Agent Manager when its readiness wait is cancelled", async () => { + const state = setup(true, false) + + await state.commands.get("kilo-code.new.addToContext")?.() + + expect(state.events).toEqual(["wait"]) + expect(state.posts).toEqual([]) + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index e841e9cab09..4f5defffd88 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -100,6 +100,7 @@ import { nextSelectionAfterDelete, adjacentHint, filterUnassignedSessions, + focusChatSearch, LOCAL, } from "./navigate" import { @@ -1107,7 +1108,8 @@ const AgentManagerContent: Component = () => { else if (msg.action === "closeWorktree") closeSelectedWorktree() else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts() else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt")) - else if (msg.action === "focusSearch") window.dispatchEvent(new CustomEvent("focusTranscriptSearch")) + else if (msg.action === "focusSearch") + focusChatSearch({ history: setHistory, review: setReviewActive, terminal: () => terms.setActiveId(undefined) }) else if (msg.action === "newTerminal") termHandlers.requestNew() else if (msg.action === "cycleAgentMode" && document.hasFocus()) cycleAgent(1) else if (msg.action === "cyclePreviousAgentMode" && document.hasFocus()) cycleAgent(-1) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts index 378923d1fbc..a7373d96962 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts @@ -120,3 +120,15 @@ export function nextSelectionAfterDelete(deletedId: string, worktreeIds: string[ // Prefer the item that was below (same index in the shortened list), else the one above return remaining[Math.min(idx, remaining.length - 1)]! } + +/** + * A "focus chat search" request only reaches TaskHeader while ChatView is + * the visible main surface — history, an active terminal tab, and the + * full-screen review each replace it. Reset to chat first, then dispatch. + */ +export function focusChatSearch(reset: { history(v: boolean): void; review(v: boolean): void; terminal(): void }) { + reset.history(false) + reset.review(false) + reset.terminal() + window.dispatchEvent(new CustomEvent("focusTranscriptSearch")) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 78afc37c7cc..40e0b72876a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -29,6 +29,7 @@ import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useI18n } from "@kilocode/kilo-ui/context/i18n" import { useProvider } from "../../context/provider" +import { useWorktreeMode } from "../../context/worktree-mode" import { WelcomeEmptyState } from "./WelcomeEmptyState" import { TranscriptRowView } from "./TranscriptRow" import type { ErrorDisplayProps } from "./ErrorDisplay" @@ -58,7 +59,7 @@ import { } from "../../context/session-queue" import { childID } from "../../context/session-utils" import { taskResult } from "./task-tool-state" -import { tr } from "./question-dock-utils" +import { activeQuestionTab, tr } from "./question-dock-utils" import { useData } from "@kilocode/kilo-ui/context/data" import { getDirectory as getRawDirectory, getFilename } from "@opencode-ai/core/util/path" import { @@ -105,6 +106,11 @@ export const MessageList: Component = (props) => { const provider = useProvider() const i18n = useI18n() const data = useData() + // Only present inside Agent Manager (see worktree-mode.tsx). Agent Manager + // never calls registerExpandedTaskTool(), so its "task" cards always fall + // back to kilo-ui's default hideDetails renderer, which never shows a + // task's result text — indexing it there would produce a phantom match. + const inAgentManager = !!useWorktreeMode() // Mirrors message-part.tsx's own (unexported) relativizeProjectPath/ // getDirectory exactly, so the directory text indexed here matches what @@ -378,7 +384,7 @@ export const MessageList: Component = (props) => { // completion — so both need handling before the completed-only gate // below, or an in-progress task/question would index nothing at all. if (part.tool === "task") return taskText(part, state) - if (part.tool === "question") return questionText(state) + if (part.tool === "question") return questionText(part, state) if (state.status !== "completed") return "title" in state && state.title ? [state.title] : [] if (CONTEXT_GROUP_TOOLS.has(part.tool)) return state.title ? [state.title] : [] if (part.tool === "bash") return bashText(state) @@ -443,11 +449,19 @@ export const MessageList: Component = (props) => { // no live child session to display instead (result() there resolves to // undefined once a child session exists) — mirror that exactly so a // completed task with no child session stays searchable, without - // indexing text that's actually replaced by the child tool list. - if (state.status === "completed") { - const child = childID({ type: "tool", tool: part.tool, state: { metadata: state.metadata } }) + // indexing text that's actually replaced by the child tool list. Agent + // Manager never registers TaskToolExpanded at all (it always uses + // kilo-ui's default hideDetails task card, which never shows result + // text there), so skip this entirely in that surface. + if (state.status === "completed" && !inAgentManager) { + const child = childID({ + type: "tool", + tool: part.tool, + metadata: part.metadata as { sessionId?: string } | undefined, + state: { metadata: state.metadata }, + }) const result = taskResult(state.output, child) - if (result) chunks.push(result) + if (result) chunks.push(stripMarkdownLinkUrls(result)) } return chunks } @@ -462,28 +476,40 @@ export const MessageList: Component = (props) => { type QuestionOption = { label?: string; description?: string; labelKey?: string; descriptionKey?: string } type QuestionInfo = { question?: string; questionKey?: string; options?: QuestionOption[] } - function questionText(state: ToolState): string[] { + // Correlates a "question" tool part to its live QuestionRequest the same + // way AssistantMessage.tsx's matchToolRequest does (by callID/messageID), + // so the pending branch below can read which page QuestionDock actually + // has mounted (question-dock-utils.ts's activeQuestionTab) instead of + // assuming it's always the first one. + function liveQuestionRequestId(part: Part & { type: "tool" }): string | undefined { + return session.questions().find((r) => r.tool?.callID === part.callID && r.tool?.messageID === part.messageID)?.id + } + + function questionText(part: Part & { type: "tool" }, state: ToolState): string[] { const input = state.input as { questions?: QuestionInfo[] } | undefined const questions = input?.questions ?? [] const done = state.status === "completed" const metadata = done ? (state.metadata as { answers?: unknown; dismissed?: unknown } | undefined) : undefined const answers = Array.isArray(metadata?.answers) ? (metadata!.answers as unknown[][]) : undefined const dismissed = metadata?.dismissed === true + const requestId = done ? undefined : liveQuestionRequestId(part) + const mountedTab = requestId ? activeQuestionTab(requestId) : 0 const chunks: string[] = [] questions.forEach((q, i) => { - // QuestionDock renders localized text via questionKey/labelKey/ - // descriptionKey (see question-dock-utils.ts's tr()), with the raw - // wire strings kept only as untranslated fallbacks/reply values — - // index what's actually displayed, not the canonical fallback. - const questionLabel = tr(language.t, q.questionKey, q.question ?? "") + // The pending QuestionDock renders localized text via questionKey/ + // labelKey/descriptionKey (see question-dock-utils.ts's tr()), with + // the raw wire strings kept only as untranslated fallbacks/reply + // values. Once completed, kilo-ui's question renderer instead shows + // the raw `q.question` directly (no questionKey lookup) — index + // whichever one that surface actually displays. + const questionLabel = done ? (q.question ?? "") : tr(language.t, q.questionKey, q.question ?? "") if (questionLabel) chunks.push(questionLabel) if (!done) { // QuestionDock only ever mounts one page (store.tab) of a pending - // multi-question at a time and there's no external signal exposing - // which page that is — indexing every question's full option list - // produces matches for pages that aren't in the DOM. Limit to the - // first question, which is always the page mounted on first render. - if (i > 0) return + // multi-question at a time — limit to the page it has published as + // mounted (question-dock-utils.ts), which stays correct as the user + // navigates instead of freezing on page 0. + if (i !== mountedTab) return for (const option of q.options ?? []) { const label = tr(language.t, option.labelKey, option.label ?? "") if (label) chunks.push(label) @@ -551,12 +577,14 @@ export const MessageList: Component = (props) => { const files = ((state.metadata as { files?: { filePath?: string; relativePath?: string }[] } | undefined)?.files ?? []) as { filePath?: string; relativePath?: string }[] // Only when there's exactly one file does the trigger also show a - // ToolMetaLine for it (filename, then bidi-isolated directory), on top - // of that file's own accordion header (bidi-isolated directory, then - // filename) — for a multi-file patch each file's name only appears - // once, in its own accordion header. Multi-file chunks are tagged with - // the file's `filePath` (matching the accordion's item key) so a match - // can force just that one nested item open instead of every file. + // ToolMetaLine for it (filename, then bidi-isolated directory), ON TOP + // OF that file's own ToolFileAccordion header (bidi-isolated directory, + // then filename) — both are rendered simultaneously for a single-file + // patch, same as edit/write's two-location layout in editWriteText. + // For a multi-file patch each file's name only appears once, in its own + // accordion header. Multi-file chunks are tagged with the file's + // `filePath` (matching the accordion's item key) so a match can force + // just that one nested item open instead of every file. const single = files.length === 1 const chunks: ToolChunk[] = [] for (const file of files) { @@ -566,6 +594,8 @@ export const MessageList: Component = (props) => { if (single) { chunks.push(getFilename(path)) if (dir) chunks.push(dir) + if (dir) chunks.push(dir) + chunks.push(getFilename(path)) continue } const tag = (text: string): ToolChunk => ({ text, file: file.filePath! }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 15695ecd04c..5f727650de8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -4,7 +4,7 @@ * Uses kilo-ui's DockPrompt component for proper surface styling. */ -import { For, Show, createMemo, createEffect } from "solid-js" +import { For, Show, createMemo, createEffect, onCleanup } from "solid-js" import type { Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@kilocode/kilo-ui/button" @@ -13,9 +13,11 @@ import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" import type { QuestionRequest } from "../../types/messages" import { + clearActiveQuestionTab, pickOutcome, resolveOptimisticQuestionAgent, resolveSelectedQuestionMode, + setActiveQuestionTab, toggleAnswer, tr, } from "./question-dock-utils" @@ -52,6 +54,12 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } }) + // Chat search indexes only the mounted page's options, and there's no + // other signal exposing which page that is — publish it here so search + // stays in sync as the user navigates instead of always assuming page 0. + createEffect(() => setActiveQuestionTab(props.request.id, store.tab)) + onCleanup(() => clearActiveQuestionTab(props.request.id)) + const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 3852ba53ec5..5138f071c9e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -36,11 +36,17 @@ const TaskToolRenderer: Component = (props) => { }) const running = createMemo(() => taskRunning(props.status)) + // BasicTool's forceOpen effect only fires onOpenChange on a false->true + // transition — a virtualized remount that starts with forceOpen already + // true never transitions, so this local signal must also seed itself from + // forceOpen directly, or the child list/result below stays hidden even + // though the accordion itself renders open. const [open, setOpen] = createSignal( initialOpen({ tool: props.tool, partID: props.partID, defaultOpen: running(), + forceOpen: props.forceOpen, }), ) @@ -148,6 +154,7 @@ const TaskToolRenderer: Component = (props) => { partID={props.partID} trigger={trigger()} defaultOpen={running()} + forceOpen={props.forceOpen} defer onOpenChange={setOpen} > diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts index 615aa02fb3d..51ed4500db9 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts @@ -1,5 +1,32 @@ +import { createSignal } from "solid-js" import type { QuestionOption } from "../../types/messages" +// Which page (question index) of a pending multi-question is currently +// mounted in the DOM, keyed by request id. QuestionDock only ever mounts +// `store.tab`, and nothing else observes which page that is — chat search +// needs it to index exactly the visible page instead of guessing it's +// always the first one (true only right after initial mount, not once the +// user has navigated). +const [activeQuestionTabs, setActiveQuestionTabs] = createSignal>({}) + +export function setActiveQuestionTab(requestId: string, tab: number): void { + setActiveQuestionTabs((prev) => (prev[requestId] === tab ? prev : { ...prev, [requestId]: tab })) +} + +export function clearActiveQuestionTab(requestId: string): void { + setActiveQuestionTabs((prev) => { + if (!(requestId in prev)) return prev + const next = { ...prev } + delete next[requestId] + return next + }) +} + +/** Returns the currently-mounted page for a request, defaulting to the first page. */ +export function activeQuestionTab(requestId: string): number { + return activeQuestionTabs()[requestId] ?? 0 +} + /** * Translate a backend-provided i18n key, falling back to the canonical label when no key is * set or the key is missing from the dictionary. diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts index 81367237f11..af7f58c0986 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts @@ -49,6 +49,8 @@ export interface ToolPart extends BasePart { type: "tool" tool: string state: ToolState + metadata?: Record + callID?: string } export interface ReasoningPart extends BasePart { From 6077c1c3b36d4c5cd68f206fc146ca472d841c5e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 14 Jul 2026 15:11:29 -0400 Subject: [PATCH 08/28] fix(jetbrains): make custom provider dialog report errors and require a model Adding a custom OpenAI-compatible provider could silently fail. The dialog now requires at least one model ID, keeps the Add button disabled until the model list is non-empty, and surfaces save/fetch errors inline so the user can correct input and retry without re-entering the form. Adds a cancellable "Select models" fetch that lists models from the provider's /models endpoint in a JBList popup styled like the model picker (check icon, hover selection, standard popup insets, select-all toggle). --- ...jetbrains-custom-provider-inline-errors.md | 5 + .../KiloBackendProviderSettingsManager.kt | 1 + .../KiloBackendProviderSettingsManagerTest.kt | 47 ++ .../settings/providers/ProvidersSettingsUi.kt | 415 +++++++++++++++++- .../resources/messages/KiloBundle.properties | 9 + .../providers/ProvidersSettingsUiTest.kt | 153 +++++++ 6 files changed, 617 insertions(+), 13 deletions(-) create mode 100644 .changeset/jetbrains-custom-provider-inline-errors.md diff --git a/.changeset/jetbrains-custom-provider-inline-errors.md b/.changeset/jetbrains-custom-provider-inline-errors.md new file mode 100644 index 00000000000..571b1ee28ea --- /dev/null +++ b/.changeset/jetbrains-custom-provider-inline-errors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix adding a Custom OpenAI-Compatible Provider silently failing. The dialog now requires at least one model and reports save errors inline so you can correct your input and retry without re-entering the form. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt index cfe2c9b3cde..76533c6ad27 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt @@ -282,6 +282,7 @@ internal class KiloBackendProviderSettingsManager( if (!input.baseUrl.startsWith("http://") && !input.baseUrl.startsWith("https://")) return "Base URL must start with http:// or https://." if (!env.isNullOrBlank() && !Regex("^[A-Za-z_][A-Za-z0-9_]*$").matches(env)) return "Environment variable name is invalid." if (input.headers.keys.any { it.isBlank() }) return "Header names cannot be empty." + if (input.models.isEmpty()) return "At least one model ID is required." if (input.models.any { it.id.isBlank() }) return "Model IDs cannot be empty." return null } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt index 9072d1d7337..10a2ea8e61e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt @@ -5,6 +5,8 @@ import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog +import ai.kilocode.rpc.dto.CustomModelDto +import ai.kilocode.rpc.dto.CustomProviderSaveDto import ai.kilocode.rpc.dto.ProviderConnectDto import ai.kilocode.rpc.dto.ProviderDisconnectDto import ai.kilocode.rpc.dto.ProviderEnableDto @@ -218,6 +220,51 @@ class KiloBackendProviderSettingsManagerTest { assertEquals(1, mock.requestCount("/global/dispose")) } + @Test + fun `saving custom provider without models returns error and does not patch config`() = runBlocking { + val manager = manager() + + mock.resetCounts() + val result = manager.saveCustom( + CustomProviderSaveDto("/test", "my-openai", "My OpenAI", "https://api.example.com/v1"), + ) + + assertEquals("At least one model ID is required.", result.error) + assertNull(mock.lastConfigPatchBody) + assertEquals(0, mock.requestCount("/global/dispose")) + } + + @Test + fun `saving custom provider with a model patches config and reloads provider`() = runBlocking { + mock.providers = """{ + "all":[{"id":"my-openai","name":"My OpenAI","source":"config","models":{"gpt-4o":{"id":"gpt-4o","name":"gpt-4o"}}}], + "default":{}, + "connected":[], + "failed":[] + }""".trimIndent() + val manager = manager() + + mock.resetCounts() + val result = manager.saveCustom( + CustomProviderSaveDto( + "/test", + "my-openai", + "My OpenAI", + "https://api.example.com/v1", + apiKey = "sk-test", + models = listOf(CustomModelDto("gpt-4o", "gpt-4o")), + ), + ) + + assertNull(result.error) + assertContains(mock.lastConfigPatchBody.orEmpty(), "\"my-openai\"") + assertContains(mock.lastConfigPatchBody.orEmpty(), "\"@ai-sdk/openai-compatible\"") + assertContains(mock.lastConfigPatchBody.orEmpty(), "\"gpt-4o\"") + assertContains(mock.lastAuthPutBody.orEmpty(), "\"key\":\"sk-test\"") + assertTrue(result.state.providers.any { it.id == "my-openai" }) + assertEquals(1, mock.requestCount("/global/dispose")) + } + @Test fun `disconnecting kilo gateway returns error without logout`() = runBlocking { mock.providers = """{ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt index 64cff8595bf..882d9336b80 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt @@ -10,11 +10,15 @@ import ai.kilocode.client.settings.base.SettingsListView import ai.kilocode.client.settings.auth.DeviceOAuthInfo import ai.kilocode.client.settings.auth.DeviceOAuthPanel import ai.kilocode.client.settings.auth.DeviceOAuthText +import ai.kilocode.client.session.ui.PickerRow import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.CustomModelDto +import ai.kilocode.rpc.dto.CustomModelFetchDto +import ai.kilocode.rpc.dto.CustomModelFetchResultDto import ai.kilocode.rpc.dto.CustomProviderSaveDto +import ai.kilocode.rpc.dto.ProviderActionResultDto import ai.kilocode.rpc.dto.ProviderAuthMethodDto import ai.kilocode.rpc.dto.ProviderAuthOptionDto import ai.kilocode.rpc.dto.ProviderConnectDto @@ -42,13 +46,22 @@ import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.ValidationInfo import com.intellij.ui.CollectionListModel import com.intellij.ui.DocumentAdapter +import com.intellij.ui.GroupHeaderSeparator +import com.intellij.ui.ListUtil import com.intellij.ui.SearchTextField import com.intellij.ui.ScrollingUtil import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextField +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.util.PopupUtil +import com.intellij.ui.NewUI import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.EmptyIcon +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -58,14 +71,22 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout +import java.awt.Component +import java.awt.Cursor +import java.awt.Dimension import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JButton import javax.swing.JComponent import javax.swing.DefaultListCellRenderer import javax.swing.JList +import javax.swing.JPanel import javax.swing.KeyStroke +import javax.swing.ListCellRenderer import javax.swing.ListSelectionModel +import javax.swing.SwingConstants import javax.swing.event.DocumentEvent import javax.swing.Timer @@ -75,6 +96,83 @@ private val OAUTH_CODE_RE = Regex("""code:\s*(\S+)""", RegexOption.IGNORE_CASE) private fun oauthCode(text: String?): String? = text?.let { OAUTH_CODE_RE.find(it)?.groupValues?.getOrNull(1) } +private const val CUSTOM_MODEL_POPUP_MIN_WIDTH = 320 +private const val CUSTOM_MODEL_POPUP_MAX_ROWS = 10 + +// Inline error text for a custom-provider save. A blank result with the provider missing from the +// returned list means the CLI dropped it (e.g. no usable models), so surface that instead of closing silently. +internal fun customSaveError(id: String, result: ProviderActionResultDto): String? { + result.error?.let { return it } + if (result.state.providers.none { it.id == id }) return KiloBundle.message("settings.providers.customNotUsable") + return null +} + +internal data class CustomModelRow( + val id: String? = null, + val selectAll: Boolean = false, +) + +internal fun customModelRows(ids: List): List { + if (ids.isEmpty()) return emptyList() + return listOf(CustomModelRow(selectAll = true)) + ids.map { CustomModelRow(id = it) } +} + +private class CustomModelRowRenderer( + private val all: Set, + private val selectedIds: () -> Set, +) : JPanel(BorderLayout()), ListCellRenderer { + private val checked: Icon = AllIcons.Actions.Checked + private val empty: Icon = EmptyIcon.create(checked) + private val check = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + verticalAlignment = SwingConstants.CENTER + } + private val title = JBLabel().apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets()).apply { + setHideLine(false) + } + private val row = JPanel(BorderLayout()).apply { + border = JBUI.Borders.empty( + UiStyle.Gap.md(), + UiStyle.Gap.lg(), + UiStyle.Gap.md(), + UiStyle.Gap.pad(), + ) + add(check, BorderLayout.WEST) + add(title, BorderLayout.CENTER) + } + private val wrap = PickerRow() + + init { + isOpaque = true + UiStyle.Components.transparent(row, check, title) + wrap.setContent(row) + add(wrap, BorderLayout.CENTER) + } + + override fun getListCellRendererComponent( + list: JList, + value: CustomModelRow, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val current = selectedIds() + val active = selected || focus || list.hasFocus() + val on = if (value.selectAll) all.isNotEmpty() && all.all { it in current } else value.id in current + check.icon = if (on) checked else empty + title.text = if (value.selectAll) KiloBundle.message("settings.providers.customModelsSelectAll") else value.id.orEmpty() + background = list.background + wrap.update(list, selected, active) + title.foreground = UIUtil.getListForeground(selected, active) + remove(sep) + if (value.selectAll) add(sep, BorderLayout.SOUTH) + return this + } +} + internal class ProvidersSettingsUi( private val cs: CoroutineScope, private val directory: String, @@ -216,14 +314,19 @@ internal class ProvidersSettingsUi( @RequiresEdt private fun custom() { checkEdt() - val dialog = CustomProviderDialog() + // The dialog performs the save itself so failures can be shown inline and the user can + // correct their input without re-typing. It only closes on a verified success. + val dialog = CustomProviderDialog( + cs, + directory, + { service().fetchCustomModels(it) }, + { service().saveCustom(it) }, + ) if (!dialog.showAndGet()) return - val input = dialog.input(directory) - if (!launch("save custom provider") { id -> - val result = service().saveCustom(input) - apply(id, result.state, result.error) - }) return - syncLoading() + val next = dialog.outcome ?: return + state = next + view.update(next) + clearProgress() } private fun toolbar(): JComponent { @@ -539,31 +642,58 @@ private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : Dial } } -private class CustomProviderDialog : DialogWrapper(true) { +internal class CustomProviderDialog( + private val cs: CoroutineScope, + private val directory: String, + private val fetch: suspend (CustomModelFetchDto) -> CustomModelFetchResultDto, + private val save: suspend (CustomProviderSaveDto) -> ProviderActionResultDto, +) : DialogWrapper(true) { private val id = JBTextField() private val name = JBTextField() private val url = JBTextField() private val key = JBPasswordField().apply { columns = 50 } private val env = JBTextField() private val models = JBTextField() + private val pick = JButton(KiloBundle.message("settings.providers.customSelectModels")) + private var saving = false + private var fetching = false + private var active = true + private var actionError: String? = null + private var popup: JBPopup? = null + private var job: Job? = null + private var draft: String? = null + private var token = 0 + + // Set once the save succeeds; the panel reads it after the dialog closes to update the list. + var outcome: ProviderSettingsDto? = null + private set init { title = KiloBundle.message("settings.providers.customTitle") + setOKButtonText(KiloBundle.message("settings.providers.customAdd")) init() initValidation() + models.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + syncActions() + } + }) + pick.addActionListener { + if (fetching) cancelFetch() + else selectModels() + } + syncActions() } @RequiresEdt - fun input(directory: String) = CustomProviderSaveDto( + private fun input() = CustomProviderSaveDto( directory = directory, id = id.text.trim(), name = name.text.trim(), baseUrl = url.text.trim(), apiKey = String(key.password).takeIf { it.isNotBlank() }, envVar = env.text.trim().takeIf { it.isNotBlank() }, - models = models.text.split(',').mapNotNull { raw -> - raw.trim().takeIf { it.isNotBlank() }?.let { CustomModelDto(it, it) } - }, + models = modelIds().map { CustomModelDto(it, it) }, ) override fun createCenterPanel(): JComponent { @@ -574,17 +704,276 @@ private class CustomProviderDialog : DialogWrapper(true) { KiloBundle.message("settings.providers.customUrl") to url, KiloBundle.message("settings.providers.apiKey") to key, KiloBundle.message("settings.providers.customEnv") to env, - KiloBundle.message("settings.providers.customModels") to models, ).forEach { (label, field) -> panel.next(JBLabel(label)) panel.next(field) } + panel.next(JBLabel(KiloBundle.message("settings.providers.customModels"))) + panel.next(JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply { + add(models, BorderLayout.CENTER) + add(pick, BorderLayout.EAST) + }) return panel } override fun doValidate(): ValidationInfo? { if (id.text.isBlank()) return ValidationInfo(KiloBundle.message("settings.providers.customIdRequired"), id) if (url.text.isBlank()) return ValidationInfo(KiloBundle.message("settings.providers.customUrlRequired"), url) + actionError?.let { return ValidationInfo(it) } + if (!fetching && modelIds().isEmpty()) return ValidationInfo(KiloBundle.message("settings.providers.customModelsRequired"), models) return null } + + override fun doOKAction() { + checkEdt() + ProvidersSettingsUi.LOG.info("custom provider add: clicked saving=$saving fetching=$fetching id='${id.text.trim()}' models=${modelIds().size}") + if (saving) { + ProvidersSettingsUi.LOG.info("custom provider add: ignored, save already in progress") + return + } + actionError = null + setErrorText(null) + val invalid = doValidate() + if (invalid != null) { + ProvidersSettingsUi.LOG.info("custom provider add: blocked by validation: ${invalid.message}") + return + } + val input = input() + ProvidersSettingsUi.LOG.info("custom provider add: saving id='${input.id}' baseUrl='${input.baseUrl}' models=${input.models.size} hasKey=${input.apiKey != null} env='${input.envVar}'") + saving = true + syncActions() + cs.launch { + val result = try { + save(input) + } catch (e: CancellationException) { + ProvidersSettingsUi.LOG.info("custom provider add: save cancelled id='${input.id}'") + throw e + } catch (e: Exception) { + ProvidersSettingsUi.LOG.warn("custom provider save failed id='${input.id}'", e) + withContext(edt) { fail("${e::class.simpleName}: ${e.message}") } + return@launch + } + withContext(edt) { + if (!active) { + ProvidersSettingsUi.LOG.info("custom provider add: dialog no longer active, dropping result id='${input.id}'") + return@withContext + } + val error = customSaveError(input.id, result) + if (error != null) { + ProvidersSettingsUi.LOG.warn("custom provider add: save reported error id='${input.id}': $error") + fail(error) + return@withContext + } + ProvidersSettingsUi.LOG.info("custom provider add: save succeeded id='${input.id}', closing dialog") + outcome = result.state + closeOk() + } + } + } + + @RequiresEdt + private fun fail(text: String) { + if (!active) return + saving = false + finishFetch() + actionError = text + setErrorText(text) + syncActions() + } + + @RequiresEdt + private fun selectModels() { + checkEdt() + if (saving || fetching) return + actionError = null + setErrorText(null) + val err = fetchValidationError() + if (err != null) { + fail(err) + return + } + startFetch() + val input = CustomModelFetchDto( + baseUrl = url.text.trim(), + apiKey = String(key.password).takeIf { it.isNotBlank() }, + ) + val current = token + job = cs.launch { + val result = try { + fetch(input) + } catch (e: CancellationException) { + return@launch + } catch (e: Exception) { + ProvidersSettingsUi.LOG.warn("custom provider model fetch failed", e) + withContext(edt) { + if (token == current) fail("${e::class.simpleName}: ${e.message}") + } + return@launch + } + withContext(edt) { + if (!active || token != current) return@withContext + finishFetch() + val error = result.error + if (error != null) { + fail(error) + return@withContext + } + val ids = result.models.mapNotNull { it.trim().takeIf(String::isNotBlank) }.distinct() + if (ids.isEmpty()) { + fail(KiloBundle.message("settings.providers.customModelsEmpty")) + return@withContext + } + showModelPopup(ids) + } + } + } + + @RequiresEdt + private fun startFetch() { + draft = models.text + token++ + fetching = true + models.isEditable = false + models.text = KiloBundle.message("settings.providers.customFetchingModels") + syncActions() + } + + @RequiresEdt + private fun cancelFetch() { + checkEdt() + job?.cancel() + token++ + finishFetch() + setErrorText(null) + } + + // Restores the field to what it held before the fetch and re-enables editing. The stale-result + // guard uses `token`, so a late response from a cancelled fetch is ignored and never lands here. + @RequiresEdt + private fun finishFetch() { + if (!fetching && draft == null) return + job = null + fetching = false + models.isEditable = true + draft?.let { models.text = it } + draft = null + syncActions() + } + + private fun fetchValidationError(): String? { + if (url.text.isBlank()) return KiloBundle.message("settings.providers.customUrlRequired") + if (!url.text.trim().let { it.startsWith("http://") || it.startsWith("https://") }) return KiloBundle.message("settings.providers.customUrlInvalid") + return null + } + + @RequiresEdt + private fun showModelPopup(ids: List) { + checkEdt() + popup?.cancel() + val rows = customModelRows(ids) + val data = CollectionListModel(rows) + val listBackground = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground() + val list = JBList(data).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + isFocusable = true + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + background = listBackground + border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false)) + cellRenderer = CustomModelRowRenderer(ids.toSet()) { modelIds().toSet() } + visibleRowCount = rows.size.coerceAtMost(CUSTOM_MODEL_POPUP_MAX_ROWS) + } + fun sync() { + list.repaint() + syncActions() + } + fun toggle(row: CustomModelRow) { + if (row.selectAll) { + val all = modelIds().toSet().containsAll(ids) + setModelIds(if (all) emptyList() else ids) + sync() + return + } + val id = row.id ?: return + val selected = modelIds().toMutableSet() + if (!selected.add(id)) selected.remove(id) + setModelIds(ids.filter { it in selected }) + sync() + } + list.addMouseListener(object : MouseAdapter() { + override fun mouseReleased(e: MouseEvent) { + if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return + val idx = list.locationToIndex(e.point).takeIf { it >= 0 } ?: return + val bounds = list.getCellBounds(idx, idx) ?: return + if (!bounds.contains(e.point)) return + toggle(data.getElementAt(idx)) + e.consume() + } + }) + list.registerKeyboardAction( + { list.selectedValue?.let(::toggle) }, + KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), + JComponent.WHEN_FOCUSED, + ) + list.registerKeyboardAction( + { list.selectedValue?.let(::toggle) }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + ListUtil.installAutoSelectOnMouseMove(list) + ScrollingUtil.installActions(list) + val scroll = JBScrollPane(list).apply { + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + background = listBackground + viewport.background = listBackground + viewport.isOpaque = true + preferredSize = Dimension(JBUI.scale(CUSTOM_MODEL_POPUP_MIN_WIDTH), preferredSize.height) + } + popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(scroll, list) + .setRequestFocus(true) + .setFocusable(true) + .setCancelOnClickOutside(true) + .setCancelKeyEnabled(true) + .setCancelOnWindowDeactivation(true) + .setLocateWithinScreenBounds(true) + .setResizable(false) + .setMovable(false) + .createPopup() + popup?.showUnderneathOf(pick) + } + + private fun modelIds(): List { + val text = draft.takeIf { fetching } ?: models.text + return text.split(',').mapNotNull { it.trim().takeIf(String::isNotBlank) } + } + + private fun setModelIds(ids: Collection) { + draft = null + models.text = ids.distinct().joinToString(", ") + } + + private fun syncActions() { + isOKActionEnabled = !saving && !fetching && modelIds().isNotEmpty() + pick.isEnabled = !saving + pick.text = if (fetching) { + KiloBundle.message("settings.providers.customCancelModels") + } else { + KiloBundle.message("settings.providers.customSelectModels") + } + } + + private fun closeOk() = super.doOKAction() + + override fun dispose() { + active = false + token++ + job?.cancel() + popup?.cancel() + super.dispose() + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Custom provider dialog updates must run on EDT" } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 0e9ff7e50da..41af43c6771 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -468,13 +468,22 @@ settings.providers.note.vercel=Unified access to AI models with smart routing settings.providers.apiKey=API key settings.providers.apiKeyRequired=API key is required. settings.providers.customTitle=Custom OpenAI-Compatible Provider +settings.providers.customAdd=Add +settings.providers.customSelectModels=Select models +settings.providers.customCancelModels=Cancel +settings.providers.customFetchingModels=Fetching models... settings.providers.customId=Provider ID settings.providers.customName=Display name settings.providers.customUrl=Base URL settings.providers.customEnv=API key environment variable settings.providers.customModels=Model IDs (comma-separated) +settings.providers.customModelsSelectAll=Select All +settings.providers.customModelsEmpty=No models found at this Base URL. settings.providers.customIdRequired=Provider ID is required. settings.providers.customUrlRequired=Base URL is required. +settings.providers.customUrlInvalid=Base URL must start with http:// or https://. +settings.providers.customModelsRequired=Add at least one model ID. +settings.providers.customNotUsable=Provider saved but has no usable models. Check the Base URL, API key, and model IDs, then try again. settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. settings.login.action=Open User Profile settings.models.defaultModel.title=Default Model diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 6ca7ad1b1b3..9a410a922d1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings.providers import ai.kilocode.client.app.KiloProviderService +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.SettingsListConfig import ai.kilocode.client.settings.base.SettingsListItem import ai.kilocode.client.settings.base.SettingsListRenderer @@ -12,7 +13,9 @@ import ai.kilocode.client.settings.base.settingsListVisibleCells import ai.kilocode.client.testing.FakeProviderRpcApi import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.CustomProviderConfigDto +import ai.kilocode.rpc.dto.CustomModelFetchResultDto import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderActionResultDto import ai.kilocode.rpc.dto.ProviderAuthMethodDto import ai.kilocode.rpc.dto.ProviderDisconnectDto import ai.kilocode.rpc.dto.ProviderMetadataDto @@ -20,6 +23,7 @@ import ai.kilocode.rpc.dto.ProviderOAuthReadyDto import ai.kilocode.rpc.dto.ProviderSettingsDto import ai.kilocode.rpc.dto.ProviderSettingsProviderDto import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ui.ValidationInfo import com.intellij.testFramework.replaceService import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.CollectionListModel @@ -71,6 +75,137 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } + fun `test custom save error surfaces backend error`() { + val result = ProviderActionResultDto(ProviderSettingsDto(), error = "boom") + assertEquals("boom", customSaveError("my-openai", result)) + } + + fun `test custom save error reports dropped provider`() { + val result = ProviderActionResultDto(ProviderSettingsDto()) + assertEquals(KiloBundle.message("settings.providers.customNotUsable"), customSaveError("my-openai", result)) + } + + fun `test custom save error passes when provider present`() { + val result = ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) + assertNull(customSaveError("my-openai", result)) + } + + fun `test custom model rows start with select all`() { + val rows = customModelRows(listOf("gpt-4o", "gpt-4o-mini")) + + assertTrue(rows[0].selectAll) + assertEquals(listOf("gpt-4o", "gpt-4o-mini"), rows.drop(1).map { it.id }) + } + + fun `test custom dialog add is disabled until model list exists`() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val dialog = edt { + val dialog = CustomProviderDialog( + cs, + "/tmp", + { CustomModelFetchResultDto(listOf("gpt-4o")) }, + { ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) }, + ) + val fields = components(center(dialog)).filterIsInstance() + fields[0].text = "my-openai" + fields[2].text = "https://example.com/v1" + dialog + } + + edt { + assertFalse(dialog.isOKActionEnabled) + components(center(dialog)).filterIsInstance()[5].text = "gpt-4o" + assertTrue(dialog.isOKActionEnabled) + components(center(dialog)).filterIsInstance()[5].text = "" + assertFalse(dialog.isOKActionEnabled) + dispose(dialog) + } + } + + fun `test custom dialog cancels model fetch and ignores late result`() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val gate = CompletableDeferred() + lateinit var pick: JButton + lateinit var field: JTextField + val dialog = edt { + val dialog = CustomProviderDialog( + cs, + "/tmp", + { gate.await() }, + { ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) }, + ) + val panel = center(dialog) + val fields = components(panel).filterIsInstance() + fields[0].text = "my-openai" + fields[2].text = "http://127.0.0.1:8080" + pick = components(panel).filterIsInstance().first() + field = fields[5] + pick.doClick() + dialog + } + + edt { + assertEquals(KiloBundle.message("settings.providers.customFetchingModels"), field.text) + assertEquals(KiloBundle.message("settings.providers.customCancelModels"), pick.text) + assertFalse(field.isEditable) + assertFalse(dialog.isOKActionEnabled) + assertNull(validation(dialog)) + + pick.doClick() + assertEquals("", field.text) + assertEquals(KiloBundle.message("settings.providers.customSelectModels"), pick.text) + assertTrue(field.isEditable) + } + + gate.complete(CustomModelFetchResultDto(listOf("gpt-4o"))) + flushUntil { edt { field.isEditable } } + + edt { + assertEquals("", field.text) + assertEquals(KiloBundle.message("settings.providers.customSelectModels"), pick.text) + assertFalse(dialog.isOKActionEnabled) + dispose(dialog) + } + } + + fun `test custom dialog save error stays until next add`() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val next = CompletableDeferred() + var calls = 0 + val dialog = edt { + val dialog = CustomProviderDialog( + cs, + "/tmp", + { CustomModelFetchResultDto(listOf("gpt-4o")) }, + { + calls++ + if (calls == 1) ProviderActionResultDto(ProviderSettingsDto(), error = "boom") else next.await() + }, + ) + val fields = components(center(dialog)).filterIsInstance() + fields[0].text = "my-openai" + fields[2].text = "https://example.com/v1" + fields[5].text = "gpt-4o" + submit(dialog) + dialog + } + + flushUntil { edt { validation(dialog) == "boom" } } + + edt { + assertEquals("boom", validation(dialog)) + submit(dialog) + assertNull(validation(dialog)) + } + + next.complete(ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI")))) + flushUntil { edt { dialog.outcome != null } } + edt { dispose(dialog) } + } + fun `test catalog provider without auth methods is connectable`() { val content = content() @@ -879,6 +1014,24 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { private fun fieldsByName(root: Container, name: String): List = components(root).filterIsInstance().filter { it.name == name } + private fun center(dialog: CustomProviderDialog) = call(dialog, "createCenterPanel") as JComponent + + private fun submit(dialog: CustomProviderDialog) { + call(dialog, "doOKAction") + } + + private fun validation(dialog: CustomProviderDialog) = (call(dialog, "doValidate") as ValidationInfo?)?.message + + private fun dispose(dialog: CustomProviderDialog) { + call(dialog, "dispose") + } + + private fun call(dialog: CustomProviderDialog, name: String): Any? { + val method = dialog.javaClass.getDeclaredMethod(name) + method.isAccessible = true + return method.invoke(dialog) + } + private fun center(rect: Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) private fun renderer(row: ProviderListRow) = SettingsListRenderer(CollectionListModel(listOf(row))) From d6b36a028cc0a4b7bfd158d75e287c110e2838f7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 14 Jul 2026 16:01:21 -0400 Subject: [PATCH 09/28] feat(jetbrains): edit custom providers and fix add dialog close Add an Edit action for editable OpenAI-compatible custom providers, prefill the dialog from stored config, keep added or edited providers selected in the list, and close the custom provider dialog on a verified save success. --- .changeset/jetbrains-custom-provider-edit.md | 5 + .../settings/providers/ProviderCatalog.kt | 3 + .../settings/providers/ProviderListRows.kt | 13 +- .../settings/providers/ProvidersSettingsUi.kt | 80 +++++++++-- .../resources/messages/KiloBundle.properties | 3 + .../providers/ProvidersSettingsUiTest.kt | 133 +++++++++++++++++- 6 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 .changeset/jetbrains-custom-provider-edit.md diff --git a/.changeset/jetbrains-custom-provider-edit.md b/.changeset/jetbrains-custom-provider-edit.md new file mode 100644 index 00000000000..dfe3afaafa8 --- /dev/null +++ b/.changeset/jetbrains-custom-provider-edit.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support editing custom OpenAI-compatible providers from JetBrains settings, keep added or edited providers selected, and close the custom provider dialog after a successful save. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt index bcf3573960d..268f70eeb46 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt @@ -97,3 +97,6 @@ internal fun hiddenProvider(provider: ProviderSettingsProviderDto) = provider.id internal fun configured(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, ids: Set) = provider.id in ids || provider.key != null || provider.source == "config" || provider.id in state.config + +internal fun customEditable(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto) = + state.config[provider.id]?.npm == CUSTOM_PROVIDER_PACKAGE diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt index ea356a4bf2b..016a43e153a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt @@ -12,6 +12,7 @@ import javax.swing.Icon internal enum class ProviderListAction { CONNECT, OAUTH, + EDIT, DISCONNECT, ENABLE, } @@ -38,7 +39,8 @@ internal data class ProviderListRow( action.name, providerListActionText(action), enabled(action), - alwaysVisible = action == ProviderListAction.DISCONNECT && connected, + alwaysVisible = (action == ProviderListAction.DISCONNECT || action == ProviderListAction.EDIT) && connected, + primary = action == ProviderListAction.EDIT, ) } @@ -48,6 +50,7 @@ internal data class ProviderListRow( internal fun providerListActionText(action: ProviderListAction) = when (action) { ProviderListAction.CONNECT -> KiloBundle.message("settings.providers.connect") ProviderListAction.OAUTH -> KiloBundle.message("settings.providers.oauth") + ProviderListAction.EDIT -> KiloBundle.message("settings.providers.edit") ProviderListAction.DISCONNECT -> KiloBundle.message("settings.providers.disconnect") ProviderListAction.ENABLE -> KiloBundle.message("settings.providers.enable") } @@ -87,7 +90,13 @@ internal fun providerActions( ): List { if (provider.id in disabled) return listOf(ProviderListAction.ENABLE) if (provider.id == KILO_PROVIDER_ID && configured(provider, state, state.connected.toSet())) return emptyList() - if (configured(provider, state, state.connected.toSet())) return listOf(ProviderListAction.DISCONNECT) + if (configured(provider, state, state.connected.toSet())) { + return if (customEditable(provider, state)) { + listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT) + } else { + listOf(ProviderListAction.DISCONNECT) + } + } val methods = providerMethods(provider, state) return buildList { if (methods.any { it.type == "oauth" }) add(ProviderListAction.OAUTH) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt index 882d9336b80..93b328c2af1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.BaseContentPanel import ai.kilocode.client.settings.base.SettingsPanel import ai.kilocode.client.settings.base.SettingsListConfig +import ai.kilocode.client.settings.base.SettingsListSelection import ai.kilocode.client.settings.base.SettingsToolbarAction import ai.kilocode.client.settings.base.SettingsListView import ai.kilocode.client.settings.auth.DeviceOAuthInfo @@ -193,7 +194,7 @@ internal class ProvidersSettingsUi( AllIcons.Actions.Refresh, { !busy }, ) { reload() } - private val view = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable) + private val view = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable, ::edit) private val search = SearchTextField(false).apply { textEditor.emptyText.text = KiloBundle.message("settings.providers.search") } @@ -314,18 +315,40 @@ internal class ProvidersSettingsUi( @RequiresEdt private fun custom() { checkEdt() - // The dialog performs the save itself so failures can be shown inline and the user can - // correct their input without re-typing. It only closes on a verified success. + openCustomDialog(null) + } + + @RequiresEdt + private fun edit(provider: ProviderSettingsProviderDto) { + checkEdt() + val cfg = state.config[provider.id] ?: return + openCustomDialog( + CustomProviderEdit( + id = provider.id, + name = cfg.name ?: provider.name, + baseUrl = cfg.options["baseURL"].orEmpty(), + envVar = cfg.env.firstOrNull(), + models = cfg.models.values.map { it.id }, + ), + ) + } + + // The dialog performs the save itself so failures can be shown inline and the user can + // correct their input without re-typing. It only closes on a verified success. + @RequiresEdt + private fun openCustomDialog(existing: CustomProviderEdit?) { + checkEdt() val dialog = CustomProviderDialog( cs, directory, { service().fetchCustomModels(it) }, { service().saveCustom(it) }, + existing, ) if (!dialog.showAndGet()) return val next = dialog.outcome ?: return state = next - view.update(next) + view.update(next, dialog.savedId) clearProgress() } @@ -522,6 +545,7 @@ internal class ProvidersContent( private val oauth: (ProviderSettingsProviderDto) -> Unit, private val disconnect: (ProviderSettingsProviderDto) -> Unit, private val enable: (ProviderSettingsProviderDto) -> Unit, + private val edit: (ProviderSettingsProviderDto) -> Unit, ) : BaseContentPanel() { private val view = SettingsListView(KiloBundle.message("settings.providers.noMatches"), SettingsListConfig.Preferred) { key, id -> activate(key, id) @@ -534,13 +558,13 @@ internal class ProvidersContent( } @RequiresEdt - fun update(state: ProviderSettingsDto) { + fun update(state: ProviderSettingsDto, select: String? = null) { checkEdt() val notes = state.providers.count { providerDescription(it).isNotBlank() } ProvidersSettingsUi.LOG.info("provider settings content update: start providers=${state.providers.size} connected=${state.connected.size} disabled=${state.disabled.size} descriptions=$notes") this.state = state val rows = providerListRows(state, "", disabledRows = busy) - view.update(rows) + if (select != null) view.update(rows, SettingsListSelection.Key(select)) else view.update(rows) ProvidersSettingsUi.LOG.info("provider settings content update: completed rows=${rows.size}") } @@ -582,6 +606,7 @@ internal class ProvidersContent( ProviderListAction.OAUTH -> oauth(row.provider) ProviderListAction.DISCONNECT -> disconnect(row.provider) ProviderListAction.ENABLE -> enable(row.provider) + ProviderListAction.EDIT -> edit(row.provider) } } @@ -642,11 +667,20 @@ private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : Dial } } +internal data class CustomProviderEdit( + val id: String, + val name: String, + val baseUrl: String, + val envVar: String?, + val models: List, +) + internal class CustomProviderDialog( private val cs: CoroutineScope, private val directory: String, private val fetch: suspend (CustomModelFetchDto) -> CustomModelFetchResultDto, private val save: suspend (CustomProviderSaveDto) -> ProviderActionResultDto, + private val existing: CustomProviderEdit? = null, ) : DialogWrapper(true) { private val id = JBTextField() private val name = JBTextField() @@ -668,11 +702,23 @@ internal class CustomProviderDialog( var outcome: ProviderSettingsDto? = null private set + // Id of the provider the save persisted; used to select the row after the dialog closes. + var savedId: String? = null + private set + init { - title = KiloBundle.message("settings.providers.customTitle") - setOKButtonText(KiloBundle.message("settings.providers.customAdd")) + title = if (existing != null) { + KiloBundle.message("settings.providers.customEditTitle") + } else { + KiloBundle.message("settings.providers.customTitle") + } + setOKButtonText( + if (existing != null) KiloBundle.message("settings.providers.customSave") + else KiloBundle.message("settings.providers.customAdd"), + ) init() initValidation() + existing?.let { prefill(it) } models.document.addDocumentListener(object : DocumentAdapter() { override fun textChanged(e: DocumentEvent) { syncActions() @@ -685,6 +731,17 @@ internal class CustomProviderDialog( syncActions() } + @RequiresEdt + private fun prefill(edit: CustomProviderEdit) { + checkEdt() + id.text = edit.id + id.isEditable = false + name.text = edit.name + url.text = edit.baseUrl + env.text = edit.envVar.orEmpty() + models.text = edit.models.joinToString(", ") + } + @RequiresEdt private fun input() = CustomProviderSaveDto( directory = directory, @@ -766,7 +823,10 @@ internal class CustomProviderDialog( } ProvidersSettingsUi.LOG.info("custom provider add: save succeeded id='${input.id}', closing dialog") outcome = result.state - closeOk() + savedId = input.id + saving = false + syncActions() + close(OK_EXIT_CODE) } } } @@ -963,8 +1023,6 @@ internal class CustomProviderDialog( } } - private fun closeOk() = super.doOKAction() - override fun dispose() { active = false token++ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 41af43c6771..320c7d5af16 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -454,6 +454,7 @@ settings.providers.oauth.starting=Starting OAuth for {0}... settings.providers.oauth.waitingTimed=Waiting for authorization... ({0}) settings.providers.oauth.cancel=Cancel settings.providers.disconnect=Disconnect +settings.providers.edit=Edit settings.providers.enable=Enable settings.providers.badge.env=env settings.providers.note.kilo=Access 500+ AI models @@ -469,6 +470,8 @@ settings.providers.apiKey=API key settings.providers.apiKeyRequired=API key is required. settings.providers.customTitle=Custom OpenAI-Compatible Provider settings.providers.customAdd=Add +settings.providers.customEditTitle=Edit OpenAI-Compatible Provider +settings.providers.customSave=Save settings.providers.customSelectModels=Select models settings.providers.customCancelModels=Cancel settings.providers.customFetchingModels=Fetching models... diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 9a410a922d1..fb0817bb8dc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -12,8 +12,8 @@ import ai.kilocode.client.settings.base.settingsListSectionTitle import ai.kilocode.client.settings.base.settingsListVisibleCells import ai.kilocode.client.testing.FakeProviderRpcApi import ai.kilocode.client.ui.UiStyle -import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.CustomModelFetchResultDto +import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderActionResultDto import ai.kilocode.rpc.dto.ProviderAuthMethodDto @@ -206,6 +206,60 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { edt { dispose(dialog) } } + fun `test custom dialog closes on save success`() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val dialog = edt { + val dialog = CustomProviderDialog( + cs, + "/tmp", + { CustomModelFetchResultDto(listOf("gpt-4o")) }, + { ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) }, + ) + val fields = components(center(dialog)).filterIsInstance() + fields[0].text = "my-openai" + fields[2].text = "https://example.com/v1" + fields[5].text = "gpt-4o" + submit(dialog) + dialog + } + + flushUntil { edt { dialog.outcome != null } } + + edt { + assertEquals("my-openai", dialog.savedId) + assertTrue(dialog.isOKActionEnabled) + dispose(dialog) + } + } + + fun `test custom dialog edit prefill locks id and leaves key blank`() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val dialog = edt { + CustomProviderDialog( + cs, + "/tmp", + { CustomModelFetchResultDto(listOf("gpt-4o")) }, + { ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) }, + CustomProviderEdit("my-openai", "My OpenAI", "https://example.com/v1", null, listOf("gpt-4o")), + ) + } + + edt { + val fields = components(center(dialog)).filterIsInstance() + assertEquals("my-openai", fields[0].text) + assertFalse(fields[0].isEditable) + assertEquals("My OpenAI", fields[1].text) + assertEquals("https://example.com/v1", fields[2].text) + assertEquals("", fields[3].text) + assertEquals("", fields[4].text) + assertEquals("gpt-4o", fields[5].text) + assertEquals(KiloBundle.message("settings.providers.customSave"), dialog.okText()) + dispose(dialog) + } + } + fun `test catalog provider without auth methods is connectable`() { val content = content() @@ -303,7 +357,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } - fun `test configured custom provider exposes only disconnect`() { + fun `test configured custom provider exposes edit and disconnect`() { val content = content() edt { @@ -318,11 +372,35 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { edt { val row = rows(content).single() - assertEquals(listOf(ProviderListAction.DISCONNECT), row.actions) + assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT), row.actions) assertTrue(row.badges.isEmpty()) } } + fun `test editable custom provider edit cell is primary`() { + val state = ProviderSettingsDto( + providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")), + config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = CUSTOM_PROVIDER_PACKAGE)), + ) + + val row = providerListRows(state, "").single() + + assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT), row.actions) + assertTrue(row.cells.first { it.id == "EDIT" }.primary) + } + + fun `test connected non custom provider still exposes only disconnect`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf(provider("anthropic", "Anthropic")), + connected = listOf("anthropic"), + ), + "", + ) + + assertEquals(listOf(ProviderListAction.DISCONNECT), rows.single().actions) + } + fun `test custom providers have no badge while env providers keep env badge`() { val rows = providerListRows( ProviderSettingsDto( @@ -409,7 +487,46 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { assertEquals("Connected providers", settingsListSectionTitle(rows, 0)) assertEquals("Popular providers", settingsListSectionTitle(rows, 1)) assertEquals("All providers", settingsListSectionTitle(rows, 2)) - assertEquals(listOf(ProviderListAction.DISCONNECT), rows[0].actions) + assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT), rows[0].actions) + } + + fun `test edit callback fires instead of disconnect on primary`() { + var edited: ProviderSettingsProviderDto? = null + var disconnected: ProviderSettingsProviderDto? = null + val content = edt { ProvidersContent({}, {}, { disconnected = it }, {}, { edited = it }) } + val state = ProviderSettingsDto( + providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")), + config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = CUSTOM_PROVIDER_PACKAGE)), + ) + + edt { + content.update(state) + triggerPrimary(content) + } + + assertEquals("local-openai", edited?.id) + assertNull(disconnected) + } + + fun `test provider content update selects saved provider`() { + val content = content() + val state = ProviderSettingsDto( + providers = listOf( + provider("aaa-openai", "AAA OpenAI", source = "custom"), + provider("local-openai", "Local OpenAI", source = "custom"), + ), + config = mapOf( + "aaa-openai" to CustomProviderConfigDto("aaa-openai", npm = CUSTOM_PROVIDER_PACKAGE), + "local-openai" to CustomProviderConfigDto("local-openai", npm = CUSTOM_PROVIDER_PACKAGE), + ), + ) + + edt { content.update(state, select = "local-openai") } + + edt { + assertEquals(listOf("aaa-openai", "local-openai"), rows(content).map { it.key }) + assertEquals("local-openai", list(content).selectedValue.key) + } } fun `test unconfigured openai compatible template provider is hidden`() { @@ -962,7 +1079,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { edt { assertTrue(rows(panel).isEmpty()) } } - private fun content() = edt { ProvidersContent({}, {}, {}, {}) } + private fun content() = edt { ProvidersContent({}, {}, {}, {}, {}) } private fun content(panel: ProvidersSettingsUi) = components(panel).filterIsInstance().single() @@ -1026,6 +1143,12 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { call(dialog, "dispose") } + private fun CustomProviderDialog.okText(): String { + val method = com.intellij.openapi.ui.DialogWrapper::class.java.getDeclaredMethod("getOKAction") + method.isAccessible = true + return (method.invoke(this) as javax.swing.Action).getValue(javax.swing.Action.NAME) as String + } + private fun call(dialog: CustomProviderDialog, name: String): Any? { val method = dialog.javaClass.getDeclaredMethod(name) method.isAccessible = true From b706b4d3b7134db50eba48ec6c001ba77ed3449a Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Wed, 15 Jul 2026 03:49:14 +0200 Subject: [PATCH 10/28] chore: retrigger CI From a4d15a58d5d5ba04993eaaba276d42b7790f6d24 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Wed, 15 Jul 2026 04:07:55 +0200 Subject: [PATCH 11/28] fix(vscode): skip posting toggleChatSearch when target webview is not ready --- .../code-actions/register-code-actions.ts | 2 +- .../tests/unit/register-code-actions.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts index e7417e11169..e0b9a77a89e 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts @@ -111,7 +111,7 @@ export function registerCodeActions( // webview closes the search bar itself if it's already open. vscode.commands.registerCommand("kilo-code.new.toggleChatSearch", async () => { const view = target() - await revealTarget(view) + if (!(await revealTarget(view))) return view.postMessage({ type: "action", action: "focusSearch" }) }), ) diff --git a/packages/kilo-vscode/tests/unit/register-code-actions.test.ts b/packages/kilo-vscode/tests/unit/register-code-actions.test.ts index cc5c812daa5..8ae27652562 100644 --- a/packages/kilo-vscode/tests/unit/register-code-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/register-code-actions.test.ts @@ -127,4 +127,22 @@ describe("registerCodeActions", () => { expect(state.events).toEqual(["wait"]) expect(state.posts).toEqual([]) }) + + it("toggles chat search on the active Agent Manager once it is ready", async () => { + const state = setup(true) + + await state.commands.get("kilo-code.new.toggleChatSearch")?.() + + expect(state.events).toEqual(["wait", "post"]) + expect(state.posts).toEqual([{ type: "action", action: "focusSearch" }]) + }) + + it("does not toggle chat search when Agent Manager readiness is cancelled", async () => { + const state = setup(true, false) + + await state.commands.get("kilo-code.new.toggleChatSearch")?.() + + expect(state.events).toEqual(["wait"]) + expect(state.posts).toEqual([]) + }) }) From 5c05b86a7fab511ddf1e9294dd7523d88f99f2b0 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Wed, 15 Jul 2026 14:29:12 +0200 Subject: [PATCH 12/28] chore: retrigger CI From e4ceeaebb911a7350b9aaa7851aa39293c0892f8 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 15 Jul 2026 18:49:59 +0200 Subject: [PATCH 13/28] fix(cli): bound background process probes --- .changeset/tidy-process-probes.md | 5 +++++ packages/opencode/src/kilocode/background-process/index.ts | 2 ++ packages/opencode/src/kilocode/background-process/runner.ts | 1 + 3 files changed, 8 insertions(+) create mode 100644 .changeset/tidy-process-probes.md diff --git a/.changeset/tidy-process-probes.md b/.changeset/tidy-process-probes.md new file mode 100644 index 00000000000..544ed48a4db --- /dev/null +++ b/.changeset/tidy-process-probes.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent stalled operating system process queries from blocking background process management. diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index 0dbb25f6685..7f7b41191ba 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -653,6 +653,7 @@ export namespace BackgroundProcess { if (!pid || !token) return "unknown" const out = await Process.text(["ps", "eww", "-axo", "pid=,pgid=,command="], { nothrow: true, + abort: AbortSignal.timeout(2_000), timeout: 2_000, }) if (out.code !== 0) return "unknown" @@ -672,6 +673,7 @@ export namespace BackgroundProcess { const query = `$p=Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { [Console]::Out.Write($p.CommandLine) }` const out = await Process.text(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query], { nothrow: true, + abort: AbortSignal.timeout(2_000), timeout: 2_000, }) if (out.code !== 0) return "unknown" diff --git a/packages/opencode/src/kilocode/background-process/runner.ts b/packages/opencode/src/kilocode/background-process/runner.ts index fe8488158d5..d53dab24925 100644 --- a/packages/opencode/src/kilocode/background-process/runner.ts +++ b/packages/opencode/src/kilocode/background-process/runner.ts @@ -97,6 +97,7 @@ export namespace BackgroundProcessRunner { "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate | ConvertTo-Json -Compress" const out = await Process.text(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query], { nothrow: true, + abort: AbortSignal.timeout(2_000), timeout: 2_000, }) if (out.code !== 0 || !out.text.trim()) return seen From fe4142630c7dddf19e81b2f3363e06b4aba8194a Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:14:55 +0000 Subject: [PATCH 14/28] fix(cli): add object type to agent manager schema Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/calm-tools-speak.md | 5 +++++ packages/opencode/src/kilocode/tool/agent-manager.ts | 2 ++ .../opencode/test/kilocode/agent-manager-tool.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+) create mode 100644 .changeset/calm-tools-speak.md diff --git a/.changeset/calm-tools-speak.md b/.changeset/calm-tools-speak.md new file mode 100644 index 00000000000..3d6dd503da5 --- /dev/null +++ b/.changeset/calm-tools-speak.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix Agent Manager tool calls through providers that require object-root input schemas. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 38812128cde..6b68276eae8 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -8,6 +8,7 @@ import * as SandboxInheritance from "@/kilocode/sandbox/inheritance" import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" import { Provider } from "@/provider/provider" import { SessionID } from "@/session/schema" +import * as ToolJsonSchema from "@/tool/json-schema" import { Tool } from "@/tool/tool" import { Effect, Schema } from "effect" import { matchesQuery } from "./model-search" @@ -237,6 +238,7 @@ export const AgentManagerTool = Tool.define< return { description: DESCRIPTION, parameters: Params, + jsonSchema: { ...ToolJsonSchema.fromSchema(Params), type: "object" }, execute: (params, ctx) => Effect.gen(function* () { if ("action" in params) { diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 3db754f68e8..32c668980d1 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -8,6 +8,7 @@ import { AgentManagerEvent, type AgentManagerStart } from "../../src/kilocode/ag import { AgentManager } from "../../src/kilocode/agent-manager/service" import { Bus } from "../../src/bus" import { Tool } from "../../src/tool/tool" +import * as ToolJsonSchema from "../../src/tool/json-schema" import { Truncate } from "../../src/tool/truncate" import { Agent } from "../../src/agent/agent" import { Provider } from "../../src/provider/provider" @@ -149,6 +150,14 @@ function publish( } describe("agent_manager tool", () => { + test("uses an object-root input schema", async () => { + const tool = await init() + const schema = ToolJsonSchema.fromTool(tool) + + expect(schema.type).toBe("object") + expect(schema.anyOf).toHaveLength(3) + }) + test("asks for agent_manager permission", async () => { const tool = await init() const calls: unknown[] = [] From a76dc773805d6cdb67e66a0533b39f44487bf742 Mon Sep 17 00:00:00 2001 From: sylwester-liljegren Date: Wed, 15 Jul 2026 19:42:04 +0200 Subject: [PATCH 15/28] fix(vscode): support filenames with spaces and unicode in @mentions (#11977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vscode): support filenames with spaces and unicode in @mentions Two bugs prevented @mentions from working correctly when filenames contained spaces or non-ASCII characters: 1. Sent-message highlighting broke for paths with spaces. buildFileAttachments did not set source.text position data on file attachments, so the renderer fell back to MENTION_RE which uses [\w./-] and stops at whitespace. Now source.text is computed via text.indexOf and included in the attachment. The renderer's resolve() path uses source.value with indexOf, which handles spaces and any Unicode correctly. 2. Server read failed with 'File not found' for paths with spaces. VS Code's webview (Chromium) does not percent-encode spaces when setting url.pathname on a file:// URL. The resulting literal space in url.href caused Bun's fileURLToPath to truncate the resolved path at the first space. Spaces are now pre-encoded as %20 before assignment. Additionally, MENTION_RE in message-highlight.ts is updated to match filenames that include space-separated segments (e.g. 'org data.xlsx') as a fallback for messages that pre-date this fix and therefore lack source.text data. * chore: add changeset for file mention spaces/unicode fix * fix(vscode): address review feedback on mention highlighting Two issues flagged by review: 1. The broadened MENTION_RE fallback regex over-matched ordinary prose. A pattern permissive enough to span space-separated path segments also swallows text following any unrelated @mention up to the next dotted/versioned token (e.g. '@agent check report for v1.2 details' highlighted the entire span through 'v1.2'). Reverted MENTION_RE to its original conservative form; the primary fix for space/unicode filenames does not depend on it; it now relies on source.text data computed in buildFileAttachments and resolved via exact text search. 2. Repeated mentions of the same path in one message only highlighted the first occurrence. mentionedPaths is a Set, so buildFileAttachments produces exactly one ref per unique path regardless of how many times it appears in the text. Once that ref carries source.text, resolve() was used instead of the old regex-based detect() fallback, which used to independently highlight every occurrence via a global regex scan. resolve() now also searches the remaining text for exact repeats of each ref's resolved mention value, so every occurrence of a path stays highlighted, not just the first. Added tests covering: over-matching prevention, repeated plain mentions, and repeated mentions containing a space. * fix(vscode): require boundary match for repeated mention detection The repeat-mention search added in 44bea9196e used a plain substring indexOf, which let a shorter mention's value match as a literal prefix of a longer, distinct mention that starts the same way (e.g. '@a.ts' matches inside '@a.tsx'). This truncated the longer mention's highlight to just its prefix and dropped its own ref from being located. Repeats are now only accepted when flanked by whitespace or a string edge on both sides, matching the same token-boundary convention already used elsewhere (syncMentionedPaths' (?:^|\\s)@path(?:\\s|$) pattern). Added a regression test for the exact a.ts/a.tsx collision case. * fix(vscode): relax repeat-mention boundary check to allow punctuation The whitespace-only boundary check added in 55c58791f0 to prevent '@a.ts' from falsely matching inside '@a.tsx' was stricter than necessary: it required literal whitespace on both sides, so a repeated mention directly followed by ordinary punctuation (a trailing comma, sentence-ending period, or closing paren) silently lost its highlight. Replaced the whitespace check with a path-continuation check matching MENTION_RE's own character class (word chars, dot, slash, hyphen). A repeat is now rejected only when the adjacent character could extend it into a longer, different path. The dot is handled with one character of lookahead: it counts as a continuation only when another word character follows (e.g. '@report.csv' + '.bak'), so a lone trailing sentence period does not block highlighting, while a genuine compound-extension collision still correctly does. Added tests for: repeat followed by a comma, a sentence-ending period, a closing paren, and a compound-extension collision that must still be rejected (@report.csv inside @report.csv.bak). * fix(vscode): make repeat-mention boundary check Unicode-aware PATH_CONTINUATION and continuesPath used \w, which in JavaScript regex matches ASCII letters/digits only. A Cyrillic or CJK mention (e.g. '@файл') was therefore not recognized as continuing into a longer, distinct mention that starts the same way (e.g. '@файлы'), silently reintroducing the same collision the check exists to prevent (the @a.ts/@a.tsx case) specifically for the non-ASCII paths this PR is meant to support. Replaced \w with Unicode property escapes (\p{L}, \p{N} with the u flag), which correctly recognize any Unicode letter or digit as a continuation character, not just ASCII ones. Added regression tests for a Cyrillic prefix collision (@файл inside @файлы) and a CJK one (@文件 inside @文件夹), both verified to fail against the prior ASCII-only implementation. * fix(vscode): fix mention truncation when reverting and resending Reverting to a message with a space-containing @mention, then resending the same text, reproduced the 'File not found' truncation error even after the buildFileAttachments/URL-encoding fix, because the revert path uses a different, unrelated mechanism to reconstruct known mention paths. Revert restores the original message's text into the input via a setChatBoxMessage window message, which PromptInput handles by calling seedFromText(text). seedFromText re-derives candidate mention paths from raw text with a regex (the same conservative, space-free pattern as MENTION_RE) — for '@mention-test/my quarterly report.txt' its slash alternative matches only 'mention-test/my', stopping at the first space. That truncated candidate is then added to knownPaths and, critically, passes syncMentionedPaths' own boundary check too: a real space genuinely follows 'my' in the full filename, so the truncated candidate looks like a complete, validly-bounded mention from the regex's perspective. The truncated path then ends up in mentionedPaths alongside the real one, and buildFileAttachments builds a second, broken attachment for it, producing the same truncated 'File not found: ...\mention-test\my' error alongside a correct read of the real file. Fix: revertSession() now also extracts the reverted message's file parts' exact source.path values (recorded by the earlier buildFileAttachments fix) and sends them alongside the restored text. PromptInput's setChatBoxMessage handler seeds these exact paths directly via a new seedFromParts() function instead of falling back to seedFromText's regex re-derivation, when they're available. seedFromParts skips the flawed candidate-discovery step entirely and just prunes the known-good paths against the current text, so it can't reintroduce the truncation. seedFromText itself is left unchanged and still used as a fallback for callers without exact path data (native undo restoring a draft, or messages sent before source.path existed) — broadening its regex to handle spaces was already tried and reverted elsewhere in this PR because it causes ordinary prose to be over-matched. Added tests: a regression test locking in seedFromText's known limitation (so future refactors don't silently 'fix' it in a way that goes unnoticed), and coverage for seedFromParts handling a space-containing path correctly, pruning stale paths, and seeding multiple paths. * fix(vscode): address review feedback on mention repeats and percent-encoding - Rework message-highlight.ts's resolve()/repeats() to locate each ref independently and track claimed ranges, instead of threading a single moving cursor through every ref. Fixes an interleaved-mentions bug where locating a later repeat of one mention (e.g. the second `@a.ts` in `@a.ts @b.ts @a.ts`) could advance past and hide a distinct mention (`@b.ts`) that sits between the repeats. - Reject a repeat match when it is a literal prefix of another known ref's exact mention text, not just when the following character looks like a generic path-continuation character. A trailing space can no longer be assumed to end a mention now that paths may contain spaces, so `@a.txt @a.txt backup.txt` no longer truncates the second, longer mention. - Escape literal percent characters (in addition to spaces) before assigning to the file:// URL's pathname in buildFileAttachments, so a real filename like "100%20real.txt" round-trips correctly instead of being decoded as "100 real.txt" server-side. * fix(vscode): fix stale-path collision in syncMentionedPaths for space-containing paths syncMentionedPaths tested each known path independently with a boundary regex that treated any whitespace after "@path" as proof the mention ends there. That assumption broke once paths can contain spaces: a stale, unrelated "a.txt" from an earlier mention would incorrectly survive pruning whenever the current text also mentions the longer, distinct "a.txt backup.txt", since a real space genuinely follows "a.txt" as part of that longer path. The stale path would then get re-attached via buildFileAttachments, silently reading the wrong file's contents. Track already-accepted (longest-first) matches and reject a candidate occurrence when it's a literal prefix of one of them at the same text position, mirroring the same fix already applied to message-highlight.ts's repeat-mention detection. --------- Co-authored-by: Sylwester Liljegren --- .changeset/file-mention-spaces-unicode.md | 5 + .../src/components/message-highlight.test.ts | 321 ++++++++++++++++++ .../src/components/message-highlight.ts | 126 ++++++- .../tests/unit/file-mention-utils.test.ts | 102 ++++++ .../tests/unit/use-file-mention.test.ts | 86 +++++ .../src/components/chat/PromptInput.tsx | 7 +- .../webview-ui/src/context/session.tsx | 10 +- .../src/hooks/file-mention-utils.ts | 64 +++- .../webview-ui/src/hooks/useFileMention.ts | 21 ++ .../src/types/messages/extension-messages.ts | 8 + 10 files changed, 724 insertions(+), 26 deletions(-) create mode 100644 .changeset/file-mention-spaces-unicode.md diff --git a/.changeset/file-mention-spaces-unicode.md b/.changeset/file-mention-spaces-unicode.md new file mode 100644 index 00000000000..da2b09b6410 --- /dev/null +++ b/.changeset/file-mention-spaces-unicode.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix file @mentions for filenames containing spaces or non-ASCII characters. Such mentions now stay fully highlighted in the sent message and are read correctly instead of failing with "File not found". diff --git a/packages/kilo-ui/src/components/message-highlight.test.ts b/packages/kilo-ui/src/components/message-highlight.test.ts index c752b412efa..a30335c7b3b 100644 --- a/packages/kilo-ui/src/components/message-highlight.test.ts +++ b/packages/kilo-ui/src/components/message-highlight.test.ts @@ -80,4 +80,325 @@ describe("message highlight", () => { { text: "@src/index.ts", type: "file" }, ]) }) + + test("highlights filename with a space when source offsets are provided", () => { + const text = "check @org data.xlsx now" + const segments = buildHighlightedTextSegments( + text, + [ + { + source: { + type: "file", + path: "org data.xlsx", + text: { value: "@org data.xlsx", start: 6, end: 20 }, + }, + }, + ], + [], + ) + + expect(segments).toEqual([ + { text: "check " }, + { text: "@org data.xlsx", type: "file" }, + { text: " now" }, + ]) + }) + + test("fallback regex does not match filenames containing spaces (requires source offsets)", () => { + // The fallback regex intentionally excludes spaces: a pattern permissive enough to + // span space-separated path segments would also swallow ordinary prose following + // any unrelated @mention. Highlighting space-containing paths relies on source.text. + const text = "check @org data.xlsx now" + expect(buildHighlightedTextSegments(text, [], [])).toEqual([{ text }]) + }) + + test("fallback regex does not over-match ordinary prose following an unrelated @mention", () => { + const text = "@code-reviewer check the report for v1.2 details" + expect(buildHighlightedTextSegments(text, [], [])).toEqual([{ text }]) + }) + + test("fallback regex still detects a plain mention without swallowing trailing prose", () => { + const text = "see @src/index.ts for v1.2 details" + expect(buildHighlightedTextSegments(text, [], [])).toEqual([ + { text: "see " }, + { text: "@src/index.ts", type: "file" }, + { text: " for v1.2 details" }, + ]) + }) + + test("highlights every repeated occurrence of a plain mention when only one ref exists", () => { + // mentionedPaths is a Set, so buildFileAttachments only ever produces one + // ref per unique path even when it's mentioned twice in the same message. + const text = "compare @src/a.ts with @src/a.ts" + const segments = buildHighlightedTextSegments( + text, + [{ source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 8, end: 17 } } }], + [], + ) + + expect(segments).toEqual([ + { text: "compare " }, + { text: "@src/a.ts", type: "file" }, + { text: " with " }, + { text: "@src/a.ts", type: "file" }, + ]) + }) + + test("does not let a shorter mention's repeat-search truncate a longer mention that starts the same way", () => { + // "@a.ts" is a literal prefix of "@a.tsx". A naive repeat-search for the + // first ref's value would match inside the second, distinct mention and + // drop its highlight (or worse, wrongly highlight only part of it). + const text = "see @a.ts and also @a.tsx" + const segments = buildHighlightedTextSegments( + text, + [ + { source: { type: "file", path: "a.ts", text: { value: "@a.ts", start: 4, end: 9 } } }, + { source: { type: "file", path: "a.tsx", text: { value: "@a.tsx", start: 19, end: 25 } } }, + ], + [], + ) + + expect(segments).toEqual([ + { text: "see " }, + { text: "@a.ts", type: "file" }, + { text: " and also " }, + { text: "@a.tsx", type: "file" }, + ]) + }) + + test("highlights a repeated mention directly followed by a comma", () => { + const text = "check @file.ts, then @file.ts, done" + const segments = buildHighlightedTextSegments( + text, + [{ source: { type: "file", path: "file.ts", text: { value: "@file.ts", start: 6, end: 14 } } }], + [], + ) + + expect(segments).toEqual([ + { text: "check " }, + { text: "@file.ts", type: "file" }, + { text: ", then " }, + { text: "@file.ts", type: "file" }, + { text: ", done" }, + ]) + }) + + test("highlights a repeated mention directly followed by a sentence-ending period", () => { + const text = "check @file.ts, then @file.ts." + const segments = buildHighlightedTextSegments( + text, + [{ source: { type: "file", path: "file.ts", text: { value: "@file.ts", start: 6, end: 14 } } }], + [], + ) + + expect(segments).toEqual([ + { text: "check " }, + { text: "@file.ts", type: "file" }, + { text: ", then " }, + { text: "@file.ts", type: "file" }, + { text: "." }, + ]) + }) + + test("highlights a repeated mention directly followed by a closing paren", () => { + const text = "see (@a.ts) and (@a.ts) again" + const segments = buildHighlightedTextSegments( + text, + [{ source: { type: "file", path: "a.ts", text: { value: "@a.ts", start: 5, end: 10 } } }], + [], + ) + + expect(segments).toEqual([ + { text: "see (" }, + { text: "@a.ts", type: "file" }, + { text: ") and (" }, + { text: "@a.ts", type: "file" }, + { text: ") again" }, + ]) + }) + + test("still rejects a repeat that is actually a prefix of a longer compound extension", () => { + // "@report.csv" is a literal prefix of "@report.csv.bak" — the second + // dot is followed by a word character, so it counts as a continuation + // and the shorter mention must not falsely match inside the longer one. + const text = "compare @report.csv with backup @report.csv.bak" + const segments = buildHighlightedTextSegments( + text, + [{ source: { type: "file", path: "report.csv", text: { value: "@report.csv", start: 8, end: 19 } } }], + [], + ) + + expect(segments).toEqual([{ text: "compare " }, { text: "@report.csv", type: "file" }, { text: " with backup @report.csv.bak" }]) + }) + + test("does not let a shorter Cyrillic mention's repeat-search collide with a longer, distinct one", () => { + // "@файл" is a literal prefix of "@файлы" (Cyrillic "ы" appended), the + // same structural collision as "@a.ts" inside "@a.tsx" but for a + // non-ASCII continuation character that \w alone would not recognize. + const text = "open @файл and also @файлы" + const segments = buildHighlightedTextSegments( + text, + [ + { source: { type: "file", path: "файл", text: { value: "@файл", start: 5, end: 10 } } }, + { source: { type: "file", path: "файлы", text: { value: "@файлы", start: 20, end: 26 } } }, + ], + [], + ) + + expect(segments).toEqual([ + { text: "open " }, + { text: "@файл", type: "file" }, + { text: " and also " }, + { text: "@файлы", type: "file" }, + ]) + }) + + test("does not let a shorter CJK mention's repeat-search collide with a longer, distinct one", () => { + // "@文件" is a literal prefix of "@文件夹" ("夹" appended). + const text = "check @文件 and also @文件夹 folder" + const segments = buildHighlightedTextSegments( + text, + [ + { source: { type: "file", path: "文件", text: { value: "@文件", start: 6, end: 9 } } }, + { source: { type: "file", path: "文件夹", text: { value: "@文件夹", start: 19, end: 23 } } }, + ], + [], + ) + + expect(segments).toEqual([ + { text: "check " }, + { text: "@文件", type: "file" }, + { text: " and also " }, + { text: "@文件夹", type: "file" }, + { text: " folder" }, + ]) + }) + + test("highlights every repeated occurrence of a mention containing a space when only one ref exists", () => { + const text = "a @dup name.ts b @dup name.ts c" + const segments = buildHighlightedTextSegments( + text, + [{ source: { type: "file", path: "dup name.ts", text: { value: "@dup name.ts", start: 2, end: 14 } } }], + [], + ) + + expect(segments).toEqual([ + { text: "a " }, + { text: "@dup name.ts", type: "file" }, + { text: " b " }, + { text: "@dup name.ts", type: "file" }, + { text: " c" }, + ]) + }) + + test("highlights Cyrillic filename when source offsets are provided", () => { + const text = "open @файл.txt please" + const segments = buildHighlightedTextSegments( + text, + [ + { + source: { + type: "file", + path: "файл.txt", + text: { value: "@файл.txt", start: 5, end: 14 }, + }, + }, + ], + [], + ) + + expect(segments).toEqual([{ text: "open " }, { text: "@файл.txt", type: "file" }, { text: " please" }]) + }) + + test("highlights Chinese filename when source offsets are provided", () => { + const text = "read @文件.txt" + const segments = buildHighlightedTextSegments( + text, + [ + { + source: { + type: "file", + path: "文件.txt", + text: { value: "@文件.txt", start: 5, end: 11 }, + }, + }, + ], + [], + ) + + expect(segments).toEqual([{ text: "read " }, { text: "@文件.txt", type: "file" }]) + }) + + test("does not let a repeated mention hide a distinct mention that sits between the repeats", () => { + // Regression: for "@a.ts @b.ts @a.ts", locating the first "@a.ts" ref must + // not swallow "@b.ts" while scanning forward for the second "@a.ts" repeat. + const text = "@a.ts @b.ts @a.ts" + const segments = buildHighlightedTextSegments( + text, + [ + { source: { type: "file", path: "a.ts", text: { value: "@a.ts", start: 0, end: 5 } } }, + { source: { type: "file", path: "b.ts", text: { value: "@b.ts", start: 6, end: 11 } } }, + ], + [], + ) + + expect(segments).toEqual([ + { text: "@a.ts", type: "file" }, + { text: " " }, + { text: "@b.ts", type: "file" }, + { text: " " }, + { text: "@a.ts", type: "file" }, + ]) + }) + + test("does not let a repeated shorter mention with spaces collide with a longer, distinct mention that starts the same way", () => { + // "@a.txt" is a literal prefix of the space-containing "@a.txt backup.txt". + // A generic continuation-character heuristic would treat the space after + // "@a.txt" as a valid boundary, since paths may now legitimately contain + // spaces. Checking against the other ref's actual mention text catches this. + const text = "@a.txt @a.txt backup.txt" + const segments = buildHighlightedTextSegments( + text, + [ + { source: { type: "file", path: "a.txt", text: { value: "@a.txt", start: 0, end: 6 } } }, + { + source: { + type: "file", + path: "a.txt backup.txt", + text: { value: "@a.txt backup.txt", start: 7, end: 24 }, + }, + }, + ], + [], + ) + + expect(segments).toEqual([ + { text: "@a.txt", type: "file" }, + { text: " " }, + { text: "@a.txt backup.txt", type: "file" }, + ]) + }) + + test("highlights filename with space in directory and source offsets", () => { + const text = "using @my folder/report.xlsx here" + const segments = buildHighlightedTextSegments( + text, + [ + { + source: { + type: "file", + path: "my folder/report.xlsx", + text: { value: "@my folder/report.xlsx", start: 6, end: 28 }, + }, + }, + ], + [], + ) + + expect(segments).toEqual([ + { text: "using " }, + { text: "@my folder/report.xlsx", type: "file" }, + { text: " here" }, + ]) + }) }) diff --git a/packages/kilo-ui/src/components/message-highlight.ts b/packages/kilo-ui/src/components/message-highlight.ts index 7bbfc7762e9..3c0e16ac9ab 100644 --- a/packages/kilo-ui/src/components/message-highlight.ts +++ b/packages/kilo-ui/src/components/message-highlight.ts @@ -21,7 +21,16 @@ type Ref = { type: "file" | "agent" } -/** Match @path mentions: `@` followed by a path-like token (contains `/` or `.`). */ +/** + * Match @path mentions: `@` followed by a path-like token (contains `/` or `.`). + * This regex is the fallback used only when no source position data is available + * (e.g. messages sent before file attachments carried source.text). It intentionally + * does not match spaces: a pattern permissive enough to span space-separated path + * segments also matches ordinary prose following any @mention (e.g. `@agent check + * the report for v1.2 details` would swallow everything up to `v1.2`). Paths with + * spaces are highlighted correctly via the source.text-based resolve() path instead, + * which locates the exact known mention text rather than pattern-matching prose. + */ const MENTION_RE = /@([\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+)/g function detect(text: string): Ref[] { @@ -31,35 +40,128 @@ function detect(text: string): Ref[] { })) } -function locate(text: string, ref: Ref, index: number): Ref | undefined { +function locate(text: string, ref: Ref, claimed: { start: number; end: number }[]): Ref | undefined { const source = ref.source if (!source.value) return undefined + const free = (start: number, end: number) => !claimed.some((c) => start < c.end && end > c.start) + if (Number.isFinite(source.start) && Number.isFinite(source.end)) { const start = Math.min(text.length, Math.max(0, source.start)) const end = Math.min(text.length, Math.max(0, source.end)) - if (start >= index && start <= end && text.slice(start, end) === source.value) { + if (text.slice(start, end) === source.value && free(start, end)) { return { ...ref, source: { ...source, start, end } } } } - const hint = Number.isFinite(source.start) ? Math.min(text.length, Math.max(index, source.start)) : index - const found = text.indexOf(source.value, hint) - const start = found === -1 ? text.indexOf(source.value, index) : found - if (start === -1) return undefined - return { ...ref, source: { ...source, start, end: start + source.value.length } } + const firstFree = (from: number) => { + let search = from + while (true) { + const found = text.indexOf(source.value, search) + if (found === -1) return undefined + const end = found + source.value.length + if (free(found, end)) return { start: found, end } + search = found + 1 + } + } + + // Prefer the first unclaimed occurrence at or after this ref's own recorded + // position, falling back to the first unclaimed occurrence anywhere. Each + // ref is searched against its own hint rather than a cursor shared across + // every other ref, so locating one ref can't skip past a distinct ref's real + // occurrence that sits between two repeats of an earlier one (see the + // interleaved-mentions regression test). + const hint = Number.isFinite(source.start) ? Math.min(text.length, Math.max(0, source.start)) : 0 + const match = firstFree(hint) ?? firstFree(0) + if (!match) return undefined + return { ...ref, source: { ...source, start: match.start, end: match.end } } +} + +// Any letter, digit, underscore, slash, or hyphen unambiguously continues a +// path token. Uses Unicode property escapes rather than \w, which matches +// ASCII letters/digits only in JavaScript regex — without this, a Cyrillic +// or CJK mention (e.g. "@файл") would not be recognized as continuing into a +// longer, distinct mention that starts the same way (e.g. "@файлы"), +// reintroducing the same collision this check exists to prevent. A dot is +// handled separately (see continuesPath) since it is both a common +// sentence-ending character and a path/extension separator. +const PATH_CONTINUATION = /[\p{L}\p{N}_/-]/u + +/** + * Whether `text[end]` extends a match into a longer, different path rather + * than ending it. A dot only counts as a continuation when another + * letter/digit follows (e.g. "@report.csv" + ".bak", or the "x" in "@a.tsx" + * itself is already caught by PATH_CONTINUATION) — a lone trailing dot, as + * in an ordinary sentence ending, does not. + */ +function continuesPath(text: string, end: number): boolean { + const char = text[end] + if (char === undefined) return false + if (PATH_CONTINUATION.test(char)) return true + return char === "." && /[\p{L}\p{N}_]/u.test(text[end + 1] ?? "") +} + +/** + * Find every position at or after `from` where `value` occurs as a complete + * token, not immediately preceded by a character that could extend it into a + * longer, different path, and not immediately claimed by `others` — the exact + * mention text of every other known ref. A plain substring search would let a + * shorter mention match as a prefix of a longer, distinct one that starts the + * same way (e.g. "@a.ts" inside "@a.tsx", or "@a.txt" inside the space-containing + * "@a.txt backup.txt"). Checking against the other refs' actual mention text, + * rather than only a generic continuation-character heuristic, is required + * because a space can no longer be assumed to end a mention now that paths may + * contain spaces — `continuesPath` alone would treat the boundary before + * "backup.txt" as valid. Ordinary punctuation such as a trailing comma, + * sentence-ending period, or closing paren is not a continuation character, + * so a repeat directly followed by it is still accepted. + */ +function repeats(text: string, value: string, others: string[], claimed: { start: number; end: number }[]): number[] { + const result: number[] = [] + let search = 0 + + while (true) { + const found = text.indexOf(value, search) + if (found === -1) break + + const end = found + value.length + const before = found === 0 || !PATH_CONTINUATION.test(text[found - 1] ?? "") + const after = !continuesPath(text, end) + const collides = others.some((other) => other !== value && other.length > value.length && text.startsWith(other, found)) + const free = !claimed.some((c) => found < c.end && end > c.start) + if (before && after && !collides && free) result.push(found) + search = found + 1 + } + + return result } function resolve(text: string, refs: Ref[]): Ref[] { + const others = refs.map((ref) => ref.source.value) + const claimed: { start: number; end: number }[] = [] const result: Ref[] = [] - let index = 0 + // Locate each ref's own primary occurrence first, independently of every + // other ref, so a distinct ref's real occurrence sitting between two + // repeats of an earlier one is never skipped over (see the + // interleaved-mentions regression test). for (const ref of [...refs].sort((a, b) => a.source.start - b.source.start || b.source.end - a.source.end)) { - const next = locate(text, ref, index) + const next = locate(text, ref, claimed) if (!next) continue - result.push(next) - index = next.source.end + claimed.push({ start: next.source.start, end: next.source.end }) + } + + // mentionedPaths is a Set, so a path mentioned more than once in the same + // message only produces a single attachment/ref. Highlight any later + // boundary-delimited repeats of each located ref's mention text too, so + // every occurrence stays highlighted, not just the first. + for (const ref of [...result]) { + for (const start of repeats(text, ref.source.value, others, claimed)) { + const end = start + ref.source.value.length + result.push({ ...ref, source: { ...ref.source, start, end } }) + claimed.push({ start, end }) + } } return result 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 877b302c5e9..b8fac7f464b 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -155,6 +155,24 @@ describe("syncMentionedPaths", () => { const result = syncMentionedPaths(paths, "@foo.ts is important") expect(result.has("foo.ts")).toBe(true) }) + + it("does not false-match a stale shorter path against a longer, space-containing path that starts the same way", () => { + // "a.txt" is a known path from an earlier, unrelated mention. A space + // genuinely follows "a.txt" in the current text, but only because it's + // the start of the longer, distinct "a.txt backup.txt" -- a whitespace-only + // boundary check would incorrectly treat that as a valid, separate match. + const paths = new Set(["a.txt", "a.txt backup.txt"]) + const result = syncMentionedPaths(paths, "@a.txt backup.txt") + expect(result.has("a.txt backup.txt")).toBe(true) + expect(result.has("a.txt")).toBe(false) + }) + + it("keeps a shorter path when it also has its own genuine, separate occurrence", () => { + const paths = new Set(["a.txt", "a.txt backup.txt"]) + const result = syncMentionedPaths(paths, "@a.txt backup.txt and also @a.txt") + expect(result.has("a.txt backup.txt")).toBe(true) + expect(result.has("a.txt")).toBe(true) + }) }) describe("buildTextAfterMentionSelect", () => { @@ -300,6 +318,90 @@ describe("buildFileAttachments", () => { const result = buildFileAttachments("@foo.ts", paths, "C:\\Users\\workspace") expect(result[0]!.url).not.toContain("\\") }) + + it("includes source.text with correct position for a plain mention", () => { + const paths = new Set(["src/foo.ts"]) + const text = "check @src/foo.ts here" + const result = buildFileAttachments(text, paths, "/workspace") + expect(result[0]!.source).toEqual({ + type: "file", + path: "src/foo.ts", + text: { value: "@src/foo.ts", start: 6, end: 17 }, + }) + }) + + it("includes source.text for a filename with spaces", () => { + const paths = new Set(["org data.xlsx"]) + const text = "see @org data.xlsx now" + const result = buildFileAttachments(text, paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.source).toEqual({ + type: "file", + path: "org data.xlsx", + text: { value: "@org data.xlsx", start: 4, end: 18 }, + }) + }) + + it("includes source.text for a Cyrillic filename", () => { + const paths = new Set(["файл.txt"]) + const text = "open @файл.txt" + const result = buildFileAttachments(text, paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.source?.text.value).toBe("@файл.txt") + expect(result[0]!.source?.text.start).toBe(5) + }) + + it("includes source.text for a Chinese filename", () => { + const paths = new Set(["文件.txt"]) + const text = "@文件.txt" + const result = buildFileAttachments(text, paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.source?.text.value).toBe("@文件.txt") + expect(result[0]!.source?.text.start).toBe(0) + }) + + it("includes source.text for a path with spaces in both dir and filename", () => { + const paths = new Set(["my folder/org data.xlsx"]) + const text = "using @my folder/org data.xlsx here" + const result = buildFileAttachments(text, paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.source).toEqual({ + type: "file", + path: "my folder/org data.xlsx", + text: { value: "@my folder/org data.xlsx", start: 6, end: 30 }, + }) + }) + + it("percent-encodes spaces in the file URL so the server can decode it correctly", () => { + const paths = new Set(["org data.xlsx"]) + const result = buildFileAttachments("@org data.xlsx", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).not.toContain(" ") + expect(result[0]!.url).toContain("%20") + }) + + it("percent-encodes spaces in nested path segments", () => { + const paths = new Set(["my folder/my file.txt"]) + const result = buildFileAttachments("@my folder/my file.txt", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).not.toContain(" ") + expect(result[0]!.url).toContain("my%20folder") + expect(result[0]!.url).toContain("my%20file.txt") + }) + + it("round-trips a filename containing a literal percent-encoded-looking sequence", () => { + // Only escaping spaces before assigning to url.pathname is not enough: a + // real filename like "100%20real.txt" already contains the literal text + // "%20". If "%" itself isn't escaped first, the URL's "%20" is + // indistinguishable from an actually-encoded space, and decoding it (as + // Bun's fileURLToPath does server-side) would produce "100 real.txt" -- + // a different, wrong filename. + const paths = new Set(["100%20real.txt"]) + const result = buildFileAttachments("@100%20real.txt", paths, "/workspace") + expect(result).toHaveLength(1) + const decoded = decodeURIComponent(new URL(result[0]!.url).pathname) + expect(decoded).toBe("/workspace/100%20real.txt") + }) }) describe("getMentionRemovalRange", () => { 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 4da93463cdc..b68878debd5 100644 --- a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts @@ -213,6 +213,92 @@ describe("useFileMention", () => { dispose.fn?.() }) + it("seedFromText truncates a mention path at the first space (known limitation, see seedFromParts)", () => { + // Documents why seedFromParts exists: seedFromText re-derives candidate + // paths from raw text via a regex that stops at whitespace. For a path + // containing a space, it discovers only the prefix before the space, and + // that truncated candidate then incorrectly passes syncMentionedPaths' + // boundary check too, since a real space genuinely follows it in the text. + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + mention.seedFromText("Say hi to @mention-test/my quarterly report.txt !") + + expect(mention.mentionedPaths().has("mention-test/my")).toBe(true) + expect(mention.mentionedPaths().has("mention-test/my quarterly report.txt")).toBe(false) + + dispose.fn?.() + }) + + it("seedFromParts seeds an exact path correctly even when it contains a space", () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const text = "Say hi to @mention-test/my quarterly report.txt !" + mention.seedFromParts(["mention-test/my quarterly report.txt"], text) + + expect(mention.mentionedPaths().has("mention-test/my quarterly report.txt")).toBe(true) + expect(mention.mentionedPaths().has("mention-test/my")).toBe(false) + + dispose.fn?.() + }) + + it("seedFromParts prunes paths no longer present in the text", () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + mention.seedFromParts(["mention-test/gone.ts"], "no mentions here anymore") + + expect(mention.mentionedPaths().size).toBe(0) + + dispose.fn?.() + }) + + it("seedFromParts seeds multiple exact paths from a single message", () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const text = "Compare @a data.ts and @b data.ts please" + mention.seedFromParts(["a data.ts", "b data.ts"], text) + + expect(mention.mentionedPaths().has("a data.ts")).toBe(true) + expect(mention.mentionedPaths().has("b data.ts")).toBe(true) + + dispose.fn?.() + }) + it("filters visible results synchronously while a new search is pending", async () => { const posted: WebviewMessage[] = [] const handlers = new Set<(message: ExtensionMessage) => void>() 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 13e6518816b..b80e70f0bbe 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -629,7 +629,12 @@ export const PromptInput: Component = (props) => { if (message.type === "setChatBoxMessage") { setText(message.text) - mention.seedFromText(message.text) + // Prefer the exact attachment paths when available (e.g. reverting to a + // message with @mentions) — seedFromText re-derives candidate mentions + // from raw text via regex, which truncates at the first space in a + // filename and cannot be relied on to reconstruct spaced paths correctly. + if (message.paths?.length) mention.seedFromParts(message.paths, message.text) + else mention.seedFromText(message.text) if (textareaRef) { textareaRef.value = message.text adjustHeight() diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index eb4b9ffc5d2..06ee1173e2b 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -2781,7 +2781,15 @@ export const SessionProvider: ParentComponent = (props) => { .filter((p) => p.type === "text" && !(p as { synthetic?: boolean }).synthetic) .map((p) => (p as { text: string }).text ?? "") .join("") - if (text) window.postMessage({ type: "setChatBoxMessage", text }, "*") + // Pass the original attachments' exact paths alongside the restored text + // so PromptInput can seed them directly rather than re-deriving mentions + // from the text via regex, which truncates at the first space in a + // filename (see PromptInput's setChatBoxMessage handler). + const paths = parts + .filter((p): p is Extract => p.type === "file") + .map((p) => p.source?.path) + .filter((p): p is string => !!p) + if (text) window.postMessage({ type: "setChatBoxMessage", text, paths }, "*") } vscode.postMessage({ type: "revertSession", sessionID: id, messageID, partID }) } diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index 19576bb892f..9658a798c42 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -33,13 +33,6 @@ export const FILE_PICKER_RESULT: MentionResult = { description: "Select a file outside the workspace", } -/** - * Escape special regex characters in a string so it can be used in a RegExp. - */ -function escape(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - export function getTerminalMentionResult(query: string): MentionResult[] { const normalized = query.toLowerCase() if (!TERMINAL_MENTION.startsWith(normalized)) return [] @@ -84,14 +77,38 @@ export function filterMentionResults(query: string, items: MentionResult[]): Men * * Uses boundary-aware matching (whitespace or start/end of string) and processes * paths longest-first to prevent `@src/a.ts` from false-matching `@src/a.tsx`. + * + * A trailing space can no longer be assumed to end a mention now that paths + * may contain spaces: `@a.txt` is a literal, whitespace-bounded prefix of the + * space-containing `@a.txt backup.txt`. Checking each candidate occurrence + * against every longer path already accepted at the same position (rather + * than relying on whitespace alone) prevents a stale, unrelated `a.txt` from + * a prior mention surviving just because it happens to collide with the + * start of a longer path mentioned in the current text. */ export function syncMentionedPaths(prev: Set, text: string): Set { const next = new Set() // Sort longest-first so e.g. "src/a.tsx" is checked before "src/a.ts" const sorted = [...prev].sort((a, b) => b.length - a.length) + const accepted: string[] = [] for (const path of sorted) { - const pattern = new RegExp(`(?:^|\\s)@${escape(path)}(?:\\s|$)`) - if (pattern.test(text)) next.add(path) + const token = `@${path}` + let search = 0 + const valid = (() => { + while (true) { + const idx = text.indexOf(token, search) + if (idx === -1) return false + const before = idx === 0 || /\s/.test(text[idx - 1] ?? "") + const end = idx + token.length + const after = end >= text.length || /\s/.test(text[end] ?? "") + const collides = accepted.some((other) => other !== path && text.startsWith(`@${other}`, idx)) + if (before && after && !collides) return true + search = idx + 1 + } + })() + if (!valid) continue + accepted.push(path) + next.add(path) } return next } @@ -228,6 +245,10 @@ function isInsideWorkspace(abs: string, dir: string): boolean { * which enforces the normal external-directory permission checks. Every * resolved path (relative or absolute) is normalized before the containment * check so a "../" sequence can't slip past a literal string-prefix match. + * + * Includes source.text position data so the message renderer can highlight + * the full mention span (including paths with spaces or non-ASCII characters) + * without falling back to the regex-based detection that stops at spaces. */ export function buildFileAttachments( text: string, @@ -237,13 +258,32 @@ export function buildFileAttachments( const result: FileAttachment[] = [] const dir = normalizeAbsolutePath(workspaceDir.replaceAll("\\", "/")).replace(/\/+$/, "") for (const path of mentionedPaths) { - if (text.includes(`@${path}`)) { + const token = `@${path}` + const idx = text.indexOf(token) + if (idx !== -1) { const raw = isAbsolutePath(path) ? path.replaceAll("\\", "/") : `${dir}/${path}` const abs = normalizeAbsolutePath(raw) if (!isInsideWorkspace(abs, dir)) continue const url = new URL("file://") - url.pathname = abs.startsWith("/") ? abs : `/${abs}` - result.push({ mime: "text/plain", url: url.href }) + // Pre-encode spaces and literal percent signs before assigning to + // pathname: VS Code's webview (Chromium) does not percent-encode spaces + // in file:// URL pathnames, which causes Bun's fileURLToPath on the + // server to truncate the path at the first space. A literal "%" in the + // filename must also be escaped first (to "%25"), otherwise a name like + // "100%20real.txt" would be indistinguishable from an already-encoded + // space and get decoded back to "100 real.txt" server-side. Other + // non-ASCII characters are encoded correctly by the URL class, so only + // "%" and " " need this explicit treatment. + url.pathname = (abs.startsWith("/") ? abs : `/${abs}`).replace(/%/g, "%25").replace(/ /g, "%20") + result.push({ + mime: "text/plain", + url: url.href, + source: { + type: "file", + path, + text: { value: token, start: idx, end: idx + token.length }, + }, + }) } } return result diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index eec16d67afd..dd9d396da93 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -68,6 +68,13 @@ export interface FileMention { seedFromText: (text: string) => void /** Insert a file-picker result at the stored cursor position. Ignored unless requestId matches the pending request. */ insertFilePickerResult: (path: string, requestId: string) => void + /** + * Seed known paths from a set of already-confirmed exact paths (e.g. a + * reverted message's file attachments), then prune against `text`. Prefer + * this over seedFromText when exact paths are available, since seedFromText + * cannot correctly rediscover paths containing spaces from raw text alone. + */ + seedFromParts: (paths: string[], text: string) => void } export function useFileMention( @@ -430,6 +437,19 @@ export function useFileMention( state.onSelect?.() } + // Seed known paths from a set of already-confirmed exact paths (e.g. the + // file attachments of a message being restored after a revert), then prune + // mentionedPaths against the current text. Unlike seedFromText, this does + // not re-derive candidate paths from the text via regex: that regex cannot + // distinguish a complete mention from a truncated prefix when the real + // path contains a space (e.g. it would discover only "dir/my" from + // "@dir/my report.txt", which then passes syncMentionedPaths' boundary + // check too, since a real space genuinely follows "my" in the full name). + const seedFromParts = (paths: string[], text: string) => { + for (const p of paths) knownPaths.add(p) + syncMentionedPaths(text) + } + return { mentionedPaths, mentionResults, @@ -447,5 +467,6 @@ export function useFileMention( snapSelection, seedFromText, insertFilePickerResult, + seedFromParts, } } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 8f492cdabe7..c3132d9d77c 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -269,6 +269,14 @@ export interface ActionMessage { export interface SetChatBoxMessage { type: "setChatBoxMessage" text: string + /** + * Exact relative paths of the file attachments carried by the restored + * message, if known (e.g. when reverting to a message that had @mentions). + * When present, PromptInput seeds these directly instead of re-deriving + * candidate mentions from the text via regex, which cannot tell a complete + * mention from a truncated prefix when the real path contains a space. + */ + paths?: string[] } export interface AppendChatBoxMessage { From 1b126c2fa7c6afd9f4a9bdca7f51d2f0c4bf004a Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:03:02 +0000 Subject: [PATCH 16/28] fix(cli): flatten agent manager tool schema Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../opencode/src/kilocode/tool/agent-manager.ts | 12 +++++++++++- .../test/kilocode/agent-manager-tool.test.ts | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 6b68276eae8..17607c9a7a6 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -77,6 +77,16 @@ const PromptParams = Schema.Struct({ export const Params = Schema.Union([StartParams, ListParams, PromptParams]) +const WireParams = Schema.Struct({ + mode: Schema.optional(StartParams.fields.mode), + versions: Schema.optional(StartParams.fields.versions), + tasks: Schema.optional(StartParams.fields.tasks), + action: Schema.optional(Schema.Literals(["list", "prompt"])), + filter: Schema.optional(ListParams.fields.filter), + sessionID: Schema.optional(PromptParams.fields.sessionID), + prompt: Schema.optional(PromptParams.fields.prompt), +}) + type Input = Schema.Schema.Type type Selected = { task?: AgentManagerTask; error?: string } type Candidate = { providerID: string; model: Provider.Info["models"][string] } @@ -238,7 +248,7 @@ export const AgentManagerTool = Tool.define< return { description: DESCRIPTION, parameters: Params, - jsonSchema: { ...ToolJsonSchema.fromSchema(Params), type: "object" }, + jsonSchema: ToolJsonSchema.fromSchema(WireParams), execute: (params, ctx) => Effect.gen(function* () { if ("action" in params) { diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 32c668980d1..177f9ebe866 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -150,12 +150,23 @@ function publish( } describe("agent_manager tool", () => { - test("uses an object-root input schema", async () => { + test("uses an object-root input schema without combinators", async () => { const tool = await init() const schema = ToolJsonSchema.fromTool(tool) expect(schema.type).toBe("object") - expect(schema.anyOf).toHaveLength(3) + expect(schema.anyOf).toBeUndefined() + expect(schema.oneOf).toBeUndefined() + expect(schema.allOf).toBeUndefined() + expect(Object.keys(schema.properties ?? {})).toEqual([ + "mode", + "versions", + "tasks", + "action", + "filter", + "sessionID", + "prompt", + ]) }) test("asks for agent_manager permission", async () => { From 7e9285a259ba8d79053f826966545a89ab495ac4 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:11:00 +0000 Subject: [PATCH 17/28] docs: clarify agent manager schema compatibility Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/calm-tools-speak.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-tools-speak.md b/.changeset/calm-tools-speak.md index 3d6dd503da5..9e288ffcff3 100644 --- a/.changeset/calm-tools-speak.md +++ b/.changeset/calm-tools-speak.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Fix Agent Manager tool calls through providers that require object-root input schemas. +Fix Agent Manager tool calls through providers that require object-root input schemas without root combinators. From 14d343b24b52200617fee120ba6fda63ce255f48 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 15 Jul 2026 14:29:02 -0400 Subject: [PATCH 18/28] refactor(jetbrains): consolidate model picker popup --- .../client/session/ui/model/ModelPicker.kt | 346 ++--------------- .../session/ui/model/ModelPickerRenderer.kt | 230 +++++------- .../client/ui/picker/PickerListRenderer.kt | 118 ++++++ .../kilocode/client/ui/picker/PickerPopup.kt | 353 ++++++++++++++++++ 4 files changed, 602 insertions(+), 445 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerListRenderer.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index b9d467c36bc..af43fa980d4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.session.ui.model import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.PickerButton +import ai.kilocode.client.ui.picker.PickerPopup +import ai.kilocode.client.ui.picker.popupBackground import ai.kilocode.rpc.dto.ModelAutoRoutingDto import ai.kilocode.rpc.dto.ModelCapabilitiesDto import ai.kilocode.rpc.dto.ModelCostDto @@ -9,57 +11,19 @@ import ai.kilocode.rpc.dto.ModelLimitDto import ai.kilocode.rpc.dto.ModelOptionsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ModelTerminalBenchDto -import ai.kilocode.client.ui.HoverIcon import com.intellij.ide.util.PropertiesComponent -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.util.IconLoader -import com.intellij.openapi.ui.popup.JBPopup -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.ui.popup.PopupShowOptions -import com.intellij.openapi.ui.popup.util.PopupUtil import com.intellij.ui.CollectionListModel -import com.intellij.ui.DocumentAdapter -import com.intellij.ui.NewUI -import com.intellij.ui.ListUtil -import com.intellij.ui.SearchTextField -import com.intellij.ui.ScrollPaneFactory -import com.intellij.ui.ScrollingUtil -import com.intellij.ui.components.JBList -import com.intellij.ui.components.JBLabel -import com.intellij.ui.popup.AbstractPopup import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import com.intellij.xml.util.XmlStringUtil -import java.awt.BorderLayout -import java.awt.Color import java.awt.Cursor -import java.awt.Dimension -import java.awt.GridBagLayout -import java.awt.event.InputEvent -import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.JComponent -import javax.swing.JList -import javax.swing.JPanel -import javax.swing.JScrollPane -import javax.swing.KeyStroke -import javax.swing.ListSelectionModel -import javax.swing.ScrollPaneConstants import javax.swing.SwingConstants -import javax.swing.SwingUtilities -import javax.swing.event.DocumentEvent -import javax.swing.Icon - -private val popupBackground: Color - get() = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground() private const val MODEL_PICKER_MIN_WIDTH = 420 private const val MODEL_PICKER_MAX_WIDTH = 760 private const val MODEL_PICKER_MAX_VISIBLE_ROWS = 10 private const val MODEL_PICKER_EMPTY_LIST_HEIGHT = 120 -private val EXPAND: Icon = IconLoader.getIcon("/icons/expand.svg", ModelPicker::class.java) -private val COLLAPSE: Icon = IconLoader.getIcon("/icons/collapse.svg", ModelPicker::class.java) class ModelPicker : PickerButton() { @@ -170,25 +134,13 @@ class ModelPicker : PickerButton() { } private fun showPopup() { - var expanded = props.getBoolean(MODEL_PICKER_EXPANDED_KEY, false) - val rows = modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall) - val model = CollectionListModel(rows) - val list = JBList(model).apply { - selectionMode = ListSelectionModel.SINGLE_SELECTION - isFocusable = false - emptyText.text = KiloBundle.message("model.picker.no.matches") - background = popupBackground - border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false)) - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - } - list.cellRenderer = ModelPickerRenderer( - model = model, + val data = CollectionListModel(modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall)) + var popup: PickerPopup? = null + val renderer = ModelPickerRenderer( + model = data, active = { selected?.key }, favorites = { favoriteKeys() }, ) - val search = SearchTextField(false).apply { - textEditor.emptyText.text = KiloBundle.message("model.picker.search") - } var refreshFavorite: (Item) -> Unit = {} val details = ModelDetailsPanel( favorites = { favoriteKeys() }, @@ -196,77 +148,17 @@ class ModelPicker : PickerButton() { ).apply { background = popupBackground } - val expand = HoverIcon().apply { - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - } - val ins = PopupUtil.getListInsets(false, false) - val side = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get() + JBUI.CurrentTheme.Popup.Selection.innerInsets().right - val expandWrap = JPanel(GridBagLayout()).apply { - background = popupBackground - border = JBUI.Borders.empty(0, JBUI.CurrentTheme.ActionsList.elementIconGap(), 0, side) - add(expand) - } - val head = JPanel(BorderLayout()).apply { - background = popupBackground - border = JBUI.Borders.empty(ins.top, ins.left, ins.bottom, 0) - add(search, BorderLayout.CENTER) - add(expandWrap, BorderLayout.EAST) - } - - lateinit var popup: JBPopup - lateinit var content: JPanel - lateinit var scroll: JScrollPane - var shown = false - - fun activeKey(): String? = list.selectedValue?.item?.key - - fun preview(): Item? = list.selectedValue?.item ?: selected - - fun syncDetails() { - details.update(preview()) - } - - fun syncExpand() { - expand.icon = if (expanded) COLLAPSE else EXPAND - expand.toolTipText = if (expanded) { - KiloBundle.message("model.picker.details.minimize") - } else { - KiloBundle.message("model.picker.details.maximize") - } - expand.accessibleContext.accessibleName = expand.toolTipText - details.isVisible = expanded - } - - fun choose(idx: Int) { - list.selectedIndex = idx - ScrollingUtil.ensureIndexIsVisible(list, idx, 0) - } - - fun sync(prefer: String? = activeKey(), at: Int? = null) { - val rows = modelPickerRows(items, favorites(), search.text, allowEmpty, emptyText, includeSmall) - model.replaceAll(rows) - val idx = at?.let { modelPickerIndex(rows, it) }?.takeIf { it >= 0 } - ?: modelPickerIndex(rows, prefer).takeIf { it >= 0 } - ?: modelPickerIndex(rows, selected?.key).takeIf { it >= 0 } - ?: rows.indices.firstOrNull() - ?: -1 - if (idx >= 0) choose(idx) - else list.clearSelection() - syncDetails() - } fun activate(item: Item) { selected = item refresh() onSelect(item) - popup.closeOk(null) } fun clear() { selected = null refresh() onClear() - popup.closeOk(null) } fun activate(row: ModelPickerRow) { @@ -278,166 +170,42 @@ class ModelPicker : PickerButton() { activate(item) } - fun move(step: Int) { - val size = model.size - if (size <= 0) return - val cur = list.selectedIndex.takeIf { it >= 0 } ?: 0 - val idx = (cur + step).coerceIn(0, size - 1) - choose(idx) - } - fun toggle(row: ModelPickerRow) { val item = row.item ?: return - val idx = list.selectedIndex onFavoriteToggle(item) - sync(at = idx) - list.selectedIndex.takeIf { it >= 0 }?.let { repaintRow(list, it) } - syncDetails() } refreshFavorite = { item -> onFavoriteToggle(item) - sync(prefer = item.key) - list.repaint() + popup?.refresh(prefer = item.key) + popup?.repaint() } - fun resize() { - val size = computeInitialPopupSize(list, scroll, head, expanded) - content.preferredSize = size - if (expanded) { - details.preferredSize = Dimension(size.width - scroll.preferredSize.width, scroll.preferredSize.height) - } - content.revalidate() - content.repaint() - if (shown) popup.setSize(size) - } - - fun setExpanded(value: Boolean) { - if (expanded == value) return - expanded = value - props.setValue(MODEL_PICKER_EXPANDED_KEY, value.toString()) - if (!expanded) list.clearSelection() - syncExpand() - syncDetails() - resize() - } - - search.textEditor.document.addDocumentListener(object : DocumentAdapter() { - override fun textChanged(e: DocumentEvent) { - sync() - } - }) - search.textEditor.registerKeyboardAction( - { move(-1) }, - KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), - JComponent.WHEN_FOCUSED, + popup = PickerPopup( + anchor = this, + placement = when (placement) { + Placement.ABOVE -> PickerPopup.Placement.ABOVE + Placement.BELOW -> PickerPopup.Placement.BELOW + }, + rows = { q -> modelPickerRows(items, favorites(), q, allowEmpty, emptyText, includeSmall) }, + model = data, + renderer = renderer, + key = { it.key }, + mode = PickerPopup.Mode.Single, + onPrimary = ::activate, + sectionTitle = ::modelPickerSectionTitle, + trailingHit = ModelPickerRenderer::isFavoriteClick, + onTrailing = ::toggle, + search = true, + details = details, + onPreview = { details.update(it?.item ?: selected) }, + expandStateKey = MODEL_PICKER_EXPANDED_KEY, + minWidth = MODEL_PICKER_MIN_WIDTH, + maxWidth = MODEL_PICKER_MAX_WIDTH, + maxVisibleRows = MODEL_PICKER_MAX_VISIBLE_ROWS, + emptyListHeight = MODEL_PICKER_EMPTY_LIST_HEIGHT, ) - search.textEditor.registerKeyboardAction( - { move(1) }, - KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), - JComponent.WHEN_FOCUSED, - ) - search.textEditor.registerKeyboardAction( - { list.selectedValue?.let(::activate) }, - KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), - JComponent.WHEN_FOCUSED, - ) - search.textEditor.registerKeyboardAction( - { popup.cancel() }, - KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), - JComponent.WHEN_FOCUSED, - ) - search.textEditor.registerKeyboardAction( - { list.selectedValue?.let(::toggle) }, - KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK), - JComponent.WHEN_FOCUSED, - ) - list.registerKeyboardAction( - { list.selectedValue?.let(::activate) }, - KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), - JComponent.WHEN_FOCUSED, - ) - list.registerKeyboardAction( - { popup.cancel() }, - KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), - JComponent.WHEN_FOCUSED, - ) - list.registerKeyboardAction( - { list.selectedValue?.let(::toggle) }, - KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK), - JComponent.WHEN_FOCUSED, - ) - list.addMouseListener(object : MouseAdapter() { - override fun mouseReleased(e: MouseEvent) { - if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return - val row = list.locationToIndex(e.point) - val bounds = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return - if (!bounds.contains(e.point)) return - val value = model.getElementAt(row) - if (ModelPickerRenderer.isFavoriteClick(list, bounds, e.point)) { - toggle(value) - e.consume() - return - } - activate(value) - } - }) - expand.addActionListener { setExpanded(!expanded) } - list.addListSelectionListener { - if (!it.valueIsAdjusting && expanded) syncDetails() - } - ListUtil.installAutoSelectOnMouseMove(list) - ScrollingUtil.installActions(list) - - scroll = ScrollPaneFactory.createScrollPane(list).apply { - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED - border = JBUI.Borders.empty() - viewportBorder = JBUI.Borders.empty() - background = popupBackground - viewport.background = popupBackground - viewport.isOpaque = true - } - content = JPanel(BorderLayout()).apply { - background = popupBackground - border = JBUI.Borders.empty() - add(head, BorderLayout.NORTH) - add(scroll, BorderLayout.CENTER) - add(details, BorderLayout.EAST) - } - PopupUtil.applyNewUIBackground(list) - list.background = popupBackground - AbstractPopup.customizeSearchFieldLook(search, true) - search.background = popupBackground - - sync(selected?.key) - syncExpand() - syncDetails() - content.preferredSize = computeInitialPopupSize(list, scroll, head, expanded) - if (expanded) details.preferredSize = Dimension(content.preferredSize.width - scroll.preferredSize.width, scroll.preferredSize.height) - popup = JBPopupFactory.getInstance() - .createComponentPopupBuilder(content, search.textEditor) - .setRequestFocus(true) - .setFocusable(true) - .setCancelOnClickOutside(true) - .setCancelKeyEnabled(true) - .setCancelOnWindowDeactivation(true) - .setLocateWithinScreenBounds(true) - .setResizable(false) - .setMovable(false) - .createPopup() - Disposer.register(popup, details) - - when (placement) { - Placement.ABOVE -> popup.show(PopupShowOptions.aboveComponent(this)) - Placement.BELOW -> popup.showUnderneathOf(this) - } - shown = true - SwingUtilities.invokeLater { - search.textEditor.requestFocusInWindow() - search.selectText() - list.selectedIndex.takeIf { it >= 0 }?.let(list::ensureIndexIsVisible) - } + popup.show() } private fun favoriteKeys(): Set = favorites().mapTo(mutableSetOf()) { "${it.providerID}/${it.modelID}" } @@ -457,56 +225,6 @@ internal data class ModelPickerRow( val isEmpty: Boolean get() = item == null } -private fun computeInitialPopupSize( - list: JList, - scroll: JScrollPane, - head: JComponent, - expanded: Boolean = false, -): Dimension { - val width = computeListPreferredWidth(list) - list.fixedCellWidth = width - - val height = computeListPreferredHeight(list) - val bar = if (list.model.size > MODEL_PICKER_MAX_VISIBLE_ROWS) scroll.verticalScrollBar.preferredSize.width else 0 - val listWidth = width + bar - val detailWidth = if (expanded) width else 0 - val content = Dimension(listWidth + detailWidth, head.preferredSize.height + height) - scroll.preferredSize = Dimension(listWidth, height) - return content -} - -private fun computeListPreferredWidth(list: JList): Int { - val renderer = list.cellRenderer ?: return JBUI.scale(MODEL_PICKER_MIN_WIDTH) - val model = list.model - val max = (0 until model.size).maxOfOrNull { idx -> - val value = model.getElementAt(idx) - renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.width - } ?: 0 - val insets = list.insets - return (max + insets.left + insets.right).coerceIn( - JBUI.scale(MODEL_PICKER_MIN_WIDTH), - JBUI.scale(MODEL_PICKER_MAX_WIDTH), - ) -} - -private fun computeListPreferredHeight(list: JList): Int { - val renderer = list.cellRenderer ?: return JBUI.scale(MODEL_PICKER_EMPTY_LIST_HEIGHT) - val model = list.model - val count = model.size.coerceAtMost(MODEL_PICKER_MAX_VISIBLE_ROWS) - if (count <= 0) return JBUI.scale(MODEL_PICKER_EMPTY_LIST_HEIGHT) - val height = (0 until count).sumOf { idx -> - val value = model.getElementAt(idx) - renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.height - } - val insets = list.insets - return height + insets.top + insets.bottom -} - -private fun repaintRow(list: JList<*>, index: Int) { - if (index < 0) return - list.getCellBounds(index, index)?.let(list::repaint) -} - internal object ModelSearch { fun matches(query: String, text: String): Boolean { val q = query.lowercase().trim() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index 0bbd694bfb8..55d27071f72 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -1,119 +1,52 @@ package ai.kilocode.client.session.ui.model -import ai.kilocode.client.session.ui.PickerRow import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.picker.PickerListRenderer import com.intellij.icons.AllIcons import com.intellij.openapi.util.IconLoader import com.intellij.ui.CollectionListModel -import com.intellij.ui.GroupHeaderSeparator -import com.intellij.ui.NewUI import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.BorderLayout import java.awt.FlowLayout import java.awt.Point import java.awt.Rectangle import javax.swing.Icon import javax.swing.JList import javax.swing.JPanel -import javax.swing.ListCellRenderer import javax.swing.SwingConstants private const val FAVORITE_CLICK_AREA_WIDTH = 32 -internal class ModelPickerRenderer( - private val model: CollectionListModel, - private val active: () -> String?, +internal class ModelPickerRenderer private constructor( + model: CollectionListModel, + active: () -> String?, private val favorites: () -> Set, -) : JPanel(BorderLayout()), ListCellRenderer { + private val parts: Parts, +) : PickerListRenderer( + model = model, + checked = { it.key == active() }, + sectionTitle = ::modelPickerSectionTitle, + content = parts.head, + trailing = parts.star, +) { + constructor( + model: CollectionListModel, + active: () -> String?, + favorites: () -> Set, + ) : this(model, active, favorites, Parts.create()) + companion object { val DATA_COLLECTED: Icon = IconLoader.getIcon("/icons/book-open-check.svg", ModelPickerRenderer::class.java) - val checked: Icon = AllIcons.Actions.Checked - val empty: Icon = EmptyIcon.create(checked) + val checked: Icon = PickerListRenderer.checkedIcon + val empty: Icon = PickerListRenderer.emptyIcon fun isFavoriteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean { - val width = JBUI.scale(FAVORITE_CLICK_AREA_WIDTH) - val inset = favoriteInset(list) - if (list.componentOrientation.isLeftToRight) { - val right = bounds.x + bounds.width - inset - return point.x in (right - width)..right - } - val left = bounds.x + inset - return point.x in left..(left + width) + return PickerListRenderer.trailingClickZone(list, bounds, point, FAVORITE_CLICK_AREA_WIDTH) } - - private fun favoriteInset(list: JList<*>): Int { - if (!NewUI.isEnabled()) return 0 - val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets() - val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get() - return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left - } - } - - private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets()) - private val top = JPanel(BorderLayout()).apply { - border = JBUI.Borders.empty() - add(sep, BorderLayout.NORTH) - } - private val check = JBLabel().apply { - horizontalAlignment = SwingConstants.CENTER - verticalAlignment = SwingConstants.CENTER - } - private val title = SimpleColoredComponent() - private val badge = FilledBadgeIcon( - ModelText.freeLabel(), - UiStyle.Badge.Highlight, - ) - private val badgeLabel = BadgeLabel(badge).apply { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - } - private val byok = FilledBadgeIcon( - "BYOK", - UiStyle.Badge.Highlight, - ) - private val byokLabel = BadgeLabel(byok).apply { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - } - private val warn = JBLabel(DATA_COLLECTED).apply { - toolTipText = ModelText.dataCollected() - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - } - private val provider = JBLabel() - private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { - add(title) - add(warn) - add(badgeLabel) - add(byokLabel) - add(provider) - } - private val star = JBLabel().apply { - horizontalAlignment = SwingConstants.CENTER - verticalAlignment = SwingConstants.CENTER - } - private val row = JPanel(BorderLayout()).apply { - add(check, BorderLayout.WEST) - add(head, BorderLayout.CENTER) - } - private val wrap = PickerRow() - - init { - isOpaque = true - top.isOpaque = true - UiStyle.Components.transparent(row, check, title, head, warn, provider, star) - row.border = JBUI.Borders.empty( - UiStyle.Gap.md(), - UiStyle.Gap.lg(), - UiStyle.Gap.md(), - UiStyle.Gap.pad(), - ) - wrap.setContent(row, star) - add(top, BorderLayout.NORTH) - add(wrap, BorderLayout.CENTER) } override fun getListCellRendererComponent( @@ -123,70 +56,105 @@ internal class ModelPickerRenderer( selected: Boolean, focused: Boolean, ): JPanel { - val focus = selected || list.hasFocus() || focused - val fg = UIUtil.getListForeground(selected, focus) - val weak = if (selected) fg else UiStyle.Colors.weak() - val current = model.items.getOrNull(index) - val section = if (current === value) modelPickerSectionTitle(model.items, index) else null + return super.getListCellRendererComponent(list, value, index, selected, focused) as JPanel + } - background = list.background - top.background = list.background - wrap.update(list, selected, focus) - sep.caption = section - sep.setHideLine(index == 0) - top.isVisible = section != null - - check.icon = if (value.key == active()) checked else empty - title.clear() + override fun update( + value: ModelPickerRow, + index: Int, + selected: Boolean, + focused: Boolean, + foreground: java.awt.Color, + weak: java.awt.Color, + ) { + parts.title.clear() val item = value.item if (item == null) { - title.append(value.emptyText, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) - badgeLabel.isVisible = false - byokLabel.isVisible = false - warn.isVisible = false - provider.isVisible = false - star.icon = EmptyIcon.ICON_16 - top.invalidate() - return this + parts.title.append(value.emptyText, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, foreground)) + parts.badgeLabel.isVisible = false + parts.byokLabel.isVisible = false + parts.warn.isVisible = false + parts.provider.isVisible = false + parts.star.icon = EmptyIcon.ICON_16 + return } val name = ModelText.parts(item) if (name.provider != null) { - title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) - title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) + parts.title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) + parts.title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) } - title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) + parts.title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, foreground)) - warn.isVisible = ModelText.collectsData(item) - badgeLabel.isVisible = item.free && !item.byok - byokLabel.isVisible = item.byok - provider.isVisible = value.favorite - provider.text = item.providerName - provider.foreground = weak - provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + parts.warn.isVisible = ModelText.collectsData(item) + parts.badgeLabel.isVisible = item.free && !item.byok + parts.byokLabel.isVisible = item.byok + parts.provider.isVisible = value.favorite + parts.provider.text = item.providerName + parts.provider.foreground = weak + parts.provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) val fav = item.key in favorites() - star.icon = when { + parts.star.icon = when { fav -> AllIcons.Nodes.Favorite selected -> AllIcons.Nodes.NotFavoriteOnHover else -> EmptyIcon.ICON_16 } - - top.invalidate() - - return this } - internal fun starIcon(): Icon? = star.icon + internal fun starIcon(): Icon? = parts.star.icon - internal fun badgeVisible(): Boolean = badgeLabel.isVisible + internal fun badgeVisible(): Boolean = parts.badgeLabel.isVisible - internal fun badgeText(): String = badge.text + internal fun badgeText(): String = parts.badge.text - internal fun byokVisible(): Boolean = byokLabel.isVisible + internal fun byokVisible(): Boolean = parts.byokLabel.isVisible - internal fun warningVisible(): Boolean = warn.isVisible + internal fun warningVisible(): Boolean = parts.warn.isVisible - internal fun warningTooltip(): String? = warn.toolTipText + internal fun warningTooltip(): String? = parts.warn.toolTipText private class BadgeLabel(icon: Icon) : JBLabel(icon) + + private data class Parts( + val title: SimpleColoredComponent, + val badge: FilledBadgeIcon, + val badgeLabel: BadgeLabel, + val byokLabel: BadgeLabel, + val warn: JBLabel, + val provider: JBLabel, + val star: JBLabel, + val head: JPanel, + ) { + companion object { + fun create(): Parts { + val title = SimpleColoredComponent() + val badge = FilledBadgeIcon(ModelText.freeLabel(), UiStyle.Badge.Highlight) + val badgeLabel = BadgeLabel(badge).apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + val byok = FilledBadgeIcon("BYOK", UiStyle.Badge.Highlight) + val byokLabel = BadgeLabel(byok).apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + val warn = JBLabel(DATA_COLLECTED).apply { + toolTipText = ModelText.dataCollected() + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + val provider = JBLabel() + val star = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + verticalAlignment = SwingConstants.CENTER + } + val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + add(title) + add(warn) + add(badgeLabel) + add(byokLabel) + add(provider) + } + UiStyle.Components.transparent(title, head, warn, provider, star) + return Parts(title, badge, badgeLabel, byokLabel, warn, provider, star, head) + } + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerListRenderer.kt new file mode 100644 index 00000000000..676dff5ab68 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerListRenderer.kt @@ -0,0 +1,118 @@ +package ai.kilocode.client.ui.picker + +import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.ui.CollectionListModel +import com.intellij.ui.GroupHeaderSeparator +import com.intellij.ui.NewUI +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.EmptyIcon +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Point +import java.awt.Rectangle +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.ListCellRenderer +import javax.swing.SwingConstants + +internal abstract class PickerListRenderer( + private val model: CollectionListModel, + private val checked: (T) -> Boolean, + private val sectionTitle: (List, Int) -> String?, + protected val content: JComponent, + private val trailing: JComponent? = null, +) : JPanel(BorderLayout()), ListCellRenderer { + companion object { + val checkedIcon: Icon = AllIcons.Actions.Checked + val emptyIcon: Icon = EmptyIcon.create(checkedIcon) + + fun trailingClickZone(list: JList<*>, bounds: Rectangle, point: Point, width: Int): Boolean { + val size = JBUI.scale(width) + val inset = trailingInset(list) + if (list.componentOrientation.isLeftToRight) { + val right = bounds.x + bounds.width - inset + return point.x in (right - size)..right + } + val left = bounds.x + inset + return point.x in left..(left + size) + } + + private fun trailingInset(list: JList<*>): Int { + if (!NewUI.isEnabled()) return 0 + val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets() + val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get() + return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left + } + } + + private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets()) + private val top = JPanel(BorderLayout()).apply { + border = JBUI.Borders.empty() + add(sep, BorderLayout.NORTH) + } + private val check = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + verticalAlignment = SwingConstants.CENTER + } + private val row = JPanel(BorderLayout()).apply { + border = JBUI.Borders.empty( + UiStyle.Gap.md(), + UiStyle.Gap.lg(), + UiStyle.Gap.md(), + UiStyle.Gap.pad(), + ) + add(check, BorderLayout.WEST) + add(content, BorderLayout.CENTER) + } + private val wrap = PickerRow() + + init { + isOpaque = true + top.isOpaque = true + UiStyle.Components.transparent(row, check, content) + trailing?.let { UiStyle.Components.transparent(it) } + wrap.setContent(row, trailing) + add(top, BorderLayout.NORTH) + add(wrap, BorderLayout.CENTER) + } + + override fun getListCellRendererComponent( + list: JList, + value: T, + index: Int, + selected: Boolean, + focused: Boolean, + ): Component { + val focus = selected || list.hasFocus() || focused + val fg = UIUtil.getListForeground(selected, focus) + val weak = if (selected) fg else UiStyle.Colors.weak() + val current = model.items.getOrNull(index) + val section = if (current === value) sectionTitle(model.items, index) else null + + background = list.background + top.background = list.background + wrap.update(list, selected, focus) + sep.caption = section + sep.setHideLine(index == 0) + top.isVisible = section != null + check.icon = if (checked(value)) checkedIcon else emptyIcon + update(value, index, selected, focus, fg, weak) + top.invalidate() + return this + } + + protected abstract fun update( + value: T, + index: Int, + selected: Boolean, + focused: Boolean, + foreground: java.awt.Color, + weak: java.awt.Color, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt new file mode 100644 index 00000000000..4736d259d3f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt @@ -0,0 +1,353 @@ +package ai.kilocode.client.ui.picker + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.Disposable +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.PopupShowOptions +import com.intellij.openapi.ui.popup.util.PopupUtil +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.CollectionListModel +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.ListUtil +import com.intellij.ui.NewUI +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.ScrollingUtil +import com.intellij.ui.SearchTextField +import com.intellij.ui.components.JBList +import com.intellij.ui.popup.AbstractPopup +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Cursor +import java.awt.Dimension +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.JScrollPane +import javax.swing.KeyStroke +import javax.swing.ListSelectionModel +import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities +import javax.swing.event.DocumentEvent + +internal val popupBackground: Color + get() = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground() + +private val EXPAND: Icon = IconLoader.getIcon("/icons/expand.svg", PickerPopup::class.java) +private val COLLAPSE: Icon = IconLoader.getIcon("/icons/collapse.svg", PickerPopup::class.java) + +internal class PickerPopup( + private val anchor: JComponent, + private val placement: Placement, + private val rows: (String) -> List, + private val model: CollectionListModel, + private val renderer: PickerListRenderer, + private val key: (T) -> Any? = { it as Any }, + private val mode: Mode, + private val onPrimary: (T) -> Unit, + private val sectionTitle: (List, Int) -> String? = { _, _ -> null }, + private val trailingHit: ((JList<*>, java.awt.Rectangle, java.awt.Point) -> Boolean)? = null, + private val onTrailing: ((T) -> Unit)? = null, + private val search: Boolean = false, + private val toolbar: List = emptyList(), + private val details: JComponent? = null, + private val onPreview: (T?) -> Unit = {}, + private val expandStateKey: String? = null, + private val minWidth: Int = 420, + private val maxWidth: Int = 760, + private val maxVisibleRows: Int = 10, + private val emptyListHeight: Int = 120, + private val emptyText: String = KiloBundle.message("model.picker.no.matches"), +) { + enum class Placement { ABOVE, BELOW, UNDERNEATH } + enum class Mode { Single, Multi } + + private val props get() = PropertiesComponent.getInstance() + private var expanded = expandStateKey?.let { props.getBoolean(it, false) } ?: false + private val list = JBList(model).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + isFocusable = !search + this.emptyText.text = this@PickerPopup.emptyText + background = popupBackground + border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false)) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + cellRenderer = renderer + } + private val field = if (search) SearchTextField(false).apply { + textEditor.emptyText.text = KiloBundle.message("model.picker.search") + } else null + private val expand = details?.let { + HoverIcon().apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } + } + private lateinit var popup: JBPopup + private lateinit var content: JPanel + private lateinit var scroll: JScrollPane + private var shown = false + + fun show(): JBPopup { + installSearch() + installKeys(list) + installMouse() + installExpand() + list.addListSelectionListener { + if (!it.valueIsAdjusting && expanded) preview() + } + ListUtil.installAutoSelectOnMouseMove(list) + ScrollingUtil.installActions(list) + + val head = header() + scroll = ScrollPaneFactory.createScrollPane(list).apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + background = popupBackground + viewport.background = popupBackground + viewport.isOpaque = true + } + content = JPanel(BorderLayout()).apply { + background = popupBackground + border = JBUI.Borders.empty() + add(head, BorderLayout.NORTH) + add(scroll, BorderLayout.CENTER) + details?.let { add(it, BorderLayout.EAST) } + } + PopupUtil.applyNewUIBackground(list) + list.background = popupBackground + field?.let { + AbstractPopup.customizeSearchFieldLook(it, true) + it.background = popupBackground + } + refresh() + syncExpand() + preview() + resize(head) + popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(content, field?.textEditor ?: list) + .setRequestFocus(true) + .setFocusable(true) + .setCancelOnClickOutside(true) + .setCancelKeyEnabled(true) + .setCancelOnWindowDeactivation(true) + .setLocateWithinScreenBounds(true) + .setResizable(false) + .setMovable(false) + .createPopup() + if (details is Disposable) Disposer.register(popup, details) + when (placement) { + Placement.ABOVE -> popup.show(PopupShowOptions.aboveComponent(anchor)) + Placement.BELOW, + Placement.UNDERNEATH -> popup.showUnderneathOf(anchor) + } + shown = true + SwingUtilities.invokeLater { + field?.let { + it.textEditor.requestFocusInWindow() + it.selectText() + } ?: list.requestFocusInWindow() + list.selectedIndex.takeIf { it >= 0 }?.let(list::ensureIndexIsVisible) + } + return popup + } + + fun refresh(prefer: Any? = selectedKey(), at: Int? = null) { + val data = rows(field?.text.orEmpty()) + model.replaceAll(data) + val idx = at?.takeIf { it in data.indices } + ?: prefer?.let { value -> data.indexOfFirst { it == value || key(it) == value }.takeIf { it >= 0 } } + ?: data.indices.firstOrNull() + ?: -1 + if (idx >= 0) choose(idx) else list.clearSelection() + preview() + } + + fun repaint() { + list.repaint() + } + + private fun header(): JComponent { + val ins = PopupUtil.getListInsets(false, false) + val pad = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get() + val head = JPanel(BorderLayout()).apply { + background = popupBackground + border = JBUI.Borders.empty(ins.top, ins.left, ins.bottom, pad) + } + field?.let { head.add(it.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER) } + val actions = toolbar + listOfNotNull(expand) + if (actions.isNotEmpty()) { + val bar = Stack.horizontal(JBUI.CurrentTheme.ActionsList.elementIconGap()).apply { + actions.forEach { next(it) } + } + head.add(bar.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST) + } + return head + } + + private fun installSearch() { + val editor = field?.textEditor ?: return + editor.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + refresh() + } + }) + editor.registerKeyboardAction({ move(-1) }, KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), JComponent.WHEN_FOCUSED) + editor.registerKeyboardAction({ move(1) }, KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), JComponent.WHEN_FOCUSED) + installKeys(editor) + } + + private fun installKeys(component: JComponent) { + component.registerKeyboardAction({ list.selectedValue?.let(::primary) }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED) + component.registerKeyboardAction({ popup.cancel() }, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_FOCUSED) + if (mode == Mode.Multi) { + component.registerKeyboardAction({ list.selectedValue?.let(::primary) }, KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), JComponent.WHEN_FOCUSED) + return + } + if (onTrailing != null) { + component.registerKeyboardAction({ list.selectedValue?.let(::trailing) }, KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK), JComponent.WHEN_FOCUSED) + } + } + + private fun installMouse() { + list.addMouseListener(object : MouseAdapter() { + override fun mouseReleased(e: MouseEvent) { + if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return + val idx = list.locationToIndex(e.point).takeIf { it >= 0 } ?: return + val bounds = list.getCellBounds(idx, idx) ?: return + if (!bounds.contains(e.point)) return + val value = model.getElementAt(idx) + if (trailingHit?.invoke(list, bounds, e.point) == true) { + trailing(value) + e.consume() + return + } + primary(value) + e.consume() + } + }) + } + + private fun installExpand() { + expand?.addActionListener { setExpanded(!expanded) } + } + + private fun primary(value: T) { + onPrimary(value) + if (mode == Mode.Single) { + popup.closeOk(null) + return + } + refresh(at = list.selectedIndex) + list.repaint() + } + + private fun trailing(value: T) { + val block = onTrailing ?: return + val idx = list.selectedIndex + block(value) + refresh(at = idx) + list.getCellBounds(list.selectedIndex, list.selectedIndex)?.let(list::repaint) + preview() + } + + private fun choose(idx: Int) { + list.selectedIndex = idx + ScrollingUtil.ensureIndexIsVisible(list, idx, 0) + } + + private fun move(step: Int) { + val size = model.size + if (size <= 0) return + val cur = list.selectedIndex.takeIf { it >= 0 } ?: 0 + choose((cur + step).coerceIn(0, size - 1)) + } + + private fun preview() { + onPreview(list.selectedValue) + } + + private fun setExpanded(value: Boolean) { + if (expanded == value) return + expanded = value + expandStateKey?.let { props.setValue(it, value.toString()) } + if (!expanded) list.clearSelection() + syncExpand() + preview() + resize(content.getComponent(0) as JComponent) + } + + private fun syncExpand() { + val details = details ?: return + val expand = expand ?: return + expand.icon = if (expanded) COLLAPSE else EXPAND + expand.toolTipText = if (expanded) { + KiloBundle.message("model.picker.details.minimize") + } else { + KiloBundle.message("model.picker.details.maximize") + } + expand.accessibleContext.accessibleName = expand.toolTipText + details.isVisible = expanded + } + + private fun resize(head: JComponent) { + val size = computeInitialPopupSize(list, scroll, head, expanded) + content.preferredSize = size + if (expanded && details != null) { + details.preferredSize = Dimension(size.width - scroll.preferredSize.width, scroll.preferredSize.height) + } + content.revalidate() + content.repaint() + if (shown) popup.setSize(size) + } + + private fun selectedKey(): Any? = list.selectedValue?.let(key) + + private fun computeInitialPopupSize(list: JList, scroll: JScrollPane, head: JComponent, expanded: Boolean): Dimension { + val width = maxOf(computeListPreferredWidth(list), head.preferredSize.width.coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth))) + list.fixedCellWidth = width + val height = computeListPreferredHeight(list) + val bar = if (list.model.size > maxVisibleRows) scroll.verticalScrollBar.preferredSize.width else 0 + val listWidth = width + bar + val detailWidth = if (expanded && details != null) width else 0 + val size = Dimension(listWidth + detailWidth, head.preferredSize.height + height) + scroll.preferredSize = Dimension(listWidth, height) + return size + } + + private fun computeListPreferredWidth(list: JList): Int { + val renderer = list.cellRenderer ?: return JBUI.scale(minWidth) + val model = list.model + val max = (0 until model.size).maxOfOrNull { idx -> + val value = model.getElementAt(idx) + renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.width + } ?: 0 + val ins = list.insets + return (max + ins.left + ins.right).coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth)) + } + + private fun computeListPreferredHeight(list: JList): Int { + val renderer = list.cellRenderer ?: return JBUI.scale(emptyListHeight) + val model = list.model + val count = model.size.coerceAtMost(maxVisibleRows) + if (count <= 0) return JBUI.scale(emptyListHeight) + val height = (0 until count).sumOf { idx -> + val value = model.getElementAt(idx) + renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.height + } + val ins = list.insets + return height + ins.top + ins.bottom + } +} From e19feecc4f3a737f7be87d9bd4992a76c29472ed Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 15 Jul 2026 14:31:59 -0400 Subject: [PATCH 19/28] feat(jetbrains): improve custom model picker --- .../settings/providers/ProvidersSettingsUi.kt | 225 +++++++----------- .../resources/messages/KiloBundle.properties | 3 +- .../providers/ProvidersSettingsUiTest.kt | 47 +++- 3 files changed, 125 insertions(+), 150 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt index 93b328c2af1..4c533f9c168 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt @@ -11,9 +11,10 @@ import ai.kilocode.client.settings.base.SettingsListView import ai.kilocode.client.settings.auth.DeviceOAuthInfo import ai.kilocode.client.settings.auth.DeviceOAuthPanel import ai.kilocode.client.settings.auth.DeviceOAuthText -import ai.kilocode.client.session.ui.PickerRow import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.picker.PickerListRenderer +import ai.kilocode.client.ui.picker.PickerPopup import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomModelFetchDto @@ -47,23 +48,13 @@ import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.ValidationInfo import com.intellij.ui.CollectionListModel import com.intellij.ui.DocumentAdapter -import com.intellij.ui.GroupHeaderSeparator -import com.intellij.ui.ListUtil import com.intellij.ui.SearchTextField -import com.intellij.ui.ScrollingUtil import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBList import com.intellij.ui.components.JBPasswordField -import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextField +import com.intellij.ui.components.ActionLink import com.intellij.openapi.ui.popup.JBPopup -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.ui.popup.util.PopupUtil -import com.intellij.ui.NewUI import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.util.ui.EmptyIcon -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -73,20 +64,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout import java.awt.Component -import java.awt.Cursor -import java.awt.Dimension import java.awt.event.KeyEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.Icon import javax.swing.JButton import javax.swing.JComponent import javax.swing.DefaultListCellRenderer import javax.swing.JList import javax.swing.JPanel +import javax.swing.JSeparator import javax.swing.KeyStroke -import javax.swing.ListCellRenderer -import javax.swing.ListSelectionModel import javax.swing.SwingConstants import javax.swing.event.DocumentEvent import javax.swing.Timer @@ -97,7 +82,7 @@ private val OAUTH_CODE_RE = Regex("""code:\s*(\S+)""", RegexOption.IGNORE_CASE) private fun oauthCode(text: String?): String? = text?.let { OAUTH_CODE_RE.find(it)?.groupValues?.getOrNull(1) } -private const val CUSTOM_MODEL_POPUP_MIN_WIDTH = 320 +private const val CUSTOM_MODEL_POPUP_WIDTH = 320 private const val CUSTOM_MODEL_POPUP_MAX_ROWS = 10 // Inline error text for a custom-provider save. A blank result with the provider missing from the @@ -108,69 +93,27 @@ internal fun customSaveError(id: String, result: ProviderActionResultDto): Strin return null } -internal data class CustomModelRow( - val id: String? = null, - val selectAll: Boolean = false, -) +private class CustomModelRenderer( + model: CollectionListModel, + selected: () -> Set, +) : PickerListRenderer( + model = model, + checked = { it in selected() }, + sectionTitle = { _, _ -> null }, + content = JBLabel(), +) { + private val label = content as JBLabel -internal fun customModelRows(ids: List): List { - if (ids.isEmpty()) return emptyList() - return listOf(CustomModelRow(selectAll = true)) + ids.map { CustomModelRow(id = it) } -} - -private class CustomModelRowRenderer( - private val all: Set, - private val selectedIds: () -> Set, -) : JPanel(BorderLayout()), ListCellRenderer { - private val checked: Icon = AllIcons.Actions.Checked - private val empty: Icon = EmptyIcon.create(checked) - private val check = JBLabel().apply { - horizontalAlignment = SwingConstants.CENTER - verticalAlignment = SwingConstants.CENTER - } - private val title = JBLabel().apply { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - } - private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets()).apply { - setHideLine(false) - } - private val row = JPanel(BorderLayout()).apply { - border = JBUI.Borders.empty( - UiStyle.Gap.md(), - UiStyle.Gap.lg(), - UiStyle.Gap.md(), - UiStyle.Gap.pad(), - ) - add(check, BorderLayout.WEST) - add(title, BorderLayout.CENTER) - } - private val wrap = PickerRow() - - init { - isOpaque = true - UiStyle.Components.transparent(row, check, title) - wrap.setContent(row) - add(wrap, BorderLayout.CENTER) - } - - override fun getListCellRendererComponent( - list: JList, - value: CustomModelRow, + override fun update( + value: String, index: Int, selected: Boolean, - focus: Boolean, - ): Component { - val current = selectedIds() - val active = selected || focus || list.hasFocus() - val on = if (value.selectAll) all.isNotEmpty() && all.all { it in current } else value.id in current - check.icon = if (on) checked else empty - title.text = if (value.selectAll) KiloBundle.message("settings.providers.customModelsSelectAll") else value.id.orEmpty() - background = list.background - wrap.update(list, selected, active) - title.foreground = UIUtil.getListForeground(selected, active) - remove(sep) - if (value.selectAll) add(sep, BorderLayout.SOUTH) - return this + focused: Boolean, + foreground: java.awt.Color, + weak: java.awt.Color, + ) { + label.text = value + label.foreground = foreground } } @@ -930,77 +873,40 @@ internal class CustomProviderDialog( private fun showModelPopup(ids: List) { checkEdt() popup?.cancel() - val rows = customModelRows(ids) - val data = CollectionListModel(rows) - val listBackground = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground() - val list = JBList(data).apply { - selectionMode = ListSelectionModel.SINGLE_SELECTION - isFocusable = true - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - background = listBackground - border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false)) - cellRenderer = CustomModelRowRenderer(ids.toSet()) { modelIds().toSet() } - visibleRowCount = rows.size.coerceAtMost(CUSTOM_MODEL_POPUP_MAX_ROWS) - } + val data = CollectionListModel(ids) + val select = ActionLink(KiloBundle.message("settings.providers.customModelsSelectAll")) + val clear = ActionLink(KiloBundle.message("settings.providers.customModelsUnselectAll")) + lateinit var picker: PickerPopup fun sync() { - list.repaint() + picker.repaint() syncActions() } - fun toggle(row: CustomModelRow) { - if (row.selectAll) { - val all = modelIds().toSet().containsAll(ids) - setModelIds(if (all) emptyList() else ids) - sync() - return - } - val id = row.id ?: return - val selected = modelIds().toMutableSet() - if (!selected.add(id)) selected.remove(id) - setModelIds(ids.filter { it in selected }) + select.addActionListener { + selectAllModels(ids) sync() } - list.addMouseListener(object : MouseAdapter() { - override fun mouseReleased(e: MouseEvent) { - if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return - val idx = list.locationToIndex(e.point).takeIf { it >= 0 } ?: return - val bounds = list.getCellBounds(idx, idx) ?: return - if (!bounds.contains(e.point)) return - toggle(data.getElementAt(idx)) - e.consume() - } - }) - list.registerKeyboardAction( - { list.selectedValue?.let(::toggle) }, - KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), - JComponent.WHEN_FOCUSED, - ) - list.registerKeyboardAction( - { list.selectedValue?.let(::toggle) }, - KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), - JComponent.WHEN_FOCUSED, - ) - ListUtil.installAutoSelectOnMouseMove(list) - ScrollingUtil.installActions(list) - val scroll = JBScrollPane(list).apply { - border = JBUI.Borders.empty() - viewportBorder = JBUI.Borders.empty() - background = listBackground - viewport.background = listBackground - viewport.isOpaque = true - preferredSize = Dimension(JBUI.scale(CUSTOM_MODEL_POPUP_MIN_WIDTH), preferredSize.height) + clear.addActionListener { + clearModels() + sync() } - popup = JBPopupFactory.getInstance() - .createComponentPopupBuilder(scroll, list) - .setRequestFocus(true) - .setFocusable(true) - .setCancelOnClickOutside(true) - .setCancelKeyEnabled(true) - .setCancelOnWindowDeactivation(true) - .setLocateWithinScreenBounds(true) - .setResizable(false) - .setMovable(false) - .createPopup() - popup?.showUnderneathOf(pick) + picker = PickerPopup( + anchor = pick, + placement = PickerPopup.Placement.UNDERNEATH, + rows = { query -> customModelRows(ids, query) }, + model = data, + renderer = CustomModelRenderer(data) { modelIds().toSet() }, + mode = PickerPopup.Mode.Multi, + onPrimary = { + toggleModel(it, ids) + syncActions() + }, + search = true, + toolbar = listOf(select, JSeparator(SwingConstants.VERTICAL), clear), + minWidth = CUSTOM_MODEL_POPUP_WIDTH, + maxWidth = CUSTOM_MODEL_POPUP_WIDTH, + maxVisibleRows = CUSTOM_MODEL_POPUP_MAX_ROWS, + ) + popup = picker.show() } private fun modelIds(): List { @@ -1022,6 +928,35 @@ internal class CustomProviderDialog( KiloBundle.message("settings.providers.customSelectModels") } } + private fun customModelRows(ids: List, query: String): List { + val text = query.trim() + if (text.isEmpty()) return ids + return ids.filter { it.contains(text, ignoreCase = true) } + } + + @RequiresEdt + internal fun toggleModel(id: String, order: List = modelIds() + id) { + checkEdt() + val selected = modelIds().toMutableSet() + if (!selected.add(id)) selected.remove(id) + setModelIds(order.filter { it in selected }) + syncActions() + } + + @RequiresEdt + internal fun selectAllModels(ids: Collection) { + checkEdt() + setModelIds(ids) + syncActions() + } + + @RequiresEdt + internal fun clearModels() { + checkEdt() + setModelIds(emptyList()) + syncActions() + } + override fun dispose() { active = false diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 320c7d5af16..70461207ea0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -480,7 +480,8 @@ settings.providers.customName=Display name settings.providers.customUrl=Base URL settings.providers.customEnv=API key environment variable settings.providers.customModels=Model IDs (comma-separated) -settings.providers.customModelsSelectAll=Select All +settings.providers.customModelsSelectAll=All +settings.providers.customModelsUnselectAll=None settings.providers.customModelsEmpty=No models found at this Base URL. settings.providers.customIdRequired=Provider ID is required. settings.providers.customUrlRequired=Base URL is required. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index fb0817bb8dc..9a029aaf614 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -90,11 +90,50 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { assertNull(customSaveError("my-openai", result)) } - fun `test custom model rows start with select all`() { - val rows = customModelRows(listOf("gpt-4o", "gpt-4o-mini")) + fun `test custom dialog toggles model ids`() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val dialog = edt { + CustomProviderDialog( + cs, + "/tmp", + { CustomModelFetchResultDto(listOf("gpt-4o")) }, + { ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) }, + ) + } - assertTrue(rows[0].selectAll) - assertEquals(listOf("gpt-4o", "gpt-4o-mini"), rows.drop(1).map { it.id }) + edt { + val field = components(center(dialog)).filterIsInstance()[5] + dialog.toggleModel("gpt-4o", listOf("gpt-4o", "gpt-4o-mini")) + assertEquals("gpt-4o", field.text) + dialog.toggleModel("gpt-4o", listOf("gpt-4o", "gpt-4o-mini")) + assertEquals("", field.text) + dispose(dialog) + } + } + + fun `test custom dialog selects and clears model ids`() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val dialog = edt { + CustomProviderDialog( + cs, + "/tmp", + { CustomModelFetchResultDto(listOf("gpt-4o")) }, + { ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) }, + ) + } + + edt { + val field = components(center(dialog)).filterIsInstance()[5] + dialog.selectAllModels(listOf("gpt-4o", "gpt-4o-mini")) + assertEquals("gpt-4o, gpt-4o-mini", field.text) + assertTrue(dialog.isOKActionEnabled) + dialog.clearModels() + assertEquals("", field.text) + assertFalse(dialog.isOKActionEnabled) + dispose(dialog) + } } fun `test custom dialog add is disabled until model list exists`() { From 0d13b79d2c2a9ff2206dfcf07b42ef9b1f22d276 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Wed, 15 Jul 2026 18:45:17 +0000 Subject: [PATCH 20/28] release: v7.4.9 --- .changeset/calm-tools-speak.md | 5 --- .changeset/chat-search-toggle-and-jump.md | 5 --- .changeset/file-mention-spaces-unicode.md | 5 --- .changeset/tidy-process-probes.md | 5 --- bun.lock | 48 ++++++++++----------- package.json | 2 +- packages/core/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++--- packages/http-recorder/package.json | 2 +- packages/kilo-console/package.json | 2 +- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-indexing/package.json | 2 +- packages/kilo-memory/package.json | 2 +- packages/kilo-sandbox/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/CHANGELOG.md | 8 ++++ packages/kilo-vscode/package.json | 2 +- packages/kilo-vscode/tests/package.json | 2 +- packages/kilo-web-ui/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/CHANGELOG.md | 8 ++++ packages/opencode/package.json | 2 +- packages/plugin-atomic-chat/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- 35 files changed, 73 insertions(+), 77 deletions(-) delete mode 100644 .changeset/calm-tools-speak.md delete mode 100644 .changeset/chat-search-toggle-and-jump.md delete mode 100644 .changeset/file-mention-spaces-unicode.md delete mode 100644 .changeset/tidy-process-probes.md diff --git a/.changeset/calm-tools-speak.md b/.changeset/calm-tools-speak.md deleted file mode 100644 index 9e288ffcff3..00000000000 --- a/.changeset/calm-tools-speak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix Agent Manager tool calls through providers that require object-root input schemas without root combinators. diff --git a/.changeset/chat-search-toggle-and-jump.md b/.changeset/chat-search-toggle-and-jump.md deleted file mode 100644 index d4a5563bf90..00000000000 --- a/.changeset/chat-search-toggle-and-jump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Toggle chat search from the Command Palette, jump focus back to the chat input when it closes, and auto-expand the collapsed tool call or reasoning block containing the current search match. diff --git a/.changeset/file-mention-spaces-unicode.md b/.changeset/file-mention-spaces-unicode.md deleted file mode 100644 index da2b09b6410..00000000000 --- a/.changeset/file-mention-spaces-unicode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix file @mentions for filenames containing spaces or non-ASCII characters. Such mentions now stay fully highlighted in the sent message and are read correctly instead of failing with "File not found". diff --git a/.changeset/tidy-process-probes.md b/.changeset/tidy-process-probes.md deleted file mode 100644 index 544ed48a4db..00000000000 --- a/.changeset/tidy-process-probes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Prevent stalled operating system process queries from blocking background process management. diff --git a/bun.lock b/bun.lock index 00724347729..3ec0011a36c 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.4.8", + "version": "7.4.9", "bin": { "opencode": "./bin/opencode", }, @@ -125,7 +125,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -139,7 +139,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "effect": "catalog:", }, @@ -151,7 +151,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -164,7 +164,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -186,7 +186,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -216,7 +216,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -252,7 +252,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.4.8", + "version": "7.4.9", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -262,7 +262,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -298,7 +298,7 @@ }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -312,7 +312,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -327,7 +327,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -341,7 +341,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -378,7 +378,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -447,7 +447,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -464,7 +464,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -482,7 +482,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.4.8", + "version": "7.4.9", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -646,7 +646,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -674,7 +674,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -688,7 +688,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "semver": "^7.6.3", }, @@ -699,7 +699,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "cross-spawn": "catalog:", }, @@ -714,7 +714,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -728,7 +728,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.4.8", + "version": "7.4.9", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -751,7 +751,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.4.8", + "version": "7.4.9", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index e6b52d53b91..ca437a47889 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,6 @@ "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.4.8", + "version": "7.4.9", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index df7391b501c..71258f03f8f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.8", + "version": "7.4.9", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 89cea939bd5..bc1abf53125 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.8", + "version": "7.4.9", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index ef355a00418..d82444e39ad 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.8", + "version": "7.4.9", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index c15e01bf0e0..4c7e0a50dcc 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.4.8" +version = "7.4.9" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.8/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.8/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.8/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.8/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.8/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.9/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index fce11fc15f6..e3c68b29162 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.8", + "version": "7.4.9", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index d3d88f83bcf..5b56a8ee98c 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.8", + "version": "7.4.9", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 5df98e8eb7d..29f47459238 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.4.8", + "version": "7.4.9", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 5387adf9687..c587dc98420 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 12fef98e5fd..db095a6f54d 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 5fca32b440d..986ac31d512 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index b3fb5b6d9a3..a7b66b41f24 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index 48a98b0eb41..4113909502f 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 209f2337246..dbfa1768a28 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index d576813bcf0..29444b544a4 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 73bffe21191..6f29820b2a4 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,13 @@ # kilo-code +## 7.4.9 + +### Patch Changes + +- [#12194](https://github.com/Kilo-Org/kilocode/pull/12194) [`46fe0a9`](https://github.com/Kilo-Org/kilocode/commit/46fe0a91d91ec42931a3af78e409334f05de91ea) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Toggle chat search from the Command Palette, jump focus back to the chat input when it closes, and auto-expand the collapsed tool call or reasoning block containing the current search match. + +- [#11977](https://github.com/Kilo-Org/kilocode/pull/11977) [`a76dc77`](https://github.com/Kilo-Org/kilocode/commit/a76dc773805d6cdb67e66a0533b39f44487bf742) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Fix file @mentions for filenames containing spaces or non-ASCII characters. Such mentions now stay fully highlighted in the sent message and are read correctly instead of failing with "File not found". + ## 7.4.8 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 97e85c80692..181f8f17799 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.4.8", + "version": "7.4.9", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index fe4d10f0706..28f8cfb0350 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.8", + "version": "7.4.9", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index aa58b185a52..69f8a6241dd 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-web-ui", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "exports": { diff --git a/packages/llm/package.json b/packages/llm/package.json index 8c907c54707..df7f03f2cf4 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.8", + "version": "7.4.9", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 3a40be54dae..3c073a6b870 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,13 @@ # @kilocode/cli +## 7.4.9 + +### Patch Changes + +- [#12244](https://github.com/Kilo-Org/kilocode/pull/12244) [`fe41426`](https://github.com/Kilo-Org/kilocode/commit/fe4142630c7dddf19e81b2f3363e06b4aba8194a) - Fix Agent Manager tool calls through providers that require object-root input schemas without root combinators. + +- [#12243](https://github.com/Kilo-Org/kilocode/pull/12243) [`e4ceeae`](https://github.com/Kilo-Org/kilocode/commit/e4ceeaebb911a7350b9aaa7851aa39293c0892f8) - Prevent stalled operating system process queries from blocking background process management. + ## 7.4.8 ### Minor Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 81f7e0fd6c6..dd6e0fd41fa 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.8", + "version": "7.4.9", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index 26ec79f3184..017241d5000 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.8", + "version": "7.4.9", "description": "Kilo Code plugin for Atomic Chat: auto-detection and dynamic model discovery (OpenAI-compatible local API)", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 991e2f7e137..b478ceef8c2 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index d9be7150fe9..bce0a9d42f2 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.4.8", + "version": "7.4.9", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index d388c543d61..25c823ab636 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index fa7ea6f78b5..11e15eb5512 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "7.4.8", + "version": "7.4.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index cc47f4afd42..ccf1ee0cb79 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.4.8", + "version": "7.4.9", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 3229d34b1f6..21b76f1691d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.4.8", + "version": "7.4.9", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index 017b20ee9a5..4b0c3bfbed1 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.4.8", + "version": "7.4.9", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", From cae3270c9dacc4097a681539cf3e07cfadceaca7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 15 Jul 2026 17:22:11 -0400 Subject: [PATCH 21/28] fix(jetbrains): polish model picker popup and provider list actions - Style the model picker Close button as the primary/default button and fix its black background by using a plain JButton with the popup background - Use the standard action-list icon gap between the picker check and label - Show provider edit/delete on selection and use a trash icon for delete, matching the other settings lists --- .../jetbrains-model-picker-close-button.md | 5 ++ .../jetbrains-providers-list-actions.md | 5 ++ .../settings/providers/ProviderListRows.kt | 11 ++- .../client/ui/picker/PickerListRenderer.kt | 2 +- .../kilocode/client/ui/picker/PickerPopup.kt | 50 ++++++++--- .../providers/ProvidersSettingsUiTest.kt | 41 ++++++--- .../client/ui/picker/PickerPopupTest.kt | 89 +++++++++++++++++++ 7 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 .changeset/jetbrains-model-picker-close-button.md create mode 100644 .changeset/jetbrains-providers-list-actions.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/picker/PickerPopupTest.kt diff --git a/.changeset/jetbrains-model-picker-close-button.md b/.changeset/jetbrains-model-picker-close-button.md new file mode 100644 index 00000000000..1b003ff6a16 --- /dev/null +++ b/.changeset/jetbrains-model-picker-close-button.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Match the model picker Close button styling to JetBrains dialog primary buttons. diff --git a/.changeset/jetbrains-providers-list-actions.md b/.changeset/jetbrains-providers-list-actions.md new file mode 100644 index 00000000000..6f6ed458f21 --- /dev/null +++ b/.changeset/jetbrains-providers-list-actions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use a trash icon for provider delete and show provider edit/delete actions on selection, matching the other settings lists. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt index 016a43e153a..470719a053f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.settings.base.SettingsListCell import ai.kilocode.client.settings.base.SettingsListItem import ai.kilocode.rpc.dto.ProviderSettingsDto import ai.kilocode.rpc.dto.ProviderSettingsProviderDto +import com.intellij.icons.AllIcons import javax.swing.Icon internal enum class ProviderListAction { @@ -14,6 +15,7 @@ internal enum class ProviderListAction { OAUTH, EDIT, DISCONNECT, + DELETE, ENABLE, } @@ -21,7 +23,6 @@ internal data class ProviderListRow( val provider: ProviderSettingsProviderDto, override val section: String, val actions: List, - val connected: Boolean = false, override val disabled: Boolean = false, ) : SettingsListItem { override val key: String get() = provider.id @@ -39,7 +40,8 @@ internal data class ProviderListRow( action.name, providerListActionText(action), enabled(action), - alwaysVisible = (action == ProviderListAction.DISCONNECT || action == ProviderListAction.EDIT) && connected, + icon = if (action == ProviderListAction.DELETE) AllIcons.Actions.GC else null, + iconOnly = action == ProviderListAction.DELETE, primary = action == ProviderListAction.EDIT, ) } @@ -52,6 +54,7 @@ internal fun providerListActionText(action: ProviderListAction) = when (action) ProviderListAction.OAUTH -> KiloBundle.message("settings.providers.oauth") ProviderListAction.EDIT -> KiloBundle.message("settings.providers.edit") ProviderListAction.DISCONNECT -> KiloBundle.message("settings.providers.disconnect") + ProviderListAction.DELETE -> KiloBundle.message("settings.providers.delete") ProviderListAction.ENABLE -> KiloBundle.message("settings.providers.enable") } @@ -77,7 +80,7 @@ internal fun providerListRows(state: ProviderSettingsDto, query: String, disable .filter { !hiddenProvider(it) } .sortedWith(compareBy { it.name.lowercase() }.thenBy { it.id }) val rows = mutableListOf() - rows += connected.map { ProviderListRow(it, KiloBundle.message("settings.providers.connected"), providerActions(it, state, disabled), connected = true, disabled = disabledRows) } + rows += connected.map { ProviderListRow(it, KiloBundle.message("settings.providers.connected"), providerActions(it, state, disabled), disabled = disabledRows) } rows += popular.map { ProviderListRow(it, KiloBundle.message("settings.providers.popular"), providerActions(it, state, disabled), disabled = disabledRows) } rows += all.map { ProviderListRow(it, KiloBundle.message("settings.providers.all"), providerActions(it, state, disabled), disabled = disabledRows) } return rows @@ -92,7 +95,7 @@ internal fun providerActions( if (provider.id == KILO_PROVIDER_ID && configured(provider, state, state.connected.toSet())) return emptyList() if (configured(provider, state, state.connected.toSet())) { return if (customEditable(provider, state)) { - listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT) + listOf(ProviderListAction.EDIT, ProviderListAction.DELETE) } else { listOf(ProviderListAction.DISCONNECT) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerListRenderer.kt index 676dff5ab68..0faea5799b7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerListRenderer.kt @@ -60,7 +60,7 @@ internal abstract class PickerListRenderer( horizontalAlignment = SwingConstants.CENTER verticalAlignment = SwingConstants.CENTER } - private val row = JPanel(BorderLayout()).apply { + private val row = JPanel(BorderLayout(JBUI.CurrentTheme.ActionsList.elementIconGap(), 0)).apply { border = JBUI.Borders.empty( UiStyle.Gap.md(), UiStyle.Gap.lg(), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt index 4736d259d3f..2d3c8c38fea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt @@ -1,11 +1,14 @@ package ai.kilocode.client.ui.picker +import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align +import com.intellij.CommonBundle +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.Disposable import com.intellij.openapi.ui.popup.JBPopup @@ -34,6 +37,7 @@ import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.Icon +import javax.swing.JButton import javax.swing.JComponent import javax.swing.JList import javax.swing.JPanel @@ -58,6 +62,7 @@ internal class PickerPopup( private val renderer: PickerListRenderer, private val key: (T) -> Any? = { it as Any }, private val mode: Mode, + private val autoClose: Boolean = mode == Mode.Single, private val onPrimary: (T) -> Unit, private val sectionTitle: (List, Int) -> String? = { _, _ -> null }, private val trailingHit: ((JList<*>, java.awt.Rectangle, java.awt.Point) -> Boolean)? = null, @@ -95,7 +100,9 @@ internal class PickerPopup( } private lateinit var popup: JBPopup private lateinit var content: JPanel + private lateinit var head: JComponent private lateinit var scroll: JScrollPane + private var foot: JComponent? = null private var shown = false fun show(): JBPopup { @@ -109,7 +116,8 @@ internal class PickerPopup( ListUtil.installAutoSelectOnMouseMove(list) ScrollingUtil.installActions(list) - val head = header() + head = header() + foot = footer() scroll = ScrollPaneFactory.createScrollPane(list).apply { horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED @@ -124,6 +132,7 @@ internal class PickerPopup( border = JBUI.Borders.empty() add(head, BorderLayout.NORTH) add(scroll, BorderLayout.CENTER) + foot?.let { add(it, BorderLayout.SOUTH) } details?.let { add(it, BorderLayout.EAST) } } PopupUtil.applyNewUIBackground(list) @@ -135,7 +144,7 @@ internal class PickerPopup( refresh() syncExpand() preview() - resize(head) + resize() popup = JBPopupFactory.getInstance() .createComponentPopupBuilder(content, field?.textEditor ?: list) .setRequestFocus(true) @@ -184,7 +193,7 @@ internal class PickerPopup( val pad = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get() val head = JPanel(BorderLayout()).apply { background = popupBackground - border = JBUI.Borders.empty(ins.top, ins.left, ins.bottom, pad) + border = JBUI.Borders.empty(pad, ins.left, ins.bottom, pad) } field?.let { head.add(it.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER) } val actions = toolbar + listOfNotNull(expand) @@ -197,6 +206,22 @@ internal class PickerPopup( return head } + private fun footer(): JComponent? { + if (autoClose) return null + val btn = JButton(CommonBundle.getCloseButtonText()).apply { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + background = popupBackground + isFocusable = false + isRequestFocusEnabled = false + addActionListener { popup.closeOk(null) } + } + return JPanel(BorderLayout()).apply { + background = popupBackground + border = JBUI.Borders.empty(UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad()) + add(btn.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.CENTER) + } + } + private fun installSearch() { val editor = field?.textEditor ?: return editor.document.addDocumentListener(object : DocumentAdapter() { @@ -246,7 +271,7 @@ internal class PickerPopup( private fun primary(value: T) { onPrimary(value) - if (mode == Mode.Single) { + if (autoClose) { popup.closeOk(null) return } @@ -286,7 +311,7 @@ internal class PickerPopup( if (!expanded) list.clearSelection() syncExpand() preview() - resize(content.getComponent(0) as JComponent) + resize() } private fun syncExpand() { @@ -302,8 +327,8 @@ internal class PickerPopup( details.isVisible = expanded } - private fun resize(head: JComponent) { - val size = computeInitialPopupSize(list, scroll, head, expanded) + private fun resize() { + val size = computeInitialPopupSize(list, scroll, head, foot, expanded) content.preferredSize = size if (expanded && details != null) { details.preferredSize = Dimension(size.width - scroll.preferredSize.width, scroll.preferredSize.height) @@ -315,14 +340,19 @@ internal class PickerPopup( private fun selectedKey(): Any? = list.selectedValue?.let(key) - private fun computeInitialPopupSize(list: JList, scroll: JScrollPane, head: JComponent, expanded: Boolean): Dimension { - val width = maxOf(computeListPreferredWidth(list), head.preferredSize.width.coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth))) + private fun computeInitialPopupSize(list: JList, scroll: JScrollPane, head: JComponent, foot: JComponent?, expanded: Boolean): Dimension { + val width = maxOf( + computeListPreferredWidth(list), + head.preferredSize.width.coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth)), + foot?.preferredSize?.width?.coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth)) ?: 0, + ) list.fixedCellWidth = width val height = computeListPreferredHeight(list) val bar = if (list.model.size > maxVisibleRows) scroll.verticalScrollBar.preferredSize.width else 0 val listWidth = width + bar val detailWidth = if (expanded && details != null) width else 0 - val size = Dimension(listWidth + detailWidth, head.preferredSize.height + height) + val footHeight = foot?.preferredSize?.height ?: 0 + val size = Dimension(listWidth + detailWidth, head.preferredSize.height + height + footHeight) scroll.preferredSize = Dimension(listWidth, height) return size } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 9a029aaf614..84c823e1519 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -22,6 +22,7 @@ import ai.kilocode.rpc.dto.ProviderMetadataDto import ai.kilocode.rpc.dto.ProviderOAuthReadyDto import ai.kilocode.rpc.dto.ProviderSettingsDto import ai.kilocode.rpc.dto.ProviderSettingsProviderDto +import com.intellij.icons.AllIcons import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.ui.ValidationInfo import com.intellij.testFramework.replaceService @@ -396,7 +397,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } - fun `test configured custom provider exposes edit and disconnect`() { + fun `test configured custom provider exposes edit and delete`() { val content = content() edt { @@ -411,7 +412,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { edt { val row = rows(content).single() - assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT), row.actions) + assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DELETE), row.actions) assertTrue(row.badges.isEmpty()) } } @@ -424,7 +425,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { val row = providerListRows(state, "").single() - assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT), row.actions) + assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DELETE), row.actions) assertTrue(row.cells.first { it.id == "EDIT" }.primary) } @@ -506,7 +507,6 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { assertEquals("Connected providers", settingsListSectionTitle(rows, 0)) assertEquals("Popular providers", settingsListSectionTitle(rows, 1)) assertEquals(listOf(ProviderListAction.DISCONNECT), rows[0].actions) - assertTrue(rows[0].connected) } fun `test source custom catalog providers remain visible while configured custom providers are connected`() { @@ -526,13 +526,13 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { assertEquals("Connected providers", settingsListSectionTitle(rows, 0)) assertEquals("Popular providers", settingsListSectionTitle(rows, 1)) assertEquals("All providers", settingsListSectionTitle(rows, 2)) - assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT), rows[0].actions) + assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DELETE), rows[0].actions) } - fun `test edit callback fires instead of disconnect on primary`() { + fun `test edit callback fires instead of delete on primary`() { var edited: ProviderSettingsProviderDto? = null - var disconnected: ProviderSettingsProviderDto? = null - val content = edt { ProvidersContent({}, {}, { disconnected = it }, {}, { edited = it }) } + var deleted: ProviderSettingsProviderDto? = null + val content = edt { ProvidersContent({}, {}, { deleted = it }, {}, { edited = it }) } val state = ProviderSettingsDto( providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")), config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = CUSTOM_PROVIDER_PACKAGE)), @@ -544,7 +544,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } assertEquals("local-openai", edited?.id) - assertNull(disconnected) + assertNull(deleted) } fun `test provider content update selects saved provider`() { @@ -668,16 +668,31 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } - fun `test renderer keeps connected disconnect action visible when unselected`() { + fun `test connected actions only appear on selection`() { edt { - val row = ProviderListRow(provider("openai", "OpenAI"), "Connected providers", listOf(ProviderListAction.DISCONNECT), connected = true) + val row = ProviderListRow(provider("openai", "OpenAI"), "Connected providers", listOf(ProviderListAction.DISCONNECT)) val list = hitList(row) - val area = actionBounds(list, selected = false).getValue(ProviderListAction.DISCONNECT) - assertEquals(ProviderListAction.DISCONNECT, actionAt(list, center(area), selected = false)) + assertTrue(actionBounds(list, selected = false).isEmpty()) + val area = actionBounds(list, selected = true).getValue(ProviderListAction.DISCONNECT) + assertEquals(ProviderListAction.DISCONNECT, actionAt(list, center(area), selected = true)) } } + fun `test delete action uses trash icon and no label`() { + val row = providerListRows( + ProviderSettingsDto( + providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")), + config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = CUSTOM_PROVIDER_PACKAGE)), + ), + "", + ).single() + + val delete = row.cells.single { it.id == ProviderListAction.DELETE.name } + assertEquals(AllIcons.Actions.GC, delete.icon) + assertTrue(delete.iconOnly) + } + fun `test renderer ignores disabled env disconnect action`() { edt { val row = ProviderListRow(provider("env", "Env", source = "env"), "All providers", listOf(ProviderListAction.DISCONNECT)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/picker/PickerPopupTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/picker/PickerPopupTest.kt new file mode 100644 index 00000000000..1a6181d01a3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/picker/PickerPopupTest.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.ui.picker + +import com.intellij.CommonBundle +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.CollectionListModel +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Container +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel + +class PickerPopupTest : BasePlatformTestCase() { + + fun `test multi picker close button uses default style`() = edt { + val model = CollectionListModel(listOf("gemma")) + val picker = PickerPopup( + anchor = JButton(), + placement = PickerPopup.Placement.UNDERNEATH, + rows = { model.items }, + model = model, + renderer = TestRenderer(model), + mode = PickerPopup.Mode.Multi, + onPrimary = {}, + ) + + val foot = footer(picker) + val btn = components(foot).filterIsInstance().single { it.text == CommonBundle.getCloseButtonText() } + + assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) + assertEquals(popupBackground, btn.background) + assertFalse(btn.isFocusable) + assertFalse(btn.isRequestFocusEnabled) + } + + fun `test picker row uses standard gap between check and text`() = edt { + val model = CollectionListModel(listOf("gemma")) + val renderer = TestRenderer(model) + + val row = row(renderer) + + assertEquals(JBUI.CurrentTheme.ActionsList.elementIconGap(), (row.layout as BorderLayout).hgap) + } + + private fun footer(picker: PickerPopup): JComponent { + val method = PickerPopup::class.java.getDeclaredMethod("footer") + method.isAccessible = true + return method.invoke(picker) as JComponent + } + + private fun row(renderer: PickerListRenderer): JPanel { + val field = PickerListRenderer::class.java.getDeclaredField("row") + field.isAccessible = true + return field.get(renderer) as JPanel + } + + private fun components(component: Component): List { + val out = mutableListOf() + fun visit(c: Component) { + out += c + if (c is Container) c.components.forEach { visit(it) } + } + visit(component) + return out + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private class TestRenderer(model: CollectionListModel) : PickerListRenderer( + model = model, + checked = { false }, + sectionTitle = { _, _ -> null }, + content = JBLabel(), + ) { + override fun update(value: String, index: Int, selected: Boolean, focused: Boolean, foreground: Color, weak: Color) { + (content as JBLabel).text = value + } + } +} From 03a72e8f9fccc93963da9a490a5ef19ebdbfb9ce Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 15 Jul 2026 17:26:21 -0400 Subject: [PATCH 22/28] fix(jetbrains): map custom provider delete action to disconnect - Route ProviderListAction.DELETE through the existing disconnect() handler so the trash-icon action actually removes the custom provider - Add the missing settings.providers.delete bundle key referenced by ProviderListRows - Preset a default id/name/URL when adding a new custom provider without an existing config to edit --- .changeset/jetbrains-custom-provider-edit.md | 2 +- .../client/settings/providers/ProvidersSettingsUi.kt | 11 ++++++++++- .../src/main/resources/messages/KiloBundle.properties | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.changeset/jetbrains-custom-provider-edit.md b/.changeset/jetbrains-custom-provider-edit.md index dfe3afaafa8..f34122179bc 100644 --- a/.changeset/jetbrains-custom-provider-edit.md +++ b/.changeset/jetbrains-custom-provider-edit.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Support editing custom OpenAI-compatible providers from JetBrains settings, keep added or edited providers selected, and close the custom provider dialog after a successful save. +Support editing custom OpenAI-compatible providers from JetBrains settings and replace their Disconnect action with Edit and Delete. Added or edited providers stay selected, and the custom provider dialog now closes after a successful save. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt index 4c533f9c168..83904265ffe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt @@ -548,6 +548,7 @@ internal class ProvidersContent( ProviderListAction.CONNECT -> connect(row.provider) ProviderListAction.OAUTH -> oauth(row.provider) ProviderListAction.DISCONNECT -> disconnect(row.provider) + ProviderListAction.DELETE -> disconnect(row.provider) ProviderListAction.ENABLE -> enable(row.provider) ProviderListAction.EDIT -> edit(row.provider) } @@ -661,7 +662,7 @@ internal class CustomProviderDialog( ) init() initValidation() - existing?.let { prefill(it) } + existing?.let { prefill(it) } ?: preset() models.document.addDocumentListener(object : DocumentAdapter() { override fun textChanged(e: DocumentEvent) { syncActions() @@ -674,6 +675,14 @@ internal class CustomProviderDialog( syncActions() } + @RequiresEdt + private fun preset() { + checkEdt() + id.text = "Anaconda" + name.text = "Anaconda" + url.text = "http://127.0.0.1:8080" + } + @RequiresEdt private fun prefill(edit: CustomProviderEdit) { checkEdt() diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 70461207ea0..07867f4f1ff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -455,6 +455,7 @@ settings.providers.oauth.waitingTimed=Waiting for authorization... ({0}) settings.providers.oauth.cancel=Cancel settings.providers.disconnect=Disconnect settings.providers.edit=Edit +settings.providers.delete=Delete settings.providers.enable=Enable settings.providers.badge.env=env settings.providers.note.kilo=Access 500+ AI models From 675d58cc44c389f6f46bcac2ffd318b6b54f442f Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 15 Jul 2026 17:26:40 -0400 Subject: [PATCH 23/28] chore(jetbrains): add planning notes for custom provider dialog and picker consolidation --- ...744472-jetbrains-custom-provider-dialog.md | 341 ++++++++++++++++++ ...633-consolidate-jetbrains-model-pickers.md | 198 ++++++++++ ...jetbrains-custom-provider-inline-errors.md | 159 ++++++++ 3 files changed, 698 insertions(+) create mode 100644 .kilo/plans/1784057744472-jetbrains-custom-provider-dialog.md create mode 100644 .kilo/plans/1784058434633-consolidate-jetbrains-model-pickers.md create mode 100644 plans/jetbrains-custom-provider-inline-errors.md diff --git a/.kilo/plans/1784057744472-jetbrains-custom-provider-dialog.md b/.kilo/plans/1784057744472-jetbrains-custom-provider-dialog.md new file mode 100644 index 00000000000..e3d9eee31a4 --- /dev/null +++ b/.kilo/plans/1784057744472-jetbrains-custom-provider-dialog.md @@ -0,0 +1,341 @@ +# JetBrains custom provider: close on OK, select, edit, edit/disconnect buttons + +## Goal + +Fix the JetBrains "Add custom provider" flow and add edit support: + +1. Confirming the dialog closes it (OK label "Add" for add, "Save" for edit). +2. The added/edited provider becomes the selected row in the list. +3. Double-clicking an editable custom provider opens it for editing instead of disconnecting it. +4. Editable custom providers show both **Edit** and **Disconnect** action buttons. + +All work is in `packages/kilo-jetbrains/frontend/`. No backend, RPC, or DTO changes. `saveCustom` already upserts, and `ProviderSettingsDto.config[id]` already carries the fields needed to prefill an edit. No `kilocode_change` markers are needed (all files are under Kilo-owned `packages/kilo-jetbrains/`). + +## Root causes (verified in code) + +- **Dialog does not close**: `CustomProviderDialog.syncActions()` sets `isOKActionEnabled = !saving && !fetching && modelIds().isNotEmpty()` (ProvidersSettingsUi.kt:957). On save success `doOKAction()` sets `outcome` and calls `closeOk()` → `super.doOKAction()` (line 966), which only closes when `getOKAction().isEnabled()` is true. `saving` stays `true`, so OK is disabled and nothing closes even though the log prints "closing dialog". +- **Double-click deletes**: `SettingsListView.mouseClicked` fires `primary(item)` on non-button double-clicks (SettingsListView.kt:65-72), and `primary` runs the first enabled cell. A configured custom provider only exposes `DISCONNECT` (ProviderListRows.kt:90), so double-click disconnects. +- **Edit feasibility**: `KiloBackendProviderSettingsManager.saveCustom` patches config as an upsert and only touches auth when a key/env is supplied (lines 154-166); a blank key on edit preserves the stored secret. Prefill source is `state.config[id]`: `name`, `options["baseURL"]`, `env` (first entry), `models` (model ids). The API key is a secret and is never returned, so the key field starts blank on edit. +- OK label is already `settings.providers.customAdd=Add`; edit mode needs a new "Save" label. + +## Exact edits + +### 1. `packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties` + +Add `settings.providers.edit=Edit` right after line 456 (`settings.providers.disconnect=Disconnect`): + +``` +settings.providers.edit=Edit +``` + +Add two keys in the custom block (after line 470 `customTitle` / line 471 `customAdd`): + +``` +settings.providers.customEditTitle=Edit OpenAI-Compatible Provider +settings.providers.customSave=Save +``` + +Only edit the base `KiloBundle.properties`; other locale files fall back to base for missing keys. + +### 2. `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt` + +Add a helper at the end of the file (after `configured(...)`, line 99): + +```kotlin +internal fun customEditable(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto) = + state.config[provider.id]?.npm == CUSTOM_PROVIDER_PACKAGE +``` + +`CUSTOM_PROVIDER_PACKAGE` (`"@ai-sdk/openai-compatible"`) is already defined at line 19 and is the same signal the backend disconnect path uses. + +### 3. `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt` + +Add `EDIT` to the enum (line 12-17): + +```kotlin +internal enum class ProviderListAction { + CONNECT, + OAUTH, + EDIT, + DISCONNECT, + ENABLE, +} +``` + +Update `cells` (lines 35-43) to mark EDIT primary and keep both EDIT and DISCONNECT visible when connected: + +```kotlin + override val cells: List + get() = actions.map { action -> + SettingsListCell( + action.name, + providerListActionText(action), + enabled(action), + alwaysVisible = (action == ProviderListAction.DISCONNECT || action == ProviderListAction.EDIT) && connected, + primary = action == ProviderListAction.EDIT, + ) + } +``` + +`enabled(action)` (line 45) already returns true for EDIT (`action != DISCONNECT`), no change. + +Add the EDIT label to `providerListActionText` (lines 48-53): + +```kotlin + ProviderListAction.EDIT -> KiloBundle.message("settings.providers.edit") +``` + +Update `providerActions` (lines 83-96) so configured editable custom providers expose EDIT + DISCONNECT: + +```kotlin +internal fun providerActions( + provider: ProviderSettingsProviderDto, + state: ProviderSettingsDto, + disabled: Set = state.disabled.toSet(), +): List { + if (provider.id in disabled) return listOf(ProviderListAction.ENABLE) + if (provider.id == KILO_PROVIDER_ID && configured(provider, state, state.connected.toSet())) return emptyList() + if (configured(provider, state, state.connected.toSet())) { + return if (customEditable(provider, state)) { + listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT) + } else { + listOf(ProviderListAction.DISCONNECT) + } + } + val methods = providerMethods(provider, state) + return buildList { + if (methods.any { it.type == "oauth" }) add(ProviderListAction.OAUTH) + if (methods.any { it.type == "api" }) add(ProviderListAction.CONNECT) + } +} +``` + +### 4. `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt` + +**4a. Import.** Add to the settings.base imports (near line 7): + +```kotlin +import ai.kilocode.client.settings.base.SettingsListSelection +``` + +**4b. `view` field (line 196).** Pass the new edit callback: + +```kotlin + private val view = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable, ::edit) +``` + +**4c. Replace `custom()` (lines 314-330)** with add + edit + shared open: + +```kotlin + @RequiresEdt + private fun custom() { + checkEdt() + openCustomDialog(null) + } + + @RequiresEdt + private fun edit(provider: ProviderSettingsProviderDto) { + checkEdt() + val cfg = state.config[provider.id] ?: return + openCustomDialog( + CustomProviderEdit( + id = provider.id, + name = cfg.name ?: provider.name, + baseUrl = cfg.options["baseURL"].orEmpty(), + envVar = cfg.env.firstOrNull(), + models = cfg.models.values.map { it.id }, + ), + ) + } + + // The dialog performs the save itself so failures can be shown inline and the user can + // correct their input without re-typing. It only closes on a verified success. + @RequiresEdt + private fun openCustomDialog(existing: CustomProviderEdit?) { + checkEdt() + val dialog = CustomProviderDialog( + cs, + directory, + { service().fetchCustomModels(it) }, + { service().saveCustom(it) }, + existing, + ) + if (!dialog.showAndGet()) return + val next = dialog.outcome ?: return + state = next + view.update(next, dialog.savedId) + clearProgress() + } +``` + +**4d. `ProvidersContent` (lines 520-591).** Add the `edit` callback and handle EDIT + selection. + +Constructor (lines 520-525): + +```kotlin +internal class ProvidersContent( + private val connect: (ProviderSettingsProviderDto) -> Unit, + private val oauth: (ProviderSettingsProviderDto) -> Unit, + private val disconnect: (ProviderSettingsProviderDto) -> Unit, + private val enable: (ProviderSettingsProviderDto) -> Unit, + private val edit: (ProviderSettingsProviderDto) -> Unit, +) : BaseContentPanel() { +``` + +`update` (lines 536-545) — add optional `select`: + +```kotlin + @RequiresEdt + fun update(state: ProviderSettingsDto, select: String? = null) { + checkEdt() + val notes = state.providers.count { providerDescription(it).isNotBlank() } + ProvidersSettingsUi.LOG.info("provider settings content update: start providers=${state.providers.size} connected=${state.connected.size} disabled=${state.disabled.size} descriptions=$notes") + this.state = state + val rows = providerListRows(state, "", disabledRows = busy) + if (select != null) view.update(rows, SettingsListSelection.Key(select)) else view.update(rows) + ProvidersSettingsUi.LOG.info("provider settings content update: completed rows=${rows.size}") + } +``` + +`activate` `when` block (lines 580-585) — add EDIT: + +```kotlin + when (action) { + ProviderListAction.CONNECT -> connect(row.provider) + ProviderListAction.OAUTH -> oauth(row.provider) + ProviderListAction.DISCONNECT -> disconnect(row.provider) + ProviderListAction.ENABLE -> enable(row.provider) + ProviderListAction.EDIT -> edit(row.provider) + } +``` + +**4e. `CustomProviderDialog`.** Add the edit-prefill data class, constructor param, prefill, `savedId`, edit-aware title/button, and the close fix. + +Add near the dialog (e.g. above `internal class CustomProviderDialog`): + +```kotlin +internal data class CustomProviderEdit( + val id: String, + val name: String, + val baseUrl: String, + val envVar: String?, + val models: List, +) +``` + +Constructor (lines 645-650) — add `existing` as the last param (keeps existing positional test calls valid): + +```kotlin +internal class CustomProviderDialog( + private val cs: CoroutineScope, + private val directory: String, + private val fetch: suspend (CustomModelFetchDto) -> CustomModelFetchResultDto, + private val save: suspend (CustomProviderSaveDto) -> ProviderActionResultDto, + private val existing: CustomProviderEdit? = null, +) : DialogWrapper(true) { +``` + +Add the `savedId` property next to `outcome` (lines 667-669): + +```kotlin + // Set once the save succeeds; the panel reads it after the dialog closes to update the list. + var outcome: ProviderSettingsDto? = null + private set + + // Id of the provider the save persisted; used to select the row after the dialog closes. + var savedId: String? = null + private set +``` + +`init` (lines 671-686) — edit-aware title/button and prefill: + +```kotlin + init { + title = if (existing != null) { + KiloBundle.message("settings.providers.customEditTitle") + } else { + KiloBundle.message("settings.providers.customTitle") + } + setOKButtonText( + if (existing != null) KiloBundle.message("settings.providers.customSave") + else KiloBundle.message("settings.providers.customAdd"), + ) + init() + initValidation() + existing?.let { prefill(it) } + models.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + syncActions() + } + }) + pick.addActionListener { + if (fetching) cancelFetch() + else selectModels() + } + syncActions() + } + + @RequiresEdt + private fun prefill(edit: CustomProviderEdit) { + id.text = edit.id + id.isEditable = false + name.text = edit.name + url.text = edit.baseUrl + env.text = edit.envVar.orEmpty() + models.text = edit.models.joinToString(", ") + } +``` + +Save-success branch in `doOKAction` (lines 767-769) — reset `saving`, re-enable OK, record id, close unconditionally: + +```kotlin + ProvidersSettingsUi.LOG.info("custom provider add: save succeeded id='${input.id}', closing dialog") + outcome = result.state + savedId = input.id + saving = false + syncActions() + close(OK_EXIT_CODE) +``` + +Remove the now-unused `closeOk()` helper (line 966), or replace its body with `close(OK_EXIT_CODE)`; do not leave `super.doOKAction()` in the success path. `OK_EXIT_CODE` is a `DialogWrapper` constant available to the subclass without an import. + +## Tests — `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt` + +Run against the real EDT/component tree with `FakeProviderRpcApi` (no mocks). + +**Update existing tests broken by the new EDIT action / callback:** + +- `content()` helper (line 965): change to `edt { ProvidersContent({}, {}, {}, {}, {}) }` (5 lambdas). +- `test configured custom provider exposes only disconnect` (lines 306-324): rename to reflect edit + disconnect and assert `assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT), row.actions)`. +- `test source custom catalog providers remain visible while configured custom providers are connected` (line 412): change the `rows[0].actions` assertion to `listOf(ProviderListAction.EDIT, ProviderListAction.DISCONNECT)`. + +**Add new tests:** + +- Dialog closes on save success: reuse the `submit` + `flushUntil { edt { dialog.outcome != null } }` pattern; after success assert `dialog.savedId == "my-openai"` and `edt { dialog.isOKActionEnabled }` is `true` (proves the disabled-OK regression is fixed). +- Edit prefill: build `CustomProviderDialog(cs, "/tmp", { CustomModelFetchResultDto(listOf("gpt-4o")) }, { ProviderActionResultDto(...) }, CustomProviderEdit("my-openai", "My OpenAI", "https://example.com/v1", null, listOf("gpt-4o")))`. Assert the text fields (indices 0/1/2/4/5 for id/name/url/env/models) are populated, the key field (index 3) is blank, `id` field `isEditable == false`, and the OK button text equals `KiloBundle.message("settings.providers.customSave")`. (Field order via `filterIsInstance()`: 0=id, 1=name, 2=url, 3=key, 4=env, 5=models — `JBPasswordField` is a `JTextField`.) +- `providerActions` editable: given a provider with `config[id].npm == "@ai-sdk/openai-compatible"` and configured, assert `[EDIT, DISCONNECT]`, and that the EDIT cell has `primary == true` (via `row.cells.first { it.id == "EDIT" }.primary`). +- Non-editable connected provider unchanged: a connected non-custom provider (e.g. `anthropic`, connected) still yields `[DISCONNECT]`. +- Edit callback fires instead of disconnect on primary: construct `ProvidersContent` with recording lambdas (capture which provider each callback got), update with a configured editable provider, trigger the primary path, and assert the edit lambda ran and disconnect did not. +- Selection after update: `content.update(state, select = "local-openai")` then assert the list's selected row key is `"local-openai"`. + +## Validation + +Run from `packages/kilo-jetbrains/`: + +- `bun run typecheck` (or `./gradlew typecheck`). +- `./gradlew test` (target `ProvidersSettingsUiTest` first if iterating). +- Manual smoke via `./gradlew runIde`: add a custom provider → dialog closes and the new row is selected; double-click the row → edit dialog opens prefilled with the id field locked and "Save" button; the connected custom row shows both Edit and Disconnect. + +Java 21 is required for Gradle; only check `java -version` if a Gradle command fails with a Java error. + +## Notes / risks + +- API key is intentionally not prefilled on edit (secret). Blank key preserves the stored key; a new value updates it — matches `saveCustom` behavior. +- The id field is locked on edit because the id is the config key; renaming would create a second provider. +- Double-click on a visible action button (Edit/Disconnect) still routes through the single-cell click path, not `primary`; only double-clicks on the non-button row area trigger the primary (EDIT) action. +- Keep new identifiers single-word where clear (`existing`, `cfg`, `select`, `edit`, `prefill`, `savedId`). + +## Out of scope + +- Editing headers, per-model capabilities (reasoning), or config scope (global vs workspace) beyond the current add dialog. +- Any changes to shared `packages/opencode/` code, RPC contracts, or DTOs. diff --git a/.kilo/plans/1784058434633-consolidate-jetbrains-model-pickers.md b/.kilo/plans/1784058434633-consolidate-jetbrains-model-pickers.md new file mode 100644 index 00000000000..a807f0ed63a --- /dev/null +++ b/.kilo/plans/1784058434633-consolidate-jetbrains-model-pickers.md @@ -0,0 +1,198 @@ +# Consolidate JetBrains model pickers into a shared PickerPopup base + +## Goal + +Remove duplication between the two list-picker popups in the JetBrains frontend by +extracting a generic base, and make both consume it: + +1. Prompt model picker — `session/ui/model/ModelPicker.kt` (`showPopup`) — single-select + commit, search field, expand/collapse details panel, sections, per-row favorite (star) + toggle. +2. Custom provider model picker — `settings/providers/ProvidersSettingsUi.kt` + (`showModelPopup` inside `CustomProviderDialog`) — multi-select toggle, no search, no + details, selection stored in the `models` text field. + +The base must expose configurable options; the provider picker enables: multi-select, no +expanded/details view, and extra header toolbar buttons ("Select All" / "Deselect All"). + +All work is under `packages/kilo-jetbrains/frontend/`. No backend/RPC/DTO changes. Files are +Kilo-owned (no `kilocode_change` markers). + +## Resolved decisions + +- **Approach**: Extract a generic `PickerPopup` base; both pickers become thin configs. +- **Selection state**: Caller owns it. Base is selection-stateless: it calls `checked(row)` + to render check state and `onPrimary(row)` on activation. Provider keeps the `models` text + field as source of truth; prompt keeps its favorites callbacks. +- **Select All / Deselect All**: Two caller-supplied header toolbar buttons. Remove the + in-list `CustomModelRow.selectAll` row entirely. +- **Renderer**: Extract a base renderer skeleton too; `ModelPickerRenderer` and the provider + renderer extend it. Generalize the favorite hit-zone into a trailing click-zone helper. +- **Location**: New package `ai/kilocode/client/ui/picker/` with `PickerPopup` and + `PickerListRenderer`. `PickerRow` and `ModelSearch` stay where they are and are reused. + +## Base design + +### `ui/picker/PickerPopup.kt` — generic popup + +Owns: `JBList` + `CollectionListModel`, `createComponentPopupBuilder` with the shared +flags (requestFocus/focusable/cancelOnClickOutside/cancelKeyEnabled/cancelOnWindowDeactivation/ +locateWithinScreenBounds, non-resizable, non-movable), `popupBackground` helper (move the +duplicated `NewUI -> Popup.BACKGROUND else getListBackground()` here), header assembly, +mouse hit-testing, keyboard bindings, auto-select-on-move + scrolling install, and size +computation. + +Config (constructor params / vars, defaults match current behavior): + +- `anchor: JComponent`, `placement: Placement` (`ABOVE` / `BELOW` / `UNDERNEATH`). Prompt uses + `PopupShowOptions.aboveComponent` / `showUnderneathOf`; provider uses `showUnderneathOf(pick)`. +- `rows: (query: String) -> List` — rebuilt when the search text changes. Provider passes a + function that ignores `query`. +- `renderer: PickerListRenderer`. +- `checked: (T) -> Boolean` — drives the row check icon (single: `row.key == active`; multi: + `isSelected(row)`). +- `sectionTitle: (rows: List, index: Int) -> String?` — optional; prompt supplies + `modelPickerSectionTitle`, provider passes `{ _, _ -> null }`. +- `mode: Mode` = `Single` | `Multi`. + - Single: on primary click/ENTER call `onPrimary(row)` then `popup.closeOk(null)`. + - Multi: on primary click/SPACE/ENTER call `onPrimary(row)`, repaint, stay open. +- `onPrimary: (T) -> Unit`. (Prompt decides activate-vs-clear inside based on `row.item == null`; + provider toggles membership in the text field.) +- Optional trailing toggle: `trailingHit: (list, bounds, point) -> Boolean` and + `onTrailing: (T) -> Unit`. Prompt supplies favorite hit-zone + favorite toggle; provider omits. + Keyboard: in Single mode `Shift+SPACE` triggers trailing when present. +- `search: Boolean` — show `SearchTextField` in header CENTER. Prompt true; provider false. +- `toolbar: List` — extra header buttons placed in header WEST. Provider passes + Select All / Deselect All; prompt passes empty. +- Optional details/expand: `details: JComponent?` + `onPreview: (T?) -> Unit` + + `expandStateKey: String?`. When non-null, base shows the expand `HoverIcon`, EAST details + panel, persists expanded state via `PropertiesComponent`, reserves details width when + expanded, and `Disposer.register(popup, details)` if `details is Disposable`. Provider passes + null (no expand toggle, no details). +- Sizing: `minWidth`, `maxWidth`, `maxVisibleRows`, `emptyListHeight` (defaults = current + ModelPicker constants 420/760/10/120). Provider passes `minWidth = 320` and a `maxWidth` + that preserves today's look. Reuse the existing `computeInitialPopupSize` / + `computeListPreferredWidth` / `computeListPreferredHeight` logic, moved into the base and + parameterized by these constants + optional details width. + +Header layout: `BorderLayout` with `toolbar` row WEST, `search` CENTER (empty when +`search=false`), expand `HoverIcon` EAST (only when details present). Preserve +`AbstractPopup.customizeSearchFieldLook` and background wiring. + +Keyboard (registered on both the list and, when present, the search editor): + +- `UP`/`DOWN` → move selection (search field only; list uses `ScrollingUtil`). +- `ENTER` → primary on selected row. +- `ESC` → `popup.cancel()`. +- Single + trailing present → `Shift+SPACE` → trailing on selected row. +- Multi → `SPACE` → primary (toggle) on selected row. + +Mouse (`mouseReleased`, `UIUtil.isActionClick`): resolve row via `locationToIndex` + +`getCellBounds`/`contains`; if trailing present and `trailingHit` → `onTrailing` + consume; +else `onPrimary` (Single closes, Multi consumes + repaints). + +### `ui/picker/PickerListRenderer.kt` — renderer skeleton + +Abstract `ListCellRenderer` base: `PickerRow` wrap + optional top `GroupHeaderSeparator` +(driven by `sectionTitle`) + `[check icon | content | trailing]` layout with the shared +transparent row + `UiStyle.Gap.md/lg/md/pad` insets. Provides: + +- `check` icon column (`AllIcons.Actions.Checked` / `EmptyIcon`), set from `checked(row)`. +- protected `content: JComponent` slot and optional `trailing: JComponent` slot. +- section separator top panel wiring (`top`/`sep` from `ModelPickerRenderer`). +- a companion `trailingClickZone(list, bounds, point, width)` generalizing + `ModelPickerRenderer.isFavoriteClick` (keep `FAVORITE_CLICK_AREA_WIDTH` behavior). + +## Ordered implementation tasks + +1. **Create `ui/picker/PickerListRenderer.kt`**: base renderer skeleton with check column, + `PickerRow` wrap, top section separator, content/trailing slots, transparent row + insets, + and the `trailingClickZone` helper (moved/generalized from `ModelPickerRenderer.isFavoriteClick` + / `favoriteInset`). +2. **Create `ui/picker/PickerPopup.kt`**: generic popup per the design above, including the + moved `popupBackground` helper and the sizing helpers (parameterized). +3. **Refactor `ModelPickerRenderer`** to extend `PickerListRenderer`: content = title + (`SimpleColoredComponent`) + warn + free/BYOK badges + provider label; trailing = favorite + star; check via `checked`. Keep existing internal test accessors (`starIcon`, + `badgeVisible`, `badgeText`, `byokVisible`, `warningVisible`, `warningTooltip`) and the + `DATA_COLLECTED`/`checked`/`empty` companion members so `ModelPickerTest` compiles unchanged. +4. **Refactor `ModelPicker.showPopup`** to build a `PickerPopup` configured + as: Single mode; `rows = { q -> modelPickerRows(items, favorites(), q, allowEmpty, emptyText, includeSmall) }`; + `checked = { it.key == selected?.key }`; `sectionTitle = ::modelPickerSectionTitle`; + `onPrimary = { row -> row.item?.let(::activate) ?: clear() }`; trailing = favorite hit-zone + + `onFavoriteToggle`; `search = true`; details = `ModelDetailsPanel` with `expandStateKey = + MODEL_PICKER_EXPANDED_KEY`; placement from `placement`; sizing constants = current values. + Keep `ModelPicker`'s public API (`setItems`, `select`, `clearSelection`, `open`, callbacks, + `Placement`, `Item`) and test hooks (`selectedForTest`, `selectionKeyForTest`, + `expandedForTest`) unchanged so `PromptPanel`, `settings/models`, and `settings/agents` + consumers are unaffected. +5. **Add provider picker renderer** (small subclass of `PickerListRenderer` in + `settings/providers/`, or a shared simple text renderer): content = a `JBLabel` showing the + model id; no trailing; check via `checked`. +6. **Rewrite `CustomProviderDialog.showModelPopup(ids)`** to build a `PickerPopup`: + Multi mode; `rows = { _ -> ids }` (raw model ids, no select-all row); `checked = { it in modelIds().toSet() }`; + `onPrimary = { toggleModel(it) }`; `sectionTitle = { _, _ -> null }`; `search = false`; + `toolbar = listOf(selectAllButton, deselectAllButton)`; details = null; `minWidth = 320`; + anchor = `pick`, placement `UNDERNEATH`. +7. **Extract provider selection mutations into testable helpers** on/near `CustomProviderDialog` + operating on the `models` text field: `toggleModel(id)`, `selectAllModels(ids)`, + `clearModels()` (reuse existing `modelIds()` / `setModelIds()`). Wire the two toolbar buttons + to `selectAllModels(ids)` and `clearModels()`. +8. **Remove dead code**: `CustomModelRow`, `customModelRows`, `CustomModelRowRenderer`, + `CUSTOM_MODEL_POPUP_MIN_WIDTH`/`CUSTOM_MODEL_POPUP_MAX_ROWS` (fold into base config), and the + now-duplicated inline `popupBackground` in `ProvidersSettingsUi.kt`. +9. **Strings**: add a "Deselect All" key to `KiloBundle.properties` (e.g. + `settings.providers.customModelsDeselectAll=Deselect All`); reuse existing + `settings.providers.customModelsSelectAll` for "Select All". + +## Tests + +- **Keep passing unchanged**: `ModelPickerTest.kt` (row builder + renderer + `ModelSearch` + + favorite hit-zone). Because `ModelPickerRenderer` keeps its accessors and companion members and + `modelPickerRows`/`ModelPickerRow` are untouched, these should compile and pass as-is. Verify + the favorite hit-zone tests still reference a working symbol (either keep + `ModelPickerRenderer.isFavoriteClick` delegating to `PickerListRenderer.trailingClickZone`, or + update the 3 call sites in `ModelPickerTest.kt` to the new helper). +- **Update** `ProvidersSettingsUiTest.kt`: + - Replace `test custom model rows start with select all` (there is no select-all row anymore) + with tests for the extracted helpers: `toggleModel` adds/removes an id in the text field; + `selectAllModels(ids)` sets all; `clearModels()` empties it. + - Keep existing add/edit/delete and dialog-close tests working. +- **New**: a small `PickerListRenderer`/`PickerPopup` unit test where feasible without a live + popup — e.g. `trailingClickZone` geometry (mirroring the current `isFavoriteClick` tests) and + the provider text renderer check-state. Avoid asserting live-popup internals; follow the + existing pattern of testing renderers/row-builders/pure helpers directly (per package AGENTS + "test the real implementation, no mocks"). + +## Validation + +From `packages/kilo-jetbrains/`: + +- `./gradlew typecheck` +- `./gradlew test` (iterate with `--tests ai.kilocode.client.session.ui.model.ModelPickerTest` + and `--tests ai.kilocode.client.settings.providers.ProvidersSettingsUiTest`). +- Manual smoke via `./gradlew runIde`: + - Prompt model picker: search, arrow/enter select, favorite star toggle, expand/collapse + details, placement — all unchanged. + - Add custom provider → fetch models → popup shows model list with "Select All"/"Deselect All" + toolbar buttons, per-row multi-select toggles, no details/expand, selection reflected in the + Model IDs field; Save persists selected models. + +## Risks / notes + +- `ModelPicker`'s public API and test hooks must stay stable — several settings screens depend on + it. Only the popup internals move. +- The details/expand panel is `Disposable`; the base must register it with the popup exactly as + today to avoid leaks. +- Sizing differs (prompt 420/760 dynamic + details; provider fixed 320). Parameterize rather than + hard-code; keep provider width visually equivalent to current. +- Popups are hard to unit test; keep coverage on pure helpers/renderers, not live-popup behavior. +- Keyboard/mouse semantics differ by mode (single-commit-close vs multi-toggle-stay-open) and by + presence of a trailing toggle — encode these in the base mode, don't special-case callers. + +## Out of scope + +- The other picker popups (`ModePicker`, `ReasoningPicker`, `SessionAccountOverlay`) — not part + of this consolidation. +- Adding search to the provider picker (kept off to match current behavior; base supports it). +- Any backend/RPC/DTO or CLI changes. diff --git a/plans/jetbrains-custom-provider-inline-errors.md b/plans/jetbrains-custom-provider-inline-errors.md new file mode 100644 index 00000000000..dfa1d0efc03 --- /dev/null +++ b/plans/jetbrains-custom-provider-inline-errors.md @@ -0,0 +1,159 @@ +# Plan: Fix silent failure when adding a Custom OpenAI-Compatible Provider (JetBrains) + +## Problem + +In the JetBrains plugin, adding a "Custom OpenAI-Compatible Provider" can silently +fail: the dialog closes with no error, the provider never appears in the list, and +the user's typed inputs are gone. Reported against plugin 7.0.2. + +## Root causes + +1. **Zero-model providers are silently dropped by the CLI.** The dialog only + requires `id` and `baseUrl`; the models field is optional + (`ProvidersSettingsUi.kt:585` `doValidate`). The save really does write a + `provider.` block to config, but when the UI re-reads the provider list the + CLI deletes any provider with no models + (`packages/opencode/src/provider/provider.ts:1667`), and + `@ai-sdk/openai-compatible` has no automatic model discovery. The provider is on + disk but never rendered. + +2. **Errors are surfaced after the dialog is already closed.** `custom()` + (`ProvidersSettingsUi.kt:216-227`) calls `dialog.showAndGet()` (which closes the + dialog), *then* runs the save and routes any error to the settings-panel overlay + behind the closed dialog via `apply()`. On success with a dropped provider, + `saveCustom` returns `error = null`, so even that overlay stays silent. Either + way the typed inputs are lost and the user cannot correct and retry. + +## Goal + +When saving a custom provider fails or the saved provider would not be usable, show +the message **inside the dialog**, keep the dialog open with all inputs intact, and +let the user fix and retry without re-typing. Prevent the zero-model silent-drop +path entirely. + +## Design + +Move the save into the dialog so it runs **before** the dialog closes: + +- The dialog owns the save. It runs the RPC on the frontend coroutine scope, keeps + itself open on failure, and shows the error via `setErrorText(...)`. It only + closes on a verified success. +- The settings panel consumes the final `ProviderSettingsDto` the dialog produced, + so there is no second save. +- Require at least one model client-side and server-side, closing the zero-model + drop path. Keep a post-save verification as a safety net for other silent drops. + +RPC must never run on the EDT (`doOKAction` runs on EDT). The dialog launches on the +existing `cs` scope and switches back to the EDT with `ModalityState.any()` for UI +updates, matching the `edt` dispatcher already defined at +`ProvidersSettingsUi.kt:72`. + +## Changes + +### 1. `frontend/.../settings/providers/ProvidersSettingsUi.kt` + +**`CustomProviderDialog` (currently `ProvidersSettingsUi.kt:542-590`)** + +- Change the constructor to accept what it needs to save and report progress: + `CustomProviderDialog(cs: CoroutineScope, directory: String, save: suspend (CustomProviderSaveDto) -> ProviderActionResultDto)`. + Pass `save = { service().saveCustom(it) }` from `custom()` so + the existing workspace-reload + profile-refresh side effects in + `KiloProviderService.action` still run. +- Add a `var outcome: ProviderSettingsDto? = null` the panel reads after a + successful close, and a `saving` guard to block double-submit (Enter while + in-flight). +- Extend `doValidate()` to require at least one model: + ```kotlin + if (models.text.split(',').none { it.isNotBlank() }) + return ValidationInfo(KiloBundle.message("settings.providers.customModelsRequired"), models) + ``` +- Override `doOKAction()` instead of letting the platform close on OK: + - Return early if `saving`. Run `doValidate()`; if non-null, let the platform show + it (do not close). + - Set `saving = true`, disable the OK action (`isOKActionEnabled = false`), clear + any previous error text. + - `cs.launch { val result = save(input(directory)); withContext(edt) { ... } }`. + - On the EDT, compute the inline error text (see helper below). If there is an + error: `setErrorText(text)`, `isOKActionEnabled = true`, `saving = false`, keep + the dialog open. If success: store `outcome = result.state`, then + `super.doOKAction()` to close. + - Wrap the RPC in the same `try/catch` shape as `launch()` + (`ProvidersSettingsUi.kt:264-297`): map `TimeoutCancellationException` and + generic `Exception` to inline error text, rethrow `CancellationException`. No + empty catch blocks. + +**Inline error helper (pure, testable)** + +Add a top-level `internal fun customSaveError(id: String, result: ProviderActionResultDto): String?`: +```kotlin +result.error?.let { return it } +val present = result.state.providers.any { it.id == id } +if (!present) return KiloBundle.message("settings.providers.customNotUsable") +return null +``` +This is the safety net: even if validation passes, a provider that came back only in +`config` but not in `providers` (the zero-model drop or an unreachable base URL) +produces an inline message rather than a silent close. + +**`custom()` (`ProvidersSettingsUi.kt:216-227`)** + +Simplify to construct the dialog with the save lambda, and after a successful close +apply the dialog's `outcome` to the panel (update `state`, `view.update(next)`, +`clearProgress()`), reusing the tail of `apply()`. The save no longer goes through +`launch()`, which also removes the `busy`-gated silent `return` at +`ProvidersSettingsUi.kt:222`/`266`. + +### 2. `backend/.../provider/KiloBackendProviderSettingsManager.kt` + +Extend `validate()` (`:279-287`) to reject empty models so the API is safe even when +called outside the JetBrains dialog: +```kotlin +if (input.models.isEmpty()) return "At least one model ID is required." +``` +This runs before any `patch`, so nothing is written to config on the empty-models +path. No change needed to `buildCustomProviderPatch`. + +### 3. `frontend/.../resources/messages/KiloBundle.properties` + +Add message keys near the existing custom-provider strings (`:470-477`): +``` +settings.providers.customModelsRequired=Add at least one model ID. +settings.providers.customNotUsable=Provider saved but has no usable models. Check the Base URL, API key, and model IDs, then try again. +``` + +## Tests + +Follow the JetBrains settings test pattern (fake-RPC frontend test + MockCliServer +backend test). Do not mock the EDT or add test-only accessors. + +### Backend — `backend/.../provider/KiloBackendProviderSettingsManagerTest.kt` + +- `saveCustom` with empty `models` returns a non-null `error` and issues **no** + `PATCH /global/config` (assert against `MockCliServer` recorded requests). +- `saveCustom` with one model PATCHes the expected body, then a subsequent reload + observes the provider in `state`. + +### Frontend — `frontend/.../settings/providers/ProvidersSettingsUiTest.kt` + +- Unit-test the pure `customSaveError` helper: returns `result.error` when set; + returns the not-usable message when the saved id is absent from + `state.providers`; returns `null` when present. +- Extend `FakeProviderRpcApi.saveCustom` (already records `custom`) so a test can + drive a configured `ProviderActionResultDto` (with/without the provider present) + and assert the panel applies the returned state on success. + +## Verification + +From `packages/kilo-jetbrains/`: +- `./gradlew typecheck` +- `./gradlew test` + +## Out of scope / follow-ups + +- Wiring the already-implemented `fetchCustomModels` RPC + (`KiloProviderService.fetchCustomModels`) into the dialog as a "Fetch models" + button. This would improve UX further but is not required to fix the silent + failure; track separately. +- Changing the CLI's zero-model drop in `packages/opencode/src/provider/provider.ts` + is intentionally avoided — it is shared upstream code, and the fix above prevents + the JetBrains client from ever reaching that path. From 95d4ab674ffa1461c7bdf506120a8a33e47f15b3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 15 Jul 2026 17:29:05 -0400 Subject: [PATCH 24/28] fix(jetbrains): remove leftover test preset in custom provider dialog Adding a new custom provider was pre-filling id/name/URL with test values ("Anaconda", 127.0.0.1:8080) instead of leaving the fields empty. --- .../client/settings/providers/ProvidersSettingsUi.kt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt index 83904265ffe..a5f77ce0028 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt @@ -662,7 +662,7 @@ internal class CustomProviderDialog( ) init() initValidation() - existing?.let { prefill(it) } ?: preset() + existing?.let { prefill(it) } models.document.addDocumentListener(object : DocumentAdapter() { override fun textChanged(e: DocumentEvent) { syncActions() @@ -675,14 +675,6 @@ internal class CustomProviderDialog( syncActions() } - @RequiresEdt - private fun preset() { - checkEdt() - id.text = "Anaconda" - name.text = "Anaconda" - url.text = "http://127.0.0.1:8080" - } - @RequiresEdt private fun prefill(edit: CustomProviderEdit) { checkEdt() From 526f7d203cf1cb4c32fe31c29c6b02482d61ba27 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Thu, 16 Jul 2026 00:58:46 +0000 Subject: [PATCH 25/28] release(jetbrains): v7.0.7 --- packages/kilo-jetbrains/CHANGELOG.md | 16 ++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 7e8b3cbb04b..1844885ee23 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -52,6 +52,22 @@ ## [Unreleased] +## [7.0.7] - 2026-07-16 + +### Added +- feat(cli): wire dev:local to local event service by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/12216 +- feat(vscode): toggle chat search from Command Palette, jump focus on close, and auto-expand matched blocks by @sylwester-liljegren in https://github.com/Kilo-Org/kilocode/pull/12194 +- feat(jetbrains): improve custom provider setup by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12217 + +### Fixed +- fix(cli): bound background process probes by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12243 +- fix(vscode): support filenames with spaces and unicode in @mentions by @sylwester-liljegren in https://github.com/Kilo-Org/kilocode/pull/11977 +- fix(cli): make agent manager schema provider-compatible by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/12244 + +### Changed +- release(jetbrains): v7.0.6 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12219 + + ## [7.0.6] - 2026-07-14 ### Fixed diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index da837bb13a9..cf34869c98e 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.6 +kilo.jetbrains.version=7.0.7 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. From e2be875b31c1ff3b23535241c558160a86efcb04 Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Wed, 15 Jul 2026 21:09:17 -0400 Subject: [PATCH 26/28] docs(jetbrains): edit changelog for v7.0.7 --- packages/kilo-jetbrains/CHANGELOG.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 1844885ee23..8cab465cfb8 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -52,21 +52,21 @@ ## [Unreleased] -## [7.0.7] - 2026-07-16 +## [7.0.7] - 2026-07-15 ### Added -- feat(cli): wire dev:local to local event service by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/12216 -- feat(vscode): toggle chat search from Command Palette, jump focus on close, and auto-expand matched blocks by @sylwester-liljegren in https://github.com/Kilo-Org/kilocode/pull/12194 -- feat(jetbrains): improve custom provider setup by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12217 + +- Add support for OpenAI-compatible custom providers. ### Fixed -- fix(cli): bound background process probes by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12243 -- fix(vscode): support filenames with spaces and unicode in @mentions by @sylwester-liljegren in https://github.com/Kilo-Org/kilocode/pull/11977 -- fix(cli): make agent manager schema provider-compatible by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/12244 + +- Improve custom provider setup by validating required fields and showing configuration errors in the dialog. +- Close the custom provider dialog correctly after adding a provider. +- Clean up deleted custom providers by using the disconnect flow. ### Changed -- release(jetbrains): v7.0.6 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12219 +- Keep the JetBrains plugin pinned to Kilo Core 7.4.5 for this release. ## [7.0.6] - 2026-07-14 From 06c23379d8e07b583591cf3296c6fab4177d3a26 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 16 Jul 2026 08:58:42 +0200 Subject: [PATCH 27/28] perf(cli): speed up local recall searches (#12242) * perf(cli): speed up local recall searches * fix(cli): bound local recall batches --- .changeset/fast-local-recall.md | 5 + .../src/kilocode/session/recall-search.ts | 118 +++++++++++------- .../test/kilocode/recall-search.test.ts | 18 +-- 3 files changed, 91 insertions(+), 50 deletions(-) create mode 100644 .changeset/fast-local-recall.md diff --git a/.changeset/fast-local-recall.md b/.changeset/fast-local-recall.md new file mode 100644 index 00000000000..3a5f43f0cda --- /dev/null +++ b/.changeset/fast-local-recall.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Speed up local conversation recall searches on large histories. diff --git a/packages/opencode/src/kilocode/session/recall-search.ts b/packages/opencode/src/kilocode/session/recall-search.ts index a605f64d059..0f03e134905 100644 --- a/packages/opencode/src/kilocode/session/recall-search.ts +++ b/packages/opencode/src/kilocode/session/recall-search.ts @@ -11,7 +11,9 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" export namespace RecallSearch { + const BATCH = 128 const PAGE_SIZE = 1_024 + const SCAN_SIZE = 16_384 const MAX_QUERY = 256 const MAX_TERMS = 12 const MAX_SNIPPETS = 3 @@ -20,6 +22,7 @@ export namespace RecallSearch { const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }) const FIELDS_SQL = ` + p.rowid AS rowid, p.id AS partID, p.session_id AS sessionID, CASE @@ -51,28 +54,63 @@ export namespace RecallSearch { OR (json_extract(p.data, '$.type') = 'tool' AND json_extract(p.data, '$.state.status') = 'error')` - const searchSql = (ids: PartID[], sessionID: SessionID | "", messageID: MessageID | "") => sql` - SELECT ${sql.raw(FIELDS_SQL)} - FROM json_each(${JSON.stringify(ids)}) AS ids - CROSS JOIN part AS p - CROSS JOIN message AS m - WHERE p.id = ids.value - AND m.id = p.message_id - AND m.session_id = p.session_id - AND NOT ( - m.session_id = ${sessionID} AND ( - (json_extract(m.data, '$.role') = 'user' AND m.id >= ${messageID}) - OR (json_extract(m.data, '$.role') = 'assistant' AND json_extract(m.data, '$.parentID') >= ${messageID}) + const pageSql = ( + ids: SessionID[], + cursor: { sessionID: SessionID | ""; rowid: number }, + rowid: number, + partID: string, + sessionID: SessionID | "", + messageID: MessageID | "", + ) => sql` + WITH page AS ( + SELECT p.rowid, p.id, p.message_id, p.session_id, p.data + FROM part AS p INDEXED BY part_session_idx + WHERE p.session_id IN (${sql.join( + ids.map((id) => sql`${id}`), + sql`,`, + )}) + AND (p.session_id > ${cursor.sessionID} OR (p.session_id = ${cursor.sessionID} AND p.rowid > ${cursor.rowid})) + AND p.rowid <= ${rowid} + AND p.id <= ${partID} + ORDER BY p.session_id, p.rowid + LIMIT ${SCAN_SIZE} + ), found AS ( + SELECT ${sql.raw(FIELDS_SQL)} + FROM page AS p + JOIN message AS m ON m.id = p.message_id + AND m.session_id = p.session_id + WHERE NOT ( + m.session_id = ${sessionID} AND ( + (json_extract(m.data, '$.role') = 'user' AND m.id >= ${messageID}) + OR (json_extract(m.data, '$.role') = 'assistant' AND json_extract(m.data, '$.parentID') >= ${messageID}) + ) ) - ) - AND (${sql.raw(FILTER_SQL)})` - - const pageSql = (sessionID: SessionID, cursor: number, rowid: number, partID: string) => sql` - SELECT p.rowid AS rowid, p.id AS partID - FROM part AS p INDEXED BY part_session_idx - WHERE p.session_id = ${sessionID} AND p.rowid > ${cursor} AND p.rowid <= ${rowid} AND p.id <= ${partID} - ORDER BY p.rowid - LIMIT ${PAGE_SIZE}` + AND (${sql.raw(FILTER_SQL)}) + ORDER BY p.session_id, p.rowid + LIMIT ${PAGE_SIZE} + ), next AS ( + SELECT + CASE WHEN (SELECT count(*) FROM found) = ${PAGE_SIZE} + THEN (SELECT sessionID FROM found ORDER BY sessionID DESC, rowid DESC LIMIT 1) + ELSE (SELECT session_id FROM page ORDER BY session_id DESC, rowid DESC LIMIT 1) + END AS sessionID, + CASE WHEN (SELECT count(*) FROM found) = ${PAGE_SIZE} + THEN (SELECT rowid FROM found ORDER BY sessionID DESC, rowid DESC LIMIT 1) + ELSE (SELECT rowid FROM page ORDER BY session_id DESC, rowid DESC LIMIT 1) + END AS rowid + ), meta AS ( + SELECT next.sessionID, next.rowid, count(*) AS parts + FROM next + JOIN page AS p ON p.session_id < next.sessionID OR (p.session_id = next.sessionID AND p.rowid <= next.rowid) + WHERE next.sessionID IS NOT NULL + GROUP BY next.sessionID, next.rowid + ) + SELECT rowid, partID, sessionID, source, text, 0 AS meta, 0 AS parts + FROM found + UNION ALL + SELECT rowid, NULL AS partID, sessionID, NULL AS source, NULL AS text, 1 AS meta, parts + FROM meta + ORDER BY meta, sessionID, rowid` export type Source = "user" | "assistant" | "reference" | "error" @@ -118,7 +156,12 @@ export namespace RecallSearch { type PageRow = { rowid: number - partID: PartID + partID: PartID | null + sessionID: SessionID + source: Source | null + text: string | null + meta: number + parts: number } export const search = Effect.fn("RecallSearch.search")(function* (input: { @@ -210,34 +253,25 @@ export namespace RecallSearch { ) } - for (let index = 0; index < ids.length; index++) { + for (let index = 0; index < ids.length; index += BATCH) { yield* abort(input.signal) - const sessionID = ids[index] - let cursor = 0 - while (cursor < rowid) { - const rows = yield* db.all(pageSql(sessionID, cursor, rowid, partID)).pipe(Effect.orDie) - if (rows.length === 0) break - cursor = rows.at(-1)!.rowid + const batch = ids.slice(index, index + BATCH) + let cursor = { sessionID: "" as SessionID | "", rowid: 0 } + while (cursor.rowid <= rowid) { const found = yield* db - .all( - searchSql( - rows.map((entry) => entry.partID), - excludeSessionID, - excludeFromMessageID, - ), - ) + .all(pageSql(batch, cursor, rowid, partID, excludeSessionID, excludeFromMessageID)) .pipe(Effect.orDie) + if (found.length === 0) break + const last = found.at(-1)! + cursor = { sessionID: last.sessionID, rowid: last.rowid } + parts += last.parts for (const row of found) { - consume(row) + if (row.meta || !row.partID || !row.source) continue + consume({ partID: row.partID, sessionID: row.sessionID, source: row.source, text: row.text ?? "" }) } - parts += rows.length - if (rows.length < PAGE_SIZE) break yield* pause yield* abort(input.signal) } - if (index % 16 !== 15) continue - yield* pause - yield* abort(input.signal) } yield* pause yield* abort(input.signal) diff --git a/packages/opencode/test/kilocode/recall-search.test.ts b/packages/opencode/test/kilocode/recall-search.test.ts index 6c96f29ce65..717b223f4eb 100644 --- a/packages/opencode/test/kilocode/recall-search.test.ts +++ b/packages/opencode/test/kilocode/recall-search.test.ts @@ -247,7 +247,7 @@ it.instance( ) it.instance( - "searches every page while respecting worktree scope", + "searches every page and batch while respecting worktree scope", () => Effect.gen(function* () { yield* seedProject @@ -258,12 +258,13 @@ it.instance( yield* add(child.id, "user", { type: "text", text: "archived-child-needle" }) const broad = yield* sessions.create({ title: "Broad" }) - for (let index = 0; index < 300; index++) { - yield* add(broad.id, "user", { type: "text", text: `page ${index}` }) + for (let index = 0; index < 1_100; index++) { + const text = index === 1_099 ? "page-boundary-needle" : `page ${index}` + yield* add(broad.id, "user", { type: "text", text }) } - for (let index = 0; index < 70; index++) { + for (let index = 0; index < 140; index++) { const session = yield* sessions.create({ title: `Batch ${index}` }) - if (index === 69) yield* add(session.id, "user", { type: "text", text: "last-session-needle" }) + if (index === 139) yield* add(session.id, "user", { type: "text", text: "last-session-needle" }) } const outside = yield* sessions.create({ title: "Outside" }) @@ -277,10 +278,11 @@ it.instance( .pipe(Effect.orDie) expect((yield* run("archived-child-needle")).results.map((item) => item.id)).toEqual([child.id]) + expect((yield* run("page-boundary-needle")).results.map((item) => item.id)).toEqual([broad.id]) const result = yield* run("last-session-needle") expect(result.results).toHaveLength(1) - expect(result.sessions).toBe(73) - expect(result.parts).toBe(302) + expect(result.sessions).toBe(143) + expect(result.parts).toBe(1_102) }), { git: true }, ) @@ -298,7 +300,7 @@ it.instance( type: "text", text: `terminal ${"x".repeat(20_000)} terminal needle ${"y".repeat(20_000)}`, }) - for (let index = 0; index < 300; index++) { + for (let index = 0; index < 1_100; index++) { yield* add(session.id, "user", { type: "text", text: `noise ${index}` }) } From 29277b16bdae1be613420318e8d4cb94e4d7f350 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 16 Jul 2026 09:36:18 +0200 Subject: [PATCH 28/28] test(cli): stabilize global skill permission test on slow CI (#12248) * test(cli): stabilize global skill permission test on slow CI The global skill permission test runs two full agent loops with real bash subprocess execution but used much tighter timeouts than every other test in the file: - Test timeout 15s vs 30s elsewhere - pollWithTimeout default 5s vs 15s elsewhere - awaitWithTimeout default 2s for fiber completion vs Fiber.await with the 30s test-level backstop On Windows CI (where the file took 64s), these bounds caused intermittent timeouts. Align all three with the rest of the file and add a best-effort finalizer to clean up the skill directory created under the global config path. * test(cli): focus global skill timeout fix --- .../session-prompt-permission-refresh.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts index 73bf22c0f45..64173ab3b5c 100644 --- a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts @@ -1040,7 +1040,9 @@ it.live( yield* permission.reply({ requestID: pending.id, reply: "always" }) expect( - Exit.isSuccess(yield* awaitWithTimeout(Fiber.await(first), "first global skill run did not finish")), + Exit.isSuccess( + yield* awaitWithTimeout(Fiber.await(first), "first global skill run did not finish", "15 seconds"), + ), ).toBe(true) yield* llm.push(reply().tool("bash", call), reply().text("second complete").stop()) @@ -1052,7 +1054,13 @@ it.live( }) const second = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkScoped) expect( - Exit.isSuccess(yield* awaitWithTimeout(Fiber.await(second), "trusted global skill prompted a second time")), + Exit.isSuccess( + yield* awaitWithTimeout( + Fiber.await(second), + "trusted global skill prompted a second time", + "15 seconds", + ), + ), ).toBe(true) expect(yield* permission.list()).toEqual([]) }), @@ -1064,7 +1072,7 @@ it.live( }), }, ), - { timeout: 15_000 }, + { timeout: 30_000 }, ) it.live("active tool calls use permissions changed after model streaming starts", () =>