From b12c7cf10d6abe4f15c644a46c3c30f669c5d571 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 3 May 2026 16:12:09 -0400 Subject: [PATCH 01/29] ci(jetbrains): publish marketplace rc builds --- .github/workflows/publish-jetbrains.yml | 166 +++++++++++++++++++++++ packages/kilo-jetbrains/README.md | 23 ++++ packages/kilo-jetbrains/RELEASE_TODO.md | 44 ++++++ packages/kilo-jetbrains/build.gradle.kts | 59 +++++++- 4 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-jetbrains.yml create mode 100644 packages/kilo-jetbrains/RELEASE_TODO.md diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml new file mode 100644 index 00000000000..8e9ccb20fda --- /dev/null +++ b/.github/workflows/publish-jetbrains.yml @@ -0,0 +1,166 @@ +name: publish-jetbrains + +on: + push: + tags: + - "jetbrains/*" + +concurrency: + group: publish-jetbrains-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + publish: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install -y patchelf zip + + - name: Validate version tag + id: version + run: | + tag="$GITHUB_REF_NAME" + if [[ "$tag" != jetbrains/* ]]; then + echo "Unsupported tag '$tag'. Expected jetbrains/." >&2 + exit 1 + fi + + version="${tag#jetbrains/}" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + { + echo "version=$version" + echo "kind=rc" + echo "marketplace_channel=eap" + echo "cli_channel=rc" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + { + echo "version=$version" + echo "kind=stable" + echo "marketplace_channel=default" + echo "cli_channel=latest" + } >> "$GITHUB_OUTPUT" + echo "Stable JetBrains Marketplace publishing is implemented but intentionally disabled; use an rc tag such as jetbrains/7.0.1-rc.1." >&2 + exit 1 + fi + + echo "Unsupported JetBrains plugin version '$version'. Expected x.y.z-rc.n or x.y.z." >&2 + exit 1 + + - name: Validate publishing secrets + run: | + missing=0 + for name in JETBRAINS_MARKETPLACE_TOKEN JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do + if [[ -z "${!name}" ]]; then + echo "Missing required secret: $name" >&2 + missing=1 + fi + done + exit "$missing" + env: + JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }} + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + + - name: Prepare CLI resources + working-directory: packages/kilo-jetbrains + run: bun script/build.ts --production --prepare-cli + env: + KILO_VERSION: ${{ steps.version.outputs.version }} + KILO_CHANNEL: ${{ steps.version.outputs.cli_channel }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + + - name: Verify plugin + working-directory: packages/kilo-jetbrains + run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" + env: + CHANNEL: ${{ steps.version.outputs.marketplace_channel }} + + - name: Publish to JetBrains Marketplace + working-directory: packages/kilo-jetbrains + run: ./gradlew publishPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" + env: + CHANNEL: ${{ steps.version.outputs.marketplace_channel }} + JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }} + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + + - name: Resolve plugin archive + id: archive + run: | + mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip") + if [[ "${#signed[@]}" -eq 1 ]]; then + echo "path=${signed[0]}" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "${#signed[@]}" -gt 1 ]]; then + echo "Expected exactly one signed JetBrains plugin ZIP, found ${#signed[@]}." >&2 + printf '%s\n' "${signed[@]}" >&2 + exit 1 + fi + + mapfile -t files < <(compgen -G "packages/kilo-jetbrains/build/distributions/*.zip") + if [[ "${#files[@]}" -ne 1 ]]; then + echo "Expected exactly one JetBrains plugin ZIP, found ${#files[@]}." >&2 + printf '%s\n' "${files[@]}" >&2 + exit 1 + fi + echo "path=${files[0]}" >> "$GITHUB_OUTPUT" + + - name: Upload to GitHub Release + run: | + tag="$GITHUB_REF_NAME" + title="JetBrains $VERSION" + notes="JetBrains plugin $VERSION." + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$tag" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY" + exit 0 + fi + gh release create "$tag" "$ARCHIVE" --title "$title" --notes "$notes" --prerelease --repo "$GITHUB_REPOSITORY" + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + ARCHIVE: ${{ steps.archive.outputs.path }} + + - name: Upload workflow artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: kilo-jetbrains-${{ steps.version.outputs.version }} + path: packages/kilo-jetbrains/build/distributions/*.zip + if-no-files-found: ignore diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index 56fbf578a11..8cd2eed1cc5 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -52,6 +52,29 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-.zip --- +## Publish release candidates + +JetBrains release-candidate builds publish from tags matching `jetbrains/x.y.z-rc.n`, for example `jetbrains/7.0.1-rc.1`. The git tag is the source of truth for the Marketplace plugin version; production Gradle builds ignore manually supplied version properties and fail if `HEAD` is not tagged with `jetbrains/`. Local dev builds can use the placeholder `0.0.0-dev` version. + +The `publish-jetbrains` workflow publishes RC builds to the JetBrains Marketplace `eap` channel and uploads the same plugin ZIP to a GitHub prerelease for the tag. Stable tags like `jetbrains/x.y.z` are recognized by the workflow but intentionally rejected until default-channel publishing is enabled. + +Testers can install or update EAP builds from this custom plugin repository: + +``` +https://plugins.jetbrains.com/plugins/eap/list +``` + +Required GitHub Actions secrets: + +- `JETBRAINS_MARKETPLACE_TOKEN` +- `JETBRAINS_CERTIFICATE_CHAIN` +- `JETBRAINS_PRIVATE_KEY` +- `JETBRAINS_PRIVATE_KEY_PASSWORD` + +Before the first publish, complete `RELEASE_TODO.md`. + +--- + ## Run the plugin Use the `runIde` Gradle task (available in the Gradle tool window or via the "Run JetBrains Plugin" run configuration) to launch a sandboxed IntelliJ instance with the plugin installed. diff --git a/packages/kilo-jetbrains/RELEASE_TODO.md b/packages/kilo-jetbrains/RELEASE_TODO.md new file mode 100644 index 00000000000..d3b8430da4d --- /dev/null +++ b/packages/kilo-jetbrains/RELEASE_TODO.md @@ -0,0 +1,44 @@ +# JetBrains Release Todo + +## One-Time Marketplace Setup + +- Confirm `ai.kilocode.jetbrains` is the final permanent Marketplace plugin ID before the first upload. +- Build a production ZIP locally or in CI for the first Marketplace version. +- Manually upload the first plugin version in JetBrains Marketplace if the plugin has not been published before. +- Confirm the plugin is owned by the correct Kilo vendor or organization. +- Confirm the `eap` custom channel exists or is accepted on the first RC upload. +- Confirm Marketplace signing requirements before publishing RC builds. + +## One-Time GitHub Setup + +- Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`. +- Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment. +- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases from `jetbrains/*` tags. +- Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers. +- If using an environment, move the Marketplace and signing secrets there and set the workflow job environment. + +## One-Time Signing Certificate Setup + +- Generate or locate the JetBrains plugin signing private key and certificate chain. +- Add `JETBRAINS_CERTIFICATE_CHAIN` with the full certificate chain PEM content. +- Add `JETBRAINS_PRIVATE_KEY` with the private key PEM content. +- Add `JETBRAINS_PRIVATE_KEY_PASSWORD` with the private key password. +- Keep PEM files out of git, local logs, and workflow output. +- If GitHub secret input mangles multiline PEM values, store base64-encoded values and verify the Gradle signing task decodes them correctly. + +## Per-RC Release + +- Choose an RC version in the form `x.y.z-rc.n`. +- Push tag `jetbrains/x.y.z-rc.n`. +- Watch the `publish-jetbrains` workflow. +- Download and retain the workflow artifact if needed. +- Confirm the update appears on the JetBrains Marketplace `eap` channel. +- Confirm the GitHub Release for the `jetbrains/x.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset. +- Share `https://plugins.jetbrains.com/plugins/eap/list` with testers. + +## Stable Release Guard + +- Stable tags like `jetbrains/x.y.z` are intentionally rejected for now. +- Before enabling stable releases, remove the workflow stable guard. +- Verify `kilo.channel=default` publishes to the default Marketplace channel. +- Update this checklist before stable releases are enabled. diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 3ecc8be04b6..ace89616df1 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -4,7 +4,32 @@ import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.SplitModeTarget group = "ai.kilocode.jetbrains" -version = "7.0.1" + +fun checked(value: String): String { + if (value == "0.0.0-dev") return value + require(Regex("^[0-9]+\\.[0-9]+\\.[0-9]+(-rc\\.[0-9]+)?$").matches(value)) { + "Invalid JetBrains plugin version: $value" + } + return value +} + +fun gitTag(): String? { + val text = providers.exec { + commandLine("git", "tag", "--points-at", "HEAD") + }.standardOutput.asText.get() + return text.lineSequence().map { it.trim() }.firstOrNull { it.startsWith("jetbrains/") } +} + +val release = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false).get() +val ver = if (release) checked( + gitTag()?.removePrefix("jetbrains/") + ?: error("Missing JetBrains plugin version. Publish builds must run from a jetbrains/ tag."), +) else checked(gitTag()?.removePrefix("jetbrains/") ?: "0.0.0-dev") + +val notes = providers.gradleProperty("kilo.changeNotes").orElse("Release candidate build.") +val channel = providers.gradleProperty("kilo.channel").map { it.trim() }.orElse("default") + +version = ver plugins { application @@ -59,9 +84,39 @@ intellijPlatform { splitMode = true splitModeTarget = SplitModeTarget.BOTH + pluginConfiguration { + id = "ai.kilocode.jetbrains" + name = "Kilo Code" + version = provider { ver } + changeNotes = notes + + ideaVersion { + untilBuild = provider { null } + } + + vendor { + name = "Kilo Code" + url = "https://kilo.ai" + } + } + + publishing { + token = providers.environmentVariable("JETBRAINS_MARKETPLACE_TOKEN") + channels = channel.map { value -> + if (value.isBlank() || value == "default") return@map listOf("default") + listOf(value) + } + } + + signing { + certificateChain = providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("JETBRAINS_PRIVATE_KEY") + password = providers.environmentVariable("JETBRAINS_PRIVATE_KEY_PASSWORD") + } + pluginVerification { ides { - create(IntelliJPlatformType.IntellijIdeaCommunity, libs.versions.intellij.platform) + create(IntelliJPlatformType.IntellijIdea, libs.versions.intellij.platform) } } } From 8b34a34d1d0499c1e9d956e923d8827cf4a77fd4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 3 May 2026 16:17:47 -0400 Subject: [PATCH 02/29] ci(jetbrains): annotate publish workflow --- .github/workflows/publish-jetbrains.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index 8e9ccb20fda..3465c4f78f1 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -1,3 +1,4 @@ +# kilocode_change - new file name: publish-jetbrains on: From c104a3b474c6f9e4c8abfc3ab11add6fad6f97e8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 6 May 2026 17:57:41 -0400 Subject: [PATCH 03/29] fix(jetbrains): map session times as doubles --- .../backend/app/KiloBackendSessionManager.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 80ce9ab7946..c3337285594 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -186,9 +186,9 @@ class KiloBackendSessionManager( title = s.title, version = s.version, time = SessionTimeDto( - created = s.time.created, - updated = s.time.updated, - archived = s.time.archived, + created = s.time.created.toDouble(), + updated = s.time.updated.toDouble(), + archived = s.time.archived?.toDouble(), ), summary = s.summary?.let { SessionSummaryDto( @@ -207,9 +207,9 @@ class KiloBackendSessionManager( title = s.title, version = s.version, time = SessionTimeDto( - created = s.time.created, - updated = s.time.updated, - archived = s.time.archived, + created = s.time.created.toDouble(), + updated = s.time.updated.toDouble(), + archived = s.time.archived?.toDouble(), ), summary = s.summary?.let { SessionSummaryDto( From 2d365fe707513909042ac5715fb93969c9491b44 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 17:17:56 -0400 Subject: [PATCH 04/29] fix(jetbrains): reduce marketplace verifier warnings --- .../ai/kilocode/client/session/SessionUi.kt | 1 + .../client/session/history/HistoryPanel.kt | 22 ++++----- .../client/session/scroll/ScrollButtonIcon.kt | 44 +++++++++++++++++ .../session/{ => scroll}/SessionScroll.kt | 47 ++----------------- .../kilocode/client/session/ui/PickerRow.kt | 4 +- .../client/session/ui/model/ModelPicker.kt | 4 +- .../session/ui/model/ModelPickerRenderer.kt | 14 +++--- .../client/session/ui/prompt/PromptPanel.kt | 2 +- .../actions/HistorySessionActionsTest.kt | 27 ++++++----- .../client/actions/NewSessionActionTest.kt | 5 +- .../client/actions/SendPromptActionTest.kt | 7 +-- .../client/actions/StopSessionActionTest.kt | 7 +-- .../client/session/SessionUiTestBase.kt | 1 + .../session/ui/model/ModelPickerTest.kt | 4 +- 14 files changed, 97 insertions(+), 92 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => scroll}/SessionScroll.kt (76%) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index cb316ab2d93..f30a434ee5d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.scroll.SessionScroll import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt index b3ad33485d8..7945be7ee2b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt @@ -5,6 +5,8 @@ import ai.kilocode.client.session.SessionManager import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.iconButton import com.intellij.icons.AllIcons import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.application.ApplicationManager @@ -12,11 +14,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataProvider -import com.intellij.openapi.actionSystem.Presentation -import com.intellij.openapi.actionSystem.impl.ActionButton import com.intellij.openapi.util.Disposer import com.intellij.ui.DocumentAdapter import com.intellij.ui.PopupHandler @@ -180,12 +178,12 @@ class HistoryPanel( private fun back(): BorderLayoutPanel { val label = KiloBundle.message("history.back") - val action = object : AnAction(label, null, AllIcons.Actions.Back) { - override fun actionPerformed(e: AnActionEvent) = nav() - } - val presentation = Presentation(label).apply { icon = AllIcons.Actions.Back } - val btn = ActionButton(action, presentation, ActionPlaces.TOOLBAR, JBUI.size(16)).apply { + val btn = HoverIcon().apply { + icon = AllIcons.Actions.Back + toolTipText = label + accessibleContext.accessibleName = label cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addActionListener { nav() } } return BorderLayoutPanel().apply { add(btn, BorderLayout.WEST) @@ -379,17 +377,17 @@ class HistoryPanel( internal fun backText(): String? { val view = activeInfo().foreSideComponent ?: return null - return UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.presentation?.text + return UIUtil.uiTraverser(view).filter(HoverIcon::class.java).firstOrNull()?.toolTipText } internal fun backCursor(): Int? { val view = activeInfo().foreSideComponent ?: return null - return UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.cursor?.type + return UIUtil.uiTraverser(view).filter(HoverIcon::class.java).firstOrNull()?.cursor?.type } internal fun clickBack() { val view = activeInfo().foreSideComponent ?: return - UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.click() + UIUtil.uiTraverser(view).filter(HoverIcon::class.java).firstOrNull()?.doClick() } internal fun clickDelete() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt new file mode 100644 index 00000000000..566ad46e25d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt @@ -0,0 +1,44 @@ +package ai.kilocode.client.session.scroll + +import com.intellij.util.ui.JBUI +import java.awt.BasicStroke +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.Icon + +internal object ScrollButtonIcon : Icon { + private val size get() = JBUI.scale(40) + private val radius get() = JBUI.scale(18) + + override fun getIconWidth() = size + override fun getIconHeight() = size + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.translate(x, y) + val cx = size / 2 + val cy = size / 2 + val r = radius + g2.color = JBUI.CurrentTheme.Button.defaultButtonColorStart() + g2.fillOval(cx - r, cy - r, r * 2, r * 2) + g2.color = JBUI.CurrentTheme.Button.defaultButtonForeground() + val stroke = JBUI.scale(1).toFloat() * 1.5f + g2.stroke = BasicStroke(stroke, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) + val x0 = JBUI.scale(20) + val lineTop = JBUI.scale(12) + val lineBot = JBUI.scale(27) + val arrowY = JBUI.scale(21) + val arrowLeft = JBUI.scale(14) + val arrowRight = JBUI.scale(26) + g2.drawLine(x0, lineTop, x0, lineBot) + g2.drawLine(x0, lineBot, arrowLeft, arrowY) + g2.drawLine(x0, lineBot, arrowRight, arrowY) + } finally { + g2.dispose() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt similarity index 76% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index eef78776d83..eb61f34727e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.scroll import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.SessionMessageListPanel @@ -7,27 +7,18 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.icons.CachedImageIcon -import com.intellij.ui.svg.SvgAttributePatcher -import com.intellij.util.SVGLoader import com.intellij.util.ui.JBUI -import java.awt.Color import java.awt.Cursor import java.awt.Point import java.awt.Rectangle import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.Icon import javax.swing.JComponent import javax.swing.JPanel import javax.swing.JScrollBar -private const val ICON_DIGEST = 0x5c011b0bb17L -private const val OPAQUE_ALPHA = 255 - internal class SessionScroll( private val root: SessionRootPanel, private val host: JPanel, @@ -35,7 +26,6 @@ internal class SessionScroll( body: JPanel, ) { companion object { - private val ICON = IconLoader.getIcon("/icons/scroll-bottom.svg", SessionScroll::class.java) private const val THRESHOLD = 32 private const val OPEN_PASSES = 12 } @@ -58,7 +48,7 @@ internal class SessionScroll( private var seq = 0 init { - jump = JBLabel(patchedIcon(ICON)).apply { + jump = JBLabel(ScrollButtonIcon).apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("session.scroll.bottom") isVisible = false @@ -130,7 +120,7 @@ internal class SessionScroll( fun applyStyle(style: SessionEditorStyle) { this.style = style - jump.icon = patchedIcon(ICON) + jump.repaint() messages.applyStyle(style) val view = component.viewport.view if (view !== messages) (view as? SessionEditorStyleTarget)?.applyStyle(style) @@ -231,34 +221,3 @@ internal class SessionScroll( root.overlay.repaint() } } - -private fun patchedIcon(icon: Icon): Icon { - val cached = icon as? CachedImageIcon ?: return icon - return cached.createWithPatcher(object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { - override fun digest(): LongArray { - val bg = JBUI.CurrentTheme.Button.defaultButtonColorStart().rgb.toLong() - val fg = JBUI.CurrentTheme.Button.defaultButtonForeground().rgb.toLong() - return longArrayOf(bg, fg, ICON_DIGEST) - } - - override fun attributeForPath(path: String) = this - - override fun patchColors(attributes: MutableMap) { - when (attributes["id"]) { - "ScrollButton.Background" -> - set(attributes, "fill", JBUI.CurrentTheme.Button.defaultButtonColorStart()) - - "ScrollButton.Foreground" -> - set(attributes, "stroke", JBUI.CurrentTheme.Button.defaultButtonForeground()) - } - } - - private fun set(attributes: MutableMap, key: String, color: Color) { - if (!attributes.containsKey(key) || attributes[key] == "none") return - attributes[key] = "rgb(${color.red},${color.green},${color.blue})" - if (color.alpha != OPAQUE_ALPHA) { - attributes["$key-opacity"] = "${color.alpha / OPAQUE_ALPHA.toFloat()}" - } - } - }) -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PickerRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PickerRow.kt index e5e24fb567d..3fd2887b184 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PickerRow.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PickerRow.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.ui import com.intellij.openapi.ui.popup.util.PopupUtil -import com.intellij.ui.ExperimentalUI +import com.intellij.ui.NewUI import com.intellij.ui.popup.list.SelectablePanel import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -23,7 +23,7 @@ internal class PickerRow : SelectablePanel() { fun update(list: JList<*>, selected: Boolean, focused: Boolean) { background = list.background selectionColor = if (selected) UIUtil.getListBackground(true, focused) else null - if (ExperimentalUI.isNewUI()) { + if (NewUI.isEnabled()) { PopupUtil.configListRendererFlexibleHeight(this) return } 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 4423679e9ef..6913142e533 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 @@ -9,8 +9,8 @@ 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.ExperimentalUI import com.intellij.ui.JBColor +import com.intellij.ui.NewUI import com.intellij.ui.ListUtil import com.intellij.ui.SearchTextField import com.intellij.ui.ScrollPaneFactory @@ -38,7 +38,7 @@ import javax.swing.SwingUtilities import javax.swing.event.DocumentEvent private val popupBackground: Color - get() = if (ExperimentalUI.isNewUI()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground() + 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 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 0a51cbaee15..1dc0ea9a5ca 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 @@ -5,8 +5,8 @@ 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.ExperimentalUI import com.intellij.ui.GroupHeaderSeparator +import com.intellij.ui.NewUI import com.intellij.ui.JBColor import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes @@ -23,7 +23,7 @@ import java.awt.Graphics2D import java.awt.Point import java.awt.Rectangle import java.awt.RenderingHints -import java.awt.Toolkit +import java.awt.font.FontRenderContext import javax.swing.Icon import javax.swing.JList import javax.swing.JPanel @@ -53,7 +53,7 @@ internal class ModelPickerRenderer( } private fun favoriteInset(list: JList<*>): Int { - if (!ExperimentalUI.isNewUI()) return 0 + 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 @@ -162,11 +162,9 @@ internal class ModelPickerRenderer( private val text = KiloBundle.message("model.picker.free") override fun getIconWidth(): Int { - val fm = JBFont.small().let { font -> - @Suppress("DEPRECATION") - Toolkit.getDefaultToolkit().getFontMetrics(font) - } - return fm.stringWidth(text) + JBUI.scale(12) + val font = JBFont.small() + val w = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() + return w + JBUI.scale(12) } override fun getIconHeight(): Int = JBUI.scale(16) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index ac4bc25f6e2..9ba3f57bfd4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -117,7 +117,7 @@ class PromptPanel( ?: return@addActionListener val ctx = DataManager.getInstance().getDataContext(button) val event = AnActionEvent.createEvent(action, ctx, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null) - action.update(event) + ActionUtil.updateAction(action, event) ActionUtil.performAction(action, event) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index 9165e8d50ef..012734473bd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -20,6 +20,7 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.Presentation @@ -71,7 +72,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val local = localItem("ses_1") val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertTrue(event.presentation.isEnabledAndVisible) } @@ -81,7 +82,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val item = cloudItem("cloud_1") val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertTrue(event.presentation.isEnabledAndVisible) } @@ -91,7 +92,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val local = localItem("ses_1") val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } @@ -100,7 +101,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val action = OpenSessionAction() val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } @@ -110,7 +111,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val items = listOf(localItem("ses_1"), localItem("ses_2")) val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } @@ -160,7 +161,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val local = localItem("ses_1") val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertTrue(event.presentation.isEnabledAndVisible) } @@ -170,7 +171,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val item = cloudItem("cloud_1") val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } @@ -180,7 +181,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val local = localItem("ses_1") val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } @@ -189,7 +190,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val action = DeleteSessionAction() val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } @@ -260,7 +261,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val local = localItem("ses_1") val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertTrue(event.presentation.isEnabledAndVisible) } @@ -269,7 +270,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val action = RenameSessionAction() val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } @@ -279,7 +280,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val items = listOf(localItem("ses_1"), localItem("ses_2")) val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } @@ -289,7 +290,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() { val item = cloudItem("cloud_1") val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabledAndVisible) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt index 3de571b0d8f..9d18dac4407 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.SessionRef import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") @@ -14,7 +15,7 @@ class NewSessionActionTest : BasePlatformTestCase() { val action = NewSessionAction() val event = event(manager) - action.update(event) + ActionUtil.updateAction(action, event) action.actionPerformed(event) assertEquals(1, manager.created) @@ -28,7 +29,7 @@ class NewSessionActionTest : BasePlatformTestCase() { val action = NewSessionAction() val presentation = Presentation().apply { copyFrom(action.templatePresentation) } - action.update(AnActionEvent.createFromDataContext("", presentation) { null }) + ActionUtil.updateAction(action, AnActionEvent.createFromDataContext("", presentation) { null }) assertFalse(presentation.isEnabled) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/SendPromptActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/SendPromptActionTest.kt index a6198250f4d..d3a670bf208 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/SendPromptActionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/SendPromptActionTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.SendPromptContext import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.Presentation import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -14,7 +15,7 @@ class SendPromptActionTest : BasePlatformTestCase() { val action = SendPromptAction() val event = event(action, ctx) - action.update(event) + ActionUtil.updateAction(action, event) action.actionPerformed(event) assertTrue(event.presentation.isEnabled) @@ -27,7 +28,7 @@ class SendPromptActionTest : BasePlatformTestCase() { val action = SendPromptAction() val event = event(action, null) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabled) } @@ -37,7 +38,7 @@ class SendPromptActionTest : BasePlatformTestCase() { val action = SendPromptAction() val event = event(action, ctx) - action.update(event) + ActionUtil.updateAction(action, event) action.actionPerformed(event) assertFalse(event.presentation.isEnabled) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/StopSessionActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/StopSessionActionTest.kt index f6670ed10ba..f74bb0910c0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/StopSessionActionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/StopSessionActionTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.SendPromptContext import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.Presentation import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -14,7 +15,7 @@ class StopSessionActionTest : BasePlatformTestCase() { val action = StopSessionAction() val event = event(action, ctx) - action.update(event) + ActionUtil.updateAction(action, event) action.actionPerformed(event) assertTrue(event.presentation.isEnabled) @@ -27,7 +28,7 @@ class StopSessionActionTest : BasePlatformTestCase() { val action = StopSessionAction() val event = event(action, null) - action.update(event) + ActionUtil.updateAction(action, event) assertFalse(event.presentation.isEnabled) } @@ -37,7 +38,7 @@ class StopSessionActionTest : BasePlatformTestCase() { val action = StopSessionAction() val event = event(action, ctx) - action.update(event) + ActionUtil.updateAction(action, event) action.actionPerformed(event) assertFalse(event.presentation.isEnabled) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index a1d28529c71..13591100a75 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -11,6 +11,7 @@ import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.scroll.SessionScroll import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt index 56dac1a9553..c1ad8e5f07f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session.ui.model import ai.kilocode.rpc.dto.ModelSelectionDto import com.intellij.icons.AllIcons import com.intellij.ui.CollectionListModel -import com.intellij.ui.ExperimentalUI +import com.intellij.ui.NewUI import com.intellij.ui.components.JBList import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.EmptyIcon @@ -273,7 +273,7 @@ class ModelPickerTest : BasePlatformTestCase() { ) = ModelPicker.Item(id, display, provider, name, index, free = free) private fun favoriteInset(list: JBList<*>): Int { - if (!ExperimentalUI.isNewUI()) return 0 + 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 From 96d23223a2f2c00bd2d53c308736e61ebbe88f3d Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 17:40:00 -0400 Subject: [PATCH 05/29] fix(jetbrains): use svg patcher for scroll icon --- .../client/session/scroll/ScrollButtonIcon.kt | 80 ++++++++++--------- .../client/session/scroll/SessionScroll.kt | 4 +- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt index 566ad46e25d..73205135298 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt @@ -1,44 +1,52 @@ package ai.kilocode.client.session.scroll +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.icons.CachedImageIcon +import com.intellij.ui.svg.SvgAttributePatcher +import com.intellij.util.SVGLoader import com.intellij.util.ui.JBUI -import java.awt.BasicStroke -import java.awt.Component -import java.awt.Graphics -import java.awt.Graphics2D -import java.awt.RenderingHints +import java.awt.Color import javax.swing.Icon -internal object ScrollButtonIcon : Icon { - private val size get() = JBUI.scale(40) - private val radius get() = JBUI.scale(18) +private const val OPAQUE_ALPHA = 255 - override fun getIconWidth() = size - override fun getIconHeight() = size +internal object ScrollButtonIcon { + private val icon = IconLoader.getIcon("/icons/scroll-bottom.svg", ScrollButtonIcon::class.java) - override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.translate(x, y) - val cx = size / 2 - val cy = size / 2 - val r = radius - g2.color = JBUI.CurrentTheme.Button.defaultButtonColorStart() - g2.fillOval(cx - r, cy - r, r * 2, r * 2) - g2.color = JBUI.CurrentTheme.Button.defaultButtonForeground() - val stroke = JBUI.scale(1).toFloat() * 1.5f - g2.stroke = BasicStroke(stroke, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) - val x0 = JBUI.scale(20) - val lineTop = JBUI.scale(12) - val lineBot = JBUI.scale(27) - val arrowY = JBUI.scale(21) - val arrowLeft = JBUI.scale(14) - val arrowRight = JBUI.scale(26) - g2.drawLine(x0, lineTop, x0, lineBot) - g2.drawLine(x0, lineBot, arrowLeft, arrowY) - g2.drawLine(x0, lineBot, arrowRight, arrowY) - } finally { - g2.dispose() - } - } + fun create(): Icon = icon.colorizeIfPossible( + fillColor = JBUI.CurrentTheme.Button.defaultButtonColorStart(), + borderColor = JBUI.CurrentTheme.Button.defaultButtonForeground(), + ) } + +private fun Icon.colorizeIfPossible(fillColor: Color, borderColor: Color = fillColor): Icon = + (this as? CachedImageIcon)?.createWithPatcher(colorPatcher = object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { + private val digest = longArrayOf(0L, 440413911775177385) + + override fun digest(): LongArray { + digest[0] = toLong(fillColor.rgb, borderColor.rgb) + return digest + } + + override fun patchColors(attributes: MutableMap) { + when (attributes["id"]) { + "ScrollButton.Background" -> setAttribute(attributes, "fill", fillColor) + "ScrollButton.Foreground" -> setAttribute(attributes, "stroke", borderColor) + } + } + + override fun attributeForPath(path: String) = this + + private fun setAttribute(attributes: MutableMap, key: String, color: Color) { + if (!attributes.containsKey(key) || attributes[key] == "none") return + attributes[key] = "rgb(${color.red},${color.green},${color.blue})" + val alpha = color.alpha + if (alpha != OPAQUE_ALPHA) { + attributes["$key-opacity"] = "${alpha / OPAQUE_ALPHA.toFloat()}" + } + } + + private fun toLong(high: Int, low: Int): Long { + return (high.toLong() shl 32) or (low.toLong() and 0xFFFFFFFFL) + } + }) ?: this diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index eb61f34727e..48c8068068d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -48,7 +48,7 @@ internal class SessionScroll( private var seq = 0 init { - jump = JBLabel(ScrollButtonIcon).apply { + jump = JBLabel(ScrollButtonIcon.create()).apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("session.scroll.bottom") isVisible = false @@ -120,7 +120,7 @@ internal class SessionScroll( fun applyStyle(style: SessionEditorStyle) { this.style = style - jump.repaint() + jump.icon = ScrollButtonIcon.create() messages.applyStyle(style) val view = component.viewport.view if (view !== messages) (view as? SessionEditorStyleTarget)?.applyStyle(style) From b40622354c4633b3dddb6732532b2a117254c67b Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 17:47:48 -0400 Subject: [PATCH 06/29] refactor(jetbrains): extract svg icon colorizer --- .../client/session/scroll/ScrollButtonIcon.kt | 41 ++--------------- .../ai/kilocode/client/ui/SvgIconColorizer.kt | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 38 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt index 73205135298..323e32f6db3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt @@ -1,52 +1,17 @@ package ai.kilocode.client.session.scroll +import ai.kilocode.client.ui.colorizeIfPossible import com.intellij.openapi.util.IconLoader -import com.intellij.ui.icons.CachedImageIcon -import com.intellij.ui.svg.SvgAttributePatcher -import com.intellij.util.SVGLoader import com.intellij.util.ui.JBUI -import java.awt.Color import javax.swing.Icon -private const val OPAQUE_ALPHA = 255 - internal object ScrollButtonIcon { private val icon = IconLoader.getIcon("/icons/scroll-bottom.svg", ScrollButtonIcon::class.java) fun create(): Icon = icon.colorizeIfPossible( fillColor = JBUI.CurrentTheme.Button.defaultButtonColorStart(), borderColor = JBUI.CurrentTheme.Button.defaultButtonForeground(), + fillId = "ScrollButton.Background", + strokeId = "ScrollButton.Foreground", ) } - -private fun Icon.colorizeIfPossible(fillColor: Color, borderColor: Color = fillColor): Icon = - (this as? CachedImageIcon)?.createWithPatcher(colorPatcher = object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { - private val digest = longArrayOf(0L, 440413911775177385) - - override fun digest(): LongArray { - digest[0] = toLong(fillColor.rgb, borderColor.rgb) - return digest - } - - override fun patchColors(attributes: MutableMap) { - when (attributes["id"]) { - "ScrollButton.Background" -> setAttribute(attributes, "fill", fillColor) - "ScrollButton.Foreground" -> setAttribute(attributes, "stroke", borderColor) - } - } - - override fun attributeForPath(path: String) = this - - private fun setAttribute(attributes: MutableMap, key: String, color: Color) { - if (!attributes.containsKey(key) || attributes[key] == "none") return - attributes[key] = "rgb(${color.red},${color.green},${color.blue})" - val alpha = color.alpha - if (alpha != OPAQUE_ALPHA) { - attributes["$key-opacity"] = "${alpha / OPAQUE_ALPHA.toFloat()}" - } - } - - private fun toLong(high: Int, low: Int): Long { - return (high.toLong() shl 32) or (low.toLong() and 0xFFFFFFFFL) - } - }) ?: this diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt new file mode 100644 index 00000000000..f51a7d9b880 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt @@ -0,0 +1,46 @@ +package ai.kilocode.client.ui + +import com.intellij.ui.icons.CachedImageIcon +import com.intellij.ui.svg.SvgAttributePatcher +import com.intellij.util.SVGLoader +import java.awt.Color +import javax.swing.Icon + +private const val OPAQUE_ALPHA = 255 + +internal fun Icon.colorizeIfPossible( + fillColor: Color, + borderColor: Color = fillColor, + fillId: String? = null, + strokeId: String? = null, +): Icon = (this as? CachedImageIcon)?.createWithPatcher( + colorPatcher = object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { + private val digest = longArrayOf(0L, 440413911775177385) + + override fun digest(): LongArray { + digest[0] = toLong(fillColor.rgb, borderColor.rgb) + return digest + } + + override fun patchColors(attributes: MutableMap) { + val id = attributes["id"] + if (fillId == null || id == fillId) setAttribute(attributes, "fill", fillColor) + if (strokeId == null || id == strokeId) setAttribute(attributes, "stroke", borderColor) + } + + override fun attributeForPath(path: String) = this + + private fun setAttribute(attributes: MutableMap, key: String, color: Color) { + if (!attributes.containsKey(key) || attributes[key] == "none") return + attributes[key] = "rgb(${color.red},${color.green},${color.blue})" + val alpha = color.alpha + if (alpha != OPAQUE_ALPHA) { + attributes["$key-opacity"] = "${alpha / OPAQUE_ALPHA.toFloat()}" + } + } + + private fun toLong(high: Int, low: Int): Long { + return (high.toLong() shl 32) or (low.toLong() and 0xFFFFFFFFL) + } + }, +) ?: this From b65c4b28c6a93d0d8e2e675f1908dd9f2c7c25a3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 22:16:57 -0400 Subject: [PATCH 07/29] Revert "Merge remote-tracking branch 'origin/morning-tibia' into kiwi-acorn" This reverts commit 72bd8f837516d0c7fc7d472e116f4ee7bb7d4ffe, reversing changes made to b40622354c4633b3dddb6732532b2a117254c67b. --- .github/workflows/publish-jetbrains.yml | 167 ------------------ packages/kilo-jetbrains/README.md | 23 --- packages/kilo-jetbrains/RELEASE_TODO.md | 44 ----- .../backend/app/KiloBackendSessionManager.kt | 4 +- packages/kilo-jetbrains/build.gradle.kts | 59 +------ 5 files changed, 4 insertions(+), 293 deletions(-) delete mode 100644 .github/workflows/publish-jetbrains.yml delete mode 100644 packages/kilo-jetbrains/RELEASE_TODO.md diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml deleted file mode 100644 index 3465c4f78f1..00000000000 --- a/.github/workflows/publish-jetbrains.yml +++ /dev/null @@ -1,167 +0,0 @@ -# kilocode_change - new file -name: publish-jetbrains - -on: - push: - tags: - - "jetbrains/*" - -concurrency: - group: publish-jetbrains-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - publish: - if: github.repository == 'Kilo-Org/kilocode' - runs-on: blacksmith-8vcpu-ubuntu-2404 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: Install build tools - run: | - sudo apt-get update - sudo apt-get install -y patchelf zip - - - name: Validate version tag - id: version - run: | - tag="$GITHUB_REF_NAME" - if [[ "$tag" != jetbrains/* ]]; then - echo "Unsupported tag '$tag'. Expected jetbrains/." >&2 - exit 1 - fi - - version="${tag#jetbrains/}" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - { - echo "version=$version" - echo "kind=rc" - echo "marketplace_channel=eap" - echo "cli_channel=rc" - } >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - { - echo "version=$version" - echo "kind=stable" - echo "marketplace_channel=default" - echo "cli_channel=latest" - } >> "$GITHUB_OUTPUT" - echo "Stable JetBrains Marketplace publishing is implemented but intentionally disabled; use an rc tag such as jetbrains/7.0.1-rc.1." >&2 - exit 1 - fi - - echo "Unsupported JetBrains plugin version '$version'. Expected x.y.z-rc.n or x.y.z." >&2 - exit 1 - - - name: Validate publishing secrets - run: | - missing=0 - for name in JETBRAINS_MARKETPLACE_TOKEN JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do - if [[ -z "${!name}" ]]; then - echo "Missing required secret: $name" >&2 - missing=1 - fi - done - exit "$missing" - env: - JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }} - JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} - JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} - JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} - - - name: Prepare CLI resources - working-directory: packages/kilo-jetbrains - run: bun script/build.ts --production --prepare-cli - env: - KILO_VERSION: ${{ steps.version.outputs.version }} - KILO_CHANNEL: ${{ steps.version.outputs.cli_channel }} - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - - - name: Verify plugin - working-directory: packages/kilo-jetbrains - run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" - env: - CHANNEL: ${{ steps.version.outputs.marketplace_channel }} - - - name: Publish to JetBrains Marketplace - working-directory: packages/kilo-jetbrains - run: ./gradlew publishPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" - env: - CHANNEL: ${{ steps.version.outputs.marketplace_channel }} - JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }} - JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} - JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} - JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} - - - name: Resolve plugin archive - id: archive - run: | - mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip") - if [[ "${#signed[@]}" -eq 1 ]]; then - echo "path=${signed[0]}" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${#signed[@]}" -gt 1 ]]; then - echo "Expected exactly one signed JetBrains plugin ZIP, found ${#signed[@]}." >&2 - printf '%s\n' "${signed[@]}" >&2 - exit 1 - fi - - mapfile -t files < <(compgen -G "packages/kilo-jetbrains/build/distributions/*.zip") - if [[ "${#files[@]}" -ne 1 ]]; then - echo "Expected exactly one JetBrains plugin ZIP, found ${#files[@]}." >&2 - printf '%s\n' "${files[@]}" >&2 - exit 1 - fi - echo "path=${files[0]}" >> "$GITHUB_OUTPUT" - - - name: Upload to GitHub Release - run: | - tag="$GITHUB_REF_NAME" - title="JetBrains $VERSION" - notes="JetBrains plugin $VERSION." - if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release upload "$tag" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY" - exit 0 - fi - gh release create "$tag" "$ARCHIVE" --title "$title" --notes "$notes" --prerelease --repo "$GITHUB_REPOSITORY" - env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.version.outputs.version }} - ARCHIVE: ${{ steps.archive.outputs.path }} - - - name: Upload workflow artifact - if: always() - uses: actions/upload-artifact@v4 - with: - name: kilo-jetbrains-${{ steps.version.outputs.version }} - path: packages/kilo-jetbrains/build/distributions/*.zip - if-no-files-found: ignore diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index 8cd2eed1cc5..56fbf578a11 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -52,29 +52,6 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-.zip --- -## Publish release candidates - -JetBrains release-candidate builds publish from tags matching `jetbrains/x.y.z-rc.n`, for example `jetbrains/7.0.1-rc.1`. The git tag is the source of truth for the Marketplace plugin version; production Gradle builds ignore manually supplied version properties and fail if `HEAD` is not tagged with `jetbrains/`. Local dev builds can use the placeholder `0.0.0-dev` version. - -The `publish-jetbrains` workflow publishes RC builds to the JetBrains Marketplace `eap` channel and uploads the same plugin ZIP to a GitHub prerelease for the tag. Stable tags like `jetbrains/x.y.z` are recognized by the workflow but intentionally rejected until default-channel publishing is enabled. - -Testers can install or update EAP builds from this custom plugin repository: - -``` -https://plugins.jetbrains.com/plugins/eap/list -``` - -Required GitHub Actions secrets: - -- `JETBRAINS_MARKETPLACE_TOKEN` -- `JETBRAINS_CERTIFICATE_CHAIN` -- `JETBRAINS_PRIVATE_KEY` -- `JETBRAINS_PRIVATE_KEY_PASSWORD` - -Before the first publish, complete `RELEASE_TODO.md`. - ---- - ## Run the plugin Use the `runIde` Gradle task (available in the Gradle tool window or via the "Run JetBrains Plugin" run configuration) to launch a sandboxed IntelliJ instance with the plugin installed. diff --git a/packages/kilo-jetbrains/RELEASE_TODO.md b/packages/kilo-jetbrains/RELEASE_TODO.md deleted file mode 100644 index d3b8430da4d..00000000000 --- a/packages/kilo-jetbrains/RELEASE_TODO.md +++ /dev/null @@ -1,44 +0,0 @@ -# JetBrains Release Todo - -## One-Time Marketplace Setup - -- Confirm `ai.kilocode.jetbrains` is the final permanent Marketplace plugin ID before the first upload. -- Build a production ZIP locally or in CI for the first Marketplace version. -- Manually upload the first plugin version in JetBrains Marketplace if the plugin has not been published before. -- Confirm the plugin is owned by the correct Kilo vendor or organization. -- Confirm the `eap` custom channel exists or is accepted on the first RC upload. -- Confirm Marketplace signing requirements before publishing RC builds. - -## One-Time GitHub Setup - -- Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`. -- Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment. -- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases from `jetbrains/*` tags. -- Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers. -- If using an environment, move the Marketplace and signing secrets there and set the workflow job environment. - -## One-Time Signing Certificate Setup - -- Generate or locate the JetBrains plugin signing private key and certificate chain. -- Add `JETBRAINS_CERTIFICATE_CHAIN` with the full certificate chain PEM content. -- Add `JETBRAINS_PRIVATE_KEY` with the private key PEM content. -- Add `JETBRAINS_PRIVATE_KEY_PASSWORD` with the private key password. -- Keep PEM files out of git, local logs, and workflow output. -- If GitHub secret input mangles multiline PEM values, store base64-encoded values and verify the Gradle signing task decodes them correctly. - -## Per-RC Release - -- Choose an RC version in the form `x.y.z-rc.n`. -- Push tag `jetbrains/x.y.z-rc.n`. -- Watch the `publish-jetbrains` workflow. -- Download and retain the workflow artifact if needed. -- Confirm the update appears on the JetBrains Marketplace `eap` channel. -- Confirm the GitHub Release for the `jetbrains/x.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset. -- Share `https://plugins.jetbrains.com/plugins/eap/list` with testers. - -## Stable Release Guard - -- Stable tags like `jetbrains/x.y.z` are intentionally rejected for now. -- Before enabling stable releases, remove the workflow stable guard. -- Verify `kilo.channel=default` publishes to the default Marketplace channel. -- Update this checklist before stable releases are enabled. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index d1998f4d64b..dd649bfd1ec 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -265,7 +265,7 @@ class KiloBackendSessionManager( time = SessionTimeDto( created = s.time.created.toDouble(), updated = s.time.updated.toDouble(), - archived = s.time.archived?.toDouble(), + archived = s.time.archived, ), summary = s.summary?.let { SessionSummaryDto( @@ -286,7 +286,7 @@ class KiloBackendSessionManager( time = SessionTimeDto( created = s.time.created.toDouble(), updated = s.time.updated.toDouble(), - archived = s.time.archived?.toDouble(), + archived = s.time.archived, ), summary = s.summary?.let { SessionSummaryDto( diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 3ce7ec96cd4..00b4bd5ea2b 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -4,32 +4,7 @@ import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.SplitModeTarget group = "ai.kilocode.jetbrains" - -fun checked(value: String): String { - if (value == "0.0.0-dev") return value - require(Regex("^[0-9]+\\.[0-9]+\\.[0-9]+(-rc\\.[0-9]+)?$").matches(value)) { - "Invalid JetBrains plugin version: $value" - } - return value -} - -fun gitTag(): String? { - val text = providers.exec { - commandLine("git", "tag", "--points-at", "HEAD") - }.standardOutput.asText.get() - return text.lineSequence().map { it.trim() }.firstOrNull { it.startsWith("jetbrains/") } -} - -val release = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false).get() -val ver = if (release) checked( - gitTag()?.removePrefix("jetbrains/") - ?: error("Missing JetBrains plugin version. Publish builds must run from a jetbrains/ tag."), -) else checked(gitTag()?.removePrefix("jetbrains/") ?: "0.0.0-dev") - -val notes = providers.gradleProperty("kilo.changeNotes").orElse("Release candidate build.") -val channel = providers.gradleProperty("kilo.channel").map { it.trim() }.orElse("default") - -version = ver +version = "7.0.1" plugins { application @@ -84,39 +59,9 @@ intellijPlatform { splitMode = true splitModeTarget = SplitModeTarget.BOTH - pluginConfiguration { - id = "ai.kilocode.jetbrains" - name = "Kilo Code" - version = provider { ver } - changeNotes = notes - - ideaVersion { - untilBuild = provider { null } - } - - vendor { - name = "Kilo Code" - url = "https://kilo.ai" - } - } - - publishing { - token = providers.environmentVariable("JETBRAINS_MARKETPLACE_TOKEN") - channels = channel.map { value -> - if (value.isBlank() || value == "default") return@map listOf("default") - listOf(value) - } - } - - signing { - certificateChain = providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN") - privateKey = providers.environmentVariable("JETBRAINS_PRIVATE_KEY") - password = providers.environmentVariable("JETBRAINS_PRIVATE_KEY_PASSWORD") - } - pluginVerification { ides { - create(IntelliJPlatformType.IntellijIdea, libs.versions.intellij.platform) + create(IntelliJPlatformType.IntellijIdeaCommunity, libs.versions.intellij.platform) } } } From da9292fbccad9f4c20f439e240790da3ab3a92d3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 14 May 2026 14:09:12 +0200 Subject: [PATCH 08/29] fix(agent-manager): support diff sidebar with terminals --- .changeset/terminals-split-diff-sidebar.md | 5 + .../agent-manager/AgentManagerApp.tsx | 128 +++++++++--------- .../webview-ui/agent-manager/DiffPanel.tsx | 11 +- .../agent-manager/FullScreenDiffView.tsx | 11 +- .../agent-manager/agent-manager.css | 32 ++++- .../agent-manager/review-annotations.ts | 8 +- .../agent-manager/terminal/TerminalTab.tsx | 10 ++ .../src/types/messages/extension-messages.ts | 8 ++ 8 files changed, 141 insertions(+), 72 deletions(-) create mode 100644 .changeset/terminals-split-diff-sidebar.md diff --git a/.changeset/terminals-split-diff-sidebar.md b/.changeset/terminals-split-diff-sidebar.md new file mode 100644 index 00000000000..8474c11fc35 --- /dev/null +++ b/.changeset/terminals-split-diff-sidebar.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support viewing Agent Manager terminal tabs alongside the right diff sidebar, and send review comments to the active terminal. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 1e7c0daf916..8cc186ff2ea 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1528,6 +1528,7 @@ const AgentManagerContent: Component = () => { const openReviewTab = () => { const sel = selection() if (sel === null) return + terms.setActiveId(undefined) setSidePanel(null) setReviewOpenForContext(sel, true) setReviewActive(true) @@ -3007,76 +3008,73 @@ const AgentManagerContent: Component = () => { /> - {/* Stacking container: the terminal layer is an absolutely- - positioned overlay on top of the chat. This sub-container - is position:relative so the overlay does not cover the - tab bar above (which lives outside this stack). */} + {/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
- {/* Terminal tabs are kept mounted regardless of visibility so - output keeps streaming across worktree switches without - detaching xterm from the paint tree. */} - {renderTerminalLayer({ state: terms })} - {/* Chat + side diff panel (hidden when review tab or terminal tab is active) */} + {/* Chat/terminal + side diff panel. Keep it mounted under the + review tab so live xterm canvases never leave the paint tree. */}
-
- { - if (localSessionIDs().includes(id)) { - session.selectSession(id) - if (selection() === null) setSelection(LOCAL) - return - } - // Navigate to owning worktree instead of forcing into local mode - if (worktreeSessionIds().has(id)) { - const ms = managedSessions().find((s) => s.id === id) - if (ms?.worktreeId) { - selectWorktree(ms.worktreeId) +
+ {/* Keep terminal tabs mounted so output streams across worktree switches. */} + {renderTerminalLayer({ state: terms })} +
+ { + if (localSessionIDs().includes(id)) { session.selectSession(id) - setReviewActive(false) + if (selection() === null) setSelection(LOCAL) return } - } - openLocally(id) - }} - onShowHistory={() => setHistory(true)} - onForkMessage={readOnly() ? undefined : handleForkSession} - readonly={readOnly()} - continueInWorktree={selection() === LOCAL} - promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} - pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} - /> - -
- - {t("agentManager.session.readonly")} - - -
-
+ // Navigate to owning worktree instead of forcing into local mode + if (worktreeSessionIds().has(id)) { + const ms = managedSessions().find((s) => s.id === id) + if (ms?.worktreeId) { + selectWorktree(ms.worktreeId) + session.selectSession(id) + setReviewActive(false) + return + } + } + openLocally(id) + }} + onShowHistory={() => setHistory(true)} + onForkMessage={readOnly() ? undefined : handleForkSession} + readonly={readOnly()} + continueInWorktree={selection() === LOCAL} + promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} + pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} + /> + +
+ + {t("agentManager.session.readonly")} + + +
+
+
@@ -3121,6 +3119,7 @@ const AgentManagerContent: Component = () => { }} onRevertFile={revertCtl.revert} revertingFiles={revertCtl.reverting()} + activeTerminalId={terms.activeId()} />
@@ -3151,6 +3150,7 @@ const AgentManagerContent: Component = () => { }} onRevertFile={revertCtl.revert} revertingFiles={revertCtl.reverting()} + activeTerminalId={terms.activeId()} onClose={closeReviewTab} />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 21e95375cfe..72de493aabd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -54,6 +54,7 @@ interface DiffPanelProps { onOpenFile?: (relativePath: string, line?: number) => void onRevertFile?: (file: string) => void revertingFiles?: Set + activeTerminalId?: string } export const DiffPanel: Component = (props) => { @@ -305,6 +306,7 @@ export const DiffPanel: Component = (props) => { deleteComment, cancelDraft, labels: labels(), + activeTerminalId: props.activeTerminalId, }) } @@ -328,7 +330,14 @@ export const DiffPanel: Component = (props) => { const all = comments() if (all.length === 0) return window.dispatchEvent( - new MessageEvent("message", { data: { type: "appendReviewComments", comments: all, autoSend: true } }), + new MessageEvent("message", { + data: { + type: props.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments", + comments: all, + autoSend: true, + targetTerminalId: props.activeTerminalId, + }, + }), ) preserveScroll(() => setComments([])) props.onSendAll?.() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index 596fa54f465..6f8b8828f77 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -59,6 +59,7 @@ interface FullScreenDiffViewProps { onOpenFile?: (relativePath: string, line?: number) => void onRevertFile?: (file: string) => void revertingFiles?: Set + activeTerminalId?: string /** Defaults to true. Hides the per-file Revert action when false. */ canRevert?: boolean /** Defaults to true. Disables comment creation and "Send all" when false. */ @@ -320,6 +321,7 @@ export const FullScreenDiffView: Component = (props) => deleteComment, cancelDraft, labels: labels(), + activeTerminalId: props.activeTerminalId, }) } @@ -336,7 +338,14 @@ export const FullScreenDiffView: Component = (props) => const all = comments() if (all.length === 0) return window.dispatchEvent( - new MessageEvent("message", { data: { type: "appendReviewComments", comments: all, autoSend: true } }), + new MessageEvent("message", { + data: { + type: props.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments", + comments: all, + autoSend: true, + targetTerminalId: props.activeTerminalId, + }, + }), ) preserveScroll(() => setComments([])) props.onSendAll?.() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 6edf23a786b..305359b3dc7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1650,16 +1650,38 @@ body.am-wt-dragging-active * { flex-direction: column; flex: 1; min-height: 0; + min-width: 0; isolation: isolate; } +.am-detail-content-hidden { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; + z-index: 0; +} + .am-detail-split { flex-direction: row; } -.am-detail-split .am-chat-wrapper { +.am-main-pane { + display: flex; + flex-direction: column; flex: 1; + min-height: 0; min-width: 0; + position: relative; + isolation: isolate; +} + +.am-main-pane-terminal-active .am-chat-wrapper { + opacity: 0; + pointer-events: none; +} + +.am-detail-split .am-main-pane { border-right: 1px solid var(--border-weak-base); } @@ -4331,9 +4353,9 @@ body.vscode-high-contrast-light { * subtree and stop xterm's internal rAF render loop, causing the * "press Enter to see content" bug on worktree switches. * - * Stack: position:relative container hosting the chat, review, and - * terminal layer. Sits below the tab bar inside `.am-detail`. - * Layer: absolute-positioned overlay covering the stack. + * Stack: position:relative container hosting the main pane, review, + * and side panel. Sits below the tab bar inside `.am-detail`. + * Layer: absolute-positioned overlay covering the main pane. * `pointer-events: none` when no terminal is active, letting * clicks fall through to the chat behind it. * Slots: absolute-positioned and stacked inside the layer; only the @@ -4355,7 +4377,7 @@ body.vscode-high-contrast-light { opacity: 0; pointer-events: none; background: var(--vscode-terminal-background, #1e1e1e); - z-index: 1; + z-index: 30; /* Force a dedicated compositor layer so opacity flips do not re-lay-out the xterm canvases underneath. */ will-change: opacity; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts index 3271f3ad854..3c46aac071c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts @@ -35,6 +35,7 @@ interface AnnotationHandlers { deleteComment: (id: string) => void cancelDraft: () => void labels: AnnotationLabels + activeTerminalId?: string } function focusWhenConnected(el: HTMLTextAreaElement): void { @@ -269,7 +270,12 @@ export function buildReviewAnnotation( makeActionButton(handlers.labels.sendToChat, makeIcon("M1 1l14 7-14 7V9l10-1L1 7z"), () => { window.dispatchEvent( new MessageEvent("message", { - data: { type: "appendReviewComments", comments: [comment], autoSend: true }, + data: { + type: handlers.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments", + comments: [comment], + autoSend: true, + targetTerminalId: handlers.activeTerminalId, + }, }), ) handlers.deleteComment(comment.id) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 551e8c482f3..57827df8bb3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -19,6 +19,8 @@ import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes" import "@xterm/xterm/css/xterm.css" import { useVSCode } from "../../src/context/vscode" import { useLanguage } from "../../src/context/language" +import { formatReviewCommentsMarkdown } from "../../src/utils/review-comment-markdown" +import type { ReviewComment } from "../../src/types/messages" interface Props { terminalId: string @@ -279,6 +281,14 @@ export const TerminalTab: Component = (props) => { pendingFrame = requestAnimationFrame(runRepaint) } const fontSub = vscode.onMessage((message) => { + if (message.type === "appendReviewCommentsToTerminal") { + if (message.targetTerminalId !== props.terminalId) return + const comments = (message as { comments?: ReviewComment[] }).comments + if (!Array.isArray(comments) || comments.length === 0) return + term.paste(`${formatReviewCommentsMarkdown(comments)}\n`) + return + } + const size = message.type === "fontSizeChanged" ? message.fontSize : message.type === "ready" ? message.fontSize : undefined if (size === undefined) return 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 6fd8493b298..a7718ae35e4 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 @@ -243,6 +243,13 @@ export interface AppendReviewCommentsMessage { autoSend?: boolean } +export interface AppendReviewCommentsToTerminalMessage { + type: "appendReviewCommentsToTerminal" + comments: ReviewComment[] + autoSend?: boolean + targetTerminalId: string +} + export interface TriggerTaskMessage { type: "triggerTask" text: string @@ -958,6 +965,7 @@ export type ExtensionMessage = | SetChatBoxMessage | AppendChatBoxMessage | AppendReviewCommentsMessage + | AppendReviewCommentsToTerminalMessage | TriggerTaskMessage | VariantsLoadedMessage | CloudSessionDataLoadedMessage From 1af79731a8ed925f1f69aa536ba90a53b89e8dfb Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 14 May 2026 14:16:51 +0200 Subject: [PATCH 09/29] feat(vscode): add speech-to-text prompt input --- .changeset/speech-input-gateway.md | 6 + .plans/speech-to-text-client.md | 32 +++ bun.lock | 2 - packages/kilo-gateway/src/server/routes.ts | 67 ++++++ packages/kilo-vscode/package.json | 26 +++ packages/kilo-vscode/script/build.ts | 4 + packages/kilo-vscode/script/ffmpeg-helper.ts | 45 ++++ packages/kilo-vscode/script/local-bin.ts | 3 + packages/kilo-vscode/src/KiloProvider.ts | 31 ++- .../src/kilo-provider/early-message.ts | 20 ++ .../kilo-provider/speech-to-text-capture.ts | 198 ++++++++++++++++++ .../kilo-provider/speech-to-text-handler.ts | 83 ++++++++ .../src/kilo-provider/speech-to-text.ts | 106 ++++++++++ .../kilo-vscode/src/services/input-tools.ts | 53 +++++ .../src/services/speech-to-text-config.ts | 13 ++ .../src/services/speech-to-text-settings.ts | 19 ++ .../src/shared/speech-to-text-models.ts | 48 +++++ .../agent-manager/NewWorktreeDialog.tsx | 46 +++- .../src/components/chat/PromptInput.tsx | 57 ++++- .../components/settings/ExperimentalTab.tsx | 52 ++++- .../settings/speech-to-text-model-selector.ts | 13 ++ .../components/shared/SpeechToTextButton.tsx | 73 +++++++ .../webview-ui/src/context/config.tsx | 12 ++ .../webview-ui/src/hooks/useSpeechToText.ts | 169 +++++++++++++++ .../kilo-vscode/webview-ui/src/i18n/en.ts | 22 ++ .../webview-ui/src/styles/prompt-input.css | 48 +++++ .../src/types/messages/extension-messages.ts | 36 ++++ .../src/types/messages/webview-messages.ts | 25 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 59 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 39 ++++ 30 files changed, 1395 insertions(+), 12 deletions(-) create mode 100644 .changeset/speech-input-gateway.md create mode 100644 .plans/speech-to-text-client.md create mode 100644 packages/kilo-vscode/script/ffmpeg-helper.ts create mode 100644 packages/kilo-vscode/src/kilo-provider/early-message.ts create mode 100644 packages/kilo-vscode/src/kilo-provider/speech-to-text-capture.ts create mode 100644 packages/kilo-vscode/src/kilo-provider/speech-to-text-handler.ts create mode 100644 packages/kilo-vscode/src/kilo-provider/speech-to-text.ts create mode 100644 packages/kilo-vscode/src/services/input-tools.ts create mode 100644 packages/kilo-vscode/src/services/speech-to-text-config.ts create mode 100644 packages/kilo-vscode/src/services/speech-to-text-settings.ts create mode 100644 packages/kilo-vscode/src/shared/speech-to-text-models.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/settings/speech-to-text-model-selector.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/shared/SpeechToTextButton.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/hooks/useSpeechToText.ts diff --git a/.changeset/speech-input-gateway.md b/.changeset/speech-input-gateway.md new file mode 100644 index 00000000000..cf48a6b19bb --- /dev/null +++ b/.changeset/speech-input-gateway.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/kilo-gateway": minor +--- + +Support experimental speech-to-text voice input in VS Code prompt fields through Kilo Gateway. diff --git a/.plans/speech-to-text-client.md b/.plans/speech-to-text-client.md new file mode 100644 index 00000000000..609ad69c086 --- /dev/null +++ b/.plans/speech-to-text-client.md @@ -0,0 +1,32 @@ +# Speech input client plan + +## Assumptions + +- Backend exposes `POST /api/gateway/v1/audio/transcriptions` through the local CLI's Kilo routes. +- Request body is JSON: `{ model, input_audio: { data, format }, language? }`. +- Response body is JSON: `{ text, usage? }`. +- Client MVP is request/response transcription after the user stops recording, not live partial transcription. +- VS Code webviews cannot access `navigator.mediaDevices.getUserMedia`, so microphone capture runs in the extension host. + +## Client scope + +1. Add an experimental Speech to Text setting, default off. +2. Add an extension-side STT handler that uses the existing Kilo auth stored in the CLI backend. +3. Record microphone audio in the extension host with a bundled FFmpeg helper, falling back to system FFmpeg if needed. +4. Add a shared webview hook for `idle -> recording -> transcribing -> error` state. +5. Add a shared prompt action button that starts/stops recording and shows clean state. +6. Wire the same hook/button into `PromptInput` and Agent Manager `NewWorktreeDialog`. +7. Insert successful transcripts at the current caret, preserve existing prompt content, and never auto-submit. + +## Error states + +- Experiment disabled: hide the button. +- Missing Kilo login or disabled Kilo provider: disable settings and hide the button. +- Missing bundled helper or OS microphone denial: show a local recording error. +- Empty recording or empty transcript: show a local error. +- Gateway/network failure: show the backend error and keep input unchanged. + +## Follow-up + +- Replace the static model list with `/api/gateway/v1/transcription-models` discovery if we want automatic model updates. +- Add microphone device selection if users need to choose a non-default input. diff --git a/bun.lock b/bun.lock index 33fca023363..68543a32236 100644 --- a/bun.lock +++ b/bun.lock @@ -615,8 +615,6 @@ "@opentui/solid": "0.2.2", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", - "@sentry/solid": "10.36.0", - "@sentry/vite-plugin": "4.6.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 269427b7f44..b4f1c4e8f60 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -132,6 +132,11 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { cost: z.number().optional(), }) + const TranscriptionResponse = z.object({ + text: z.string(), + usage: z.unknown().optional(), + }) + return new Hono() .get( "/profile", @@ -390,6 +395,68 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }) }, ) + .post( + "/audio/transcriptions", + describeRoute({ + summary: "Speech to text transcription", + description: "Proxy an audio transcription request to the Kilo Gateway", + operationId: "kilo.audio.transcriptions", + responses: { + 200: { + description: "Transcription response", + content: { + "application/json": { + schema: resolver(TranscriptionResponse), + }, + }, + }, + ...errors(400, 401), + }, + }), + validator( + "json", + z.object({ + model: z.string(), + input_audio: z.object({ + data: z.string(), + format: z.string(), + }), + language: z.string().optional(), + temperature: z.number().optional(), + }), + ), + async (c: any) => { + const auth = await Auth.get("kilo") + if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + + const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined + if (!token) return c.json({ error: "No valid token found" }, 401) + + const organizationId = auth.type === "oauth" ? auth.accountId : undefined + const body = c.req.valid("json") + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), + [HEADER_FEATURE]: "vscode-extension", + } + + const response = await fetch(`${KILO_API_BASE}/api/gateway/v1/audio/transcriptions`, { + method: "POST", + headers, + signal: c.req.raw.signal, + body: JSON.stringify(body), + }) + + const text = await response.text() + return new Response(text, { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + }, + }) + }, + ) .get( "/notifications", describeRoute({ diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index a66d000db5b..4a7187c1a84 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -776,6 +776,32 @@ "default": false, "description": "Enable chat textarea autocomplete" }, + "kilo-code.new.speechToText.enabled": { + "type": "boolean", + "default": false, + "description": "Enable experimental speech-to-text voice input in prompt fields. Uses your Kilo account through the Kilo Gateway." + }, + "kilo-code.new.speechToText.model": { + "type": "string", + "default": "openai/gpt-4o-mini-transcribe", + "enum": [ + "openai/gpt-4o-mini-transcribe", + "openai/gpt-4o-transcribe", + "openai/whisper-1", + "openai/whisper-large-v3-turbo", + "openai/whisper-large-v3", + "google/chirp-3" + ], + "enumDescriptions": [ + "OpenAI GPT-4o Mini Transcribe", + "OpenAI GPT-4o Transcribe", + "OpenAI Whisper 1", + "Groq-hosted Whisper Large V3 Turbo", + "Groq-hosted Whisper Large V3", + "Google Chirp 3" + ], + "description": "Model to use for experimental speech-to-text voice input. Requires Kilo Gateway." + }, "kilo-code.new.claudeCodeCompat": { "type": "boolean", "default": false, diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index cb285b327b4..58e71c24095 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -2,6 +2,7 @@ import { $ } from "bun" import { join } from "node:path" import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs" +import { ensureFfmpegForTarget } from "./ffmpeg-helper" const packageJsonPath = join(import.meta.dir, "..", "package.json") const packageJson = await Bun.file(packageJsonPath).json() @@ -81,6 +82,9 @@ for (const config of targets) { console.log(` ✅ Binary ready at ${targetBinary}`) + console.log(" 🎙️ Adding bundled FFmpeg helper...") + await ensureFfmpegForTarget(config.target, binDir) + console.log(` 📦 Packaging .vsix for ${config.target}${prerelease ? " (pre-release)" : ""}...`) const vsixPath = join(outDir, `kilo-vscode-${config.target}.vsix`) const args = ["--no-dependencies", "--skip-license", "--target", config.target, "-o", vsixPath] diff --git a/packages/kilo-vscode/script/ffmpeg-helper.ts b/packages/kilo-vscode/script/ffmpeg-helper.ts new file mode 100644 index 00000000000..798f513d82b --- /dev/null +++ b/packages/kilo-vscode/script/ffmpeg-helper.ts @@ -0,0 +1,45 @@ +import { $ } from "bun" +import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs" +import { join } from "node:path" + +const packages: Record = { + "darwin-x64": "@ffmpeg-installer/darwin-x64@4.1.0", + "darwin-arm64": "@ffmpeg-installer/darwin-arm64@4.1.5", + "linux-x64": "@ffmpeg-installer/linux-x64@4.1.0", + "linux-arm64": "@ffmpeg-installer/linux-arm64@4.1.4", + "alpine-x64": "@ffmpeg-installer/linux-x64@4.1.0", + "alpine-arm64": "@ffmpeg-installer/linux-arm64@4.1.4", + "win32-x64": "@ffmpeg-installer/win32-x64@4.1.0", + "win32-arm64": "@ffmpeg-installer/win32-x64@4.1.0", +} + +export async function ensureFfmpegForTarget(target: string, bin: string): Promise { + const spec = packages[target] + if (!spec) throw new Error(`No FFmpeg helper package configured for target ${target}`) + + const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg" + const dest = join(bin, exe) + if (existsSync(dest)) return + + const tmp = join(bin, ".ffmpeg-tmp") + rmSync(tmp, { recursive: true, force: true }) + mkdirSync(tmp, { recursive: true }) + + try { + const packed = await $`npm pack ${spec} --pack-destination ${tmp}`.quiet() + const name = packed.text().trim().split(/\s+/).pop() + if (!name) throw new Error(`npm pack did not return a tarball for ${spec}`) + + await $`tar -xzf ${join(tmp, name)} -C ${tmp}`.quiet() + copyFileSync(join(tmp, "package", exe), dest) + if (!target.startsWith("win32")) chmodSync(dest, 0o755) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +} + +export function currentFfmpegTarget(): string { + const os = process.platform === "win32" ? "win32" : process.platform + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch + return `${os}-${arch}` +} diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index aeb57e81643..a8d2f669bde 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -2,6 +2,7 @@ import { $ } from "bun" import { join, relative, dirname, basename } from "node:path" import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs" +import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") @@ -158,6 +159,7 @@ async function main() { log( `CLI binary already present at ${relative(kiloVscodeDir, targetBinPath)} (${Math.round(st.size / 1024 / 1024)}MB). Use --force to rebuild.`, ) + await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) return } @@ -181,6 +183,7 @@ async function main() { await $`mkdir -p ${targetBinDir}` await $`cp ${sourceBinPath} ${targetBinPath}` chmodSync(targetBinPath, 0o755) + await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) // Record the CLI source version so future runs detect when a rebuild is needed const hash = await cliSourceHash() diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index cf3c33479e2..7a2344436f1 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -68,10 +68,12 @@ import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" import { abortSession } from "./kilo-provider/abort" import { buildAutocompleteSettingsMessage, - routeAutocompleteMessage, validAutocompleteSetting, watchAutocompleteConfig, } from "./services/autocomplete/settings" +import { validSpeechToTextSetting } from "./services/speech-to-text-settings" +import { sendSpeechToTextSettings, watchSpeechToTextConfig } from "./services/speech-to-text-config" +import { routeEarlyMessage } from "./kilo-provider/early-message" import * as ModelState from "./kilo-provider/model-state" import { handleForkSession } from "./kilo-provider/fork-session" import { openConfig } from "./kilo-provider/open-config" @@ -112,7 +114,7 @@ import { handleQuestionReject, fetchAndSendPendingQuestions, } from "./kilo-provider/handlers/question" -import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion" +import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" import { @@ -229,6 +231,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private webviewMessageDisposable: vscode.Disposable | null = null private autocompleteConfigDisposable: vscode.Disposable | null = null private telemetryStateDisposable: vscode.Disposable | null = null + private speechToTextDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null private autoApproveBridge: ReturnType | null = null @@ -592,6 +595,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) + this.speechToTextDisposable?.dispose() + this.speechToTextDisposable = watchSpeechToTextConfig(this) this.telemetryStateDisposable?.dispose() this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { @@ -604,9 +609,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (intercepted === null) return message = intercepted - await routeSuggestionWebviewMessage(this.questionCtx, message) - if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return - if (await routeAutocompleteMessage(message, (msg) => this.postMessage(msg))) return + if ( + await routeEarlyMessage(message, { + question: this.questionCtx, + client: this.client, + connection: this.connectionService, + dir: this.getWorkspaceDirectory(this.currentSession?.id), + post: (msg) => this.postMessage(msg), + }) + ) { + return + } if ( await handleSidebarWorktreeMessage(message, { post: (msg) => this.postMessage(msg), @@ -1301,6 +1314,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo }) this.sendNotificationSettings() this.sendTimelineSetting() + this.sendSpeechToTextSettings() this.postMessage({ type: "extensionDataReady" }) if (this.cachedGitRepo) this.startStatsPolling() @@ -2341,6 +2355,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } + private sendSpeechToTextSettings(): void { + sendSpeechToTextSettings(this) + } + /** Returns the number of sessions currently in "busy" state. */ private getBusySessionCount(): number { return getBusySessionCount(this.sessionStatusMap) @@ -2920,6 +2938,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async handleUpdateSetting(key: string, value: unknown): Promise { const { section, leaf } = buildSettingPath(key) if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return + if (section === "speechToText" && !validSpeechToTextSetting(leaf, value)) return const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`) await config.update(leaf, value, vscode.ConfigurationTarget.Global) } @@ -2960,6 +2979,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Re-send all settings to the webview so the UI reflects the reset this.postMessage(buildAutocompleteSettingsMessage()) + this.sendSpeechToTextSettings() this.sendBrowserSettings() this.sendNotificationSettings() this.sendTimelineSetting() @@ -3460,6 +3480,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibilityDisposable?.dispose() this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() + this.speechToTextDisposable?.dispose() this.telemetryStateDisposable?.dispose() this.autoApproveBridge?.dispose() this.streams.dispose() diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts new file mode 100644 index 00000000000..74b73a14955 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -0,0 +1,20 @@ +import { routeSuggestionWebviewMessage } from "./handlers/suggestion" +import * as ModelState from "./model-state" +import { routeInputToolMessage } from "../services/input-tools" +import type { KiloConnectionService } from "../services/cli-backend/connection-service" +import type { SuggestionContext } from "./handlers/suggestion" +import type { KiloClient } from "@kilocode/sdk/v2/client" + +type Ctx = { + question: SuggestionContext + client: KiloClient | null + connection: KiloConnectionService + dir: string + post: (msg: unknown) => void +} + +export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Promise { + await routeSuggestionWebviewMessage(ctx.question, message) + if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true + return await routeInputToolMessage(message, { connection: ctx.connection, dir: ctx.dir, post: ctx.post }) +} diff --git a/packages/kilo-vscode/src/kilo-provider/speech-to-text-capture.ts b/packages/kilo-vscode/src/kilo-provider/speech-to-text-capture.ts new file mode 100644 index 00000000000..89895114298 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/speech-to-text-capture.ts @@ -0,0 +1,198 @@ +import { existsSync } from "fs" +import { readFile, stat, unlink } from "fs/promises" +import * as os from "os" +import * as path from "path" +import type { ChildProcess } from "child_process" +import { exec, spawn } from "../util/process" + +type Input = { + requestId: string + model: string + language?: string +} + +type Recording = Input & { + file: string + proc: ChildProcess + stderr: string[] + stopped: boolean + exit?: { + code: number | null + signal: string | null + } +} + +type Audio = { + data: string + format: "wav" + model: string + language?: string +} + +let active: Recording | undefined + +export async function startSpeechCapture(input: Input): Promise { + if (active) throw new Error("Speech recording is already in progress") + + const bin = await findFFmpeg() + const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`) + const args = ["-y", ...inputArgs(), "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", file] + const proc = spawn(bin, args, { stdio: ["pipe", "ignore", "pipe"] }) + const state: Recording = { ...input, file, proc, stderr: [], stopped: false } + active = state + + proc.stderr?.on("data", (data: Buffer) => { + if (state.stderr.length < 20) state.stderr.push(data.toString()) + }) + proc.on("exit", (code, signal) => { + state.exit = { code, signal } + if (active === state && !state.stopped) active = undefined + }) + proc.on("error", (err) => { + state.stderr.push(err.message) + if (active === state && !state.stopped) active = undefined + }) + + await waitForStart(state) +} + +export async function stopSpeechCapture(requestId: string): Promise
-
+
+ + + +
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 3ff6d7650bb..db8d11675bf 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -19,8 +19,10 @@ import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" import { useConfig } from "../../context/config" +import { useProvider } from "../../context/provider" import { ModelSelector } from "../shared/ModelSelector" import { ModeSwitcher } from "../shared/ModeSwitcher" +import { SpeechToTextButton } from "../shared/SpeechToTextButton" import { ThinkingSelector } from "../shared/ThinkingSelector" import { useFileMention } from "../../hooks/useFileMention" import { useTerminalContext } from "../../hooks/useTerminalContext" @@ -29,6 +31,7 @@ import { hasTerminalMention } from "../../hooks/terminal-context-utils" import { hasGitChangesMention } from "../../hooks/git-changes-context-utils" import { useSlashCommand } from "../../hooks/useSlashCommand" import { useGhostText } from "../../hooks/useGhostText" +import { useSpeechToText } from "../../hooks/useSpeechToText" import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments" import { convertToMentionPath } from "../../utils/path-mentions" import { usePromptHistory } from "../../hooks/usePromptHistory" @@ -37,6 +40,8 @@ import { fileName, dirName, buildHighlightSegments, atEnd, isPromptBusy } from " import type { ReviewComment, TextPart } from "../../types/messages" import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown" import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts" +import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model" +import { getSpeechToTextModel } from "../../../../src/shared/speech-to-text-models" // Per-session input text storage (module-level so it survives remounts) const drafts = new Map() @@ -66,7 +71,8 @@ export const PromptInput: Component = (props) => { const session = useSession() const server = useServer() const indexing = useIndexing() - const { features } = useConfig() + const { config, features, settings } = useConfig() + const provider = useProvider() const language = useLanguage() const vscode = useVSCode() const worktree = useWorktreeMode() @@ -135,6 +141,7 @@ export const PromptInput: Component = (props) => { let preEnhanceText: string | null = null const ghost = useGhostText(vscode, text, () => server.isConnected()) + const speech = useSpeechToText(vscode, server, language) const replaceReviewComments = (next: ReviewComment[]) => { setReviewComments(next) @@ -325,8 +332,15 @@ export const PromptInput: Component = (props) => { const isBusy = () => isPromptBusy(session.status(), !!props.suggesting?.(), !!props.questioning?.()) const isDisabled = () => !server.isConnected() + const canUseSpeech = () => + settings()["speechToText.enabled"] === true && + provider.connected().includes(KILO_PROVIDER_ID) && + !config().disabled_providers?.includes(KILO_PROVIDER_ID) && + !!server.profileData() + const speechModel = () => getSpeechToTextModel(String(settings()["speechToText.model"] ?? "")).id const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 - const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !git.pending() && !props.blocked?.() + const canSend = () => + hasInput() && !isDisabled() && !speech.active() && !terminal.pending() && !git.pending() && !props.blocked?.() const showStop = () => isBusy() && !hasInput() const isAtEnd = () => textareaRef ? atEnd(textareaRef.selectionStart, textareaRef.selectionEnd, textareaRef.value.length) : false @@ -645,6 +659,33 @@ export const PromptInput: Component = (props) => { vscode.postMessage({ type: "enhancePrompt", text: draft, requestId: `enhance-${draftKey()}-${enhanceCounter}` }) } + const insertSpeechText = (value: string) => { + const ref = textareaRef + const current = text() + const start = ref?.selectionStart ?? current.length + const end = ref?.selectionEnd ?? start + const before = current.slice(0, start) + const after = current.slice(end) + const prefix = before && !/\s$/.test(before) ? " " : "" + const suffix = after && !/^\s/.test(after) ? " " : "" + const inserted = `${prefix}${value}${suffix}` + const next = `${before}${inserted}${after}` + const pos = before.length + inserted.length + + setText(next) + if (!ref) return + ref.value = next + ref.setSelectionRange(pos, pos) + ref.focus() + adjustHeight() + syncHighlightScroll() + ghost.scheduleRequest(next, ref) + } + + const startSpeech = () => { + speech.start({ model: speechModel(), insert: insertSpeechText }) + } + const handleSend = async () => { const draft = text().trim() @@ -676,7 +717,14 @@ export const PromptInput: Component = (props) => { const pending = reviewComments() const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : "" const message = draft && review ? `${review}\n\n${draft}` : draft || review - if ((!message && imgs.length === 0) || isDisabled() || terminal.pending() || git.pending() || props.blocked?.()) + if ( + (!message && imgs.length === 0) || + isDisabled() || + speech.active() || + terminal.pending() || + git.pending() || + props.blocked?.() + ) return const mentionFiles = mention.parseFileAttachments(draft) @@ -1042,6 +1090,9 @@ export const PromptInput: Component = (props) => { + + + { - const { config, updateConfig } = useConfig() + const { config, settings, updateConfig, updateSetting } = useConfig() const language = useLanguage() + const provider = useProvider() + const server = useServer() const vscode = useVSCode() const [active, setActive] = createSignal(false) @@ -39,6 +46,13 @@ const ExperimentalTab: Component = () => { }) const experimental = createMemo(() => config().experimental ?? {}) + const kiloReady = createMemo( + () => + provider.connected().includes(KILO_PROVIDER_ID) && + !config().disabled_providers?.includes(KILO_PROVIDER_ID) && + !!server.profileData(), + ) + const speechModel = createMemo(() => getSpeechToTextModel(String(settings()["speechToText.model"] ?? "")).id) const updateExperimental = (key: string, value: unknown) => { updateConfig({ @@ -193,6 +207,42 @@ const ExperimentalTab: Component = () => { + + updateSetting("speechToText.enabled", checked)} + disabled={!kiloReady()} + hideLabel + > + {language.t("settings.experimental.speechToText.title")} + + + + +