From b12c7cf10d6abe4f15c644a46c3c30f669c5d571 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 3 May 2026 16:12:09 -0400 Subject: [PATCH 01/60] 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/60] 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/60] 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 b91457673cd6a810e356f4f4cae0c6d0f257c6b2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 11 May 2026 10:13:25 +0200 Subject: [PATCH 04/60] fix(vscode): restore only previously open Kilo views --- .changeset/smart-views-restore.md | 5 +++ packages/kilo-vscode/src/KiloProvider.ts | 9 ++++-- .../src/agent-manager/AgentManagerProvider.ts | 7 +++++ packages/kilo-vscode/src/extension.ts | 31 ++++++++++++++++++- .../kilo-vscode/src/kilo-provider/options.ts | 1 + 5 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 .changeset/smart-views-restore.md diff --git a/.changeset/smart-views-restore.md b/.changeset/smart-views-restore.md new file mode 100644 index 00000000000..8cfb0295f83 --- /dev/null +++ b/.changeset/smart-views-restore.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Restore only the Kilo Code sidebar, Agent Manager, or both on VS Code restart based on which views were open before shutdown. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index cf3c33479e2..2b688648917 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -446,10 +446,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper webviewView.webview.html = this._getHtmlForWebview(webviewView.webview) this.setupWebviewMessageHandler(webviewView.webview) - vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible) + this.setSidebarVisible(webviewView.visible) this.visibilityDisposable?.dispose() this.visibilityDisposable = webviewView.onDidChangeVisibility(() => { - vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible) + this.setSidebarVisible(webviewView.visible) if (this.statsPoller) { this.statsPoller.setEnabled(webviewView.visible) this.statsPoller.setVisible(webviewView.visible) @@ -459,6 +459,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.initializeConnection() } + private setSidebarVisible(visible: boolean): void { + vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", visible) + this.opts.onSidebarVisibilityChange?.(visible) + } + /** * Resolve a WebviewPanel for displaying the Kilo webview in an editor tab. */ diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 4890788eaeb..432ef83755e 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -71,6 +71,7 @@ export class AgentManagerProvider implements Disposable { private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined private unsubTool: (() => void) | undefined private closing: Promise | undefined + private onVisibilityChange: ((visible: boolean) => void) | undefined /** Session ID most recently loaded via a `loadMessages` message from the webview. * Updated synchronously — unlike the session provider's currentSession which depends on @@ -186,6 +187,10 @@ export class AgentManagerProvider implements Disposable { ) } + public onPanelVisibilityChange(cb: (visible: boolean) => void): void { + this.onVisibilityChange = cb + } + /** Restore the Agent Manager panel from a previously serialized state. * The caller (extension.ts / vscode-host.ts) wraps the raw panel before passing it. */ public deserializePanel(ctx: PanelContext): void { @@ -213,6 +218,7 @@ export class AgentManagerProvider implements Disposable { this.panel = ctx this.statsPoller.setVisible(ctx.visible) + this.onVisibilityChange?.(true) ctx.onDidChangeVisibility((visible) => { this.statsPoller.setVisible(visible) }) @@ -234,6 +240,7 @@ export class AgentManagerProvider implements Disposable { this.prBridge.poller.stop() this.diffs.stop() this.panel = undefined + this.onVisibilityChange?.(false) } ctx.sessions.dispose() }) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 8c2ae51cdad..218fe209943 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -23,6 +23,14 @@ import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" let agentManager: AgentManagerProvider | undefined +let shuttingDown = false + +const RESTORE_KEY = "kilo.workbench.restore" + +type RestoreState = { + sidebar?: boolean + agentManager?: boolean +} const panelTitleHandler = (panel: vscode.WebviewPanel) => (title: string) => { panel.title = title || EXTENSION_DISPLAY_NAME @@ -34,11 +42,21 @@ const panelTitleHandler = (panel: vscode.WebviewPanel) => (title: string) => { // it starts lazily when a webview connects or when ensureBackendForAutocomplete() triggers it. export function activate(context: vscode.ExtensionContext) { console.log("Kilo Code extension is now active") + shuttingDown = false const telemetry = TelemetryProxy.getInstance() // Create shared connection service (one server for all webviews) const connectionService = new KiloConnectionService(context) + let restore = context.workspaceState.get(RESTORE_KEY) ?? {} + const closeSidebar = restore.sidebar === false + const remember = (patch: RestoreState) => { + const next = { ...restore, ...patch } + if (shuttingDown && patch.sidebar === false) next.sidebar = restore.sidebar + if (shuttingDown && patch.agentManager === false) next.agentManager = restore.agentManager + restore = next + void context.workspaceState.update(RESTORE_KEY, restore) + } // Create browser automation service (manages Playwright MCP registration) const browserAutomationService = new BrowserAutomationService(connectionService) @@ -105,7 +123,9 @@ export function activate(context: vscode.ExtensionContext) { } // Create the provider with shared service - const provider = new KiloProvider(context.extensionUri, connectionService, context) + const provider = new KiloProvider(context.extensionUri, connectionService, context, { + onSidebarVisibilityChange: (visible) => remember({ sidebar: visible }), + }) provider.setRemoteService(remoteService) // Register the webview view provider for the sidebar. @@ -115,6 +135,7 @@ export function activate(context: vscode.ExtensionContext) { webviewOptions: { retainContextWhenHidden: true }, }), ) + if (closeSidebar) void vscode.commands.executeCommand("workbench.action.closeSidebar") // Ensure Agent Manager navigation keybindings work when a VS Code terminal has focus. // The terminal intercepts all keystrokes unless the command is listed in @@ -131,6 +152,7 @@ export function activate(context: vscode.ExtensionContext) { // Create Agent Manager provider for editor panel const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context) const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService) + agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible })) agentManager = agentManagerProvider context.subscriptions.push(agentManagerProvider) @@ -169,6 +191,11 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.window.registerWebviewPanelSerializer(AgentManagerProvider.viewType, { deserializeWebviewPanel(panel: vscode.WebviewPanel) { + if (restore.agentManager === false) { + panel.dispose() + remember({ agentManager: false }) + return Promise.resolve() + } const ctx = agentManagerHost.wrapExistingPanel(panel, { onBeforeMessage: (msg) => agentManagerProvider.handleMessage(msg), }) @@ -452,6 +479,7 @@ export function activate(context: vscode.ExtensionContext) { // Dispose services when extension deactivates (kills the server) context.subscriptions.push({ dispose: () => { + shuttingDown = true unsubscribeStateChange() browserAutomationService.dispose() provider.dispose() @@ -461,6 +489,7 @@ export function activate(context: vscode.ExtensionContext) { } export async function deactivate() { + shuttingDown = true await agentManager?.shutdown() TelemetryProxy.getInstance().shutdown() } diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index 5889a6ea5e2..54764e1b505 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -2,4 +2,5 @@ export type KiloProviderOptions = { projectDirectory?: string | null slimEditMetadata?: boolean tabTitle?: (title: string) => void + onSidebarVisibilityChange?: (visible: boolean) => void } From 1f3df35576994ee17a5a3b6400a4017e8685faa6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 11 May 2026 10:18:03 +0200 Subject: [PATCH 05/60] fix(vscode): address restore review feedback --- packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts | 2 +- packages/kilo-vscode/src/extension.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 432ef83755e..e6b326582c7 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -218,7 +218,7 @@ export class AgentManagerProvider implements Disposable { this.panel = ctx this.statsPoller.setVisible(ctx.visible) - this.onVisibilityChange?.(true) + this.onVisibilityChange?.(ctx.visible) ctx.onDidChangeVisibility((visible) => { this.statsPoller.setVisible(visible) }) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 218fe209943..8dfb983a626 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -193,7 +193,6 @@ export function activate(context: vscode.ExtensionContext) { deserializeWebviewPanel(panel: vscode.WebviewPanel) { if (restore.agentManager === false) { panel.dispose() - remember({ agentManager: false }) return Promise.resolve() } const ctx = agentManagerHost.wrapExistingPanel(panel, { From 25df75d1770361f5d3c28629e3f499fc32ab0398 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 11 May 2026 16:58:39 -0400 Subject: [PATCH 06/60] fix(vscode): render plan exit as file link --- .../kilo-vscode/tests/unit/plan-exit.test.ts | 101 ++++++++++ .../src/components/chat/AssistantMessage.tsx | 184 ++++++++++++++++-- .../kilo-vscode/webview-ui/src/i18n/en.ts | 5 + .../webview-ui/src/styles/chat.css | 1 + .../webview-ui/src/styles/plan-exit.css | 39 ++++ .../webview-ui/src/utils/plan-path.ts | 43 ++++ 6 files changed, 357 insertions(+), 16 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/plan-exit.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/styles/plan-exit.css create mode 100644 packages/kilo-vscode/webview-ui/src/utils/plan-path.ts diff --git a/packages/kilo-vscode/tests/unit/plan-exit.test.ts b/packages/kilo-vscode/tests/unit/plan-exit.test.ts new file mode 100644 index 00000000000..b67337cb7fc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/plan-exit.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for plan_exit webview helpers: + * - planDisplayPath: relative/absolute path display logic + * - plan_exit renderer uses openFile, not openDiff + */ + +import { describe, expect, it } from "bun:test" +import { planDisplayPath } from "../../webview-ui/src/utils/plan-path" +import fs from "node:fs" +import path from "node:path" + +describe("planDisplayPath", () => { + it("returns a relative path unchanged", () => { + expect(planDisplayPath(".kilo/plans/my-plan.md", "/repo")).toBe(".kilo/plans/my-plan.md") + }) + + it("returns absolute path inside repo as repo-relative", () => { + expect(planDisplayPath("/repo/.kilo/plans/my-plan.md", "/repo")).toBe(".kilo/plans/my-plan.md") + }) + + it("returns absolute path inside repo with trailing slash on root as repo-relative", () => { + expect(planDisplayPath("/repo/.kilo/plans/my-plan.md", "/repo/")).toBe(".kilo/plans/my-plan.md") + }) + + it("returns absolute path outside repo unchanged", () => { + expect(planDisplayPath("/other/path/plan.md", "/repo")).toBe("/other/path/plan.md") + }) + + it("handles Windows absolute paths inside root", () => { + expect(planDisplayPath("C:\\repo\\.kilo\\plans\\plan.md", "C:\\repo")).toBe(".kilo\\plans\\plan.md") + }) + + it("handles Windows absolute paths outside root", () => { + expect(planDisplayPath("D:\\other\\plan.md", "C:\\repo")).toBe("D:\\other\\plan.md") + }) + + it("returns empty string unchanged", () => { + expect(planDisplayPath("", "/repo")).toBe("") + }) + + it("path equal to root returns original", () => { + // Edge: plan path IS the root directory itself — fall back to original + expect(planDisplayPath("/repo", "/repo")).toBe("/repo") + }) +}) + +describe("plan_exit renderer uses openFile not openDiff (source)", () => { + const ROOT = path.resolve(import.meta.dir, "../..") + const FILE = path.join(ROOT, "webview-ui/src/components/chat/AssistantMessage.tsx") + const src = fs.readFileSync(FILE, "utf-8") + + it("PlanExitCard calls data.openFile", () => { + expect(src).toContain("data.openFile") + }) + + it("infers status from write/edit tools in the same assistant turn", () => { + expect(src).toContain("function inferPlanStatus") + expect(src).toContain('part.tool === "edit"') + expect(src).toContain('part.tool === "read"') + expect(src).toContain('part.tool === "write"') + expect(src).toContain('part.tool === "apply_patch"') + expect(src).toContain("toolDeletions(part) > 0") + expect(src).toContain("patchUpdatedPlan(plan, part)") + expect(src).toContain("if (read && write) return \"updated\"") + }) + + it("matches apply_patch metadata files against the plan path", () => { + expect(src).toContain("function patchUpdatedPlan") + expect(src).toContain("meta.files") + expect(src).toContain('file.type === "update"') + expect(src).toContain("file.deletions > 0") + }) + + it("infers status from all loaded message parts", () => { + expect(src).toContain("Object.values(data.store.part ?? {}).flat()") + expect(src).toContain("[...props.parts, ...all()]") + }) + + it("does not depend on opencode-provided plan status metadata", () => { + expect(src).toContain('meta.status === "updated" || meta.status === "new"') + expect(src).toContain("inferPlanStatus(plan, parts, tp)") + }) + + it("PlanExitCard does not call openDiffVirtual", () => { + // Extract just the PlanExitCard function body to scope the assertion + const start = src.indexOf("function PlanExitCard") + const end = src.indexOf("\nfunction ", start + 1) + const block = end === -1 ? src.slice(start) : src.slice(start, end) + expect(block).not.toContain("openDiffVirtual") + expect(block).not.toContain("openDiff") + }) + + it("plan_exit tool is handled before generic Part renderer", () => { + const planExitIdx = src.indexOf("planExit()") + // }).metadata ?? {} + const plan = typeof meta.plan === "string" ? meta.plan : undefined + if (!plan) return undefined + const status = meta.status === "updated" || meta.status === "new" ? meta.status : inferPlanStatus(plan, parts, tp) + return { plan, status } +} + +function inferPlanStatus(plan: string, parts: SDKPart[], exit: ToolPart): PlanExitStatus { + const idx = parts.findIndex((part) => part.type === "tool" && (part as unknown as ToolPart).id === exit.id) + const prior = idx === -1 ? parts : parts.slice(0, idx) + const matches = prior + .filter((part): part is SDKPart & { type: "tool" } => part.type === "tool") + .map((part) => part as unknown as ToolPart) + .filter((part) => part.state?.status === "completed") + .filter((part) => toolTouchesPlan(plan, part)) + + const read = matches.some((part) => part.tool === "read") + const write = matches.some((part) => part.tool === "write") + + if (matches.some((part) => part.tool === "edit")) return "updated" + if (matches.some((part) => part.tool === "apply_patch" && patchUpdatedPlan(plan, part))) return "updated" + if (matches.some((part) => part.tool === "write" && toolDeletions(part) > 0)) return "updated" + if (read && write) return "updated" + if (write) return "new" + return "ready" +} + +function toolFilePath(part: ToolPart): string | undefined { + const state = part.state as { input?: Record; metadata?: Record } + const input = state.input ?? {} + const meta = state.metadata ?? {} + if (typeof input.filePath === "string") return input.filePath + if (typeof input.path === "string") return input.path + const diff = meta.filediff as { file?: unknown } | undefined + if (typeof diff?.file === "string") return diff.file + return undefined +} + +function toolTouchesPlan(plan: string, part: ToolPart): boolean { + if (samePlanPath(plan, toolFilePath(part))) return true + const meta = (part.state as { metadata?: Record }).metadata ?? {} + const files = meta.files + if (!Array.isArray(files)) return false + return files.some((item) => samePlanPath(plan, planPath(item))) +} + +function planPath(item: unknown): string | undefined { + if (!item || typeof item !== "object") return undefined + const obj = item as { filePath?: unknown; relativePath?: unknown } + if (typeof obj.relativePath === "string") return obj.relativePath + if (typeof obj.filePath === "string") return obj.filePath + return undefined +} + +function patchUpdatedPlan(plan: string, part: ToolPart): boolean { + const meta = (part.state as { metadata?: Record }).metadata ?? {} + const files = meta.files + if (!Array.isArray(files)) return false + return files.some((item) => { + if (!samePlanPath(plan, planPath(item))) return false + const file = item as { type?: unknown; deletions?: unknown } + if (file.type === "update" || file.type === "delete" || file.type === "move") return true + return typeof file.deletions === "number" && file.deletions > 0 + }) +} + +function toolDeletions(part: ToolPart): number { + const meta = (part.state as { metadata?: Record }).metadata ?? {} + const diff = meta.filediff as { deletions?: unknown } | undefined + return typeof diff?.deletions === "number" ? diff.deletions : 0 +} + +function samePlanPath(plan: string, file: string | undefined): boolean { + if (!file) return false + const a = normalizePlanPath(plan) + const b = normalizePlanPath(file) + if (a === b) return true + return a.endsWith("/" + b) || b.endsWith("/" + a) +} + +function normalizePlanPath(value: string): string { + return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "") +} + +function PlanExitCard(props: { part: ToolPart; parts: SDKPart[] }) { + const language = useLanguage() + const server = useServer() + const data = useData() + const all = createMemo(() => Object.values(data.store.part ?? {}).flat() as SDKPart[]) + const info = createMemo(() => + planExitInfo(props.part as unknown as SDKPart, [...props.parts, ...all()]), + ) + const display = createMemo(() => { + const i = info() + if (!i) return "" + return planDisplayPath(i.plan, server.workspaceDirectory()) + }) + const label = createMemo(() => { + const i = info() + if (!i) return "" + if (i.status === "updated") return language.t("plan.exit.readyUpdated") + if (i.status === "new") return language.t("plan.exit.readyNew") + return language.t("plan.exit.ready") + }) + const open = (e: MouseEvent) => { + e.preventDefault() + const i = info() + if (!i || !data.openFile) return + data.openFile(i.plan) + } + return ( + +
+ {label()} + {" "} + + {display()} + +
+
+ ) +} + function isRenderable(part: SDKPart): boolean { if (part.type === "tool") { const tool = (part as SDKPart & { tool: string }).tool @@ -154,10 +292,17 @@ export const AssistantMessage: Component = (props) => { if (tool.state?.status === "error") return return part }) + const planExit = createMemo(() => { + if (part.type !== "tool") return + const tp = part as unknown as ToolPart + if (tp.tool !== "plan_exit") return + if (tp.state?.status !== "completed") return + return tp + }) return (
= (props) => { when={activeSuggestion()} fallback={ } - /> + > + + } > - + {(tool) => } } > - {(tool) => } + {(tp) => } } > diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 412d8a6ed65..39ef0754cb3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1584,4 +1584,9 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + + // plan_exit tool part display + "plan.exit.ready": "Plan is ready:", + "plan.exit.readyNew": "New plan is ready:", + "plan.exit.readyUpdated": "Existing plan was updated:", } diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index a40375411cc..9eda4835ed9 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -19,6 +19,7 @@ @import "./notifications.css"; @import "./tool-overrides.css"; @import "./question-dock.css"; +@import "./plan-exit.css"; @import "./suggest-bar.css"; @import "./settings.css"; @import "./high-contrast.css"; diff --git a/packages/kilo-vscode/webview-ui/src/styles/plan-exit.css b/packages/kilo-vscode/webview-ui/src/styles/plan-exit.css new file mode 100644 index 00000000000..c9378481d5f --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/styles/plan-exit.css @@ -0,0 +1,39 @@ +/* ============================================ + Plan Exit Card + ============================================ */ + +[data-component="tool-part-wrapper"]:has([data-component="plan-exit-card"]) { + border: none !important; + border-radius: 0; + overflow: visible; +} + +[data-component="plan-exit-card"] { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px; + padding: 6px 0; + font-family: var(--font-family-sans); + font-size: var(--kilo-font-size-13); + line-height: var(--line-height-large); + + [data-slot="plan-exit-label"] { + color: var(--text-base); + font-weight: var(--font-weight-medium); + } + + [data-slot="plan-exit-link"] { + cursor: pointer; + color: var(--text-interactive-base); + text-decoration: none; + text-underline-offset: 2px; + font-family: var(--font-family-mono); + font-size: var(--kilo-font-size-12); + word-break: break-all; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/packages/kilo-vscode/webview-ui/src/utils/plan-path.ts b/packages/kilo-vscode/webview-ui/src/utils/plan-path.ts new file mode 100644 index 00000000000..99569f52512 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/plan-path.ts @@ -0,0 +1,43 @@ +/** + * Compute the display path for a plan file link. + * + * - Relative paths are returned as-is. + * - Absolute paths inside `root` are returned as a repo-relative path. + * - Absolute paths outside `root` are returned as-is (absolute). + * + * `root` is the workspace/worktree directory string from the server context. + * Both Unix and Windows separators are normalised before comparison. + */ +export function planDisplayPath(plan: string, root: string): string { + if (!plan) return plan + + // Already relative — nothing to do. + if (!isAbsolutePlan(plan)) return plan + + const normalRoot = normaliseDir(root) + const normalPlan = normalisePath(plan) + + if (!normalRoot || !normalPlan.startsWith(normalRoot)) return plan + + // Strip the root prefix (and a trailing separator if present). + const rel = plan.slice(normalRoot.length).replace(/^[\\/]/, "") + return rel || plan +} + +function normalisePath(p: string): string { + return p.replace(/\\/g, "/") +} + +function normaliseDir(dir: string): string { + if (!dir) return "" + const n = dir.replace(/\\/g, "/") + return n.endsWith("/") ? n : n + "/" +} + +function isAbsolutePlan(p: string): boolean { + // Unix absolute + if (p.startsWith("/")) return true + // Windows absolute: C:\ or C:/ + if (/^[A-Za-z]:[\\/]/.test(p)) return true + return false +} From ce64a962becfc387f220b9ef442e51e77712ff5f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 13 May 2026 19:49:48 +0200 Subject: [PATCH 07/60] fix(vscode): attribute Agent Manager sessions by root context --- packages/kilo-gateway/src/provider-debug.ts | 8 +-- packages/kilo-gateway/src/provider.ts | 15 +++--- packages/kilo-gateway/test/provider.test.ts | 23 ++++++++ packages/kilo-vscode/src/KiloProvider.ts | 10 +++- .../src/agent-manager/vscode-host.ts | 2 + .../kilo-vscode/src/kilo-provider/options.ts | 1 + .../src/kilo-sessions/kilo-sessions.ts | 18 ++++++- .../opencode/src/kilocode/session/index.ts | 54 +++++++++++++++++++ packages/opencode/src/session/llm.ts | 9 +++- packages/opencode/src/session/session.ts | 11 ++-- .../opencode/test/session/session.test.ts | 21 ++++++++ 11 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 packages/kilo-gateway/test/provider.test.ts diff --git a/packages/kilo-gateway/src/provider-debug.ts b/packages/kilo-gateway/src/provider-debug.ts index 57d779f7f38..f97bcbb7cdc 100644 --- a/packages/kilo-gateway/src/provider-debug.ts +++ b/packages/kilo-gateway/src/provider-debug.ts @@ -5,6 +5,7 @@ import { getApiKey } from "./auth/token.js" import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" import { ANONYMOUS_API_KEY } from "./api/constants.js" import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" +import { buildRequestHeaders } from "./provider.js" /** * Debug version of createKilo with extensive logging @@ -40,12 +41,7 @@ export function createKiloDebug(options: KiloProviderOptions = {}): SDK { console.log(" - URL:", String(input)) console.log(" - Method:", init?.method || "GET") - const headers = new Headers(init?.headers) - - // Add custom headers - Object.entries(customHeaders).forEach(([key, value]) => { - headers.set(key, value) - }) + const headers = buildRequestHeaders(customHeaders, init?.headers) // Add authorization if API key exists if (apiKey) { diff --git a/packages/kilo-gateway/src/provider.ts b/packages/kilo-gateway/src/provider.ts index 21fc818cc26..c36551469e6 100644 --- a/packages/kilo-gateway/src/provider.ts +++ b/packages/kilo-gateway/src/provider.ts @@ -10,6 +10,14 @@ import { ANONYMOUS_API_KEY } from "./api/constants.js" import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" import { sanitizeResponsesBody } from "./responses.js" +export function buildRequestHeaders(defaultHeaders: Record, requestHeaders?: HeadersInit): Headers { + const headers = new Headers(defaultHeaders) + new Headers(requestHeaders).forEach((value, key) => { + headers.set(key, value) + }) + return headers +} + /** * Create a KiloCode provider instance * @@ -45,14 +53,9 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { // Create custom fetch wrapper to add dynamic headers const originalFetch = options.fetch ?? fetch const wrappedFetch = async (input: string | URL | Request, init?: RequestInit) => { - const headers = new Headers(init?.headers) + const headers = buildRequestHeaders(customHeaders, init?.headers) const body = sanitizeResponsesBody(input, init?.body) - // Add custom headers - Object.entries(customHeaders).forEach(([key, value]) => { - headers.set(key, value) - }) - // Add authorization if API key exists if (apiKey) { headers.set("Authorization", `Bearer ${apiKey}`) diff --git a/packages/kilo-gateway/test/provider.test.ts b/packages/kilo-gateway/test/provider.test.ts new file mode 100644 index 00000000000..34f468a80b0 --- /dev/null +++ b/packages/kilo-gateway/test/provider.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { buildRequestHeaders } from "../src/provider" + +describe("Kilo provider request headers", () => { + test("request headers override provider defaults", () => { + const headers = buildRequestHeaders( + { + "content-type": "application/json", + "x-kilocode-feature": "vscode-extension", + "x-default-only": "kept", + }, + { + "x-kilocode-feature": "agent-manager", + "x-request-only": "kept-too", + }, + ) + + expect(headers.get("content-type")).toBe("application/json") + expect(headers.get("x-kilocode-feature")).toBe("agent-manager") + expect(headers.get("x-default-only")).toBe("kept") + expect(headers.get("x-request-only")).toBe("kept-too") + }) +}) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 96932f34d35..9a9399a9a32 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1336,7 +1336,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getContextDirectory() - const { data: session } = await this.client.session.create({ directory: workspaceDir }, { throwOnError: true }) + const { data: session } = await this.client.session.create( + { directory: workspaceDir, platform: this.opts.platform }, + { throwOnError: true }, + ) this.setCurrentSession(session) this.contextSessionID = session.id this.trackDirectory(session.id, workspaceDir) @@ -2438,7 +2441,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) if (!sessionID && !this.currentSession) { - const { data: session } = await this.client.session.create({ directory: dir }, { throwOnError: true }) + const { data: session } = await this.client.session.create( + { directory: dir, platform: this.opts.platform }, + { throwOnError: true }, + ) this.setCurrentSession(session) this.contextSessionID = session.id this.trackDirectory(session.id, dir) diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 222ea03857d..ae00ee79614 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -9,6 +9,7 @@ import * as vscode from "vscode" import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host" import type { KiloConnectionService } from "../services/cli-backend" import { KiloProvider } from "../KiloProvider" +import { PLATFORM } from "./constants" import { DiffVirtualProvider } from "../DiffVirtualProvider" import { buildWebviewHtml } from "../utils" import { openFileInEditor, getWorkspaceRoot } from "../review-utils" @@ -85,6 +86,7 @@ export class VscodeHost implements Host { }) const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { + platform: PLATFORM, slimEditMetadata: true, }) if (this.diffVirtual) { diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index 5889a6ea5e2..0d2e863fbcb 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -1,5 +1,6 @@ export type KiloProviderOptions = { projectDirectory?: string | null + platform?: string slimEditMetadata?: boolean tabTitle?: (title: string) => void } diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 5c627ea8039..96161ac4b76 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -467,11 +467,25 @@ export namespace KiloSessions { return } + const session = await Session.get(SessionID.make(sessionId)).catch(() => undefined) + if (session?.parentID) { + const parent = await get(session.parentID).catch(() => undefined) + if (!parent) await bootstrap(session.parentID) + } + log.info("creating session", { sessionId }) + const metadata = await meta(sessionId) + const response = await client.fetch(`${client.url}/api/session`, { method: "POST", - body: JSON.stringify({ sessionId }), + body: JSON.stringify({ + sessionId, + ...(session?.parentID ? { parentSessionId: session.parentID } : {}), + ...(session?.title ? { title: session.title } : {}), + platform: metadata.platform, + metadata, + }), }) if (!response.ok) { @@ -691,7 +705,7 @@ export namespace KiloSessions { } async function meta(sessionId?: string) { - const override = sessionId ? KiloSession.getPlatformOverride(sessionId) : undefined + const override = sessionId ? KiloSession.resolvePlatform(sessionId) : undefined const platform = override || process.env["KILO_PLATFORM"] || "cli" const orgId = await getOrgId() const gitBranch = await Vcs.branch().catch(() => undefined) diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 9e56cab0fbf..6da703af4a3 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -58,6 +58,60 @@ export namespace KiloSession { return overrides.get(id) } + function getParentID(id: string): string | undefined { + const row = Database.use((db) => + db + .select({ parentID: SessionTable.parent_id }) + .from(SessionTable) + .where(eq(SessionTable.id, SessionID.make(id))) + .get(), + ) + return row?.parentID ?? undefined + } + + export function resolvePlatform(id: string): string | undefined { + const override = overrides.get(id) + if (override) return override + + let current: string | undefined = id + const seen = new Set() + while (current && !seen.has(current)) { + seen.add(current) + const parentID = getParentID(current) + if (!parentID) break + const parentOverride = overrides.get(parentID) + if (parentOverride) return parentOverride + current = parentID + } + + return undefined + } + + export function resolveRoot(id: string): string { + let root = id + const seen = new Set() + while (!seen.has(root)) { + seen.add(root) + const parentID = getParentID(root) + if (!parentID) return root + root = parentID + } + return root + } + + export function featureForPlatform(platform: string | undefined): string | undefined { + switch (platform) { + case "agent-manager": + return "agent-manager" + case "vscode": + return "vscode-extension" + case "cli": + return "cli" + default: + return undefined + } + } + export function clearPlatformOverride(id: string) { overrides.delete(id) } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 7cdb5e85c05..4d8abaad55b 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,9 +22,10 @@ import { Auth } from "@/auth" // kilocode_change start import { DEFAULT_HEADERS } from "@/kilocode/const" import { getKiloProjectId } from "@/kilocode/project-id" -import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway" +import { ENV_FEATURE, HEADER_FEATURE, HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway" import { Identity } from "@kilocode/kilo-telemetry" import { makeRuntime } from "@/effect/run-service" +import { KiloSession } from "@/kilocode/session" // kilocode_change end import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -104,6 +105,9 @@ const live: Layer.Layer< ], { concurrency: "unbounded" }, ) + const rootSessionID = KiloSession.resolveRoot(input.sessionID) + const platform = KiloSession.resolvePlatform(rootSessionID) ?? process.env["KILO_PLATFORM"] + const feature = KiloSession.featureForPlatform(platform) ?? process.env[ENV_FEATURE] // TODO: move this to a proper hook const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" @@ -417,7 +421,8 @@ const live: Layer.Layer< ...(isKilo && input.agent.name ? { "x-kilocode-mode": input.agent.name.toLowerCase() } : {}), ...(isKilo && kiloProjectId ? { [HEADER_PROJECTID]: kiloProjectId } : {}), ...(isKilo && machineId ? { [HEADER_MACHINEID]: machineId } : {}), - ...(isKilo ? { [HEADER_TASKID]: input.sessionID } : {}), + ...(isKilo ? { [HEADER_TASKID]: rootSessionID } : {}), + ...(isKilo && feature ? { [HEADER_FEATURE]: feature } : {}), // kilocode_change end ...input.model.headers, ...headers, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 89e7ddeb7c4..49d95f32476 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -489,6 +489,7 @@ export const layer: Layer.Layer { }) }) +describe("session platform attribution", () => { + test("child sessions inherit the root platform override", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const root = await create({ platform: "agent-manager" }) + const child = await create({ parentID: root.id, title: "child" }) + + expect(KiloSession.getPlatformOverride(root.id)).toBe("agent-manager") + expect(KiloSession.getPlatformOverride(child.id)).toBe("agent-manager") + expect(KiloSession.resolvePlatform(child.id)).toBe("agent-manager") + expect(KiloSession.resolveRoot(child.id)).toBe(root.id) + expect(KiloSession.featureForPlatform(KiloSession.resolvePlatform(child.id))).toBe("agent-manager") + + await remove(root.id) + }, + }) + }) +}) + describe("step-finish token propagation via Bus event", () => { test( "non-zero tokens propagate through PartUpdated event", From 827e75acc8c691e7b0d6de650088991880508205 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 13 May 2026 19:59:05 +0200 Subject: [PATCH 08/60] refactor(cli): keep attribution lookups in memory --- .../opencode/src/kilocode/session/index.ts | 58 +++-- packages/opencode/src/session/llm.ts | 12 +- packages/opencode/src/session/session.ts | 10 +- .../session/platform-attribution.test.ts | 118 ++++++++++ .../test/kilocode/session/session.test.ts | 105 +++++++++ .../opencode/test/session/session.test.ts | 206 ------------------ 6 files changed, 259 insertions(+), 250 deletions(-) create mode 100644 packages/opencode/test/kilocode/session/platform-attribution.test.ts create mode 100644 packages/opencode/test/kilocode/session/session.test.ts delete mode 100644 packages/opencode/test/session/session.test.ts diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 6da703af4a3..f2e3aeea7f2 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -16,6 +16,7 @@ import { SessionTable } from "@/session/session.sql" import * as Log from "@opencode-ai/core/util/log" import type { LanguageModelUsage, ProviderMetadata } from "ai" import type { Provider } from "@/provider/provider" +import { ENV_FEATURE } from "@kilocode/kilo-gateway" export namespace KiloSession { const log = Log.create({ service: "session.kilo" }) @@ -49,6 +50,17 @@ export namespace KiloSession { // --------------------------------------------------------------------------- const overrides = new Map() + const parents = new Map() + const roots = new Map() + + export function register(input: { id: string; parentID?: string; platform?: string }) { + const root = input.parentID ? (roots.get(input.parentID) ?? input.parentID) : input.id + const platform = input.platform ?? (input.parentID ? resolvePlatform(input.parentID) : undefined) + + roots.set(input.id, root) + if (input.parentID) parents.set(input.id, input.parentID) + if (platform) overrides.set(input.id, platform) + } export function setPlatformOverride(id: string, platform: string) { overrides.set(id, platform) @@ -58,45 +70,16 @@ export namespace KiloSession { return overrides.get(id) } - function getParentID(id: string): string | undefined { - const row = Database.use((db) => - db - .select({ parentID: SessionTable.parent_id }) - .from(SessionTable) - .where(eq(SessionTable.id, SessionID.make(id))) - .get(), - ) - return row?.parentID ?? undefined - } - export function resolvePlatform(id: string): string | undefined { const override = overrides.get(id) if (override) return override - - let current: string | undefined = id - const seen = new Set() - while (current && !seen.has(current)) { - seen.add(current) - const parentID = getParentID(current) - if (!parentID) break - const parentOverride = overrides.get(parentID) - if (parentOverride) return parentOverride - current = parentID - } - - return undefined + const parent = parents.get(id) + if (!parent) return undefined + return resolvePlatform(parent) } export function resolveRoot(id: string): string { - let root = id - const seen = new Set() - while (!seen.has(root)) { - seen.add(root) - const parentID = getParentID(root) - if (!parentID) return root - root = parentID - } - return root + return roots.get(id) ?? id } export function featureForPlatform(platform: string | undefined): string | undefined { @@ -114,6 +97,15 @@ export namespace KiloSession { export function clearPlatformOverride(id: string) { overrides.delete(id) + parents.delete(id) + roots.delete(id) + } + + export function attribution(id: string): { rootID: string; feature?: string } { + const rootID = resolveRoot(id) + const platform = resolvePlatform(rootID) ?? process.env["KILO_PLATFORM"] + const feature = featureForPlatform(platform) ?? process.env[ENV_FEATURE] + return { rootID, ...(feature ? { feature } : {}) } } // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4d8abaad55b..074c475b76f 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,7 +22,7 @@ import { Auth } from "@/auth" // kilocode_change start import { DEFAULT_HEADERS } from "@/kilocode/const" import { getKiloProjectId } from "@/kilocode/project-id" -import { ENV_FEATURE, HEADER_FEATURE, HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway" +import { HEADER_FEATURE, HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway" import { Identity } from "@kilocode/kilo-telemetry" import { makeRuntime } from "@/effect/run-service" import { KiloSession } from "@/kilocode/session" @@ -105,9 +105,9 @@ const live: Layer.Layer< ], { concurrency: "unbounded" }, ) - const rootSessionID = KiloSession.resolveRoot(input.sessionID) - const platform = KiloSession.resolvePlatform(rootSessionID) ?? process.env["KILO_PLATFORM"] - const feature = KiloSession.featureForPlatform(platform) ?? process.env[ENV_FEATURE] + // kilocode_change start - attribute Kilo gateway usage to the root product session + const attr = KiloSession.attribution(input.sessionID) + // kilocode_change end // TODO: move this to a proper hook const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" @@ -421,8 +421,8 @@ const live: Layer.Layer< ...(isKilo && input.agent.name ? { "x-kilocode-mode": input.agent.name.toLowerCase() } : {}), ...(isKilo && kiloProjectId ? { [HEADER_PROJECTID]: kiloProjectId } : {}), ...(isKilo && machineId ? { [HEADER_MACHINEID]: machineId } : {}), - ...(isKilo ? { [HEADER_TASKID]: rootSessionID } : {}), - ...(isKilo && feature ? { [HEADER_FEATURE]: feature } : {}), + ...(isKilo ? { [HEADER_TASKID]: attr.rootID } : {}), + ...(isKilo && attr.feature ? { [HEADER_FEATURE]: attr.feature } : {}), // kilocode_change end ...input.model.headers, ...headers, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 49d95f32476..e5e223e1ded 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -489,7 +489,7 @@ export const layer: Layer.Layer svc.create(input))) +} + +function remove(id: SessionID) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id))) +} + +function updateMessage(msg: T) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg))) +} + +function updatePart(part: T) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) +} + +describe("session platform attribution", () => { + test("child sessions inherit the root platform override", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const root = await create({ platform: "agent-manager" }) + const child = await create({ parentID: root.id, title: "child" }) + const attr = KiloSession.attribution(child.id) + + expect(KiloSession.getPlatformOverride(root.id)).toBe("agent-manager") + expect(KiloSession.getPlatformOverride(child.id)).toBe("agent-manager") + expect(KiloSession.resolvePlatform(child.id)).toBe("agent-manager") + expect(attr.rootID).toBe(root.id) + expect(attr.feature).toBe("agent-manager") + + await remove(root.id) + }, + }) + }) +}) + +describe("step-finish token propagation via Bus event", () => { + test( + "non-zero tokens propagate through PartUpdated event", + async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const info = await create({}) + + const messageID = MessageID.ascending() + await updateMessage({ + id: messageID, + sessionID: info.id, + role: "user", + time: { created: Date.now() }, + agent: "user", + model: { providerID: "test", modelID: "test" }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) + + let received: MessageV2.Part | undefined + const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { + received = event.properties.part as MessageV2.Part + }) + + const tokens = { + total: 1500, + input: 500, + output: 800, + reasoning: 200, + cache: { read: 100, write: 50 }, + } + + const part = { + id: PartID.ascending(), + messageID, + sessionID: info.id, + type: "step-finish" as const, + reason: "stop", + cost: 0.005, + tokens, + } + + await updatePart(part) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(received).toBeDefined() + expect(received!.type).toBe("step-finish") + const finish = received as MessageV2.StepFinishPart + expect(finish.tokens.input).toBe(500) + expect(finish.tokens.output).toBe(800) + expect(finish.tokens.reasoning).toBe(200) + expect(finish.tokens.total).toBe(1500) + expect(finish.tokens.cache.read).toBe(100) + expect(finish.tokens.cache.write).toBe(50) + expect(finish.cost).toBe(0.005) + expect(received).not.toBe(part) + + unsub() + await remove(info.id) + }, + }) + }, + { timeout: 30000 }, + ) +}) diff --git a/packages/opencode/test/kilocode/session/session.test.ts b/packages/opencode/test/kilocode/session/session.test.ts new file mode 100644 index 00000000000..b0190cc57d7 --- /dev/null +++ b/packages/opencode/test/kilocode/session/session.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Session as SessionNs } from "@/session/session" +import { Bus } from "../../../src/bus" +import * as Log from "@opencode-ai/core/util/log" +import { Instance } from "../../../src/project/instance" +import { AppRuntime } from "../../../src/effect/app-runtime" +import { tmpdir } from "../../fixture/fixture" +import type { SessionID } from "../../../src/session/schema" + +const projectRoot = path.join(__dirname, "../../..") +void Log.init({ print: false }) + +function create(input?: SessionNs.CreateInput) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input))) +} + +function get(id: SessionID) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id))) +} + +function remove(id: SessionID) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id))) +} + +describe("session.created event", () => { + test("should emit session.created event when session is created", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + let eventReceived = false + let receivedInfo: SessionNs.Info | undefined + + const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => { + eventReceived = true + receivedInfo = event.properties.info as SessionNs.Info + }) + + const info = await create({}) + await new Promise((resolve) => setTimeout(resolve, 100)) + unsub() + + expect(eventReceived).toBe(true) + expect(receivedInfo).toBeDefined() + expect(receivedInfo?.id).toBe(info.id) + expect(receivedInfo?.projectID).toBe(info.projectID) + expect(receivedInfo?.directory).toBe(info.directory) + expect(receivedInfo?.path).toBe(info.path) + expect(receivedInfo?.title).toBe(info.title) + + await remove(info.id) + }, + }) + }) + + test("session.created event should be emitted before session.updated", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const events: string[] = [] + + const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => { + events.push("created") + }) + + const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => { + events.push("updated") + }) + + const info = await create({}) + await new Promise((resolve) => setTimeout(resolve, 100)) + unsubCreated() + unsubUpdated() + + expect(events).toContain("created") + expect(events).toContain("updated") + expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated")) + + await remove(info.id) + }, + }) + }) +}) + +describe("Session", () => { + test("remove works without an instance", async () => { + await using tmp = await tmpdir({ git: true }) + + const info = await Instance.provide({ + directory: tmp.path, + fn: () => create({ title: "remove-without-instance" }), + }) + + await expect(async () => { + await remove(info.id) + }).not.toThrow() + + let missing = false + await get(info.id).catch(() => { + missing = true + }) + + expect(missing).toBe(true) + }) +}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts deleted file mode 100644 index 80a4a52fed6..00000000000 --- a/packages/opencode/test/session/session.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "path" -import { Session as SessionNs } from "@/session/session" -import { Bus } from "../../src/bus" -import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "../../src/project/instance" -import { MessageV2 } from "../../src/session/message-v2" -import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { AppRuntime } from "../../src/effect/app-runtime" -import { tmpdir } from "../fixture/fixture" -import { KiloSession } from "../../src/kilocode/session" - -const projectRoot = path.join(__dirname, "../..") -void Log.init({ print: false }) - -function create(input?: SessionNs.CreateInput) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input))) -} - -function get(id: SessionID) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id))) -} - -function remove(id: SessionID) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id))) -} - -function updateMessage(msg: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg))) -} - -function updatePart(part: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) -} - -describe("session.created event", () => { - test("should emit session.created event when session is created", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - let eventReceived = false - let receivedInfo: SessionNs.Info | undefined - - const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => { - eventReceived = true - receivedInfo = event.properties.info as SessionNs.Info - }) - - const info = await create({}) - await new Promise((resolve) => setTimeout(resolve, 100)) - unsub() - - expect(eventReceived).toBe(true) - expect(receivedInfo).toBeDefined() - expect(receivedInfo?.id).toBe(info.id) - expect(receivedInfo?.projectID).toBe(info.projectID) - expect(receivedInfo?.directory).toBe(info.directory) - expect(receivedInfo?.path).toBe(info.path) - expect(receivedInfo?.title).toBe(info.title) - - await remove(info.id) - }, - }) - }) - - test("session.created event should be emitted before session.updated", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const events: string[] = [] - - const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => { - events.push("created") - }) - - const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => { - events.push("updated") - }) - - const info = await create({}) - await new Promise((resolve) => setTimeout(resolve, 100)) - unsubCreated() - unsubUpdated() - - expect(events).toContain("created") - expect(events).toContain("updated") - expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated")) - - await remove(info.id) - }, - }) - }) -}) - -describe("session platform attribution", () => { - test("child sessions inherit the root platform override", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const root = await create({ platform: "agent-manager" }) - const child = await create({ parentID: root.id, title: "child" }) - - expect(KiloSession.getPlatformOverride(root.id)).toBe("agent-manager") - expect(KiloSession.getPlatformOverride(child.id)).toBe("agent-manager") - expect(KiloSession.resolvePlatform(child.id)).toBe("agent-manager") - expect(KiloSession.resolveRoot(child.id)).toBe(root.id) - expect(KiloSession.featureForPlatform(KiloSession.resolvePlatform(child.id))).toBe("agent-manager") - - await remove(root.id) - }, - }) - }) -}) - -describe("step-finish token propagation via Bus event", () => { - test( - "non-zero tokens propagate through PartUpdated event", - async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const info = await create({}) - - const messageID = MessageID.ascending() - await updateMessage({ - id: messageID, - sessionID: info.id, - role: "user", - time: { created: Date.now() }, - agent: "user", - model: { providerID: "test", modelID: "test" }, - tools: {}, - mode: "", - } as unknown as MessageV2.Info) - - // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` - // is the mutable domain type. Cast bridges the two — safe because the - // test only reads the value afterwards. - let received: MessageV2.Part | undefined - const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { - received = event.properties.part as MessageV2.Part - }) - - const tokens = { - total: 1500, - input: 500, - output: 800, - reasoning: 200, - cache: { read: 100, write: 50 }, - } - - const partInput = { - id: PartID.ascending(), - messageID, - sessionID: info.id, - type: "step-finish" as const, - reason: "stop", - cost: 0.005, - tokens, - } - - await updatePart(partInput) - await new Promise((resolve) => setTimeout(resolve, 100)) - - expect(received).toBeDefined() - expect(received!.type).toBe("step-finish") - const finish = received as MessageV2.StepFinishPart - expect(finish.tokens.input).toBe(500) - expect(finish.tokens.output).toBe(800) - expect(finish.tokens.reasoning).toBe(200) - expect(finish.tokens.total).toBe(1500) - expect(finish.tokens.cache.read).toBe(100) - expect(finish.tokens.cache.write).toBe(50) - expect(finish.cost).toBe(0.005) - expect(received).not.toBe(partInput) - - unsub() - await remove(info.id) - }, - }) - }, - { timeout: 30000 }, - ) -}) - -describe("Session", () => { - test("remove works without an instance", async () => { - await using tmp = await tmpdir({ git: true }) - - const info = await Instance.provide({ - directory: tmp.path, - fn: () => create({ title: "remove-without-instance" }), - }) - - await expect(async () => { - await remove(info.id) - }).not.toThrow() - - let missing = false - await get(info.id).catch(() => { - missing = true - }) - - expect(missing).toBe(true) - }) -}) From 187c7a6314d88e4d77e947658272a479e8239513 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 13 May 2026 20:02:44 +0200 Subject: [PATCH 09/60] fix(cli): guard session bootstrap parent recursion --- packages/opencode/src/kilo-sessions/kilo-sessions.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 96161ac4b76..9bccaa92123 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -455,7 +455,13 @@ export namespace KiloSessions { return result } - export async function bootstrap(sessionId: string) { + export async function bootstrap(sessionId: string, seen = new Set()) { + if (seen.has(sessionId)) { + log.warn("session bootstrap skipped: parent cycle detected", { sessionId }) + return + } + seen.add(sessionId) + if (ingestDisabled) { log.info("session bootstrap skipped: ingest disabled", { sessionId }) return @@ -470,7 +476,7 @@ export namespace KiloSessions { const session = await Session.get(SessionID.make(sessionId)).catch(() => undefined) if (session?.parentID) { const parent = await get(session.parentID).catch(() => undefined) - if (!parent) await bootstrap(session.parentID) + if (!parent) await bootstrap(session.parentID, seen) } log.info("creating session", { sessionId }) From 997f522d155d5ef82e2fd5fa2490e1d19cd55674 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 13 May 2026 20:20:58 +0200 Subject: [PATCH 10/60] fix(cli): keep session bootstrap compatible --- .../src/kilo-sessions/kilo-sessions.ts | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 9bccaa92123..321558db6f0 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -455,13 +455,7 @@ export namespace KiloSessions { return result } - export async function bootstrap(sessionId: string, seen = new Set()) { - if (seen.has(sessionId)) { - log.warn("session bootstrap skipped: parent cycle detected", { sessionId }) - return - } - seen.add(sessionId) - + export async function bootstrap(sessionId: string) { if (ingestDisabled) { log.info("session bootstrap skipped: ingest disabled", { sessionId }) return @@ -473,25 +467,11 @@ export namespace KiloSessions { return } - const session = await Session.get(SessionID.make(sessionId)).catch(() => undefined) - if (session?.parentID) { - const parent = await get(session.parentID).catch(() => undefined) - if (!parent) await bootstrap(session.parentID, seen) - } - log.info("creating session", { sessionId }) - const metadata = await meta(sessionId) - const response = await client.fetch(`${client.url}/api/session`, { method: "POST", - body: JSON.stringify({ - sessionId, - ...(session?.parentID ? { parentSessionId: session.parentID } : {}), - ...(session?.title ? { title: session.title } : {}), - platform: metadata.platform, - metadata, - }), + body: JSON.stringify({ sessionId }), }) if (!response.ok) { From e5f7f6d8e41140f4f4c521c20c2b9fd0b9b3824d Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 15:31:27 -0400 Subject: [PATCH 11/60] fix(vscode): simplify plan ready label --- .../kilo-vscode/tests/unit/plan-exit.test.ts | 41 ++++---- .../src/components/chat/AssistantMessage.tsx | 98 ++----------------- 2 files changed, 27 insertions(+), 112 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/plan-exit.test.ts b/packages/kilo-vscode/tests/unit/plan-exit.test.ts index b67337cb7fc..1c202b447c7 100644 --- a/packages/kilo-vscode/tests/unit/plan-exit.test.ts +++ b/packages/kilo-vscode/tests/unit/plan-exit.test.ts @@ -47,38 +47,37 @@ describe("planDisplayPath", () => { describe("plan_exit renderer uses openFile not openDiff (source)", () => { const ROOT = path.resolve(import.meta.dir, "../..") const FILE = path.join(ROOT, "webview-ui/src/components/chat/AssistantMessage.tsx") + const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx") const src = fs.readFileSync(FILE, "utf-8") + const turnSrc = fs.readFileSync(TURN_FILE, "utf-8") it("PlanExitCard calls data.openFile", () => { expect(src).toContain("data.openFile") }) - it("infers status from write/edit tools in the same assistant turn", () => { - expect(src).toContain("function inferPlanStatus") - expect(src).toContain('part.tool === "edit"') - expect(src).toContain('part.tool === "read"') - expect(src).toContain('part.tool === "write"') - expect(src).toContain('part.tool === "apply_patch"') - expect(src).toContain("toolDeletions(part) > 0") - expect(src).toContain("patchUpdatedPlan(plan, part)") - expect(src).toContain("if (read && write) return \"updated\"") + it("always uses the generic ready label", () => { + expect(src).toContain('language.t("plan.exit.ready")') + expect(src).not.toContain('language.t("plan.exit.readyUpdated")') + expect(src).not.toContain('language.t("plan.exit.readyNew")') }) - it("matches apply_patch metadata files against the plan path", () => { - expect(src).toContain("function patchUpdatedPlan") - expect(src).toContain("meta.files") - expect(src).toContain('file.type === "update"') - expect(src).toContain("file.deletions > 0") - }) - - it("infers status from all loaded message parts", () => { - expect(src).toContain("Object.values(data.store.part ?? {}).flat()") - expect(src).toContain("[...props.parts, ...all()]") + it("does not infer status from tool history", () => { + expect(src).not.toContain("function inferPlanStatus") + expect(src).not.toContain("function patchUpdatedPlan") + expect(src).not.toContain("function toolTouchesPlan") + expect(src).not.toContain("toolDeletions") + expect(src).not.toContain("readyUpdated") + expect(src).not.toContain("readyNew") + expect(src).not.toContain("Object.values(data.store.part ?? {}).flat()") + expect(src).not.toContain("[...props.parts, ...all()]") + expect(src).not.toContain("turnParts") + expect(turnSrc).not.toContain("assistantMessages().flatMap") + expect(turnSrc).not.toContain("turnParts={assistantParts()}") }) it("does not depend on opencode-provided plan status metadata", () => { - expect(src).toContain('meta.status === "updated" || meta.status === "new"') - expect(src).toContain("inferPlanStatus(plan, parts, tp)") + expect(src).not.toContain('meta.status === "updated" || meta.status === "new"') + expect(src).not.toContain("meta.status") }) it("PlanExitCard does not call openDiffVirtual", () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 6c75bc1a0bb..6cd78cecdc6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -33,10 +33,8 @@ import { SuggestBar } from "./SuggestBar" // so the user can see what the AI set up. export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"]) -type PlanExitStatus = "new" | "updated" | "ready" - -/** Extract plan path and status from a completed plan_exit tool part. */ -function planExitInfo(part: SDKPart, parts: SDKPart[]): { plan: string; status: PlanExitStatus } | undefined { +/** Extract plan path from a completed plan_exit tool part. */ +function planExitInfo(part: SDKPart): { plan: string } | undefined { if (part.type !== "tool") return undefined const tp = part as unknown as ToolPart if (tp.tool !== "plan_exit") return undefined @@ -44,94 +42,15 @@ function planExitInfo(part: SDKPart, parts: SDKPart[]): { plan: string; status: const meta = (tp.state as { metadata?: Record }).metadata ?? {} const plan = typeof meta.plan === "string" ? meta.plan : undefined if (!plan) return undefined - const status = meta.status === "updated" || meta.status === "new" ? meta.status : inferPlanStatus(plan, parts, tp) - return { plan, status } + return { plan } } -function inferPlanStatus(plan: string, parts: SDKPart[], exit: ToolPart): PlanExitStatus { - const idx = parts.findIndex((part) => part.type === "tool" && (part as unknown as ToolPart).id === exit.id) - const prior = idx === -1 ? parts : parts.slice(0, idx) - const matches = prior - .filter((part): part is SDKPart & { type: "tool" } => part.type === "tool") - .map((part) => part as unknown as ToolPart) - .filter((part) => part.state?.status === "completed") - .filter((part) => toolTouchesPlan(plan, part)) - - const read = matches.some((part) => part.tool === "read") - const write = matches.some((part) => part.tool === "write") - - if (matches.some((part) => part.tool === "edit")) return "updated" - if (matches.some((part) => part.tool === "apply_patch" && patchUpdatedPlan(plan, part))) return "updated" - if (matches.some((part) => part.tool === "write" && toolDeletions(part) > 0)) return "updated" - if (read && write) return "updated" - if (write) return "new" - return "ready" -} - -function toolFilePath(part: ToolPart): string | undefined { - const state = part.state as { input?: Record; metadata?: Record } - const input = state.input ?? {} - const meta = state.metadata ?? {} - if (typeof input.filePath === "string") return input.filePath - if (typeof input.path === "string") return input.path - const diff = meta.filediff as { file?: unknown } | undefined - if (typeof diff?.file === "string") return diff.file - return undefined -} - -function toolTouchesPlan(plan: string, part: ToolPart): boolean { - if (samePlanPath(plan, toolFilePath(part))) return true - const meta = (part.state as { metadata?: Record }).metadata ?? {} - const files = meta.files - if (!Array.isArray(files)) return false - return files.some((item) => samePlanPath(plan, planPath(item))) -} - -function planPath(item: unknown): string | undefined { - if (!item || typeof item !== "object") return undefined - const obj = item as { filePath?: unknown; relativePath?: unknown } - if (typeof obj.relativePath === "string") return obj.relativePath - if (typeof obj.filePath === "string") return obj.filePath - return undefined -} - -function patchUpdatedPlan(plan: string, part: ToolPart): boolean { - const meta = (part.state as { metadata?: Record }).metadata ?? {} - const files = meta.files - if (!Array.isArray(files)) return false - return files.some((item) => { - if (!samePlanPath(plan, planPath(item))) return false - const file = item as { type?: unknown; deletions?: unknown } - if (file.type === "update" || file.type === "delete" || file.type === "move") return true - return typeof file.deletions === "number" && file.deletions > 0 - }) -} - -function toolDeletions(part: ToolPart): number { - const meta = (part.state as { metadata?: Record }).metadata ?? {} - const diff = meta.filediff as { deletions?: unknown } | undefined - return typeof diff?.deletions === "number" ? diff.deletions : 0 -} - -function samePlanPath(plan: string, file: string | undefined): boolean { - if (!file) return false - const a = normalizePlanPath(plan) - const b = normalizePlanPath(file) - if (a === b) return true - return a.endsWith("/" + b) || b.endsWith("/" + a) -} - -function normalizePlanPath(value: string): string { - return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "") -} - -function PlanExitCard(props: { part: ToolPart; parts: SDKPart[] }) { +function PlanExitCard(props: { part: ToolPart }) { const language = useLanguage() const server = useServer() const data = useData() - const all = createMemo(() => Object.values(data.store.part ?? {}).flat() as SDKPart[]) const info = createMemo(() => - planExitInfo(props.part as unknown as SDKPart, [...props.parts, ...all()]), + planExitInfo(props.part as unknown as SDKPart), ) const display = createMemo(() => { const i = info() @@ -139,10 +58,7 @@ function PlanExitCard(props: { part: ToolPart; parts: SDKPart[] }) { return planDisplayPath(i.plan, server.workspaceDirectory()) }) const label = createMemo(() => { - const i = info() - if (!i) return "" - if (i.status === "updated") return language.t("plan.exit.readyUpdated") - if (i.status === "new") return language.t("plan.exit.readyNew") + if (!info()) return "" return language.t("plan.exit.ready") }) const open = (e: MouseEvent) => { @@ -342,7 +258,7 @@ export const AssistantMessage: Component = (props) => { } > - {(tp) => } + {(tp) => } } > From efc411eeb820013eb75da1a5904eee0dab00bec2 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 15:53:19 -0400 Subject: [PATCH 12/60] fix(i18n): add plan ready translations --- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/br.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/da.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/de.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/en.ts | 2 -- packages/kilo-vscode/webview-ui/src/i18n/es.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/no.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/th.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 2 ++ 19 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index cb292f16288..c3d6ca84c7e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1560,4 +1560,6 @@ export const dict = { "diffViewer.baseBranch.empty": "لا توجد فروع مطابقة", "diffViewer.baseBranch.loading": "جارٍ تحميل الفروع…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "الخطة جاهزة:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index c7dd24dc01c..28e26bc40b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1602,4 +1602,6 @@ export const dict = { "diffViewer.baseBranch.empty": "Nenhum branch correspondente", "diffViewer.baseBranch.loading": "Carregando branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Plano pronto:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 461584d1240..e918daeb665 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1598,4 +1598,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Plan je spreman:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 77996e22001..318e353d109 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1588,4 +1588,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Planen er klar:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 231bab9d25a..f7b45732b50 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1618,4 +1618,6 @@ export const dict = { "diffViewer.baseBranch.empty": "Keine passenden Branches", "diffViewer.baseBranch.loading": "Branches werden geladen…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Plan ist bereit:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 9c0754c551c..b2791df00e6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1592,6 +1592,4 @@ export const dict = { // plan_exit tool part display "plan.exit.ready": "Plan is ready:", - "plan.exit.readyNew": "New plan is ready:", - "plan.exit.readyUpdated": "Existing plan was updated:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 626627dadfa..114b6a01f00 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1608,4 +1608,6 @@ export const dict = { "diffViewer.baseBranch.empty": "Sin ramas que coincidan", "diffViewer.baseBranch.loading": "Cargando ramas…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "El plan está listo:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 159e32340c2..4d62e8f174a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1626,4 +1626,6 @@ export const dict = { "diffViewer.baseBranch.empty": "Aucune branche correspondante", "diffViewer.baseBranch.loading": "Chargement des branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Le plan est prêt :", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index a6a13784a65..339fdd30dde 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1582,4 +1582,6 @@ export const dict = { "diffViewer.baseBranch.empty": "一致するブランチがありません", "diffViewer.baseBranch.loading": "ブランチを読み込み中…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "プランの準備ができました:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 2367e50cc87..4bed0abd4a5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1567,4 +1567,6 @@ export const dict = { "diffViewer.baseBranch.empty": "일치하는 브랜치 없음", "diffViewer.baseBranch.loading": "브랜치 로딩 중…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "계획이 준비되었습니다:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 6c93dc790a1..ab395da73de 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1620,4 +1620,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Plan is klaar:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index a7b08921e48..6e96ba5fb10 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1582,4 +1582,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Planen er klar:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 84550227de4..76bf5da3ad4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1592,4 +1592,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Plan jest gotowy:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index baf9174e0d4..ec33d2fcd1c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1592,4 +1592,6 @@ export const dict = { "diffViewer.baseBranch.empty": "Нет подходящих веток", "diffViewer.baseBranch.loading": "Загрузка веток…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "План готов:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 5438042d7ee..c298e56030f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1565,4 +1565,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "แผนพร้อมแล้ว:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 8a7d0c756ae..d079bf58868 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1609,4 +1609,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "Plan hazır:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index e2f36ebc4a3..26e1ef502c5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1608,4 +1608,6 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "План готовий:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 9d55d18cfa9..51b45b71e6c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1526,4 +1526,6 @@ export const dict = { "diffViewer.baseBranch.empty": "没有匹配的分支", "diffViewer.baseBranch.loading": "正在加载分支…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "计划已准备就绪:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 4cddda95852..aa5d88539a3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1533,4 +1533,6 @@ export const dict = { "diffViewer.baseBranch.empty": "沒有相符的分支", "diffViewer.baseBranch.loading": "正在載入分支…", "diffViewer.baseBranch.none": "—", + // plan_exit tool part display + "plan.exit.ready": "計畫已準備就緒:", } satisfies Partial> From 0db81958036a11c8de4f66b512cc94a8aa510ae9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 15:57:46 -0400 Subject: [PATCH 13/60] chore: add plan files --- .kilo/plans/1778523694544-stellar-panda.md | 133 +++++++++++ .kilo/plans/1778697937544-misty-wolf.md | 242 +++++++++++++++++++++ .kilo/plans/1778699914978-brave-moon.md | 177 +++++++++++++++ 3 files changed, 552 insertions(+) create mode 100644 .kilo/plans/1778523694544-stellar-panda.md create mode 100644 .kilo/plans/1778697937544-misty-wolf.md create mode 100644 .kilo/plans/1778699914978-brave-moon.md diff --git a/.kilo/plans/1778523694544-stellar-panda.md b/.kilo/plans/1778523694544-stellar-panda.md new file mode 100644 index 00000000000..650ce30897b --- /dev/null +++ b/.kilo/plans/1778523694544-stellar-panda.md @@ -0,0 +1,133 @@ +# Plan: Fix Plan Updated Label Without Performance Regression + +## Goal +Fix the VS Code `plan_exit` label so updated plans reliably show `Existing plan was updated:` while avoiding broad scans or reactive work that could slow down chat rendering. + +## Current Behavior +The plan-ready card sometimes shows `Plan is ready:` even when the preceding tool clearly updated the plan, e.g.: + +- `Write ... .kilo/plans/ +218 -44` +- `Patch ... .kilo/plans/ +17 -3` + +Those should show `Existing plan was updated:`. + +## Why The Current Fix Fails +The current implementation in `packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx` tries to infer status inside `PlanExitCard`: + +```ts +const all = createMemo(() => Object.values(data.store.part ?? {}).flat() as SDKPart[]) +planExitInfo(props.part as unknown as SDKPart, [...props.parts, ...all()]) +``` + +This has two problems: + +1. Ordering bug + - `props.parts` is the current assistant message’s parts. + - When the current assistant message contains only `plan_exit`, the combined array starts with `plan_exit`. + - `inferPlanStatus()` finds the first `plan_exit` index and only checks parts before it: + ```ts + const idx = parts.findIndex(...exit.id...) + const prior = idx === -1 ? parts : parts.slice(0, idx) + ``` + - Because `plan_exit` is first, `prior` is empty, so it never sees the earlier `write`/`apply_patch` tool. + +2. Performance risk + - `Object.values(data.store.part ?? {}).flat()` runs inside every `PlanExitCard` render and can scan all loaded part arrays in the session store. + - This is unnecessary work for almost every assistant message and can grow with long sessions. + +## Correct Approach +Use turn-local context instead of scanning all loaded session parts. + +`VscodeSessionTurn.tsx` already has the full current turn: + +- `props.turn.user` +- `props.turn.assistant` + +It also already hydrates parts for that exact turn: + +```ts +const ids = turn.partial ? turn.assistant.map((m) => m.id) : [turn.user.id, ...turn.assistant.map((m) => m.id)] +session.hydrateParts(ids) +``` + +The status inference only needs tools in this planning turn, not the whole session. The update/write/patch that produced the plan and the `plan_exit` part are part of the same visible turn. So we should compute ordered turn parts once in `VscodeSessionTurn` and pass them into every `AssistantMessage` for that turn. + +## Implementation Plan + +1. Add a turn-local ordered assistant parts memo in `VscodeSessionTurn.tsx`. + - Compute it from `assistantMessages()` in order. + - For each assistant message id, read `data.store.part?.[msg.id] ?? emptyParts`. + - Flatten only those arrays. + - Example shape: + ```ts + const assistantParts = createMemo(() => + assistantMessages().flatMap((msg) => (data.store.part?.[msg.id] ?? emptyParts) as SDKPart[]), + ) + ``` + - This is bounded by the current turn, not all loaded session history. + +2. Pass the turn-local parts into `AssistantMessage`. + - Extend `AssistantMessageProps` with `parts?: SDKPart[]` or `turnParts?: SDKPart[]`. + - In `VscodeSessionTurn.tsx`, pass `turnParts={assistantParts()}`. + - Keep naming clear; `turnParts` is probably best. + +3. Remove the broad store scan from `PlanExitCard`. + - Remove: + ```ts + const all = createMemo(() => Object.values(data.store.part ?? {}).flat() as SDKPart[]) + ``` + - Call: + ```ts + planExitInfo(props.part as unknown as SDKPart, props.turnParts) + ``` + - Do not concatenate `props.parts` in front of the ordered list. + +4. Make `inferPlanStatus()` robust to list ordering. + - Keep current behavior of considering only parts before the current `plan_exit` if the exit id is found. + - If the exit id is not found in `turnParts` for any reason, use all `turnParts` except any `plan_exit` parts. + - This avoids false positives from tools after `plan_exit` while still avoiding the empty-prior bug. + - Example: + ```ts + const idx = parts.findIndex(...) + const prior = idx >= 0 ? parts.slice(0, idx) : parts.filter((p) => !isPlanExit(p)) + ``` + +5. Keep the existing status heuristics but apply them to turn-local ordered parts. + - `edit` touching plan -> updated + - `apply_patch` touching plan with `type: "update" | "delete" | "move"` or deletions > 0 -> updated + - `write` touching plan with `filediff.deletions > 0` -> updated + - `read` + `write` touching plan -> updated + - `write` touching plan only -> new + - no match -> ready + +6. Add/adjust tests to lock the performance-sensitive shape. + - Update `packages/kilo-vscode/tests/unit/plan-exit.test.ts` static assertions: + - Assert it no longer contains `Object.values(data.store.part ?? {}).flat()`. + - Assert `VscodeSessionTurn.tsx` contains a turn-local assistant parts memo using `assistantMessages().flatMap`. + - Assert `AssistantMessage` receives/passes `turnParts`. + - Keep assertions for: + - `data.openFile` and no `openDiff` usage. + - `write`, `edit`, `apply_patch`, `read + write`, and deletion heuristics. + +7. Run targeted checks. + - `bun test tests/unit/plan-exit.test.ts` from `packages/kilo-vscode/`. + - If making TypeScript prop changes, run a targeted typecheck if feasible: + - `bun run typecheck` from `packages/kilo-vscode/` or at least `bun turbo typecheck --filter=kilo-code` if that is the established path after the merge. + - No opencode annotation check should be necessary because this fix should stay entirely in `packages/kilo-vscode/`. + +## Performance Notes + +- Current approach is O(total loaded parts) per `PlanExitCard` recomputation. +- Proposed approach is O(parts in current turn), computed once per visible turn and reused by its assistant messages. +- Typical turns have a small bounded number of parts compared to the full loaded session history. +- This also avoids allocating a flattened array for every plan-ready card from the full store. +- No backend or opencode changes are needed. + +## Expected Outcome + +After the fix: + +- The screenshot case `Write ... +218 -44` should render `Existing plan was updated: .kilo/plans/...`. +- The `Patch ... +17 -3` case should render `Existing plan was updated: .kilo/plans/...`. +- A true first-time `write` with no deletions and no prior read should render `New plan is ready: .kilo/plans/...`. +- The renderer should remain fast because it uses only current-turn parts, not all loaded parts. diff --git a/.kilo/plans/1778697937544-misty-wolf.md b/.kilo/plans/1778697937544-misty-wolf.md new file mode 100644 index 00000000000..8e76ad8aff8 --- /dev/null +++ b/.kilo/plans/1778697937544-misty-wolf.md @@ -0,0 +1,242 @@ +# Plan: Colored Git Prompt Showing Branch + +Goal: create a custom shell prompt that displays the current Git branch in color when the shell is inside a Git repository, hides the Git segment elsewhere, and remains easy to remove or extend later. + +## Scope + +- Target shell: `zsh`, because the environment default shell is `/bin/zsh`. +- Target config file: usually `~/.zshrc`, unless the existing prompt is sourced from another file. +- Prompt behavior: + - Show the current directory. + - Show the current Git branch in a colored segment when inside a Git repo. + - Show a short commit hash when in detached HEAD state. + - Show no Git text and no Git errors outside a repo. +- Implementation style: + - Prefer native `zsh` prompt escapes and shell functions. + - Avoid prompt frameworks unless one is already installed and clearly managing the prompt. + - Keep the change small, readable, and reversible. + +## Design Choices + +1. Use a helper function instead of embedding Git commands directly in `PROMPT`. + - This keeps the prompt definition readable. + - It makes later features like dirty-state or ahead/behind indicators easier to add. + - It isolates error suppression for non-Git directories. + +2. Use `git symbolic-ref --short HEAD` for normal branches. + - This is simple and directly returns branch names such as `main` or `feature/login`. + - It fails in detached HEAD state, which can be handled by a fallback. + +3. Use `git rev-parse --short HEAD` as the detached HEAD fallback. + - This keeps the prompt useful when checking out a commit, tag, rebase state, or bisect state. + - The prompt can display a short hash such as `(a1b2c3d)`. + +4. Use `setopt PROMPT_SUBST`. + - This allows `$(git_branch)` to be evaluated every time the prompt is redrawn. + - Without this option, the Git branch would either not appear or would be evaluated only once. + +5. Use zsh prompt color escapes instead of raw ANSI codes. + - `%F{green}` starts green foreground text. + - `%F{blue}` starts blue foreground text. + - `%f` resets the foreground color. + - These escapes are prompt-aware and avoid cursor-position issues caused by unwrapped ANSI sequences. + +## Pre-Implementation Inspection + +Before editing any shell config, inspect the current prompt setup: + +1. Check `~/.zshrc`. + - Look for existing `PROMPT=`, `PS1=`, `RPROMPT=`, or `RPS1=` assignments. + - Look for `source` lines that load prompt-related files. + - Look for `setopt PROMPT_SUBST` or `unsetopt PROMPT_SUBST`. + +2. Check whether a prompt framework is already active. + - Oh My Zsh indicators: + - `export ZSH=...` + - `ZSH_THEME=...` + - `source $ZSH/oh-my-zsh.sh` + - Powerlevel10k indicators: + - `powerlevel10k/powerlevel10k` + - `source ~/.p10k.zsh` + - Starship indicators: + - `eval "$(starship init zsh)"` + - Spaceship or other theme indicators: + - `SPACESHIP_...` + - theme plugin source lines. + +3. Decide how to apply the prompt change based on what exists. + - If no framework manages the prompt, add the custom helper and `PROMPT` directly. + - If a framework manages the prompt, do not blindly override it. + - If the user already has a custom `PROMPT`, modify it carefully or add the Git segment in the least disruptive place. + - If Starship or Powerlevel10k is active, prefer configuring that framework instead of replacing it. + +## Backup Strategy + +Before changing shell config: + +1. Create a timestamped backup of the file to be edited. + - Example: `~/.zshrc.backup.20260513-144700` + +2. Keep the change grouped and labeled. + - Add a short marker comment before the custom prompt block. + - This makes rollback simple without restoring the whole backup. + +3. Do not modify unrelated shell settings. + - Avoid reorganizing aliases, exports, plugins, or path setup. + - Keep the prompt block near existing prompt configuration if possible. + +## Implementation Plan + +1. Add prompt substitution if needed. + - Add `setopt PROMPT_SUBST` near the prompt block. + - If it already exists, do not duplicate it. + +2. Add the Git branch function. + - Use a compact helper named `git_branch`. + - Suppress Git errors with `2>/dev/null`. + - Return no output outside Git repositories. + - Print a leading space only when a Git value exists, so the prompt spacing stays clean. + +3. Define the colored prompt. + - Keep the current directory visible with `%~`. + - Color the directory blue. + - Color the branch segment green. + - Reset colors after each colored segment. + - End with `%#`, which shows `%` for normal users and `#` for root. + +4. Keep the prompt single-line by default. + - A simple default is easier to validate and less disruptive. + - Multi-line prompt variants can be added later if desired. + +5. Avoid adding expensive Git status checks in the first version. + - Branch lookup is fast enough for normal use. + - Dirty-state checks can be added later, but they may slow prompts in large repos. + +## Proposed zsh Snippet + +```zsh +# Custom colored Git prompt +setopt PROMPT_SUBST + +git_branch() { + local branch + branch=$(git symbolic-ref --short HEAD 2>/dev/null) || branch=$(git rev-parse --short HEAD 2>/dev/null) || return + print -r -- " %F{green}(${branch})%f" +} + +PROMPT='%F{blue}%~%f$(git_branch) %# ' +``` + +Expected examples: + +```text +~/projects/app (main) % +~/projects/app (feature/git-prompt) % +~/projects/app (a1b2c3d) % +~/Downloads % +``` + +## Optional Enhanced Variant + +If the user wants a slightly richer prompt, add a dirty working tree marker later: + +```zsh +git_branch() { + local branch dirty + branch=$(git symbolic-ref --short HEAD 2>/dev/null) || branch=$(git rev-parse --short HEAD 2>/dev/null) || return + git diff --quiet --ignore-submodules HEAD 2>/dev/null || dirty='*' + print -r -- " %F{green}(${branch}${dirty})%f" +} +``` + +Behavior: + +- `(main)` means clean working tree. +- `(main*)` means the repo has uncommitted changes. + +Caveat: + +- Dirty-state checks can be slower in very large repositories, so this should be optional rather than the default first implementation. + +## Validation Plan + +Validate in a new shell process so a broken prompt does not disrupt the current session. + +1. Syntax-load the config. + - Run a new `zsh` process that sources the modified config. + - Confirm there are no syntax errors. + +2. Test outside a Git repo. + - Move to a non-repository directory. + - Confirm the prompt shows only the path and prompt symbol. + - Confirm no Git error messages appear. + +3. Test inside a Git repo on a branch. + - Move to any repository with a checked-out branch. + - Confirm the branch appears in green. + - Confirm the directory appears in blue. + +4. Test after changing branches. + - Switch to another branch in a disposable repo if available. + - Confirm the prompt updates without restarting the shell. + +5. Test detached HEAD if practical. + - In a safe test repo, check out a commit hash temporarily. + - Confirm the prompt shows a short hash. + - Return to the original branch afterward. + +6. Test command readability. + - Type a long command and use backspace/navigation. + - Confirm cursor movement is correct, which validates that color escapes are prompt-safe. + +## Rollback Plan + +If the prompt behaves incorrectly: + +1. Remove the custom prompt block. + - Delete the `git_branch` function and the related `PROMPT` assignment. + - Remove `setopt PROMPT_SUBST` only if it was added solely for this prompt. + +2. Restore the backup if needed. + - Copy the timestamped backup over the edited config. + +3. Start a new shell. + - Confirm the old prompt is restored. + +## Risks and Mitigations + +- Risk: overriding an existing framework prompt. + - Mitigation: inspect for Oh My Zsh, Powerlevel10k, Starship, or theme setup before editing. + +- Risk: raw ANSI colors break cursor movement. + - Mitigation: use zsh prompt escapes like `%F{green}` and `%f`. + +- Risk: Git command errors appear outside repositories. + - Mitigation: redirect Git stderr to `/dev/null` and return no output when Git commands fail. + +- Risk: prompt becomes slow in large repos. + - Mitigation: only resolve branch by default; make dirty-state checks optional. + +- Risk: function name conflicts with an existing shell function. + - Mitigation: check for an existing `git_branch` function first, or rename to something more specific such as `prompt_git_branch`. + +## Completion Criteria + +The task is complete when: + +- The prompt config is backed up before modification. +- The prompt shows a colored Git branch inside repositories. +- The prompt hides the Git segment outside repositories. +- Detached HEAD state displays a short commit hash. +- Existing framework-managed prompts are not accidentally overwritten. +- A new shell can load the config without syntax errors. +- Rollback instructions are clear and tested enough to be reliable. + +## Future Enhancements + +- Add dirty-state marker such as `*` for uncommitted changes. +- Add staged/unstaged indicators with different symbols. +- Add upstream ahead/behind counts. +- Add right-side prompt support with `RPROMPT`. +- Add a multi-line prompt layout. +- Switch to Starship if the user wants a cross-shell prompt with TOML configuration. \ No newline at end of file diff --git a/.kilo/plans/1778699914978-brave-moon.md b/.kilo/plans/1778699914978-brave-moon.md new file mode 100644 index 00000000000..f4ee4b4a2a6 --- /dev/null +++ b/.kilo/plans/1778699914978-brave-moon.md @@ -0,0 +1,177 @@ +# Plan: Deliberate Custom Colored Git Prompt + +## Objective +Create a polished, intentional shell prompt that clearly displays the current Git branch in color when inside a repository, stays clean outside Git repositories, and avoids slow Git operations on every prompt render. + +## Design Principles +- Keep the prompt readable at a glance: path first, Git context second, prompt symbol last. +- Use color to communicate meaning, not decoration only. +- Avoid noisy output in non-Git directories. +- Support normal branches and detached HEAD states. +- Start with branch display only; add dirty-state indicators only if the user wants that extra signal. +- Keep the implementation self-contained in the shell config and easy to remove. + +## Target Shell +Use `zsh` by default because the current environment uses zsh. + +Primary config file: +```sh +~/.zshrc +``` + +If the target shell is different, adapt the same structure: +- `bash`: use `~/.bashrc` or `~/.bash_profile` +- `fish`: use `~/.config/fish/config.fish` with fish-specific syntax + +## Prompt Layout +Use this deliberate layout: +```sh + +``` + +Examples: +```sh +~/projects % +~/projects/app (main) % +~/projects/app (a1b2c3d) % +``` + +Color choices: +- Path: cyan, because it is stable location context. +- Git branch: green, because it is contextual state and should stand out. +- Detached HEAD: yellow, because it is notable but not necessarily an error. +- Prompt symbol: default color, to avoid visual clutter. + +## Git Branch Behavior +The branch helper should return: +- nothing outside a Git worktree +- the branch name on a normal branch +- a short commit hash in detached HEAD state + +Use this order: +1. `git symbolic-ref --quiet --short HEAD` for normal branches. +2. `git rev-parse --short HEAD` for detached HEAD. +3. return nothing if both fail. + +Redirect errors to `/dev/null` so the prompt never prints Git errors. + +## Implementation Plan +1. Back up the existing prompt configuration mentally before editing. + - Inspect `~/.zshrc` for an existing `PROMPT`, `PS1`, `precmd`, or prompt framework setup. + - If a framework is active, such as Oh My Zsh, Starship, Powerlevel10k, or Prezto, do not blindly override it; either disable that framework's prompt or integrate the Git segment into the existing prompt style. + +2. Add prompt substitution for zsh. + ```sh + setopt PROMPT_SUBST + ``` + This allows `$(git_prompt)` to be evaluated every time the prompt is drawn. + +3. Add a small helper for the current Git ref. + - Keep the function focused on data retrieval only. + - Do not format colors in this helper. + +4. Add a formatter for the Git prompt segment. + - Return an empty string outside Git repos. + - Wrap normal branches in green. + - Wrap detached HEAD hashes in yellow. + - Keep parentheses around the value for visual separation. + +5. Set the prompt. + - Use `%~` for compact path display. + - Use `%#` for the prompt symbol. + - Keep one space between path, Git segment, and symbol. + +6. Reload the shell config. + ```sh + source ~/.zshrc + ``` + +7. Verify behavior in three directories. + - Home or another non-Git directory: no Git segment appears. + - A Git repo on a branch: branch appears in green. + - Detached HEAD state: short commit hash appears in yellow. + +## Recommended zsh Snippet +```sh +setopt PROMPT_SUBST + +_git_ref() { + git symbolic-ref --quiet --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null +} + +_git_prompt() { + local ref="$(_git_ref)" + + if [[ -z "$ref" ]]; then + return + fi + + if git symbolic-ref --quiet HEAD >/dev/null 2>&1; then + printf '%%F{green}(%s)%%f' "$ref" + return + fi + + printf '%%F{yellow}(%s)%%f' "$ref" +} + +PROMPT='%F{cyan}%~%f $(_git_prompt) %# ' +``` + +## More Polished Variant +If the user wants a more styled prompt, use separators while keeping it simple: +```sh +PROMPT='%F{cyan}%~%f %F{242}|%f $(_git_prompt) %# ' +``` + +Example: +```sh +~/projects/app | (main) % +``` + +This is visually cleaner in long paths, but the plain version is better if minimalism is preferred. + +## Optional Dirty-State Indicator +Add this only after the basic prompt works. + +Desired behavior: +- Clean repo: `(main)` in green +- Dirty repo: `(main*)` in yellow +- Detached HEAD: `(a1b2c3d)` in yellow + +Implementation idea: +```sh +_git_dirty() { + git diff --quiet --ignore-submodules -- 2>/dev/null && git diff --cached --quiet --ignore-submodules -- 2>/dev/null +} +``` + +Tradeoff: +- Useful signal, but adds Git work on every prompt render. +- Usually fine in small repos, but can feel slow in very large repos. +- Do not include remote ahead/behind checks by default because they are more expensive. + +## Framework Compatibility Notes +If a prompt framework is already installed: +- Starship: configure Git branch in `~/.config/starship.toml` instead of writing custom zsh functions. +- Oh My Zsh: either select a theme with Git support or add this custom segment after theme initialization. +- Powerlevel10k: use `p10k configure` or edit `~/.p10k.zsh`; do not overwrite `PROMPT` directly. + +Default recommendation: only use the custom snippet when the shell config does not already delegate prompt rendering to a framework. + +## Verification Checklist +- `source ~/.zshrc` exits with no errors. +- Prompt has no empty `()` segment outside Git repos. +- Branch appears in green inside a normal Git branch. +- Detached HEAD appears as a short hash in yellow. +- Prompt remains responsive after several `cd` operations. +- Existing shell aliases, completions, and prompt framework behavior are not accidentally broken. + +## Rollback Plan +If the prompt behaves incorrectly: +1. Reopen `~/.zshrc`. +2. Remove or comment out the custom functions and `PROMPT=...` line. +3. Restore the previous `PROMPT`, `PS1`, or framework initialization. +4. Run `source ~/.zshrc` again. + +## Final Recommendation +Implement the basic zsh snippet first. It gives a clean, colored branch display with detached HEAD support and minimal performance cost. Add dirty-state indicators only after confirming the base prompt feels good in daily use. From fb420a1af369247315d8207e28a855decc2e5b45 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 16:02:58 -0400 Subject: [PATCH 14/60] fix(vscode): use inert plan link href --- packages/kilo-vscode/tests/unit/plan-exit.test.ts | 5 +++++ .../webview-ui/src/components/chat/AssistantMessage.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/tests/unit/plan-exit.test.ts b/packages/kilo-vscode/tests/unit/plan-exit.test.ts index 1c202b447c7..11042c38fa6 100644 --- a/packages/kilo-vscode/tests/unit/plan-exit.test.ts +++ b/packages/kilo-vscode/tests/unit/plan-exit.test.ts @@ -55,6 +55,11 @@ describe("plan_exit renderer uses openFile not openDiff (source)", () => { expect(src).toContain("data.openFile") }) + it("uses a safe inert anchor href", () => { + expect(src).toContain('href="#"') + expect(src).not.toContain("href={display()}") + }) + it("always uses the generic ready label", () => { expect(src).toContain('language.t("plan.exit.ready")') expect(src).not.toContain('language.t("plan.exit.readyUpdated")') diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 6cd78cecdc6..9e160ef9f1b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -74,7 +74,7 @@ function PlanExitCard(props: { part: ToolPart }) { {" "} {display()} From 62e98e8b3dd8a110e23c42bcf912d487f116f1d9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 16:10:18 -0400 Subject: [PATCH 15/60] style(vscode): format assistant message --- .../src/components/chat/AssistantMessage.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 9e160ef9f1b..18662f53a64 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -49,9 +49,7 @@ function PlanExitCard(props: { part: ToolPart }) { const language = useLanguage() const server = useServer() const data = useData() - const info = createMemo(() => - planExitInfo(props.part as unknown as SDKPart), - ) + const info = createMemo(() => planExitInfo(props.part as unknown as SDKPart)) const display = createMemo(() => { const i = info() if (!i) return "" @@ -70,13 +68,8 @@ function PlanExitCard(props: { part: ToolPart }) { return ( @@ -218,7 +211,14 @@ export const AssistantMessage: Component = (props) => { return (
Date: Wed, 13 May 2026 16:31:21 -0400 Subject: [PATCH 16/60] chore: remove plan files --- .kilo/plans/1778523694544-stellar-panda.md | 133 ----------- .kilo/plans/1778697937544-misty-wolf.md | 242 --------------------- .kilo/plans/1778699914978-brave-moon.md | 177 --------------- 3 files changed, 552 deletions(-) delete mode 100644 .kilo/plans/1778523694544-stellar-panda.md delete mode 100644 .kilo/plans/1778697937544-misty-wolf.md delete mode 100644 .kilo/plans/1778699914978-brave-moon.md diff --git a/.kilo/plans/1778523694544-stellar-panda.md b/.kilo/plans/1778523694544-stellar-panda.md deleted file mode 100644 index 650ce30897b..00000000000 --- a/.kilo/plans/1778523694544-stellar-panda.md +++ /dev/null @@ -1,133 +0,0 @@ -# Plan: Fix Plan Updated Label Without Performance Regression - -## Goal -Fix the VS Code `plan_exit` label so updated plans reliably show `Existing plan was updated:` while avoiding broad scans or reactive work that could slow down chat rendering. - -## Current Behavior -The plan-ready card sometimes shows `Plan is ready:` even when the preceding tool clearly updated the plan, e.g.: - -- `Write ... .kilo/plans/ +218 -44` -- `Patch ... .kilo/plans/ +17 -3` - -Those should show `Existing plan was updated:`. - -## Why The Current Fix Fails -The current implementation in `packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx` tries to infer status inside `PlanExitCard`: - -```ts -const all = createMemo(() => Object.values(data.store.part ?? {}).flat() as SDKPart[]) -planExitInfo(props.part as unknown as SDKPart, [...props.parts, ...all()]) -``` - -This has two problems: - -1. Ordering bug - - `props.parts` is the current assistant message’s parts. - - When the current assistant message contains only `plan_exit`, the combined array starts with `plan_exit`. - - `inferPlanStatus()` finds the first `plan_exit` index and only checks parts before it: - ```ts - const idx = parts.findIndex(...exit.id...) - const prior = idx === -1 ? parts : parts.slice(0, idx) - ``` - - Because `plan_exit` is first, `prior` is empty, so it never sees the earlier `write`/`apply_patch` tool. - -2. Performance risk - - `Object.values(data.store.part ?? {}).flat()` runs inside every `PlanExitCard` render and can scan all loaded part arrays in the session store. - - This is unnecessary work for almost every assistant message and can grow with long sessions. - -## Correct Approach -Use turn-local context instead of scanning all loaded session parts. - -`VscodeSessionTurn.tsx` already has the full current turn: - -- `props.turn.user` -- `props.turn.assistant` - -It also already hydrates parts for that exact turn: - -```ts -const ids = turn.partial ? turn.assistant.map((m) => m.id) : [turn.user.id, ...turn.assistant.map((m) => m.id)] -session.hydrateParts(ids) -``` - -The status inference only needs tools in this planning turn, not the whole session. The update/write/patch that produced the plan and the `plan_exit` part are part of the same visible turn. So we should compute ordered turn parts once in `VscodeSessionTurn` and pass them into every `AssistantMessage` for that turn. - -## Implementation Plan - -1. Add a turn-local ordered assistant parts memo in `VscodeSessionTurn.tsx`. - - Compute it from `assistantMessages()` in order. - - For each assistant message id, read `data.store.part?.[msg.id] ?? emptyParts`. - - Flatten only those arrays. - - Example shape: - ```ts - const assistantParts = createMemo(() => - assistantMessages().flatMap((msg) => (data.store.part?.[msg.id] ?? emptyParts) as SDKPart[]), - ) - ``` - - This is bounded by the current turn, not all loaded session history. - -2. Pass the turn-local parts into `AssistantMessage`. - - Extend `AssistantMessageProps` with `parts?: SDKPart[]` or `turnParts?: SDKPart[]`. - - In `VscodeSessionTurn.tsx`, pass `turnParts={assistantParts()}`. - - Keep naming clear; `turnParts` is probably best. - -3. Remove the broad store scan from `PlanExitCard`. - - Remove: - ```ts - const all = createMemo(() => Object.values(data.store.part ?? {}).flat() as SDKPart[]) - ``` - - Call: - ```ts - planExitInfo(props.part as unknown as SDKPart, props.turnParts) - ``` - - Do not concatenate `props.parts` in front of the ordered list. - -4. Make `inferPlanStatus()` robust to list ordering. - - Keep current behavior of considering only parts before the current `plan_exit` if the exit id is found. - - If the exit id is not found in `turnParts` for any reason, use all `turnParts` except any `plan_exit` parts. - - This avoids false positives from tools after `plan_exit` while still avoiding the empty-prior bug. - - Example: - ```ts - const idx = parts.findIndex(...) - const prior = idx >= 0 ? parts.slice(0, idx) : parts.filter((p) => !isPlanExit(p)) - ``` - -5. Keep the existing status heuristics but apply them to turn-local ordered parts. - - `edit` touching plan -> updated - - `apply_patch` touching plan with `type: "update" | "delete" | "move"` or deletions > 0 -> updated - - `write` touching plan with `filediff.deletions > 0` -> updated - - `read` + `write` touching plan -> updated - - `write` touching plan only -> new - - no match -> ready - -6. Add/adjust tests to lock the performance-sensitive shape. - - Update `packages/kilo-vscode/tests/unit/plan-exit.test.ts` static assertions: - - Assert it no longer contains `Object.values(data.store.part ?? {}).flat()`. - - Assert `VscodeSessionTurn.tsx` contains a turn-local assistant parts memo using `assistantMessages().flatMap`. - - Assert `AssistantMessage` receives/passes `turnParts`. - - Keep assertions for: - - `data.openFile` and no `openDiff` usage. - - `write`, `edit`, `apply_patch`, `read + write`, and deletion heuristics. - -7. Run targeted checks. - - `bun test tests/unit/plan-exit.test.ts` from `packages/kilo-vscode/`. - - If making TypeScript prop changes, run a targeted typecheck if feasible: - - `bun run typecheck` from `packages/kilo-vscode/` or at least `bun turbo typecheck --filter=kilo-code` if that is the established path after the merge. - - No opencode annotation check should be necessary because this fix should stay entirely in `packages/kilo-vscode/`. - -## Performance Notes - -- Current approach is O(total loaded parts) per `PlanExitCard` recomputation. -- Proposed approach is O(parts in current turn), computed once per visible turn and reused by its assistant messages. -- Typical turns have a small bounded number of parts compared to the full loaded session history. -- This also avoids allocating a flattened array for every plan-ready card from the full store. -- No backend or opencode changes are needed. - -## Expected Outcome - -After the fix: - -- The screenshot case `Write ... +218 -44` should render `Existing plan was updated: .kilo/plans/...`. -- The `Patch ... +17 -3` case should render `Existing plan was updated: .kilo/plans/...`. -- A true first-time `write` with no deletions and no prior read should render `New plan is ready: .kilo/plans/...`. -- The renderer should remain fast because it uses only current-turn parts, not all loaded parts. diff --git a/.kilo/plans/1778697937544-misty-wolf.md b/.kilo/plans/1778697937544-misty-wolf.md deleted file mode 100644 index 8e76ad8aff8..00000000000 --- a/.kilo/plans/1778697937544-misty-wolf.md +++ /dev/null @@ -1,242 +0,0 @@ -# Plan: Colored Git Prompt Showing Branch - -Goal: create a custom shell prompt that displays the current Git branch in color when the shell is inside a Git repository, hides the Git segment elsewhere, and remains easy to remove or extend later. - -## Scope - -- Target shell: `zsh`, because the environment default shell is `/bin/zsh`. -- Target config file: usually `~/.zshrc`, unless the existing prompt is sourced from another file. -- Prompt behavior: - - Show the current directory. - - Show the current Git branch in a colored segment when inside a Git repo. - - Show a short commit hash when in detached HEAD state. - - Show no Git text and no Git errors outside a repo. -- Implementation style: - - Prefer native `zsh` prompt escapes and shell functions. - - Avoid prompt frameworks unless one is already installed and clearly managing the prompt. - - Keep the change small, readable, and reversible. - -## Design Choices - -1. Use a helper function instead of embedding Git commands directly in `PROMPT`. - - This keeps the prompt definition readable. - - It makes later features like dirty-state or ahead/behind indicators easier to add. - - It isolates error suppression for non-Git directories. - -2. Use `git symbolic-ref --short HEAD` for normal branches. - - This is simple and directly returns branch names such as `main` or `feature/login`. - - It fails in detached HEAD state, which can be handled by a fallback. - -3. Use `git rev-parse --short HEAD` as the detached HEAD fallback. - - This keeps the prompt useful when checking out a commit, tag, rebase state, or bisect state. - - The prompt can display a short hash such as `(a1b2c3d)`. - -4. Use `setopt PROMPT_SUBST`. - - This allows `$(git_branch)` to be evaluated every time the prompt is redrawn. - - Without this option, the Git branch would either not appear or would be evaluated only once. - -5. Use zsh prompt color escapes instead of raw ANSI codes. - - `%F{green}` starts green foreground text. - - `%F{blue}` starts blue foreground text. - - `%f` resets the foreground color. - - These escapes are prompt-aware and avoid cursor-position issues caused by unwrapped ANSI sequences. - -## Pre-Implementation Inspection - -Before editing any shell config, inspect the current prompt setup: - -1. Check `~/.zshrc`. - - Look for existing `PROMPT=`, `PS1=`, `RPROMPT=`, or `RPS1=` assignments. - - Look for `source` lines that load prompt-related files. - - Look for `setopt PROMPT_SUBST` or `unsetopt PROMPT_SUBST`. - -2. Check whether a prompt framework is already active. - - Oh My Zsh indicators: - - `export ZSH=...` - - `ZSH_THEME=...` - - `source $ZSH/oh-my-zsh.sh` - - Powerlevel10k indicators: - - `powerlevel10k/powerlevel10k` - - `source ~/.p10k.zsh` - - Starship indicators: - - `eval "$(starship init zsh)"` - - Spaceship or other theme indicators: - - `SPACESHIP_...` - - theme plugin source lines. - -3. Decide how to apply the prompt change based on what exists. - - If no framework manages the prompt, add the custom helper and `PROMPT` directly. - - If a framework manages the prompt, do not blindly override it. - - If the user already has a custom `PROMPT`, modify it carefully or add the Git segment in the least disruptive place. - - If Starship or Powerlevel10k is active, prefer configuring that framework instead of replacing it. - -## Backup Strategy - -Before changing shell config: - -1. Create a timestamped backup of the file to be edited. - - Example: `~/.zshrc.backup.20260513-144700` - -2. Keep the change grouped and labeled. - - Add a short marker comment before the custom prompt block. - - This makes rollback simple without restoring the whole backup. - -3. Do not modify unrelated shell settings. - - Avoid reorganizing aliases, exports, plugins, or path setup. - - Keep the prompt block near existing prompt configuration if possible. - -## Implementation Plan - -1. Add prompt substitution if needed. - - Add `setopt PROMPT_SUBST` near the prompt block. - - If it already exists, do not duplicate it. - -2. Add the Git branch function. - - Use a compact helper named `git_branch`. - - Suppress Git errors with `2>/dev/null`. - - Return no output outside Git repositories. - - Print a leading space only when a Git value exists, so the prompt spacing stays clean. - -3. Define the colored prompt. - - Keep the current directory visible with `%~`. - - Color the directory blue. - - Color the branch segment green. - - Reset colors after each colored segment. - - End with `%#`, which shows `%` for normal users and `#` for root. - -4. Keep the prompt single-line by default. - - A simple default is easier to validate and less disruptive. - - Multi-line prompt variants can be added later if desired. - -5. Avoid adding expensive Git status checks in the first version. - - Branch lookup is fast enough for normal use. - - Dirty-state checks can be added later, but they may slow prompts in large repos. - -## Proposed zsh Snippet - -```zsh -# Custom colored Git prompt -setopt PROMPT_SUBST - -git_branch() { - local branch - branch=$(git symbolic-ref --short HEAD 2>/dev/null) || branch=$(git rev-parse --short HEAD 2>/dev/null) || return - print -r -- " %F{green}(${branch})%f" -} - -PROMPT='%F{blue}%~%f$(git_branch) %# ' -``` - -Expected examples: - -```text -~/projects/app (main) % -~/projects/app (feature/git-prompt) % -~/projects/app (a1b2c3d) % -~/Downloads % -``` - -## Optional Enhanced Variant - -If the user wants a slightly richer prompt, add a dirty working tree marker later: - -```zsh -git_branch() { - local branch dirty - branch=$(git symbolic-ref --short HEAD 2>/dev/null) || branch=$(git rev-parse --short HEAD 2>/dev/null) || return - git diff --quiet --ignore-submodules HEAD 2>/dev/null || dirty='*' - print -r -- " %F{green}(${branch}${dirty})%f" -} -``` - -Behavior: - -- `(main)` means clean working tree. -- `(main*)` means the repo has uncommitted changes. - -Caveat: - -- Dirty-state checks can be slower in very large repositories, so this should be optional rather than the default first implementation. - -## Validation Plan - -Validate in a new shell process so a broken prompt does not disrupt the current session. - -1. Syntax-load the config. - - Run a new `zsh` process that sources the modified config. - - Confirm there are no syntax errors. - -2. Test outside a Git repo. - - Move to a non-repository directory. - - Confirm the prompt shows only the path and prompt symbol. - - Confirm no Git error messages appear. - -3. Test inside a Git repo on a branch. - - Move to any repository with a checked-out branch. - - Confirm the branch appears in green. - - Confirm the directory appears in blue. - -4. Test after changing branches. - - Switch to another branch in a disposable repo if available. - - Confirm the prompt updates without restarting the shell. - -5. Test detached HEAD if practical. - - In a safe test repo, check out a commit hash temporarily. - - Confirm the prompt shows a short hash. - - Return to the original branch afterward. - -6. Test command readability. - - Type a long command and use backspace/navigation. - - Confirm cursor movement is correct, which validates that color escapes are prompt-safe. - -## Rollback Plan - -If the prompt behaves incorrectly: - -1. Remove the custom prompt block. - - Delete the `git_branch` function and the related `PROMPT` assignment. - - Remove `setopt PROMPT_SUBST` only if it was added solely for this prompt. - -2. Restore the backup if needed. - - Copy the timestamped backup over the edited config. - -3. Start a new shell. - - Confirm the old prompt is restored. - -## Risks and Mitigations - -- Risk: overriding an existing framework prompt. - - Mitigation: inspect for Oh My Zsh, Powerlevel10k, Starship, or theme setup before editing. - -- Risk: raw ANSI colors break cursor movement. - - Mitigation: use zsh prompt escapes like `%F{green}` and `%f`. - -- Risk: Git command errors appear outside repositories. - - Mitigation: redirect Git stderr to `/dev/null` and return no output when Git commands fail. - -- Risk: prompt becomes slow in large repos. - - Mitigation: only resolve branch by default; make dirty-state checks optional. - -- Risk: function name conflicts with an existing shell function. - - Mitigation: check for an existing `git_branch` function first, or rename to something more specific such as `prompt_git_branch`. - -## Completion Criteria - -The task is complete when: - -- The prompt config is backed up before modification. -- The prompt shows a colored Git branch inside repositories. -- The prompt hides the Git segment outside repositories. -- Detached HEAD state displays a short commit hash. -- Existing framework-managed prompts are not accidentally overwritten. -- A new shell can load the config without syntax errors. -- Rollback instructions are clear and tested enough to be reliable. - -## Future Enhancements - -- Add dirty-state marker such as `*` for uncommitted changes. -- Add staged/unstaged indicators with different symbols. -- Add upstream ahead/behind counts. -- Add right-side prompt support with `RPROMPT`. -- Add a multi-line prompt layout. -- Switch to Starship if the user wants a cross-shell prompt with TOML configuration. \ No newline at end of file diff --git a/.kilo/plans/1778699914978-brave-moon.md b/.kilo/plans/1778699914978-brave-moon.md deleted file mode 100644 index f4ee4b4a2a6..00000000000 --- a/.kilo/plans/1778699914978-brave-moon.md +++ /dev/null @@ -1,177 +0,0 @@ -# Plan: Deliberate Custom Colored Git Prompt - -## Objective -Create a polished, intentional shell prompt that clearly displays the current Git branch in color when inside a repository, stays clean outside Git repositories, and avoids slow Git operations on every prompt render. - -## Design Principles -- Keep the prompt readable at a glance: path first, Git context second, prompt symbol last. -- Use color to communicate meaning, not decoration only. -- Avoid noisy output in non-Git directories. -- Support normal branches and detached HEAD states. -- Start with branch display only; add dirty-state indicators only if the user wants that extra signal. -- Keep the implementation self-contained in the shell config and easy to remove. - -## Target Shell -Use `zsh` by default because the current environment uses zsh. - -Primary config file: -```sh -~/.zshrc -``` - -If the target shell is different, adapt the same structure: -- `bash`: use `~/.bashrc` or `~/.bash_profile` -- `fish`: use `~/.config/fish/config.fish` with fish-specific syntax - -## Prompt Layout -Use this deliberate layout: -```sh - -``` - -Examples: -```sh -~/projects % -~/projects/app (main) % -~/projects/app (a1b2c3d) % -``` - -Color choices: -- Path: cyan, because it is stable location context. -- Git branch: green, because it is contextual state and should stand out. -- Detached HEAD: yellow, because it is notable but not necessarily an error. -- Prompt symbol: default color, to avoid visual clutter. - -## Git Branch Behavior -The branch helper should return: -- nothing outside a Git worktree -- the branch name on a normal branch -- a short commit hash in detached HEAD state - -Use this order: -1. `git symbolic-ref --quiet --short HEAD` for normal branches. -2. `git rev-parse --short HEAD` for detached HEAD. -3. return nothing if both fail. - -Redirect errors to `/dev/null` so the prompt never prints Git errors. - -## Implementation Plan -1. Back up the existing prompt configuration mentally before editing. - - Inspect `~/.zshrc` for an existing `PROMPT`, `PS1`, `precmd`, or prompt framework setup. - - If a framework is active, such as Oh My Zsh, Starship, Powerlevel10k, or Prezto, do not blindly override it; either disable that framework's prompt or integrate the Git segment into the existing prompt style. - -2. Add prompt substitution for zsh. - ```sh - setopt PROMPT_SUBST - ``` - This allows `$(git_prompt)` to be evaluated every time the prompt is drawn. - -3. Add a small helper for the current Git ref. - - Keep the function focused on data retrieval only. - - Do not format colors in this helper. - -4. Add a formatter for the Git prompt segment. - - Return an empty string outside Git repos. - - Wrap normal branches in green. - - Wrap detached HEAD hashes in yellow. - - Keep parentheses around the value for visual separation. - -5. Set the prompt. - - Use `%~` for compact path display. - - Use `%#` for the prompt symbol. - - Keep one space between path, Git segment, and symbol. - -6. Reload the shell config. - ```sh - source ~/.zshrc - ``` - -7. Verify behavior in three directories. - - Home or another non-Git directory: no Git segment appears. - - A Git repo on a branch: branch appears in green. - - Detached HEAD state: short commit hash appears in yellow. - -## Recommended zsh Snippet -```sh -setopt PROMPT_SUBST - -_git_ref() { - git symbolic-ref --quiet --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null -} - -_git_prompt() { - local ref="$(_git_ref)" - - if [[ -z "$ref" ]]; then - return - fi - - if git symbolic-ref --quiet HEAD >/dev/null 2>&1; then - printf '%%F{green}(%s)%%f' "$ref" - return - fi - - printf '%%F{yellow}(%s)%%f' "$ref" -} - -PROMPT='%F{cyan}%~%f $(_git_prompt) %# ' -``` - -## More Polished Variant -If the user wants a more styled prompt, use separators while keeping it simple: -```sh -PROMPT='%F{cyan}%~%f %F{242}|%f $(_git_prompt) %# ' -``` - -Example: -```sh -~/projects/app | (main) % -``` - -This is visually cleaner in long paths, but the plain version is better if minimalism is preferred. - -## Optional Dirty-State Indicator -Add this only after the basic prompt works. - -Desired behavior: -- Clean repo: `(main)` in green -- Dirty repo: `(main*)` in yellow -- Detached HEAD: `(a1b2c3d)` in yellow - -Implementation idea: -```sh -_git_dirty() { - git diff --quiet --ignore-submodules -- 2>/dev/null && git diff --cached --quiet --ignore-submodules -- 2>/dev/null -} -``` - -Tradeoff: -- Useful signal, but adds Git work on every prompt render. -- Usually fine in small repos, but can feel slow in very large repos. -- Do not include remote ahead/behind checks by default because they are more expensive. - -## Framework Compatibility Notes -If a prompt framework is already installed: -- Starship: configure Git branch in `~/.config/starship.toml` instead of writing custom zsh functions. -- Oh My Zsh: either select a theme with Git support or add this custom segment after theme initialization. -- Powerlevel10k: use `p10k configure` or edit `~/.p10k.zsh`; do not overwrite `PROMPT` directly. - -Default recommendation: only use the custom snippet when the shell config does not already delegate prompt rendering to a framework. - -## Verification Checklist -- `source ~/.zshrc` exits with no errors. -- Prompt has no empty `()` segment outside Git repos. -- Branch appears in green inside a normal Git branch. -- Detached HEAD appears as a short hash in yellow. -- Prompt remains responsive after several `cd` operations. -- Existing shell aliases, completions, and prompt framework behavior are not accidentally broken. - -## Rollback Plan -If the prompt behaves incorrectly: -1. Reopen `~/.zshrc`. -2. Remove or comment out the custom functions and `PROMPT=...` line. -3. Restore the previous `PROMPT`, `PS1`, or framework initialization. -4. Run `source ~/.zshrc` again. - -## Final Recommendation -Implement the basic zsh snippet first. It gives a clean, colored branch display with detached HEAD support and minimal performance cost. Add dirty-state indicators only after confirming the base prompt feels good in daily use. From fff4277c4ed172cef433012bc2c78461eb837e21 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 16:34:49 -0400 Subject: [PATCH 17/60] chore(i18n): remove plan ready comments --- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/br.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/da.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/de.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/en.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/es.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/no.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/th.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 1 - packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 1 - 19 files changed, 19 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 2ddc1c7703d..5423280f4bb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1563,6 +1563,5 @@ export const dict = { "diffViewer.baseBranch.empty": "لا توجد فروع مطابقة", "diffViewer.baseBranch.loading": "جارٍ تحميل الفروع…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "الخطة جاهزة:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 70aaac9a47b..82051426117 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1605,6 +1605,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Nenhum branch correspondente", "diffViewer.baseBranch.loading": "Carregando branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Plano pronto:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 02c7551c8df..d19f4c44912 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1601,6 +1601,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Plan je spreman:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index fdd5c33c2c0..0b7d73b3169 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1591,6 +1591,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Planen er klar:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 5a86d270c1d..844bbc64941 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1621,6 +1621,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Keine passenden Branches", "diffViewer.baseBranch.loading": "Branches werden geladen…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Plan ist bereit:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 2274127a0ee..1af4206e182 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1593,6 +1593,5 @@ export const dict = { "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Plan is ready:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index b62d6c7fe02..605328efa99 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1611,6 +1611,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Sin ramas que coincidan", "diffViewer.baseBranch.loading": "Cargando ramas…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "El plan está listo:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 631e8b4d032..98a7a90149b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1630,6 +1630,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Aucune branche correspondante", "diffViewer.baseBranch.loading": "Chargement des branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Le plan est prêt :", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index a0922f60bda..ba4f4e14f73 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1585,6 +1585,5 @@ export const dict = { "diffViewer.baseBranch.empty": "一致するブランチがありません", "diffViewer.baseBranch.loading": "ブランチを読み込み中…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "プランの準備ができました:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 35ceb502a44..fb5951c77bb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1570,6 +1570,5 @@ export const dict = { "diffViewer.baseBranch.empty": "일치하는 브랜치 없음", "diffViewer.baseBranch.loading": "브랜치 로딩 중…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "계획이 준비되었습니다:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 99beaf4a7f8..88275330baa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1623,6 +1623,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Plan is klaar:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 34ece930889..83c8cca9ea7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1585,6 +1585,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Planen er klar:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index b7771c7f2a7..b785479908b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1595,6 +1595,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Plan jest gotowy:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 06092e9480c..5b1d9ba1823 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1595,6 +1595,5 @@ export const dict = { "diffViewer.baseBranch.empty": "Нет подходящих веток", "diffViewer.baseBranch.loading": "Загрузка веток…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "План готов:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 6c21148fb97..953155c1fbc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1568,6 +1568,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "แผนพร้อมแล้ว:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 96d2c6ccd1b..4ba9fe8e7c9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1612,6 +1612,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "Plan hazır:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 8353b094830..8deb7a453d2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1611,6 +1611,5 @@ export const dict = { "diffViewer.baseBranch.empty": "No matching branches", "diffViewer.baseBranch.loading": "Loading branches…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "План готовий:", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 978d87fb65f..132b0b676a5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1528,6 +1528,5 @@ export const dict = { "diffViewer.baseBranch.empty": "没有匹配的分支", "diffViewer.baseBranch.loading": "正在加载分支…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "计划已准备就绪:", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 765d7a41770..d45a41f29fa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1535,6 +1535,5 @@ export const dict = { "diffViewer.baseBranch.empty": "沒有相符的分支", "diffViewer.baseBranch.loading": "正在載入分支…", "diffViewer.baseBranch.none": "—", - // plan_exit tool part display "plan.exit.ready": "計畫已準備就緒:", } satisfies Partial> From 2d365fe707513909042ac5715fb93969c9491b44 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 17:17:56 -0400 Subject: [PATCH 18/60] 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 19/60] 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 20/60] 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 21/60] 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 9f6997ef3126531c363146039db66e4925edea8b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 14 May 2026 10:04:55 +0200 Subject: [PATCH 22/60] fix(cli): preserve child task attribution --- packages/kilo-gateway/src/api/constants.ts | 1 + packages/kilo-gateway/src/headers.ts | 2 ++ packages/kilo-gateway/src/index.ts | 1 + packages/opencode/src/session/llm.ts | 11 +++++++++-- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index 44acecfa75d..8c556af15f0 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -65,6 +65,7 @@ export const MODELS_FETCH_TIMEOUT_MS = 10 * 1000 */ export const HEADER_ORGANIZATIONID = "X-KILOCODE-ORGANIZATIONID" export const HEADER_TASKID = "X-KILOCODE-TASKID" +export const HEADER_PARENT_TASKID = "X-KILOCODE-PARENT-TASKID" export const HEADER_PROJECTID = "X-KILOCODE-PROJECTID" export const HEADER_TESTER = "X-KILOCODE-TESTER" export const HEADER_EDITORNAME = "X-KILOCODE-EDITORNAME" diff --git a/packages/kilo-gateway/src/headers.ts b/packages/kilo-gateway/src/headers.ts index e2446ae2353..aaddc274cb0 100644 --- a/packages/kilo-gateway/src/headers.ts +++ b/packages/kilo-gateway/src/headers.ts @@ -1,6 +1,7 @@ import { HEADER_ORGANIZATIONID, HEADER_TASKID, + HEADER_PARENT_TASKID, HEADER_PROJECTID, HEADER_TESTER, HEADER_EDITORNAME, @@ -21,6 +22,7 @@ import { */ export const X_KILOCODE_ORGANIZATIONID = HEADER_ORGANIZATIONID export const X_KILOCODE_TASKID = HEADER_TASKID +export const X_KILOCODE_PARENT_TASKID = HEADER_PARENT_TASKID export const X_KILOCODE_PROJECTID = HEADER_PROJECTID export const X_KILOCODE_TESTER = HEADER_TESTER export const X_KILOCODE_EDITORNAME = HEADER_EDITORNAME diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index cfd97e06e90..e84cb0c7dcf 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -100,6 +100,7 @@ export { MODELS_FETCH_TIMEOUT_MS, HEADER_ORGANIZATIONID, HEADER_TASKID, + HEADER_PARENT_TASKID, HEADER_PROJECTID, HEADER_TESTER, HEADER_EDITORNAME, diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 074c475b76f..fed131b9795 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,7 +22,13 @@ import { Auth } from "@/auth" // kilocode_change start import { DEFAULT_HEADERS } from "@/kilocode/const" import { getKiloProjectId } from "@/kilocode/project-id" -import { HEADER_FEATURE, HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway" +import { + HEADER_FEATURE, + HEADER_PARENT_TASKID, + HEADER_PROJECTID, + HEADER_MACHINEID, + HEADER_TASKID, +} from "@kilocode/kilo-gateway" import { Identity } from "@kilocode/kilo-telemetry" import { makeRuntime } from "@/effect/run-service" import { KiloSession } from "@/kilocode/session" @@ -421,7 +427,8 @@ const live: Layer.Layer< ...(isKilo && input.agent.name ? { "x-kilocode-mode": input.agent.name.toLowerCase() } : {}), ...(isKilo && kiloProjectId ? { [HEADER_PROJECTID]: kiloProjectId } : {}), ...(isKilo && machineId ? { [HEADER_MACHINEID]: machineId } : {}), - ...(isKilo ? { [HEADER_TASKID]: attr.rootID } : {}), + ...(isKilo ? { [HEADER_TASKID]: input.sessionID } : {}), + ...(isKilo && input.parentSessionID ? { [HEADER_PARENT_TASKID]: input.parentSessionID } : {}), ...(isKilo && attr.feature ? { [HEADER_FEATURE]: attr.feature } : {}), // kilocode_change end ...input.model.headers, From c1a797c1f127a84f152a51f6533d426894aa228a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 14 May 2026 11:26:28 -0300 Subject: [PATCH 23/60] fix(cli): chunk oversized compactions --- .../src/kilocode/session/compaction-chunks.ts | 344 +++++++++++ packages/opencode/src/kilocode/session/llm.ts | 52 ++ packages/opencode/src/session/compaction.ts | 73 ++- packages/opencode/src/session/llm.ts | 9 + packages/opencode/src/session/prompt.ts | 1 + .../session-compaction-chunks.test.ts | 582 ++++++++++++++++++ 6 files changed, 1045 insertions(+), 16 deletions(-) create mode 100644 packages/opencode/src/kilocode/session/compaction-chunks.ts create mode 100644 packages/opencode/src/kilocode/session/llm.ts create mode 100644 packages/opencode/test/kilocode/session-compaction-chunks.test.ts diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts new file mode 100644 index 00000000000..6f7cbda1aab --- /dev/null +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -0,0 +1,344 @@ +import { Effect } from "effect" +import type { Agent } from "@/agent/agent" +import type { Config } from "@/config/config" +import type { Provider } from "@/provider/provider" +import type { LLM } from "@/session/llm" +import { MessageV2 } from "@/session/message-v2" +import { usable } from "@/session/overflow" +import type { SessionProcessor } from "@/session/processor" +import { MessageID, PartID, type SessionID } from "@/session/schema" +import type { Session } from "@/session/session" +import { Token } from "@/util/token" +import * as Log from "@opencode-ai/core/util/log" + +type Update = (part: T) => Effect.Effect +type UpdateMessage = (msg: T) => Effect.Effect + +const log = Log.create({ service: "kilocode.compaction.chunks" }) +const TOOL_OUTPUT_MAX_CHARS = 2_000 +const TRANSCRIPT_MAX_CHARS = 16_000 +const RATIO = 0.6 +const CONCURRENCY = 3 +const DEPTH = 3 +const OUTPUT = 2_048 + +export namespace KiloCompactionChunks { + type Chunk = { + index: number + messages: MessageV2.WithParts[] + } + + type Output = { + result: SessionProcessor.Result + output: string | undefined + } + + type Deps = { + processors: SessionProcessor.Interface + session: Pick + } + + type Input = Deps & { + user: MessageV2.User + agent: Agent.Info + sessionID: SessionID + model: Provider.Model + cfg: Config.Info + messages: MessageV2.WithParts[] + prompt: string + target: MessageV2.Assistant + updateMessage: UpdateMessage + updatePart: Update + } + + type Replay = { + info: MessageV2.User + parts: MessageV2.Part[] + } + + export function eligible(input: { result: SessionProcessor.Result; error: MessageV2.Assistant["error"] }) { + if (input.result === "compact") return true + return input.result === "stop" && input.error?.name === "ContextOverflowError" + } + + export function needed(input: { cfg: Config.Info; model: Provider.Model; tokens: number }) { + // Apply 1.3x multiplier to token estimate to compensate for Token.estimate + // under-counting actual provider tokenizer counts by ~15-30%. + return Math.ceil(input.tokens * 1.3) + model(input.model).limit.output > usable({ cfg: input.cfg, model: model(input.model) }) + } + + export function replay(input: Input & { replay: Replay }) { + return Effect.gen(function* () { + const chunk: Chunk = { + index: 0, + messages: [{ info: input.replay.info, parts: input.replay.parts }], + } + const size = budget({ cfg: input.cfg, model: input.model }) + if (!(yield* large({ messages: chunk.messages, model: input.model, size }))) return input.replay + const result = yield* summarize({ ...input, chunk, total: 1 }) + if (result.result !== "continue" || !result.output) return input.replay + return { + info: input.replay.info, + parts: [ + { + id: PartID.ascending(), + messageID: input.replay.info.id, + sessionID: input.sessionID, + type: "text" as const, + synthetic: true, + text: [ + "The original replayed request was too large to send after compaction.", + "Use this compacted representation of that request instead:", + result.output, + ].join("\n\n"), + }, + ], + } satisfies Replay + }) + } + + function budget(input: { cfg: Config.Info; model: Provider.Model }) { + return Math.max(1_000, Math.floor(usable({ cfg: input.cfg, model: model(input.model) }) * RATIO)) + } + + function model(input: Provider.Model) { + return { + ...input, + limit: { + ...input.limit, + output: Math.min(input.limit.output, OUTPUT), + }, + } satisfies Provider.Model + } + + function large(input: { messages: MessageV2.WithParts[]; model: Provider.Model; size: number }) { + return estimate({ messages: input.messages, model: input.model }).pipe(Effect.map((count) => count > input.size)) + } + + function estimate(input: { messages: MessageV2.WithParts[]; model: Provider.Model }) { + return Effect.gen(function* () { + const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) + return Token.estimate(JSON.stringify(msgs)) + }) + } + + export function split(input: { messages: MessageV2.WithParts[]; model: Provider.Model; size: number }) { + return Effect.gen(function* () { + const chunks: Chunk[] = [] + let buf: MessageV2.WithParts[] = [] + for (const msg of input.messages) { + const next = [...buf, msg] + const size = yield* estimate({ messages: next, model: input.model }) + if (buf.length && size > input.size) { + chunks.push({ index: chunks.length, messages: buf }) + buf = [msg] + continue + } + buf = next + } + if (buf.length) chunks.push({ index: chunks.length, messages: buf }) + return chunks + }) + } + + function text(msg: MessageV2.Assistant, parts: MessageV2.Part[]) { + return parts + .filter((part): part is MessageV2.TextPart => part.type === "text" && part.messageID === msg.id) + .map((part) => part.text.trim()) + .filter(Boolean) + .join("\n\n") + .trim() + } + + function clip(input: { text: string; chars: number; label: string }) { + if (input.text.length <= input.chars) return input.text + const cut = input.text.length - input.chars + return `${input.text.slice(0, input.chars)}\n[${input.label} truncated for compaction: omitted ${cut} chars]` + } + + function part(part: MessageV2.Part) { + if (part.type === "text") return clip({ text: part.text, chars: TRANSCRIPT_MAX_CHARS, label: "Text" }) + if (part.type === "reasoning") return `[Reasoning]: ${clip({ text: part.text, chars: TRANSCRIPT_MAX_CHARS, label: "Reasoning" })}` + if (part.type === "file") return `[File attachment]: ${part.filename ?? part.url} (${part.mime})` + if (part.type === "agent") return `[Agent]: ${part.name}` + if (part.type === "subtask") return `[Subtask ${part.agent}]: ${part.description}\n${clip({ text: part.prompt, chars: TRANSCRIPT_MAX_CHARS, label: "Subtask prompt" })}` + if (part.type === "tool") { + const head = `[Tool ${part.tool} ${part.state.status}]` + if (part.state.status === "completed") { + return [ + head, + `input: ${clip({ text: JSON.stringify(part.state.input), chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool input" })}`, + `output: ${clip({ text: part.state.output, chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool output" })}`, + ].join("\n") + } + if (part.state.status === "error") return `${head}\n${clip({ text: part.state.error, chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool error" })}` + return `${head}\ninput: ${clip({ text: JSON.stringify(part.state.input), chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool input" })}` + } + if (part.type === "step-finish") return `[Step finished]: ${part.reason}` + if (part.type === "compaction") return "[Compaction requested]" + return `[${part.type}]` + } + + function transcript(input: { messages: MessageV2.WithParts[] }) { + return input.messages + .map((msg, index) => { + const body = msg.parts + .map(part) + .filter(Boolean) + .join("\n\n") + return [``, body || "[no content]", ""].join("\n") + }) + .join("\n\n") + } + + function prompt(input: { chunk: Chunk; total: number }) { + return [ + `Summarize conversation chunk ${input.chunk.index + 1} of ${input.total}.`, + "Only summarize facts present in this chunk.", + "Preserve concrete file paths, commands, errors, decisions, and unresolved tasks.", + "Use terse Markdown bullets. Do not mention chunking or compaction.", + ].join("\n") + } + + function messages(input: { summaries: string[] }) { + return input.summaries.map((summary, index) => ({ + role: "user" as const, + content: [ + { + type: "text" as const, + text: [``, summary, ""].join("\n"), + }, + ], + })) + } + + function assistant(input: { base: MessageV2.Assistant; sessionID: SessionID }) { + return { + ...input.base, + id: MessageID.ascending(), + parentID: input.base.parentID, + sessionID: input.sessionID, + cost: 0, + tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + finish: undefined, + error: undefined, + } satisfies MessageV2.Assistant + } + + function run(input: Input & { data: LLM.StreamInput["messages"]; text: string }) { + return Effect.gen(function* () { + const msg = yield* input.session.updateMessage(assistant({ base: input.target, sessionID: input.sessionID })) + const mdl = model(input.model) + const worker = yield* input.processors.create({ assistantMessage: msg, sessionID: input.sessionID, model: mdl }) + const opts = input.agent.options + input.agent.options = { + ...opts, + maxOutputTokens: Math.min( + OUTPUT, + typeof opts?.maxOutputTokens === "number" ? opts.maxOutputTokens : OUTPUT, + ), + } + const result = yield* worker.process({ + user: input.user, + agent: input.agent, + sessionID: input.sessionID, + tools: {}, + system: [], + messages: [...input.data, { role: "user", content: [{ type: "text", text: input.text }] }], + model: mdl, + }).pipe(Effect.ensuring(Effect.sync(() => { input.agent.options = opts }))) + const parts = MessageV2.parts(worker.message.id) + const output = text(worker.message, parts) + yield* input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore) + if (result !== "continue") return { result, output: undefined } + if (!output) return { result: "stop" as const, output: undefined } + return { result, output } + }) + } + + function summarize(input: Input & { chunk: Chunk; total: number }) { + return Effect.gen(function* () { + const size = budget({ cfg: input.cfg, model: input.model }) + const data = (yield* large({ messages: input.chunk.messages, model: input.model, size })) + ? [ + { + role: "user" as const, + content: [ + { + type: "text" as const, + text: [ + "The following compacted transcript represents an oversized conversation chunk.", + "Summarize only facts present in the transcript.", + "", + transcript({ messages: input.chunk.messages }), + "", + ].join("\n"), + }, + ], + }, + ] + : yield* MessageV2.toModelMessagesEffect(input.chunk.messages, input.model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) + return yield* run({ ...input, data, text: prompt({ chunk: input.chunk, total: input.total }) }) + }) + } + + function reduce(input: Input & { summaries: string[]; depth: number }): Effect.Effect { + return Effect.gen(function* () { + const result = yield* run({ ...input, data: messages({ summaries: input.summaries }), text: input.prompt }) + if (result.result === "continue") return result + if (input.depth >= DEPTH || input.summaries.length <= 1) return result + + const size = Math.ceil(input.summaries.length / 2) + const groups = Array.from({ length: Math.ceil(input.summaries.length / size) }, (_, index) => + input.summaries.slice(index * size, index * size + size), + ) + const next: Output[] = yield* Effect.forEach( + groups, + (group) => reduce({ ...input, summaries: group, depth: input.depth + 1 }), + { concurrency: 1 }, + ) + if (next.some((item) => item.result !== "continue" || !item.output)) return result + return yield* reduce({ ...input, summaries: next.map((item) => item.output!), depth: input.depth + 1 }) + }) + } + + export function process(input: Input) { + return Effect.gen(function* () { + const size = budget({ cfg: input.cfg, model: input.model }) + const chunks = yield* split({ messages: input.messages, model: input.model, size }) + log.info("fallback", { chunks: chunks.length, concurrency: CONCURRENCY }) + + const partial = yield* Effect.forEach( + chunks, + (chunk) => summarize({ ...input, chunk, total: chunks.length }), + { concurrency: Math.min(CONCURRENCY, chunks.length) }, + ) + if (partial.some((item) => item.result !== "continue" || !item.output)) return "compact" as const + + const final = chunks.length === 1 && (yield* large({ messages: chunks[0].messages, model: input.model, size })) + ? partial[0] + : yield* reduce({ ...input, summaries: partial.map((item) => item.output!), depth: 0 }) + if (!final || final.result !== "continue" || !final.output) return "compact" as const + + yield* input.updatePart({ + id: PartID.ascending(), + messageID: input.target.id, + sessionID: input.sessionID, + type: "text", + text: final.output, + }) + input.target.finish = "stop" + input.target.error = undefined + input.target.time.completed = Date.now() + yield* input.updateMessage(input.target) + return "continue" as const + }) + } +} diff --git a/packages/opencode/src/kilocode/session/llm.ts b/packages/opencode/src/kilocode/session/llm.ts new file mode 100644 index 00000000000..f72e0df9ced --- /dev/null +++ b/packages/opencode/src/kilocode/session/llm.ts @@ -0,0 +1,52 @@ +import type { ModelMessage } from "ai" +import type { Provider } from "@/provider/provider" +import { Token } from "@/util/token" + +// Token.estimate consistently under-counts by ~15-30% vs. actual provider tokenizers. +// Multiply all estimates by this factor and add a fixed safety margin to compensate. +const ESTIMATE_FACTOR = 1.3 +const SAFETY = 2048 +const MIN_OUTPUT = 1024 + +export namespace KiloLLM { + /** + * Caps `maxOutputTokens` to fit within the model's context window after + * accounting for the actual estimated input tokens (messages + tool schemas). + * + * Many small models (e.g. qwen 7B, 32K context) ship with a default + * max_output of 32K, leaving no room for input once tools are included. + * This prevents the provider from rejecting the request with a context + * overflow error. + */ + export function capOutputTokens(input: { + model: Provider.Model + messages: ModelMessage[] + tools: Record + configured: number | undefined + }): number | undefined { + if (!input.configured) return input.configured + const { context } = input.model.limit + if (!context) return input.configured + + const msgTokens = Math.ceil(Token.estimate(JSON.stringify(input.messages)) * ESTIMATE_FACTOR) + const toolTokens = Math.ceil( + Token.estimate( + JSON.stringify( + Object.entries(input.tools).map(([name, t]) => ({ + name, + description: t.description, + inputSchema: t.inputSchema, + })), + ), + ) * ESTIMATE_FACTOR, + ) + + const available = context - msgTokens - toolTokens - SAFETY + // If available is ≤0 the input alone exceeds context — return the original + // value so the provider returns a natural overflow error which triggers + // compaction (compactionAttempts guard stops the loop eventually). + if (available <= 0) return input.configured + if (available >= input.configured) return input.configured + return Math.max(MIN_OUTPUT, available) + } +} diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index e96332bcbfb..81811af3d39 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -20,6 +20,7 @@ import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloCompactionPayloadRecovery } from "@/kilocode/session/compaction-payload-recovery" // kilocode_change +import { KiloCompactionChunks } from "@/kilocode/session/compaction-chunks" // kilocode_change const log = Log.create({ service: "session.compaction" }) @@ -416,6 +417,7 @@ export const layer: Layer.Layer< stripMedia: true, toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, }) + const tokens = Token.estimate(JSON.stringify(modelMessages)) // kilocode_change const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -450,21 +452,41 @@ export const layer: Layer.Layer< model, }) // kilocode_change start - const result = yield* KiloCompactionPayloadRecovery.process({ - processor, - user: userMessage, - agent, - sessionID: input.sessionID, - model, - messages: modelMessages, - prompt: nextPrompt, - recovery: selected.head, - updateMessage: session.updateMessage, - updatePart: session.updatePart, - }) + const result = KiloCompactionChunks.needed({ cfg, model, tokens }) + ? "compact" + : yield* KiloCompactionPayloadRecovery.process({ + processor, + user: userMessage, + agent, + sessionID: input.sessionID, + model, + messages: modelMessages, + prompt: nextPrompt, + recovery: selected.head, + updateMessage: session.updateMessage, + updatePart: session.updatePart, + }) // kilocode_change end - if (result === "compact") { + // kilocode_change start - fallback to chunked compaction when the first summary overflows + const fallback = KiloCompactionChunks.eligible({ result, error: processor.message.error ?? processor.compactError?.() }) + ? yield* KiloCompactionChunks.process({ + processors, + session, + user: userMessage, + agent, + sessionID: input.sessionID, + model, + cfg, + messages: selected.head, + prompt: nextPrompt, + target: processor.message, + updateMessage: session.updateMessage, + updatePart: session.updatePart, + }) + : result + if (fallback === "compact") { + // kilocode_change end processor.message.error = new MessageV2.ContextOverflowError({ message: replay ? "Conversation history too large to compact - exceeds model context limit" @@ -482,8 +504,25 @@ export const layer: Layer.Layer< }) } - if (result === "continue" && input.auto) { + if (fallback === "continue" && input.auto) { // kilocode_change if (replay) { + // kilocode_change start - compact oversized replay turns instead of looping into replay overflow + replay = yield* KiloCompactionChunks.replay({ + processors, + session, + user: userMessage, + agent, + sessionID: input.sessionID, + model, + cfg, + messages: selected.head, + prompt: nextPrompt, + target: processor.message, + updateMessage: session.updateMessage, + updatePart: session.updatePart, + replay, + }) + // kilocode_change end const original = replay.info const replayMsg = yield* session.updateMessage({ id: MessageID.ascending(), @@ -496,6 +535,7 @@ export const layer: Layer.Layer< tools: original.tools, system: original.system, }) + KiloSessionPromptQueue.retarget(input.sessionID, replayMsg.id) // kilocode_change - expose replay to scope() for (const part of replay.parts) { if (part.type === "compaction") continue const replayPart = @@ -539,6 +579,7 @@ export const layer: Layer.Layer< agent: userMessage.agent, model: userMessage.model, }) + KiloSessionPromptQueue.retarget(input.sessionID, continueMsg.id) // kilocode_change - expose auto-continue to scope() const text = (input.overflow ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" @@ -566,12 +607,12 @@ export const layer: Layer.Layer< // kilocode_change start - compaction already invalidates cache, so collapse stale tool outputs too if (processor.message.error) return "stop" - if (result === "continue") { + if (fallback === "continue") { // kilocode_change yield* prune({ sessionID: input.sessionID, reason: "post-compaction" }) yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) } // kilocode_change end - return result + return fallback // kilocode_change }) const create = Effect.fn("SessionCompaction.create")(function* (input: { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 7cdb5e85c05..b509aff5300 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -25,6 +25,7 @@ import { getKiloProjectId } from "@/kilocode/project-id" import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway" import { Identity } from "@kilocode/kilo-telemetry" import { makeRuntime } from "@/effect/run-service" +import { KiloLLM } from "@/kilocode/session/llm" // kilocode_change end import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -223,6 +224,14 @@ const live: Layer.Layer< // kilocode_change end const tools = resolveTools(input) + // kilocode_change start - cap maxOutputTokens to fit within context after estimating real input size + params.maxOutputTokens = KiloLLM.capOutputTokens({ + model: input.model, + messages, + tools, + configured: params.maxOutputTokens, + }) + // kilocode_change end // LiteLLM and some Anthropic proxies require the tools parameter to be present // when message history contains tool calls, even if no tools are being used. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8564c2273f2..2f8eb529241 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1387,6 +1387,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") if (task && !lastFinished) tasks.push(...task) } + // kilocode_change end if (!lastUser) throw new Error("No user message found in stream. This should never happen.") diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts new file mode 100644 index 00000000000..e48d59f6c47 --- /dev/null +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -0,0 +1,582 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { Effect, Layer, ManagedRuntime } from "effect" +import * as Stream from "effect/Stream" +import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import { Instance } from "../../src/project/instance" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Snapshot } from "../../src/snapshot" +import { KiloCompactionChunks } from "../../src/kilocode/session/compaction-chunks" +import { LLM } from "../../src/session/llm" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionCompaction } from "../../src/session/compaction" +import * as SessionProcessorModule from "../../src/session/processor" +import type { SessionProcessor } from "../../src/session/processor" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { Session as SessionNs } from "../../src/session/session" +import { SessionStatus } from "../../src/session/status" +import { SessionSummary } from "../../src/session/summary" +import { ProviderTest } from "../fake/provider" +import { tmpdir } from "../fixture/fixture" + +const providerID = ProviderID.make("test") +const modelID = ModelID.make("test-model") +const ref = { providerID, modelID } + +function run(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + create(input?: SessionNs.CreateInput) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, + messages(input: Parameters[0]) { + return run(SessionNs.Service.use((svc) => svc.messages(input))) + }, + updateMessage(msg: T) { + return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) + }, + updatePart(part: T) { + return run(SessionNs.Service.use((svc) => svc.updatePart(part))) + }, +} + +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + +async function user(sessionID: SessionID, text: string) { + const msg = await svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + await svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +} + +async function assistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: root, root }, + cost: 0, + tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID, + providerID, + parentID, + time: { created: Date.now() }, + finish: "stop", + } + await svc.updateMessage(msg) + await svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +} + +function llm() { + const queue: Array< + Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) + > = [] + + return { + push(stream: Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream)) { + queue.push(stream) + }, + layer: Layer.succeed( + LLM.Service, + LLM.Service.of({ + stream: (input) => { + const item = queue.shift() ?? Stream.empty + const stream = typeof item === "function" ? item(input) : item + return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event))) + }, + raw: () => Effect.die("raw not implemented in test LLM"), + }), + ), + } +} + +function reply(text: string, capture?: (input: LLM.StreamInput) => void) { + return (input: LLM.StreamInput) => { + capture?.(input) + return Stream.make( + { type: "start" } as LLM.Event, + { type: "text-start", id: "txt-0" } as LLM.Event, + { type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event, + { type: "text-end", id: "txt-0" } as LLM.Event, + { + type: "finish-step", + finishReason: "stop", + rawFinishReason: "stop", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + } as LLM.Event, + { + type: "finish", + finishReason: "stop", + rawFinishReason: "stop", + totalUsage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + } as LLM.Event, + ) + } +} + +function overflow() { + return Stream.make( + { type: "start" } as LLM.Event, + { + type: "finish-step", + finishReason: "stop", + rawFinishReason: "stop", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 20_000, + outputTokens: 1, + totalTokens: 20_001, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + } as LLM.Event, + { + type: "finish", + finishReason: "stop", + rawFinishReason: "stop", + totalUsage: { + inputTokens: 20_000, + outputTokens: 1, + totalTokens: 20_001, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + } as LLM.Event, + ) +} + +function runtime(layer: Layer.Layer, context = 7_000) { + const bus = Bus.layer + const status = SessionStatus.layer.pipe(Layer.provide(bus)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) + const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } }) + return ManagedRuntime.make( + Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( + Layer.provide(ProviderTest.fake({ model }).layer), + Layer.provide(SessionNs.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(layer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(status), + Layer.provide(bus), + Layer.provide(Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }) })), + ), + ) +} + +function fakeRuntime() { + const calls: string[] = [] + const outputs: number[] = [] + const bus = Bus.layer + const processor = Layer.effect( + SessionProcessorModule.SessionProcessor.Service, + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + return SessionProcessorModule.SessionProcessor.Service.of({ + create: Effect.fn("TestSessionProcessor.create")((input) => + Effect.succeed({ + get message() { + return input.assistantMessage + }, + updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)), + completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void), + process: Effect.fn("TestSessionProcessor.process")((stream: LLM.StreamInput) => + Effect.gen(function* () { + outputs.push(input.model.limit.output) + calls.push(JSON.stringify(stream.messages)) + const text = stream.messages.some((msg) => JSON.stringify(msg).includes("Create a new anchored summary")) + ? "final summary" + : calls.length === 1 + ? "chunk one" + : "chunk two" + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "text", + text, + }) + input.assistantMessage.finish = "stop" + return "continue" as const + }), + ), + } satisfies SessionProcessor.Handle), + ), + }) + }), + ) + const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 10_000, output: 1_000 } }) + return { + calls, + outputs, + rt: ManagedRuntime.make( + Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus).pipe( + Layer.provide(ProviderTest.fake({ model }).layer), + Layer.provide(SessionNs.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(bus), + Layer.provide( + Layer.mock(Config.Service)({ + get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }), + }), + ), + ), + ), + } +} + +function liveRuntime(layer: Layer.Layer, context = 10_000) { + const bus = Bus.layer + const status = SessionStatus.layer.pipe(Layer.provide(bus)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) + const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } }) + return ManagedRuntime.make( + Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( + Layer.provide(ProviderTest.fake({ model }).layer), + Layer.provide(SessionNs.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(layer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(status), + Layer.provide(bus), + Layer.provide( + Layer.mock(Config.Service)({ + get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }), + }), + ), + ), + ) +} + +afterEach(() => { + mock.restore() +}) + +describe("KiloCompactionChunks", () => { + test("splits oversized history into chronological chunks", async () => { + const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 7_000, output: 1_000 } }) + const sessionID = SessionID.make("ses_chunks_split") + const messages: MessageV2.WithParts[] = Array.from({ length: 4 }, (_, index) => ({ + info: { + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }, + parts: [ + { + id: PartID.ascending(), + messageID: MessageID.ascending(), + sessionID, + type: "text", + text: `${index}: ${"x".repeat(8_000)}`, + }, + ], + })) + + const chunks = await Effect.runPromise(KiloCompactionChunks.split({ messages, model, size: 2_000 })) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.flatMap((chunk) => chunk.messages.map((msg) => msg.info.id))).toEqual(messages.map((msg) => msg.info.id)) + }) + + test("falls back to chunk workers after the first compaction overflows", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const first = await user(session.id, "first " + "a".repeat(10_000)) + await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000)) + const second = await user(session.id, "second " + "c".repeat(10_000)) + await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000)) + await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + + const { rt, calls } = fakeRuntime() + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const all = await svc.messages({ sessionID: session.id }) + const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary) + const parts = summaries.flatMap((msg) => msg.parts).filter((part): part is MessageV2.TextPart => part.type === "text") + + expect(result).toBe("continue") + expect(calls.length).toBeGreaterThanOrEqual(1) + expect(calls.at(-1)).toContain("Create a new anchored summary") + expect(summaries).toHaveLength(1) + expect(parts.map((part) => part.text)).toEqual(["final summary"]) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("uses chunk fallback before sending oversized normal compaction", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const first = await user(session.id, "first " + "a".repeat(10_000)) + await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000)) + const second = await user(session.id, "second " + "c".repeat(10_000)) + await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000)) + await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + + const { rt, calls } = fakeRuntime() + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(result).toBe("continue") + expect(calls[0]).toContain("Summarize conversation chunk") + expect(calls[0]).not.toContain("Create a new anchored summary") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("uses a worker even when fallback selection produces one oversized chunk", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const first = await user(session.id, "first " + "a".repeat(20_000)) + await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000)) + await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + + const { rt, calls } = fakeRuntime() + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const all = await svc.messages({ sessionID: session.id }) + const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary) + const parts = summaries.flatMap((msg) => msg.parts).filter((part): part is MessageV2.TextPart => part.type === "text") + + expect(result).toBe("continue") + expect(calls.length).toBeGreaterThan(0) + expect(calls[0]).toContain("Summarize conversation chunk") + expect(summaries).toHaveLength(1) + expect(parts.map((part) => part.text)).toEqual(["final summary"]) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("serializes oversized fallback chunks before summarizing", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const first = await user(session.id, "single huge request " + "a".repeat(80_000)) + await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + + const { rt, calls } = fakeRuntime() + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(result).toBe("continue") + expect(calls[0]).toContain("compacted transcript") + expect(calls[0]).toContain("Text truncated for compaction") + expect(calls[0]).toContain("Summarize conversation chunk") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("caps worker output budget below oversized model output limit", async () => { + const { rt, calls, outputs } = fakeRuntime() + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const first = await user(session.id, "first " + "a".repeat(1_000)) + await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(1_000)) + await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(result).toBe("continue") + expect(calls.length).toBeGreaterThan(0) + expect(outputs.every((value) => value <= 2_048)).toBe(true) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("compacts oversized replay turns after overflow compaction", async () => { + const stub = llm() + const calls: string[] = [] + stub.push(reply("history summary")) + stub.push(reply("replay summary", (input) => calls.push(JSON.stringify(input.messages)))) + + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const old = await user(session.id, "old context") + await assistant(session.id, old.id, tmp.path, "old reply") + const large = await user(session.id, "large replay " + "x".repeat(40_000)) + await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: true, overflow: true }) + + const rt = liveRuntime(stub.layer) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }), + ), + ) + + const all = await svc.messages({ sessionID: session.id }) + const replay = all.findLast((msg) => msg.info.role === "user" && msg.info.id !== large.id) + const part = replay?.parts.find((part): part is MessageV2.TextPart => part.type === "text") + + expect(result).toBe("continue") + expect(calls).toHaveLength(1) + expect(calls[0]).toContain("Summarize conversation chunk 1 of 1") + expect(part?.text).toContain("compacted representation") + expect(part?.text).toContain("replay summary") + } finally { + await rt.dispose() + } + }, + }) + }) +}) From 1d80231da0080db70d95b58282e947221a3ca541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 14 May 2026 11:40:15 -0300 Subject: [PATCH 24/60] refactor: address the comments --- .../src/kilocode/session/compaction-chunks.ts | 26 ++++++++++++------- packages/opencode/src/kilocode/session/llm.ts | 3 ++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts index 6f7cbda1aab..68be798778d 100644 --- a/packages/opencode/src/kilocode/session/compaction-chunks.ts +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -235,25 +235,31 @@ export namespace KiloCompactionChunks { const mdl = model(input.model) const worker = yield* input.processors.create({ assistantMessage: msg, sessionID: input.sessionID, model: mdl }) const opts = input.agent.options - input.agent.options = { - ...opts, - maxOutputTokens: Math.min( - OUTPUT, - typeof opts?.maxOutputTokens === "number" ? opts.maxOutputTokens : OUTPUT, - ), + const agent = { + ...input.agent, + options: { + ...opts, + maxOutputTokens: Math.min( + OUTPUT, + typeof opts?.maxOutputTokens === "number" ? opts.maxOutputTokens : OUTPUT, + ), + }, } const result = yield* worker.process({ user: input.user, - agent: input.agent, + agent, sessionID: input.sessionID, tools: {}, system: [], messages: [...input.data, { role: "user", content: [{ type: "text", text: input.text }] }], model: mdl, - }).pipe(Effect.ensuring(Effect.sync(() => { input.agent.options = opts }))) + }).pipe( + Effect.ensuring( + input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore), + ), + ) const parts = MessageV2.parts(worker.message.id) const output = text(worker.message, parts) - yield* input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore) if (result !== "continue") return { result, output: undefined } if (!output) return { result: "stop" as const, output: undefined } return { result, output } @@ -305,7 +311,7 @@ export namespace KiloCompactionChunks { { concurrency: 1 }, ) if (next.some((item) => item.result !== "continue" || !item.output)) return result - return yield* reduce({ ...input, summaries: next.map((item) => item.output!), depth: input.depth + 1 }) + return yield* reduce({ ...input, summaries: next.map((item) => item.output!), depth: input.depth + 2 }) }) } diff --git a/packages/opencode/src/kilocode/session/llm.ts b/packages/opencode/src/kilocode/session/llm.ts index f72e0df9ced..ce58b4681ef 100644 --- a/packages/opencode/src/kilocode/session/llm.ts +++ b/packages/opencode/src/kilocode/session/llm.ts @@ -24,7 +24,8 @@ export namespace KiloLLM { tools: Record configured: number | undefined }): number | undefined { - if (!input.configured) return input.configured + if (input.configured == null) return input.configured + if (input.configured <= 0) return undefined const { context } = input.model.limit if (!context) return input.configured From 0f1626420f9da37dc16cf346a2405599be60c8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 14 May 2026 12:04:19 -0300 Subject: [PATCH 25/60] fix: tests --- .../opencode/src/kilocode/session/compaction-chunks.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts index 68be798778d..1e29b08a717 100644 --- a/packages/opencode/src/kilocode/session/compaction-chunks.ts +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -253,13 +253,10 @@ export namespace KiloCompactionChunks { system: [], messages: [...input.data, { role: "user", content: [{ type: "text", text: input.text }] }], model: mdl, - }).pipe( - Effect.ensuring( - input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore), - ), - ) + }) const parts = MessageV2.parts(worker.message.id) const output = text(worker.message, parts) + yield* input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore) if (result !== "continue") return { result, output: undefined } if (!output) return { result: "stop" as const, output: undefined } return { result, output } From fe969a18e3999a6468ab02a54b6cc6ce52550911 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 14 May 2026 17:52:01 +0200 Subject: [PATCH 26/60] fix(vscode): update speech capture tooltip (#10266) --- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/br.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/da.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/de.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/en.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/es.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/no.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/th.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index d2dc0c3988d..ad481ca0ab6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -279,7 +279,7 @@ export const dict = { "زر «حسّن الموجه» يطوّر موجهك بإضافة سياق أو توضيح أو إعادة صياغة. جرّب اكتب موجه هنا ثم اضغط الزر مرة ثانية وشوف النتيجة.", "speechToText.tooltip.start": "بدء الإدخال الصوتي", - "speechToText.tooltip.stop": "إيقاف التسجيل", + "speechToText.tooltip.stop": "إيقاف التقاط الصوت", "speechToText.tooltip.transcribing": "جاري تحويل الصوت إلى نص... انقر للإلغاء.", "speechToText.tooltip.error": "فشل الإدخال الصوتي. انقر للمسح.", "speechToText.error.title": "فشل الإدخال الصوتي", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 85d42ea5dee..143ab9c7667 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -283,7 +283,7 @@ export const dict = { "prompt.action.indexing": "Configurações de indexação", "speechToText.tooltip.start": "Iniciar entrada de voz", - "speechToText.tooltip.stop": "Parar gravação", + "speechToText.tooltip.stop": "Parar captura", "speechToText.tooltip.transcribing": "Transcrevendo... Clique para cancelar.", "speechToText.tooltip.error": "Falha na entrada de voz. Clique para limpar.", "speechToText.error.title": "Falha na entrada de voz", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 566e6ef1917..a2740abea73 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -283,7 +283,7 @@ export const dict = { "Dugme 'Poboljšaj prompt' pomaže poboljšati vaš zahtjev pružajući dodatni kontekst, pojašnjenje ili preformulaciju. Pokušajte upisati zahtjev ovdje i ponovo kliknite na dugme da vidite kako funkcioniše.", "speechToText.tooltip.start": "Započni glasovni unos", - "speechToText.tooltip.stop": "Zaustavi snimanje", + "speechToText.tooltip.stop": "Zaustavi hvatanje zvuka", "speechToText.tooltip.transcribing": "Prepisivanje... Kliknite da otkažete.", "speechToText.tooltip.error": "Glasovni unos nije uspio. Kliknite da očistite.", "speechToText.error.title": "Glasovni unos nije uspio", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 11be8693f0b..1f63f7dfc9b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -282,7 +282,7 @@ export const dict = { "Knappen 'Forbedr prompt' hjælper med at forbedre din forespørgsel ved at give ekstra kontekst, præcisering eller omformulering. Prøv at skrive en forespørgsel her og klik på knappen igen for at se hvordan det virker.", "speechToText.tooltip.start": "Start stemmeinput", - "speechToText.tooltip.stop": "Stop optagelse", + "speechToText.tooltip.stop": "Stop lydoptagelse", "speechToText.tooltip.transcribing": "Transskriberer... Klik for at annullere.", "speechToText.tooltip.error": "Stemmeinput mislykkedes. Klik for at rydde.", "speechToText.error.title": "Stemmeinput mislykkedes", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 75adb9802bb..a619e8ce4e0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -286,7 +286,7 @@ export const dict = { "Die Schaltfläche 'Prompt verbessern' hilft, deine Anfrage durch zusätzlichen Kontext, Klarstellungen oder Umformulierungen zu verbessern. Versuche, hier eine Anfrage einzugeben und klicke erneut auf die Schaltfläche, um zu sehen, wie es funktioniert.", "speechToText.tooltip.start": "Spracheingabe starten", - "speechToText.tooltip.stop": "Aufnahme beenden", + "speechToText.tooltip.stop": "Audioerfassung beenden", "speechToText.tooltip.transcribing": "Transkribieren... Zum Abbrechen klicken.", "speechToText.tooltip.error": "Spracheingabe fehlgeschlagen. Zum Löschen klicken.", "speechToText.error.title": "Spracheingabe fehlgeschlagen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index aaf39dbf892..950febc9ad6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -281,7 +281,7 @@ export const dict = { "prompt.action.enhanceDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.", "speechToText.tooltip.start": "Start voice input", - "speechToText.tooltip.stop": "Stop recording", + "speechToText.tooltip.stop": "Stop capturing", "speechToText.tooltip.transcribing": "Transcribing... Click to cancel.", "speechToText.tooltip.error": "Speech input failed. Click to clear.", "speechToText.error.title": "Speech input failed", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 3e875cc6362..15ced4745b9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -284,7 +284,7 @@ export const dict = { "prompt.action.indexing": "Configuración de indexación", "speechToText.tooltip.start": "Iniciar entrada de voz", - "speechToText.tooltip.stop": "Detener grabación", + "speechToText.tooltip.stop": "Detener captura", "speechToText.tooltip.transcribing": "Transcribiendo... Haz clic para cancelar.", "speechToText.tooltip.error": "Falló la entrada de voz. Haz clic para borrar.", "speechToText.error.title": "Falló la entrada de voz", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index b06fc1d6ff5..c474ff84f5a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -285,7 +285,7 @@ export const dict = { "prompt.action.indexing": "Paramètres d'indexation", "speechToText.tooltip.start": "Démarrer la saisie vocale", - "speechToText.tooltip.stop": "Arrêter l'enregistrement", + "speechToText.tooltip.stop": "Arrêter la capture audio", "speechToText.tooltip.transcribing": "Transcription en cours... Cliquez pour annuler.", "speechToText.tooltip.error": "La saisie vocale a échoué. Cliquez pour effacer.", "speechToText.error.title": "La saisie vocale a échoué", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 41f791fd1a0..9c001c31ec6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -280,7 +280,7 @@ export const dict = { "prompt.action.indexing": "インデックス設定", "speechToText.tooltip.start": "音声入力を開始", - "speechToText.tooltip.stop": "録音を停止", + "speechToText.tooltip.stop": "音声キャプチャを停止", "speechToText.tooltip.transcribing": "文字起こし中... クリックしてキャンセル。", "speechToText.tooltip.error": "音声入力に失敗しました。クリックしてクリア。", "speechToText.error.title": "音声入力に失敗しました", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 77f8e454a33..a5f1664fcb2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -282,7 +282,7 @@ export const dict = { "'프롬프트 향상' 버튼은 추가 컨텍스트, 명확화 또는 재구성을 제공하여 요청을 개선합니다. 여기에 요청을 입력한 다음 버튼을 다시 클릭하여 작동 방식을 확인해보세요.", "speechToText.tooltip.start": "음성 입력 시작", - "speechToText.tooltip.stop": "녹음 중지", + "speechToText.tooltip.stop": "음성 캡처 중지", "speechToText.tooltip.transcribing": "변환 중... 취소하려면 클릭하세요.", "speechToText.tooltip.error": "음성 입력에 실패했습니다. 지우려면 클릭하세요.", "speechToText.error.title": "음성 입력 실패", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 8e67956970d..c5c32cdb92b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -284,7 +284,7 @@ export const dict = { "De knop 'Prompt verbeteren' helpt je prompt te verbeteren door extra context, verduidelijking of herformulering te bieden. Typ hier een prompt en klik nogmaals op de knop om te zien hoe het werkt.", "speechToText.tooltip.start": "Spraakinvoer starten", - "speechToText.tooltip.stop": "Opname stoppen", + "speechToText.tooltip.stop": "Audio vastleggen stoppen", "speechToText.tooltip.transcribing": "Transcriberen... Klik om te annuleren.", "speechToText.tooltip.error": "Spraakinvoer mislukt. Klik om te wissen.", "speechToText.error.title": "Spraakinvoer mislukt", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index de692b32a7a..faaf2c2f17d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -286,7 +286,7 @@ export const dict = { "Knappen 'Forbedre prompt' hjelper med å forbedre forespørselen din ved å gi ekstra kontekst, avklaring eller omformulering. Prøv å skrive en forespørsel her og klikk på knappen igjen for å se hvordan det fungerer.", "speechToText.tooltip.start": "Start taleinndata", - "speechToText.tooltip.stop": "Stopp opptak", + "speechToText.tooltip.stop": "Stopp lydfangst", "speechToText.tooltip.transcribing": "Transkriberer... Klikk for å avbryte.", "speechToText.tooltip.error": "Taleinndata mislyktes. Klikk for å tømme.", "speechToText.error.title": "Taleinndata mislyktes", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 3dff2197358..84aa8bc3450 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -283,7 +283,7 @@ export const dict = { "prompt.action.indexing": "Ustawienia indeksowania", "speechToText.tooltip.start": "Rozpocznij wprowadzanie głosowe", - "speechToText.tooltip.stop": "Zatrzymaj nagrywanie", + "speechToText.tooltip.stop": "Zatrzymaj przechwytywanie dźwięku", "speechToText.tooltip.transcribing": "Transkrybowanie... Kliknij, aby anulować.", "speechToText.tooltip.error": "Wprowadzanie głosowe nie powiodło się. Kliknij, aby wyczyścić.", "speechToText.error.title": "Wprowadzanie głosowe nie powiodło się", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index ec455981d62..24fc4f38eee 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -281,7 +281,7 @@ export const dict = { "Кнопка 'Улучшить запрос' помогает сделать ваш запрос лучше, предоставляя дополнительный контекст, уточнения или переформулировку. Попробуйте ввести запрос и снова нажать кнопку, чтобы увидеть, как это работает.", "speechToText.tooltip.start": "Начать голосовой ввод", - "speechToText.tooltip.stop": "Остановить запись", + "speechToText.tooltip.stop": "Остановить захват звука", "speechToText.tooltip.transcribing": "Распознавание... Нажмите для отмены.", "speechToText.tooltip.error": "Ошибка голосового ввода. Нажмите, чтобы очистить.", "speechToText.error.title": "Ошибка голосового ввода", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 47e94e2c208..5874d390537 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -281,7 +281,7 @@ export const dict = { "prompt.action.indexing": "การตั้งค่าการสร้างดัชนี", "speechToText.tooltip.start": "เริ่มการป้อนข้อมูลด้วยเสียง", - "speechToText.tooltip.stop": "หยุดบันทึก", + "speechToText.tooltip.stop": "หยุดจับเสียง", "speechToText.tooltip.transcribing": "กำลังถอดเสียง... คลิกเพื่อยกเลิก", "speechToText.tooltip.error": "การป้อนข้อมูลด้วยเสียงล้มเหลว คลิกเพื่อล้าง", "speechToText.error.title": "การป้อนข้อมูลด้วยเสียงล้มเหลว", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 0c1736f0b45..8b8d9a74ad8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -281,7 +281,7 @@ export const dict = { "'Komutu Geliştir' düğmesi, ek bağlam, açıklama veya yeniden ifadelendirme sağlayarak komutunuzu iyileştirmeye yardımcı olur. Buraya bir komut yazıp düğmeye tekrar tıklayarak nasıl çalıştığını görebilirsiniz.", "speechToText.tooltip.start": "Sesli girişi başlat", - "speechToText.tooltip.stop": "Kaydı durdur", + "speechToText.tooltip.stop": "Ses yakalamayı durdur", "speechToText.tooltip.transcribing": "Metne dönüştürülüyor... İptal etmek için tıklayın.", "speechToText.tooltip.error": "Sesli giriş başarısız oldu. Temizlemek için tıklayın.", "speechToText.error.title": "Sesli giriş başarısız oldu", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 6ca7663b7d0..8f6a9c9cd4b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -283,7 +283,7 @@ export const dict = { "Кнопка 'Покращити запит' допомагає вдосконалити ваш запит, надаючи додатковий контекст, уточнення або перефразування. Введіть запит тут і натисніть кнопку ще раз, щоб побачити, як це працює.", "speechToText.tooltip.start": "Почати голосове введення", - "speechToText.tooltip.stop": "Зупинити запис", + "speechToText.tooltip.stop": "Зупинити захоплення звуку", "speechToText.tooltip.transcribing": "Транскрибування... Натисніть, щоб скасувати.", "speechToText.tooltip.error": "Помилка голосового введення. Натисніть, щоб очистити.", "speechToText.error.title": "Помилка голосового введення", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index c5024648aa3..26434fde1e5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -280,7 +280,7 @@ export const dict = { "'增强提示'按钮通过提供额外上下文、澄清或重新表述来帮助改进您的请求。尝试在此处输入请求,然后再次点击按钮查看其工作原理。", "speechToText.tooltip.start": "开始语音输入", - "speechToText.tooltip.stop": "停止录音", + "speechToText.tooltip.stop": "停止捕获音频", "speechToText.tooltip.transcribing": "正在转录... 点击取消。", "speechToText.tooltip.error": "语音输入失败。点击清除。", "speechToText.error.title": "语音输入失败", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 2cd8ed1cc6f..d2b4d0dc713 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -279,7 +279,7 @@ export const dict = { "「強化提示詞」按鈕可透過提供額外內容、說明或改寫來協助改善提示詞。試著在這裡輸入提示詞,再點選一次按鈕以了解其運作方式。", "speechToText.tooltip.start": "開始語音輸入", - "speechToText.tooltip.stop": "停止錄音", + "speechToText.tooltip.stop": "停止擷取音訊", "speechToText.tooltip.transcribing": "正在轉錄... 點擊取消。", "speechToText.tooltip.error": "語音輸入失敗。點擊清除。", "speechToText.error.title": "語音輸入失敗", From 43d9dd23ed95c0ef7137be3dc9296e8ba3ff6e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 14 May 2026 13:18:03 -0300 Subject: [PATCH 27/60] fix: comment --- .../src/kilocode/session/compaction-chunks.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts index 1e29b08a717..991ad718201 100644 --- a/packages/opencode/src/kilocode/session/compaction-chunks.ts +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -245,18 +245,25 @@ export namespace KiloCompactionChunks { ), }, } - const result = yield* worker.process({ - user: input.user, - agent, - sessionID: input.sessionID, - tools: {}, - system: [], - messages: [...input.data, { role: "user", content: [{ type: "text", text: input.text }] }], - model: mdl, - }) - const parts = MessageV2.parts(worker.message.id) - const output = text(worker.message, parts) - yield* input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore) + const out = yield* Effect.gen(function* () { + const result = yield* worker.process({ + user: input.user, + agent, + sessionID: input.sessionID, + tools: {}, + system: [], + messages: [...input.data, { role: "user", content: [{ type: "text", text: input.text }] }], + model: mdl, + }) + const parts = MessageV2.parts(worker.message.id) + return { result, output: text(worker.message, parts) } + }).pipe( + Effect.ensuring( + input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore), + ), + ) + const result = out.result + const output = out.output if (result !== "continue") return { result, output: undefined } if (!output) return { result: "stop" as const, output: undefined } return { result, output } From 5e4682338db6dd69f0d34213cdee5c9e0f81f8f6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 14 May 2026 12:59:16 -0400 Subject: [PATCH 28/60] revert: remove out-of-scope fix in KiloBackendSessionManager --- .../ai/kilocode/backend/app/KiloBackendSessionManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 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( From 9a8225cbb2852f8ada6bec4202893fdf14ef6142 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 14 May 2026 13:14:07 -0400 Subject: [PATCH 29/60] ci(jetbrains): require v-prefixed publish tags --- .github/workflows/publish-jetbrains.yml | 10 +++++----- packages/kilo-jetbrains/README.md | 4 ++-- packages/kilo-jetbrains/RELEASE_TODO.md | 8 ++++---- packages/kilo-jetbrains/build.gradle.kts | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index 3465c4f78f1..4db34d33be8 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -50,12 +50,12 @@ jobs: id: version run: | tag="$GITHUB_REF_NAME" - if [[ "$tag" != jetbrains/* ]]; then - echo "Unsupported tag '$tag'. Expected jetbrains/." >&2 + if [[ "$tag" != jetbrains/v* ]]; then + echo "Unsupported tag '$tag'. Expected jetbrains/v." >&2 exit 1 fi - version="${tag#jetbrains/}" + version="${tag#jetbrains/v}" if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then { echo "version=$version" @@ -73,11 +73,11 @@ jobs: 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 + echo "Stable JetBrains Marketplace publishing is implemented but intentionally disabled; use an rc tag such as jetbrains/v7.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 + echo "Unsupported JetBrains plugin version '$version'. Expected jetbrains/vx.y.z-rc.n or jetbrains/vx.y.z." >&2 exit 1 - name: Validate publishing secrets diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index 8cd2eed1cc5..4cfb14ddd24 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -54,9 +54,9 @@ 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. +JetBrains release-candidate builds publish from tags matching `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`. The git tag is the source of truth for the Marketplace plugin version; the `v` prefix is stripped before the version is set in the plugin metadata so the Marketplace version remains a plain semver (`x.y.z-rc.n`). Production Gradle builds fail if `HEAD` is not tagged with `jetbrains/v`. Local dev builds 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. +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/vx.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: diff --git a/packages/kilo-jetbrains/RELEASE_TODO.md b/packages/kilo-jetbrains/RELEASE_TODO.md index d3b8430da4d..1f8efae536f 100644 --- a/packages/kilo-jetbrains/RELEASE_TODO.md +++ b/packages/kilo-jetbrains/RELEASE_TODO.md @@ -13,7 +13,7 @@ - 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. +- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases from `jetbrains/v*` 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. @@ -29,16 +29,16 @@ ## Per-RC Release - Choose an RC version in the form `x.y.z-rc.n`. -- Push tag `jetbrains/x.y.z-rc.n`. +- Push tag `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`. - 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. +- Confirm the GitHub Release for the `jetbrains/vx.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. +- Stable tags like `jetbrains/vx.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 3ce7ec96cd4..975f360bae2 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -17,14 +17,14 @@ 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/") } + return text.lineSequence().map { it.trim() }.firstOrNull { it.startsWith("jetbrains/v") } } 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") + gitTag()?.removePrefix("jetbrains/v") + ?: error("Missing JetBrains plugin version. Publish builds must run from a jetbrains/v tag."), +) else checked(gitTag()?.removePrefix("jetbrains/v") ?: "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") From 410bae8c2aba574f1a774d0eea2fa107764dc6cc Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 14 May 2026 13:19:02 -0400 Subject: [PATCH 30/60] ci: add publish-jetbrains.yml to workflow allowlist --- script/check-workflows.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/script/check-workflows.ts b/script/check-workflows.ts index 487b03f20a5..64588fb79d2 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -42,6 +42,7 @@ const active = new Set([ "generate.yml", "nix-eval.yml", "nix-hashes.yml", + "publish-jetbrains.yml", "publish.yml", "smoke-test.yml", "source-check-links.yml", From bcb21806fca613d04cd9c9e98519f4b33fbdeb5e Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 14 May 2026 18:48:34 -0400 Subject: [PATCH 31/60] docs(jetbrains): add RELEASING.md with RC release instructions and link from READMEs --- README.md | 4 +- packages/kilo-jetbrains/README.md | 21 +--------- packages/kilo-jetbrains/RELEASING.md | 62 ++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 packages/kilo-jetbrains/RELEASING.md diff --git a/README.md b/README.md index afbb34ef673..81bc35bbd55 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ kilo run --auto "run tests and fix any failures" We welcome contributions from developers, writers, and enthusiasts! To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests. -See [RELEASING.md](RELEASING.md) for the release process. +See [RELEASING.md](RELEASING.md) for the VS Code extension and CLI release process. + +See [packages/kilo-jetbrains/RELEASING.md](packages/kilo-jetbrains/RELEASING.md) for the JetBrains plugin release process. ## Code of Conduct diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index 4cfb14ddd24..87e89b664c5 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -52,26 +52,9 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-.zip --- -## Publish release candidates +## Releasing -JetBrains release-candidate builds publish from tags matching `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`. The git tag is the source of truth for the Marketplace plugin version; the `v` prefix is stripped before the version is set in the plugin metadata so the Marketplace version remains a plain semver (`x.y.z-rc.n`). Production Gradle builds fail if `HEAD` is not tagged with `jetbrains/v`. Local dev builds 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/vx.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`. +See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, and how to install RC builds via the custom plugin repository. --- diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md new file mode 100644 index 00000000000..a5d223cb501 --- /dev/null +++ b/packages/kilo-jetbrains/RELEASING.md @@ -0,0 +1,62 @@ +# Releasing the JetBrains Plugin + +## RC releases (currently the only supported flow) + +Stable release tags (`jetbrains/vx.y.z`) are recognized by the workflow but intentionally rejected. Only RC tags are accepted right now. + +### 1. Create and push a tag + +Tag format: `jetbrains/v..-rc.` + +``` +git tag jetbrains/v7.0.1-rc.1 +git push origin jetbrains/v7.0.1-rc.1 +``` + +### 2. Watch the workflow + +The `publish-jetbrains` workflow starts automatically on tag push. Follow progress at: + +[https://github.com/Kilo-Org/kilocode/actions/workflows/publish-jetbrains.yml](https://github.com/Kilo-Org/kilocode/actions/workflows/publish-jetbrains.yml) + +The workflow: +1. Validates the tag format and required secrets. +2. Downloads CLI binaries for all 6 platforms from the matching GitHub Release. +3. Verifies and signs the plugin with `./gradlew verifyPlugin publishPlugin -Pproduction=true`. +4. Publishes the signed ZIP to the JetBrains Marketplace `eap` channel. +5. Uploads the signed ZIP to a GitHub prerelease for the tag. + +### 3. Verify on the Marketplace + +Once the workflow succeeds, the new version should appear in the plugin's version list: + +[https://plugins.jetbrains.com/plugin/28350-kilo-code/edit/versions](https://plugins.jetbrains.com/plugin/28350-kilo-code/edit/versions) + +--- + +## Installing RC builds via the custom plugin repository + +RC builds are published to the `eap` channel, not the default channel. To get them in IntelliJ IDEA: + +1. Open **Settings > Plugins**. +2. Click the gear icon and choose **Manage Plugin Repositories**. +3. Add the following URL: + +``` +https://plugins.jetbrains.com/plugins/list?channel=eap&pluginId=28350 +``` + +4. Search for **Kilo Code** in the Marketplace tab — the latest RC version will appear and update automatically. + +--- + +## Required GitHub Actions secrets + +| Secret | Purpose | +|---|---| +| `JETBRAINS_MARKETPLACE_TOKEN` | Marketplace API token for publishing | +| `JETBRAINS_CERTIFICATE_CHAIN` | PEM certificate chain for plugin signing | +| `JETBRAINS_PRIVATE_KEY` | PEM private key for plugin signing | +| `JETBRAINS_PRIVATE_KEY_PASSWORD` | Password for the private key | + +Before the first publish, complete `RELEASE_TODO.md` to set up these secrets and the Marketplace plugin entry. From 14a25b037bbbd7596aea88729989511ffe4dfce6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 15 May 2026 09:26:03 +0200 Subject: [PATCH 32/60] feat(agent-manager): add voice input to inline review comments --- .changeset/inline-review-speech.md | 5 + .../tests/unit/review-comments.test.ts | 54 ++++++++ .../webview-ui/agent-manager/DiffPanel.tsx | 32 +++++ .../agent-manager/FullScreenDiffView.tsx | 32 +++++ .../agent-manager/agent-manager.css | 5 + .../review-annotation-speech.tsx | 129 ++++++++++++++++++ .../agent-manager/review-annotations.ts | 27 ++++ 7 files changed, 284 insertions(+) create mode 100644 .changeset/inline-review-speech.md create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx diff --git a/.changeset/inline-review-speech.md b/.changeset/inline-review-speech.md new file mode 100644 index 00000000000..30cf6eeb87b --- /dev/null +++ b/.changeset/inline-review-speech.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support speech-to-text voice input in Agent Manager inline review comments. diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts index 247da56c478..ca9b9353cfe 100644 --- a/packages/kilo-vscode/tests/unit/review-comments.test.ts +++ b/packages/kilo-vscode/tests/unit/review-comments.test.ts @@ -8,6 +8,11 @@ import { type ReviewComment, } from "../../webview-ui/agent-manager/review-comments" import { markdownCommentBlocks } from "../../webview-ui/agent-manager/markdown-comment-ranges" +import { + reviewAnnotationSpeechKey, + reviewDraftSpeechKey, + reviewEditSpeechKey, +} from "../../webview-ui/agent-manager/review-annotations" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(file: string, before: string, after: string): WorktreeFileDiff { @@ -236,6 +241,55 @@ describe("markdownCommentBlocks", () => { }) }) +// ── review annotation speech keys ─────────────────────────────────────────── + +describe("review annotation speech keys", () => { + it("keeps draft keys scoped to the selected review location", () => { + expect(reviewDraftSpeechKey({ file: "src/a.ts", side: "additions", line: 4 })).toBe( + "draft:src/a.ts:additions:4:4", + ) + expect(reviewDraftSpeechKey({ file: "src/a.ts", side: "deletions", line: 4, endLine: 8 })).toBe( + "draft:src/a.ts:deletions:4:8", + ) + }) + + it("keeps edit keys scoped to the review comment id", () => { + expect(reviewEditSpeechKey("c-123")).toBe("edit:c-123") + }) + + it("only exposes annotation speech keys for active draft and edit composers", () => { + const current = comment({ file: "src/a.ts", line: 7 }) + expect( + reviewAnnotationSpeechKey({ + type: "draft", + comment: null, + file: "src/a.ts", + side: "additions", + line: 7, + }), + ).toBe("draft:src/a.ts:additions:7:7") + expect( + reviewAnnotationSpeechKey({ + type: "comment", + comment: current, + file: current.file, + side: current.side, + line: current.line, + editing: true, + }), + ).toBe(`edit:${current.id}`) + expect( + reviewAnnotationSpeechKey({ + type: "comment", + comment: current, + file: current.file, + side: current.side, + line: current.line, + }), + ).toBeUndefined() + }) +}) + // ── getDirectory / getFilename ────────────────────────────────────────────── describe("getDirectory", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 72de493aabd..f7b01b37690 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -14,13 +14,22 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie import type { WorktreeFileDiff } from "../src/types/messages" import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" import { useLanguage } from "../src/context/language" +import { useVSCode } from "../src/context/vscode" +import { useServer } from "../src/context/server" +import { useProvider } from "../src/context/provider" +import { useConfig } from "../src/context/config" +import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" +import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" import { buildFileAnnotations, buildReviewAnnotation, + reviewDraftSpeechKey, + reviewEditSpeechKey, type AnnotationLabels, type AnnotationMeta, } from "./review-annotations" +import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" import { LONG_DIFF_MARKER_FILE_COUNT, allOpenFiles, @@ -59,6 +68,13 @@ interface DiffPanelProps { export const DiffPanel: Component = (props) => { const { t } = useLanguage() + const vscode = useVSCode() + const server = useServer() + const provider = useProvider() + const { config, settings } = useConfig() + const speech = useSpeechToText(vscode, server, { t }) + const canUseSpeech = () => canUseSpeechToText(settings(), config(), provider.connected(), server.profileData()) + const speechModel = () => selectedSpeechToTextModel(settings()) const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) const sendAllKeybind = () => isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") @@ -78,6 +94,21 @@ export const DiffPanel: Component = (props) => { null, ) const [editing, setEditing] = createSignal(null) + const speechKeys = createMemo(() => { + const keys = new Set() + const current = draft() + const edit = editing() + if (current) keys.add(reviewDraftSpeechKey(current)) + if (edit) keys.add(reviewEditSpeechKey(edit)) + return keys + }) + const reviewSpeech = createReviewAnnotationSpeechRenderer({ + speech, + enabled: canUseSpeech, + model: speechModel, + label: t, + keys: speechKeys, + }) let nextId = 0 // Tracks the session key for which initial open state has already run. When the // key changes (different worktree) we expand reviewable files. Within the same key, @@ -307,6 +338,7 @@ export const DiffPanel: Component = (props) => { cancelDraft, labels: labels(), activeTerminalId: props.activeTerminalId, + speech: reviewSpeech, }) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index 6f8b8828f77..e9ad869f4a8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -20,15 +20,24 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie import type { WorktreeFileDiff } from "../src/types/messages" import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" import { useLanguage } from "../src/context/language" +import { useVSCode } from "../src/context/vscode" +import { useServer } from "../src/context/server" +import { useProvider } from "../src/context/provider" +import { useConfig } from "../src/context/config" +import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" +import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" import { FileTree } from "./FileTree" import { treeOrder } from "./file-tree-utils" import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" import { buildFileAnnotations, buildReviewAnnotation, + reviewDraftSpeechKey, + reviewEditSpeechKey, type AnnotationLabels, type AnnotationMeta, } from "./review-annotations" +import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" import { LONG_DIFF_MARKER_FILE_COUNT, allOpenFiles, @@ -69,6 +78,13 @@ interface FullScreenDiffViewProps { export const FullScreenDiffView: Component = (props) => { const { t } = useLanguage() + const vscode = useVSCode() + const server = useServer() + const provider = useProvider() + const { config, settings } = useConfig() + const speech = useSpeechToText(vscode, server, { t }) + const canUseSpeech = () => canUseSpeechToText(settings(), config(), provider.connected(), server.profileData()) + const speechModel = () => selectedSpeechToTextModel(settings()) const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) const sendAllKeybind = () => isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") @@ -88,6 +104,21 @@ export const FullScreenDiffView: Component = (props) => null, ) const [editing, setEditing] = createSignal(null) + const speechKeys = createMemo(() => { + const keys = new Set() + const current = draft() + const edit = editing() + if (current) keys.add(reviewDraftSpeechKey(current)) + if (edit) keys.add(reviewEditSpeechKey(edit)) + return keys + }) + const reviewSpeech = createReviewAnnotationSpeechRenderer({ + speech, + enabled: canUseSpeech, + model: speechModel, + label: t, + keys: speechKeys, + }) const [activeFile, setActiveFile] = createSignal(null) const [treeWidth, setTreeWidth] = createSignal(240) let nextId = 0 @@ -322,6 +353,7 @@ export const FullScreenDiffView: Component = (props) => cancelDraft, labels: labels(), activeTerminalId: props.activeTerminalId, + speech: reviewSpeech, }) } 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 305359b3dc7..ad549141990 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -2146,6 +2146,11 @@ body.am-wt-dragging-active * { gap: 6px; } +.am-annotation-speech { + display: flex; + align-items: center; +} + .am-annotation-btn { padding: 2px 10px; font-size: inherit; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx b/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx new file mode 100644 index 00000000000..ae8314e1b52 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx @@ -0,0 +1,129 @@ +import { createEffect, createSignal, onCleanup, Show, type Accessor } from "solid-js" +import { render as renderSolid } from "solid-js/web" +import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton" +import { insertSpacedText } from "../src/components/chat/prompt-input-utils" +import type { SpeechState, SpeechToText } from "../src/components/speech-to-text/useSpeechToText" +import { reviewAnnotationSpeechKey, type AnnotationMeta } from "./review-annotations" + +type Props = { + speech: SpeechToText + enabled: Accessor + model: Accessor + label: (key: string) => string + keys: Accessor> +} + +type Node = { + host: HTMLDivElement + dispose: VoidFunction + setTextarea: (textarea: HTMLTextAreaElement) => void +} + +export function insertReviewSpeechText(textarea: HTMLTextAreaElement, value: string): void { + const text = textarea.value + const start = textarea.selectionStart ?? text.length + const end = textarea.selectionEnd ?? start + const result = insertSpacedText(text, value, start, end) + + textarea.value = result.text + textarea.setSelectionRange(result.pos, result.pos) + textarea.focus() +} + +export function createReviewAnnotationSpeechRenderer(props: Props) { + const nodes = new Map() + const [owner, setOwner] = createSignal() + + const mount = (key: string, textarea: HTMLTextAreaElement): Node | undefined => { + if (typeof document === "undefined") return undefined + + const host = document.createElement("div") + host.className = "am-annotation-speech" + let field = textarea + const mine = () => owner() === key + const state = (): SpeechState => (mine() ? props.speech.state() : "idle") + const speech: SpeechToText = { + state, + error: () => (mine() ? props.speech.error() : undefined), + active: () => mine() && props.speech.active(), + start: props.speech.start, + stop: () => { + if (!mine()) return + props.speech.stop() + }, + cancel: () => { + if (!mine()) return + props.speech.cancel() + setOwner(undefined) + }, + clear: () => { + if (!mine()) return + props.speech.clear() + setOwner(undefined) + }, + } + const blocked = () => props.speech.active() && !mine() + const start = () => { + setOwner(key) + props.speech.start({ + model: props.model(), + insert: (value) => insertReviewSpeechText(field, value), + }) + } + + const dispose = renderSolid( + () => ( + + + + ), + host, + ) + + const node = { + host, + dispose, + setTextarea: (next: HTMLTextAreaElement) => { + field = next + }, + } + nodes.set(key, node) + return node + } + + const render = (meta: AnnotationMeta, textarea: HTMLTextAreaElement): HTMLElement | undefined => { + const key = reviewAnnotationSpeechKey(meta) + if (!key) return undefined + const node = nodes.get(key) ?? mount(key, textarea) + if (!node) return undefined + node.setTextarea(textarea) + return node.host + } + + createEffect(() => { + const keys = props.keys() + const current = owner() + if (current && !keys.has(current)) { + if (props.speech.active()) props.speech.cancel() + if (props.speech.state() === "error") props.speech.clear() + setOwner(undefined) + } + + for (const [key, node] of nodes) { + if (keys.has(key)) continue + node.dispose() + nodes.delete(key) + } + }) + + onCleanup(() => { + if (owner() && props.speech.active()) props.speech.cancel() + for (const [, node] of nodes) node.dispose() + nodes.clear() + }) + + return { + active: props.speech.active, + render, + } +} 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 3c46aac071c..380a501998e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts @@ -26,6 +26,22 @@ export interface AnnotationMeta { editing?: boolean } +type SpeechDraft = Pick + +export function reviewDraftSpeechKey(draft: SpeechDraft): string { + return `draft:${draft.file}:${draft.side}:${draft.line}:${draft.endLine ?? draft.line}` +} + +export function reviewEditSpeechKey(id: string): string { + return `edit:${id}` +} + +export function reviewAnnotationSpeechKey(meta: AnnotationMeta): string | undefined { + if (meta.type === "draft") return reviewDraftSpeechKey(meta) + if (!meta.editing || !meta.comment) return undefined + return reviewEditSpeechKey(meta.comment.id) +} + interface AnnotationHandlers { diffs: WorktreeFileDiff[] editing: string | null @@ -36,6 +52,10 @@ interface AnnotationHandlers { cancelDraft: () => void labels: AnnotationLabels activeTerminalId?: string + speech?: { + active: () => boolean + render: (meta: AnnotationMeta, textarea: HTMLTextAreaElement) => HTMLElement | undefined + } } function focusWhenConnected(el: HTMLTextAreaElement): void { @@ -150,6 +170,8 @@ export function buildReviewAnnotation( submitButton.className = "am-annotation-btn am-annotation-btn-submit" submitButton.textContent = handlers.labels.comment + const speech = handlers.speech?.render(meta, textarea) + if (speech) actions.appendChild(speech) actions.appendChild(cancelButton) actions.appendChild(submitButton) wrapper.appendChild(header) @@ -159,6 +181,7 @@ export function buildReviewAnnotation( focusWhenConnected(textarea) const submit = () => { + if (handlers.speech?.active()) return const text = textarea.value.trim() if (!text) return const diff = handlers.diffs.find((item) => item.file === meta.file) @@ -216,6 +239,8 @@ export function buildReviewAnnotation( saveButton.className = "am-annotation-btn am-annotation-btn-submit" saveButton.textContent = handlers.labels.save + const speech = handlers.speech?.render(meta, textarea) + if (speech) actions.appendChild(speech) actions.appendChild(cancelButton) actions.appendChild(saveButton) wrapper.appendChild(header) @@ -231,6 +256,7 @@ export function buildReviewAnnotation( saveButton.addEventListener("click", (event) => { event.stopPropagation() + if (handlers.speech?.active()) return const text = textarea.value.trim() if (!text) return handlers.updateComment(comment.id, text) @@ -244,6 +270,7 @@ export function buildReviewAnnotation( } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault() + if (handlers.speech?.active()) return const text = textarea.value.trim() if (!text) return handlers.updateComment(comment.id, text) From f5dc95b99394c17ad7140bb034bc15a0f9de60b6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 15 May 2026 09:29:06 +0200 Subject: [PATCH 33/60] fix(vscode): preserve spoken transcription wording --- .changeset/verbatim-speech-transcripts.md | 6 ++ packages/kilo-gateway/src/server/routes.ts | 1 + .../kilo-vscode/src/speech-to-text/models.ts | 3 + .../src/speech-to-text/transcribe.ts | 7 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 94 +++++++++++++++++++ 7 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 .changeset/verbatim-speech-transcripts.md diff --git a/.changeset/verbatim-speech-transcripts.md b/.changeset/verbatim-speech-transcripts.md new file mode 100644 index 00000000000..65cb57079d0 --- /dev/null +++ b/.changeset/verbatim-speech-transcripts.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/kilo-gateway": patch +--- + +Keep GPT-4o voice transcriptions closer to the words that were spoken. diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index f2cd17498fa..f92c00f3f31 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -429,6 +429,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { format: z.string(), }), language: z.string().optional(), + prompt: z.string().optional(), temperature: z.number().optional(), }), ), diff --git a/packages/kilo-vscode/src/speech-to-text/models.ts b/packages/kilo-vscode/src/speech-to-text/models.ts index 5936641366f..be872d81c96 100644 --- a/packages/kilo-vscode/src/speech-to-text/models.ts +++ b/packages/kilo-vscode/src/speech-to-text/models.ts @@ -2,6 +2,7 @@ export interface SpeechToTextModelDef { readonly id: string readonly label: string readonly provider: string + readonly verbatim?: boolean } const models: SpeechToTextModelDef[] = [ @@ -9,11 +10,13 @@ const models: SpeechToTextModelDef[] = [ id: "openai/gpt-4o-mini-transcribe", label: "GPT-4o Mini Transcribe", provider: "OpenAI", + verbatim: true, }, { id: "openai/gpt-4o-transcribe", label: "GPT-4o Transcribe", provider: "OpenAI", + verbatim: true, }, { id: "openai/whisper-1", diff --git a/packages/kilo-vscode/src/speech-to-text/transcribe.ts b/packages/kilo-vscode/src/speech-to-text/transcribe.ts index 10cbb422f1a..1e978bf028c 100644 --- a/packages/kilo-vscode/src/speech-to-text/transcribe.ts +++ b/packages/kilo-vscode/src/speech-to-text/transcribe.ts @@ -3,6 +3,8 @@ import { getErrorMessage } from "../kilo-provider-utils" import { getSpeechToTextModel } from "./models" const PATH = "/kilo/audio/transcriptions" +const PROMPT = + "Transcribe exactly what is spoken. Do not paraphrase, summarize, infer intent, or rewrite for clarity. Preserve the speaker's original wording as closely as possible, including incomplete phrases and unusual wording when audible." type Req = { model?: string @@ -39,6 +41,8 @@ export async function transcribeSpeech( const auth = Buffer.from(`kilo:${cfg.password}`).toString("base64") const url = new URL(PATH, cfg.baseUrl) + const model = getSpeechToTextModel(input.model) + const prompt = model.verbatim ? PROMPT : undefined if (dir) url.searchParams.set("directory", dir) try { @@ -50,12 +54,13 @@ export async function transcribeSpeech( "Content-Type": "application/json", }, body: JSON.stringify({ - model: getSpeechToTextModel(input.model).id, + model: model.id, input_audio: { data: input.data, format: input.format, }, ...(input.language ? { language: input.language } : {}), + ...(prompt ? { prompt } : {}), }), }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 199053fd00d..742d0d9367c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5763,6 +5763,7 @@ export class Audio extends HeyApiClient { format: string } language?: string + prompt?: string temperature?: number }, options?: Options, @@ -5777,6 +5778,7 @@ export class Audio extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "input_audio" }, { in: "body", key: "language" }, + { in: "body", key: "prompt" }, { in: "body", key: "temperature" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7ec2e38bb27..e5323886eed 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -7255,6 +7255,7 @@ export type KiloAudioTranscriptionsData = { format: string } language?: string + prompt?: string temperature?: number } path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 33c1727634a..2d33a0d2a24 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10481,6 +10481,100 @@ ] } }, + "/kilo/audio/transcriptions": { + "post": { + "operationId": "kilo.audio.transcriptions", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Speech to text transcription", + "description": "Proxy an audio transcription request to the Kilo Gateway", + "responses": { + "200": { + "description": "Transcription response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "usage": {} + }, + "required": ["text"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "input_audio": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "format": { + "type": "string" + } + }, + "required": ["data", "format"] + }, + "language": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "temperature": { + "type": "number" + } + }, + "required": ["model", "input_audio"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilo.audio.transcriptions({\n ...\n})" + } + ] + } + }, "/kilo/notifications": { "get": { "operationId": "kilo.notifications", From 62bd0289104501afccf9a961cd6907bd9cfff742 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 15 May 2026 09:34:27 +0200 Subject: [PATCH 34/60] test(agent-manager): include speech annotation source --- packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index c1ce05633dd..fc4de9e19f8 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -30,6 +30,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/DiffEndMarker.tsx"), path.join(ROOT, "webview-ui/agent-manager/FileTree.tsx"), path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"), + path.join(ROOT, "webview-ui/agent-manager/review-annotation-speech.tsx"), path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), From 4b816f621baa163c8aaf3ec84ce15219d77bae19 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 15 May 2026 09:45:44 +0200 Subject: [PATCH 35/60] fix(vscode): send recorded prompts from send button --- .changeset/swift-voices-send.md | 5 ++ .../tests/unit/use-speech-to-text.test.ts | 84 +++++++++++++++++++ .../src/components/chat/PromptInput.tsx | 64 ++++++++++---- .../speech-to-text/useSpeechToText.ts | 17 +++- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + 23 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 .changeset/swift-voices-send.md create mode 100644 packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts diff --git a/.changeset/swift-voices-send.md b/.changeset/swift-voices-send.md new file mode 100644 index 00000000000..6ee05869f9b --- /dev/null +++ b/.changeset/swift-voices-send.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Send recorded voice prompts directly when the prompt send button is pressed. diff --git a/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts b/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts new file mode 100644 index 00000000000..66c0b74a2f8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { useSpeechToText } from "../../webview-ui/src/components/speech-to-text/useSpeechToText" +import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages" + +function setup() { + const sent: WebviewMessage[] = [] + let handler: ((message: ExtensionMessage) => void) | undefined + + const root = createRoot((dispose) => ({ + dispose, + speech: useSpeechToText( + { + postMessage: (message) => sent.push(message), + onMessage: (next) => { + handler = next + return () => { + handler = undefined + } + }, + }, + { profileData: () => ({}), goToLogin: () => {} }, + { t: (key) => key }, + ), + })) + + const fire = (message: ExtensionMessage) => handler?.(message) + return { ...root, fire, sent } +} + +describe("useSpeechToText", () => { + it("runs the stop completion after inserting a transcript", () => { + const ctx = setup() + const text: string[] = [] + let done = 0 + + ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + ctx.speech.stop({ done: () => done++ }) + ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Recorded prompt" }) + + expect(text).toEqual(["Recorded prompt"]) + expect(done).toBe(1) + expect(ctx.speech.state()).toBe("idle") + ctx.dispose() + }) + + it("drops the stop completion when transcription is cancelled", () => { + const ctx = setup() + let done = 0 + + ctx.speech.start({ model: "scribe", insert: () => {} }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + ctx.speech.stop({ done: () => done++ }) + ctx.speech.cancel() + ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Ignored prompt" }) + + expect(done).toBe(0) + expect(ctx.speech.state()).toBe("idle") + ctx.dispose() + }) + + it("drops the stop completion when the send context changes", () => { + const ctx = setup() + const text: string[] = [] + let done = 0 + + ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + ctx.speech.stop({ done: () => done++, ready: () => false }) + ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Keep as draft" }) + + expect(text).toEqual(["Keep as draft"]) + expect(done).toBe(0) + expect(ctx.speech.state()).toBe("idle") + ctx.dispose() + }) +}) 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 27d984c56a0..1047bd13d1c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -335,8 +335,17 @@ export const PromptInput: Component = (props) => { const speechModel = () => selectedSpeechToTextModel(settings()) const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 const canSend = () => - hasInput() && !isDisabled() && !speech.active() && !terminal.pending() && !git.pending() && !props.blocked?.() - const showStop = () => isBusy() && !hasInput() + !isDisabled() && + !terminal.pending() && + !git.pending() && + !props.blocked?.() && + (speech.state() === "recording" || (hasInput() && !speech.active())) + const sendLabel = () => { + if (props.blocked?.()) return language.t("prompt.action.send.blocked") + if (speech.state() === "recording") return language.t("prompt.action.send.recording") + return language.t("prompt.action.send") + } + const showStop = () => isBusy() && !hasInput() && speech.state() !== "recording" const isAtEnd = () => textareaRef ? atEnd(textareaRef.selectionStart, textareaRef.selectionEnd, textareaRef.value.length) : false const highlightMentions = () => { @@ -675,6 +684,33 @@ export const PromptInput: Component = (props) => { speech.start({ model: speechModel(), insert: insertSpeechText }) } + const transcribeAndSend = () => { + const key = draftKey() + const id = sid() + const context = ctx() + const value = text() + const comments = reviewComments() + const images = imageAttach.images() + speech.stop({ + done: () => void handleSend(), + ready: () => + draftKey() === key && + sid() === id && + ctx() === context && + text() === value && + reviewComments() === comments && + imageAttach.images() === images, + }) + } + + const handleSendClick = () => { + if (speech.state() !== "recording" || !canSend()) { + void handleSend() + return + } + transcribeAndSend() + } + const handleSend = async () => { const draft = text().trim() @@ -721,6 +757,8 @@ export const PromptInput: Component = (props) => { const pendingId = props.pendingSessionID ?? session.draftSessionID() const id = sid() const sel = session.selected(id) + const context = ctx() + const key = draftKey() const terminalFile = await terminal.resolveAttachment(message, id).catch((err: Error) => { showToast({ variant: "error", title: "Terminal context unavailable", description: err.message }) @@ -742,16 +780,20 @@ export const PromptInput: Component = (props) => { ] const attachments = allFiles.length > 0 ? allFiles : undefined - const key = draftKey() // Server-side slash command (cmdMatch/matched already computed above) if (matched) { const rest = draft.slice(cmdMatch![0].length).trim() const args = review && rest ? `${review}\n\n${rest}` : rest || review - session.sendCommand(matched.name, args, sel?.providerID, sel?.modelID, attachments, pendingId, ctx()) + session.sendCommand(matched.name, args, sel?.providerID, sel?.modelID, attachments, pendingId, context) } else { - session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, ctx()) + session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context) } + drafts.delete(key) + reviewDrafts.delete(key) + imageDrafts.delete(key) + if (draftKey() !== key) return + history.append(draft) history.reset() setText("") @@ -759,9 +801,6 @@ export const PromptInput: Component = (props) => { imageAttach.clear() mention.closeMention() slash.close() - drafts.delete(key) - reviewDrafts.delete(key) - imageDrafts.delete(key) if (textareaRef) textareaRef.style.height = "auto" } @@ -1085,16 +1124,13 @@ export const PromptInput: Component = (props) => { +