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/.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/.changeset/jetbrains-custom-provider-edit.md b/.changeset/jetbrains-custom-provider-edit.md new file mode 100644 index 00000000000..f34122179bc --- /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 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/.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/.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/.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/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-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 7e8b3cbb04b..8cab465cfb8 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -52,6 +52,22 @@ ## [Unreleased] +## [7.0.7] - 2026-07-15 + +### Added + +- Add support for OpenAI-compatible custom providers. + +### Fixed + +- 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 + +- Keep the JetBrains plugin pinned to Kilo Core 7.4.5 for this release. + ## [7.0.6] - 2026-07-14 ### Fixed 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 dcbc25b3232..a046fc4d342 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 @@ -275,6 +275,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/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/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..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,12 +7,15 @@ 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 { CONNECT, OAUTH, + EDIT, DISCONNECT, + DELETE, ENABLE, } @@ -20,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 @@ -38,7 +40,9 @@ internal data class ProviderListRow( action.name, providerListActionText(action), enabled(action), - alwaysVisible = action == ProviderListAction.DISCONNECT && connected, + icon = if (action == ProviderListAction.DELETE) AllIcons.Actions.GC else null, + iconOnly = action == ProviderListAction.DELETE, + primary = action == ProviderListAction.EDIT, ) } @@ -48,7 +52,9 @@ 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.DELETE -> KiloBundle.message("settings.providers.delete") ProviderListAction.ENABLE -> KiloBundle.message("settings.providers.enable") } @@ -74,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 @@ -87,7 +93,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.DELETE) + } 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 64cff8595bf..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 @@ -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 @@ -12,9 +13,14 @@ import ai.kilocode.client.settings.auth.DeviceOAuthPanel import ai.kilocode.client.settings.auth.DeviceOAuthText 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 +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 @@ -43,13 +49,12 @@ import com.intellij.openapi.ui.ValidationInfo import com.intellij.ui.CollectionListModel import com.intellij.ui.DocumentAdapter 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.JBTextField +import com.intellij.ui.components.ActionLink +import com.intellij.openapi.ui.popup.JBPopup import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -58,14 +63,16 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout +import java.awt.Component import java.awt.event.KeyEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent +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.ListSelectionModel +import javax.swing.SwingConstants import javax.swing.event.DocumentEvent import javax.swing.Timer @@ -75,6 +82,41 @@ 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_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 +} + +private class CustomModelRenderer( + model: CollectionListModel, + selected: () -> Set, +) : PickerListRenderer( + model = model, + checked = { it in selected() }, + sectionTitle = { _, _ -> null }, + content = JBLabel(), +) { + private val label = content as JBLabel + + override fun update( + value: String, + index: Int, + selected: Boolean, + focused: Boolean, + foreground: java.awt.Color, + weak: java.awt.Color, + ) { + label.text = value + label.foreground = foreground + } +} + internal class ProvidersSettingsUi( private val cs: CoroutineScope, private val directory: String, @@ -95,7 +137,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") } @@ -216,14 +258,41 @@ internal class ProvidersSettingsUi( @RequiresEdt private fun custom() { checkEdt() - val dialog = CustomProviderDialog() + 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 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, dialog.savedId) + clearProgress() } private fun toolbar(): JComponent { @@ -419,6 +488,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) @@ -431,13 +501,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}") } @@ -478,7 +548,9 @@ 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) } } @@ -539,31 +611,90 @@ private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : Dial } } -private class CustomProviderDialog : DialogWrapper(true) { +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() 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 + + // 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") + 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 - fun input(directory: String) = CustomProviderSaveDto( + 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, 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 +705,269 @@ 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 + savedId = input.id + saving = false + syncActions() + close(OK_EXIT_CODE) + } + } + } + + @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 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() { + picker.repaint() + syncActions() + } + select.addActionListener { + selectAllModels(ids) + sync() + } + clear.addActionListener { + clearModels() + sync() + } + 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 { + 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 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 + 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/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..0faea5799b7 --- /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(JBUI.CurrentTheme.ActionsList.elementIconGap(), 0)).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..2d3c8c38fea --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/picker/PickerPopup.kt @@ -0,0 +1,383 @@ +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 +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.JButton +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 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, + 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 head: JComponent + private lateinit var scroll: JScrollPane + private var foot: JComponent? = null + 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) + + head = header() + foot = footer() + 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) + foot?.let { add(it, BorderLayout.SOUTH) } + 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() + 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(pad, 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 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() { + 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 (autoClose) { + 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() + } + + 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() { + 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) + } + 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, 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 footHeight = foot?.preferredSize?.height ?: 0 + val size = Dimension(listWidth + detailWidth, head.preferredSize.height + height + footHeight) + 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 + } +} 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..07867f4f1ff 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,8 @@ 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.delete=Delete settings.providers.enable=Enable settings.providers.badge.env=env settings.providers.note.kilo=Access 500+ AI models @@ -468,13 +470,25 @@ 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.customEditTitle=Edit OpenAI-Compatible Provider +settings.providers.customSave=Save +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=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. +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..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 @@ -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 @@ -11,15 +12,19 @@ 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.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 import ai.kilocode.rpc.dto.ProviderDisconnectDto 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 import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.CollectionListModel @@ -71,6 +76,230 @@ 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 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"))) }, + ) + } + + 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`() { + 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 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() @@ -168,7 +397,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } - fun `test configured custom provider exposes only disconnect`() { + fun `test configured custom provider exposes edit and delete`() { val content = content() edt { @@ -183,11 +412,35 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { edt { val row = rows(content).single() - assertEquals(listOf(ProviderListAction.DISCONNECT), row.actions) + assertEquals(listOf(ProviderListAction.EDIT, ProviderListAction.DELETE), 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.DELETE), 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( @@ -254,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`() { @@ -274,7 +526,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.DELETE), rows[0].actions) + } + + fun `test edit callback fires instead of delete on primary`() { + var edited: ProviderSettingsProviderDto? = null + 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)), + ) + + edt { + content.update(state) + triggerPrimary(content) + } + + assertEquals("local-openai", edited?.id) + assertNull(deleted) + } + + 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`() { @@ -377,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)) @@ -827,7 +1133,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() @@ -879,6 +1185,30 @@ 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 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 + 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))) 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 + } + } +} 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. 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-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-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ce5205552f8..ecbc45b2fc9 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -142,6 +142,14 @@ 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 + /** 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 @@ -974,6 +982,8 @@ export function Part(props: MessagePartProps) { message={props.message} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + forceOpen={props.forceOpen} + forceOpenFile={props.forceOpenFile} reasoningAutoCollapse={props.reasoningAutoCollapse} showAssistantCopyPartID={props.showAssistantCopyPartID} showTurnDiffSummary={props.showTurnDiffSummary} @@ -999,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 @@ -1198,6 +1211,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 +1279,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { attachments={part.state.attachments} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + forceOpen={props.forceOpen} + forceOpenFile={props.forceOpenFile} animate reveal={props.animate} /> @@ -1519,6 +1535,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. @@ -2238,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 @@ -2276,7 +2311,12 @@ ToolRegistry.register({ } > - + @@ -2588,6 +2628,31 @@ 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. `forceOpenFile` (from MessageList's per-chunk file + // 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) { + 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 "" @@ -2640,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/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 1238a098650..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", @@ -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/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index a3d661e2793..9a43a3a7619 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1775,6 +1775,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/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..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 @@ -8,12 +8,36 @@ 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() } + // 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. + // + // 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 true + } + if (view === agentManager) { + return agentManager.waitForReady() + } + await view.waitForReady() + return true + } context.subscriptions.push( vscode.commands.registerCommand("kilo-code.new.explainCode", async () => { @@ -69,18 +93,26 @@ export function registerCodeActions( selectedText: ctx.selectedText, }) const view = target() - if (view === provider) { - await reveal() - } + if (!(await revealTarget(view))) return view.postMessage({ type: "appendChatBoxMessage", text: prompt }) }), vscode.commands.registerCommand("kilo-code.new.focusChatInput", async () => { const view = target() - if (view === provider) { - await reveal() - } + if (!(await revealTarget(view))) return 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 (!(await revealTarget(view))) return + view.postMessage({ type: "action", action: "focusSearch" }) + }), ) } 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-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/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index c3fa1547bbb..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", () => { @@ -388,7 +391,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]*?() const executed: unknown[][] = [] const events: string[] = [] @@ -46,6 +46,11 @@ function setup(active = false) { events.push("post") posts.push(msg) }, + waitForReady: async () => { + events.push("wait") + waits.push("agent") + return agentReady + }, } api.commands.registerCommand = (command, callback) => { @@ -103,9 +108,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", @@ -113,4 +118,31 @@ 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([]) + }) + + 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([]) + }) }) 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/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index e899662e064..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,6 +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") + 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/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 491f469f742..4a50029265d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -106,6 +106,13 @@ 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 + /** 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 /** Part behind the currently hovered/focused task-timeline bar, if any. */ highlight?: () => TimelineHighlight | undefined } @@ -119,7 +126,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 ( @@ -135,6 +142,7 @@ function TodoToolCard(props: { part: ToolPart }) { output={state()?.output} status={state()?.status} defaultOpen + forceOpen={props.forceOpen} reveal={false} /> )} @@ -142,7 +150,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 ( @@ -159,6 +167,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"} /> @@ -261,6 +270,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) // Lights up when this part is behind the hovered/focused task-timeline // bar, using that bar's own color so the two stay easy to correlate. @@ -283,6 +293,7 @@ export const AssistantMessage: Component = (props) => {
= (props) => { message={props.message as SDKMessage} showAssistantCopyPartID={props.showAssistantCopyPartID} defaultOpen={editOpen(part, edit())} + forceOpen={forceOpen()} + forceOpenFile={forceOpen() ? props.forceOpenFile : undefined} reasoningAutoCollapse={display.reasoningAutoCollapse()} feedback={props.feedback} animate={ @@ -318,11 +331,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 0d932828cd5..40e0b72876a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -27,7 +27,9 @@ 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 { useWorktreeMode } from "../../context/worktree-mode" import { WelcomeEmptyState } from "./WelcomeEmptyState" import { TranscriptRowView } from "./TranscriptRow" import type { ErrorDisplayProps } from "./ErrorDisplay" @@ -55,6 +57,11 @@ import { stableMessageTurns, type MessageTurn, } from "../../context/session-queue" +import { childID } from "../../context/session-utils" +import { taskResult } from "./task-tool-state" +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 { partitionRows, retainTurn, @@ -97,6 +104,32 @@ export const MessageList: Component = (props) => { const server = useServer() const language = useLanguage() 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 + // 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", @@ -164,9 +197,27 @@ 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 + /** 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 + // 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/ @@ -175,13 +226,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, 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, file }) + 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 @@ -191,14 +251,21 @@ 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)) { + if (typeof chunk === "string") push(chunk, part.id) + else push(chunk.text, part.id, chunk.file) + } 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 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 @@ -274,13 +341,56 @@ 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"]) - function toolText(part: Part & { type: "tool" }): string[] { + // 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'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. + // 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" }): ToolChunk[] { 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(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) + if (part.tool === "apply_patch") return applyPatchText(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) collectStrings(state.input, chunks) @@ -289,6 +399,212 @@ 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 + // 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) ?? + [] + 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 }[] } { + 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) + // 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. 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(stripMarkdownLinkUrls(result)) + } + 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). + type QuestionOption = { label?: string; description?: string; labelKey?: string; descriptionKey?: string } + type QuestionInfo = { question?: string; questionKey?: string; options?: QuestionOption[] } + + // 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) => { + // 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 — 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) + const description = option.description ? tr(language.t, option.descriptionKey, option.description) : "" + if (description) chunks.push(description) + } + return + } + if (dismissed) return + for (const value of answers?.[i] ?? []) { + if (typeof value === "string" && value) chunks.push(value) + } + }) + return chunks + } + + 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 + } + + // 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 (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) { + const path = file.relativePath ?? file.filePath + 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) + if (dir) chunks.push(dir) + chunks.push(getFilename(path)) + continue + } + const tag = (text: string): ToolChunk => ({ text, file: file.filePath! }) + if (dir) chunks.push(tag(dir)) + chunks.push(tag(getFilename(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 @@ -410,7 +726,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) @@ -420,7 +736,8 @@ export const MessageList: Component = (props) => { hit = p.exec(text) continue } - result.push({ key: row.key, messageId: row.message.id, occurrence }) + const range = rangeAt(ranges, hit.index) + result.push({ key: row.key, occurrence, partId: range?.partId, partFile: range?.file }) occurrence += 1 hit = p.exec(text) } @@ -483,6 +800,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 @@ -497,7 +834,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 @@ -827,6 +1169,8 @@ export const MessageList: Component = (props) => { onForkMessage={props.onForkMessage} highlight={highlight} activeSearch={activeKey() === row.key} + activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined} + activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined} /> )} @@ -838,6 +1182,8 @@ export const MessageList: Component = (props) => { onForkMessage={props.onForkMessage} highlight={highlight} activeSearch={activeKey() === key} + activeSearchPartID={activeKey() === key ? activeMatch()?.partId : undefined} + activeSearchPartFile={activeKey() === key ? activeMatch()?.partFile : undefined} /> )} @@ -847,7 +1193,14 @@ export const MessageList: Component = (props) => { - {(row) => } + {(row) => ( + + )} 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/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/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index e35195bc3f6..0ed14b06202 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,36 @@ export const TaskHeader: Component = (props) => { window.addEventListener("message", handler) onCleanup(() => window.removeEventListener("message", handler)) + // "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.active() ? search.closeSearch() : search.setActive(true)) + window.addEventListener("focusTranscriptSearch", toggleSearch) + onCleanup(() => window.removeEventListener("focusTranscriptSearch", toggleSearch)) + + // 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.closeSignal(), + () => { + window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } })) + }, + { defer: true }, + ), + ) + const toggle = () => { const next = !expanded() setExpanded(next) @@ -238,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/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/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx index 868545a5cfb..c75b6880cee 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,11 @@ interface TranscriptRowViewProps { /** Part behind the currently hovered/focused task-timeline bar, if any. */ highlight?: () => TimelineHighlight | undefined activeSearch?: boolean + /** 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) => { @@ -81,6 +86,8 @@ 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} + forceOpenFile={props.activeSearchPartFile} highlight={props.highlight} feedback={{ enabled: feedback.telemetryEnabled(), 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/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/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/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/context/transcript-search.tsx b/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx index eabc0e55f76..1edb13bdc11 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,16 @@ 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 + /** 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 { @@ -18,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 @@ -48,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) @@ -67,6 +89,8 @@ export const TranscriptSearchProvider: ParentComponent = (props) => { setRegex, active, setActive, + closeSignal, + closeSearch, index, setIndex, count, 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 { 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 { 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/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 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/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 38812128cde..17607c9a7a6 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" @@ -76,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] } @@ -237,6 +248,7 @@ export const AgentManagerTool = Tool.define< return { description: DESCRIPTION, parameters: Params, + jsonSchema: ToolJsonSchema.fromSchema(WireParams), execute: (params, ctx) => Effect.gen(function* () { if ("action" in params) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 44dfc7576cc..b113f8197d1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -735,7 +735,11 @@ export function variants(model: Provider.Model): Record { + 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).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 () => { const tool = await init() const calls: unknown[] = [] 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}` }) } 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", () => diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 7a1f323daa3..a4865e32c08 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3443,6 +3443,25 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) 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", + 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" }) + }) + // kilocode_change end }) describe("@ai-sdk/deepinfra", () => { 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/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. 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",