diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index d489dc63ebc..065672280e6 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -390,8 +390,8 @@ You can also set options that apply to all models from a provider: | `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | | `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | -{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} -`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config. +{% callout type="warning" title="{env:} requires trusted config; {file:} is project-confined" %} +`{env:VAR}` references in `apiKey` (or any option) resolve **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}`; if one appears anywhere in that file, Kilo drops the entire file's config and logs a warning, so unrelated settings in the same file stop applying too. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` works in trusted config and still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config. {% /callout %} ## Filtering Available Models diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index d785539288e..be2bafc75d4 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -465,8 +465,8 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab } ``` -{% callout type="warning" title="Only works in trusted config" %} -`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. +{% callout type="warning" title="{env:} requires trusted config; {file:} is project-confined" %} +`{env:VAR}` references resolve **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}`; if one appears anywhere in that file, Kilo drops the entire file's config and logs a warning, so unrelated settings in the same file stop applying too. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` works in trusted config and still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. {% /callout %} For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config). diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index 269331e13ce..181d114efc7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -16,8 +16,10 @@ import okhttp3.Response import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import java.io.File +import java.io.RandomAccessFile import java.security.MessageDigest import java.time.Instant +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream import kotlin.math.roundToInt @@ -36,55 +38,113 @@ class KiloCliDownloader( companion object { private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") private val JSON = Json { ignoreUnknownKeys = true } + private val LOCKS = ConcurrentHashMap() } suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File = withContext(Dispatchers.IO) { - val platform = KiloCliPlatform.current() - val dir = File(File(root, version), platform) - val exe = File(dir, "bin/${KiloCliPlatform.exe()}") - val done = File(dir, ".complete") - val cached = done.takeIf { it.isFile }?.readText()?.trim() - val ext = KiloCliPlatform.archive(platform) - val archive = File(dir, "kilo-$platform.$ext") + locked { + val platform = KiloCliPlatform.current() + val dir = File(File(root, version), platform) + val exe = File(dir, "bin/${KiloCliPlatform.exe()}") + val done = File(dir, ".complete") + val ext = KiloCliPlatform.archive(platform) - if (!force && exe.isFile && cached != null && cached.matches(DIGEST)) { - log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") - if (!SystemInfo.isWindows) exe.setExecutable(true) - prune(version) - return@withContext exe - } + if (!force) { + cached(version, platform, exe, done)?.let { return@locked it } + } - val digest = asset(version, platform, ext) + val digest = asset(version, platform, ext) + val stage = stage(version, platform) + try { + val archive = File(stage, "kilo-$platform.$ext") + val staged = File(stage, "bin/${KiloCliPlatform.exe()}") + val complete = File(stage, ".complete") - if (dir.exists()) { - log.info("Deleting cached CLI $version under ${dir.absolutePath}") - if (!dir.deleteRecursively()) { - throw IllegalStateException("Failed to delete cached Kilo CLI $version under ${dir.absolutePath}") + log.info( + "Kilo CLI $version for $platform is not cached; downloading new release into ${stage.absolutePath}" + ) + onProgress(CliDownload(0, version, platform)) + download(version, platform, ext, archive, onProgress) + verify(archive, digest) + log.info( + "Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)" + ) + extract(archive, stage) + if (!staged.isFile) { + throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}") + } + if (!SystemInfo.isWindows) staged.setExecutable(true) + if (archive.exists() && !archive.delete()) { + log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}") + } + complete.writeText("$digest\n") + replace(dir, stage) + onProgress(CliDownload(100, version, platform)) + prune(version) + exe + } finally { + if (stage.exists() && !stage.deleteRecursively()) { + log.warn("Failed to delete staged Kilo CLI download ${stage.absolutePath}") + } } } - - if (!dir.isDirectory && !dir.mkdirs()) { - throw IllegalStateException("Failed to create Kilo CLI cache directory ${dir.absolutePath}") - } - - log.info("Kilo CLI $version for $platform is not cached; downloading new release into ${dir.absolutePath}") - onProgress(CliDownload(0, version, platform)) - download(version, platform, ext, archive, onProgress) - verify(archive, digest) - log.info("Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)") - extract(archive, dir) - if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}") - if (!SystemInfo.isWindows) exe.setExecutable(true) - if (archive.exists() && !archive.delete()) { - log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}") - } - done.writeText("$digest\n") - onProgress(CliDownload(100, version, platform)) - prune(version) - exe } + private fun cached(version: String, platform: String, exe: File, done: File): File? { + val digest = done.takeIf { it.isFile }?.readText()?.trim() + if (!exe.isFile || digest == null || !digest.matches(DIGEST)) return null + log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") + if (!SystemInfo.isWindows) exe.setExecutable(true) + prune(version) + return exe + } + + private fun locked(block: () -> T): T { + if (!root.isDirectory && !root.mkdirs()) { + throw IllegalStateException("Failed to create Kilo CLI cache root ${root.absolutePath}") + } + val file = File(root, ".lock").canonicalFile + val mutex = LOCKS.computeIfAbsent(file.absolutePath) { Any() } + return synchronized(mutex) { + RandomAccessFile(file, "rw").channel.use { channel -> + channel.lock().use { block() } + } + } + } + + private fun stage(version: String, platform: String): File { + val tmp = File(root, ".tmp") + val dir = File(tmp, "$version-$platform-${System.nanoTime()}") + if (!dir.isDirectory && !dir.mkdirs()) { + throw IllegalStateException("Failed to create Kilo CLI staging directory ${dir.absolutePath}") + } + return dir + } + + private fun replace(dir: File, stage: File) { + val parent = dir.parentFile + if (!parent.isDirectory && !parent.mkdirs()) { + throw IllegalStateException("Failed to create Kilo CLI cache directory ${parent.absolutePath}") + } + + val backup = File(parent, ".${dir.name}.backup-${System.nanoTime()}") + if (dir.exists() && !dir.renameTo(backup)) { + throw IllegalStateException("Failed to move existing Kilo CLI cache ${dir.absolutePath} aside") + } + if (stage.renameTo(dir)) { + if (backup.exists() && !backup.deleteRecursively()) { + log.warn("Failed to delete previous Kilo CLI cache ${backup.absolutePath}") + } + return + } + + if (backup.exists() && !backup.renameTo(dir)) { + log.warn("Failed to restore previous Kilo CLI cache ${backup.absolutePath} to ${dir.absolutePath}") + } + throw IllegalStateException("Failed to install Kilo CLI cache ${stage.absolutePath} to ${dir.absolutePath}") + } + private fun fail(message: String): Nothing { log.warn(message) throw IllegalStateException(message) @@ -248,7 +308,7 @@ class KiloCliDownloader( private fun prune(keep: String) { val entries = root.listFiles() ?: return for (entry in entries) { - if (!entry.isDirectory || entry.name == keep) continue + if (!entry.isDirectory || entry.name == keep || entry.name.startsWith(".")) continue log.info("Removing stale Kilo CLI version ${entry.absolutePath}") if (!entry.deleteRecursively()) { log.warn("Failed to remove stale Kilo CLI version ${entry.absolutePath}") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index face684e5f7..1c4d3131c0b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -48,7 +48,12 @@ class KiloCliDownloaderTest { assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path) assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first()) assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() }) - assertContains(log.messages, "INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ${cli.parentFile.parentFile.absolutePath}") + assertTrue( + log.messages.any { + it.startsWith("INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ") && + it.contains("/.tmp/") + } + ) val cachedProgress = mutableListOf() val cached = KiloCliDownloader( @@ -113,23 +118,55 @@ class KiloCliDownloaderTest { @Test fun `forced resolve re-downloads and keeps only the active version`() = runBlocking { MockWebServer().use { server -> - val bytes = archive() - repeat(2) { - server.enqueue(metadata(bytes)) - server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) - } + val first = archive("#!/bin/old\n") + val next = archive("#!/bin/new\n") + server.enqueue(metadata(first)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(first))) + server.enqueue(metadata(next)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(next))) val cli = KiloCliDownloader( root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), ) - cli.resolve("1.2.3") + val old = cli.resolve("1.2.3") + assertEquals("#!/bin/old\n", old.readText()) assertEquals(2, server.requestCount) val forced = cli.resolve("1.2.3", force = true) assertTrue(forced.isFile) + assertEquals("#!/bin/new\n", forced.readText()) assertEquals(4, server.requestCount) - assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory }?.map { it.name }) + assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory && !it.name.startsWith(".") }?.map { it.name }) + } + } + + @Test + fun `forced resolve keeps the existing cli when download fails`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive("#!/bin/old\n") + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(503).setBody("unavailable")) + + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + val ex = assertFailsWith { + KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3", force = true) + } + + assertContains(ex.message.orEmpty(), "Failed to download") + assertTrue(cli.isFile) + assertEquals("#!/bin/old\n", cli.readText()) + assertTrue(File(cli.parentFile.parentFile, ".complete").isFile) } } @@ -232,9 +269,9 @@ class KiloCliDownloaderTest { } } - private fun archive(): ByteArray { + private fun archive(script: String = "#!/bin/sh\n"): ByteArray { val files = mapOf( - "bin/${KiloCliPlatform.exe()}" to "#!/bin/sh\n".toByteArray(), + "bin/${KiloCliPlatform.exe()}" to script.toByteArray(), "bin/kilo-sandbox-mutation-worker.js" to "worker\n".toByteArray(), ) if (KiloCliPlatform.archive() == "zip") return zip(files) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 35db7427dde..b24e42f992f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -50,6 +50,9 @@ class KiloAppService internal constructor( /** Core version and platform from the backend, or null if unknown. */ @Volatile private var info: CoreInfo? = null + private val coreLock = Any() + private val coreDone = mutableListOf<(CoreInfo?) -> Unit>() + private var coreJob: Job? = null val core: CoreInfo? get() = info @@ -151,18 +154,37 @@ class KiloAppService internal constructor( /** Fetch the pinned Core version and platform and cache it. */ fun fetchCoreInfoAsync(done: (CoreInfo?) -> Unit = {}) { - info?.let { - done(it) + val cached = info + if (cached != null) { + done(cached) return } - cs.launch { - LOG.info("fetchCoreInfo: requesting Core version and platform") - val next = coreInfo() - if (next == null) { - done(null) - return@launch + + synchronized(coreLock) { + val current = info + if (current != null) { + done(current) + return } - done(next) + coreDone.add(done) + if (coreJob != null) return + coreJob = cs.launch { + fetchCoreInfo() + } + } + } + + private suspend fun fetchCoreInfo() { + LOG.info("fetchCoreInfo: requesting Core version and platform") + val next = coreInfo() + val list = synchronized(coreLock) { + val items = coreDone.toList() + coreDone.clear() + coreJob = null + items + } + list.forEach { it(next) } + if (next != null) { LOG.info("fetchCoreInfo: Core version is ${next.version} (${next.platform})") } } 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 233e075c40b..ff04e16a265 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 @@ -174,9 +174,9 @@ class PromptPanel( ed.scrollPane.border = JBUI.Borders.empty() ed.scrollPane.viewportBorder = JBUI.Borders.empty( 0, - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), 0, - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), ) ed.backgroundColor = style.editorScheme.defaultBackground ed.scrollPane.background = style.editorScheme.defaultBackground diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index afd24afe2ff..95e3b494af3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -78,6 +78,8 @@ object SessionUiStyle { const val CONTROL_GAP = 4 const val SHELL_VERTICAL_PADDING = 6 const val SHELL_HORIZONTAL_PADDING = 8 + // Horizontal editor inset intentionally matches vertical shell padding to balance text and chrome. + const val EDITOR_HORIZONTAL_INSET = SHELL_VERTICAL_PADDING fun separator(): Color = JBColor.namedColor( "EditorTabs.underTabsBorderColor", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAppServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAppServiceTest.kt index abfe104f09f..35086670ee4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAppServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAppServiceTest.kt @@ -7,11 +7,14 @@ import ai.kilocode.rpc.dto.ProfileBalanceDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.ProfileOrganizationDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlinx.coroutines.withTimeout /** * Service-level tests for [KiloAppService] profile/login/logout/org operations. @@ -49,6 +52,47 @@ class KiloAppServiceTest : BasePlatformTestCase() { currentOrgId: String? = null, ) = ProfileDto(email = email, name = name, organizations = orgs, balance = balance, currentOrgId = currentOrgId) + private suspend fun waitUntil(done: () -> Boolean) { + withTimeout(5_000) { + while (!done()) yield() + } + } + + fun `test fetchCoreInfoAsync dedupes in flight requests`() = runBlocking(Dispatchers.Default) { + rpc.cliInfoGate = CompletableDeferred() + val seen = mutableListOf() + + app.fetchCoreInfoAsync { seen.add(it) } + app.fetchCoreInfoAsync { seen.add(it) } + waitUntil { rpc.cliVersionCalls == 1 } + + assertEquals(1, rpc.cliVersionCalls) + assertEquals(0, rpc.cliPlatformCalls) + rpc.cliInfoGate!!.complete(Unit) + waitUntil { seen.size == 2 } + + assertEquals(1, rpc.cliVersionCalls) + assertEquals(1, rpc.cliPlatformCalls) + assertEquals(listOf(CoreInfo("1.0.0", "darwin-arm64"), CoreInfo("1.0.0", "darwin-arm64")), seen) + } + + fun `test fetchCoreInfoAsync retries after failure`() = runBlocking(Dispatchers.Default) { + val seen = mutableListOf() + rpc.cliInfoError = RuntimeException("core failed") + + app.fetchCoreInfoAsync { seen.add(it) } + waitUntil { seen.size == 1 } + + assertNull(seen.single()) + assertNull(app.core) + rpc.cliInfoError = null + app.fetchCoreInfoAsync { seen.add(it) } + waitUntil { seen.size == 2 } + + assertEquals(CoreInfo("1.0.0", "darwin-arm64"), seen.last()) + assertEquals(2, rpc.cliVersionCalls) + } + // ------ refreshProfile ------ fun `test refreshProfile updates app state profile on success`() = runBlocking(Dispatchers.Default) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index fa1f42d9ea7..c1ae8e54c45 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -175,13 +175,13 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(editor, applied) } - fun `test prompt editor horizontal insets match top padding`() { + fun `test prompt editor horizontal insets use dedicated prompt inset`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) realize(panel, 260, 400) val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! val ins = editor.scrollPane.viewportBorder.getBorderInsets(editor.scrollPane) - val pad = JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING) + val pad = JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET) assertEquals(pad, ins.left) assertEquals(pad, ins.right) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index bc67e9f2d89..ed6e3e928fa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -33,6 +33,12 @@ class FakeAppRpcApi : KiloAppRpcApi { var health = HealthDto(healthy = true, version = "1.0.0") var cliVersion = "1.0.0" var cliPlatform = "darwin-arm64" + var cliInfoGate: CompletableDeferred? = null + var cliInfoError: Exception? = null + var cliVersionCalls = 0 + private set + var cliPlatformCalls = 0 + private set var models = ModelStateDto() val selections = mutableListOf() val cleared = mutableListOf() @@ -71,11 +77,16 @@ class FakeAppRpcApi : KiloAppRpcApi { override suspend fun cliVersion(): String { assertNotEdt("cliVersion") + cliVersionCalls += 1 + cliInfoGate?.await() + cliInfoError?.let { throw it } return cliVersion } override suspend fun cliPlatform(): String { assertNotEdt("cliPlatform") + cliPlatformCalls += 1 + cliInfoError?.let { throw it } return cliPlatform } diff --git a/packages/kilo-vscode/tests/unit/config-scope.test.ts b/packages/kilo-vscode/tests/unit/config-scope.test.ts index eca588a5d1b..aee2c488ce7 100644 --- a/packages/kilo-vscode/tests/unit/config-scope.test.ts +++ b/packages/kilo-vscode/tests/unit/config-scope.test.ts @@ -47,4 +47,19 @@ describe("splitConfigByScope", () => { }) expect(split.project).toEqual({}) }) + + it("writes sandbox writable paths to global config", () => { + const split = splitConfigByScope({ + experimental: { + sandbox_writable_paths: ["/tmp/kilo"], + }, + }) + + expect(split.global).toEqual({ + experimental: { + sandbox_writable_paths: ["/tmp/kilo"], + }, + }) + expect(split.project).toEqual({}) + }) }) diff --git a/packages/kilo-vscode/tests/unit/speech-to-text-button.test.ts b/packages/kilo-vscode/tests/unit/speech-to-text-button.test.ts new file mode 100644 index 00000000000..56db776d55f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/speech-to-text-button.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test" +import { cleanupSpeechCapture } from "../../webview-ui/src/components/speech-to-text/speech-cleanup" + +describe("speech-to-text button", () => { + it("cancels active capture on component cleanup", () => { + let cancels = 0 + + cleanupSpeechCapture({ + active: () => true, + cancel: () => { + cancels += 1 + }, + }) + + expect(cancels).toBe(1) + }) + + it("does not cancel idle capture on component cleanup", () => { + let cancels = 0 + + cleanupSpeechCapture({ + active: () => false, + cancel: () => { + cancels += 1 + }, + }) + + expect(cancels).toBe(0) + }) +}) 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 1593dadf0c2..0298535f8a4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -1394,7 +1394,7 @@ export const PromptInput: Component = (props) => { - + = (props) => { props.start() } - onCleanup(() => { - if (props.speech.active()) props.speech.cancel() - }) + onCleanup(() => cleanupSpeechCapture(props.speech)) return ( diff --git a/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts b/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts new file mode 100644 index 00000000000..fa97233f3e9 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts @@ -0,0 +1,5 @@ +import type { SpeechToText } from "./useSpeechToText" + +export function cleanupSpeechCapture(speech: Pick) { + if (speech.active()) speech.cancel() +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9b3c279feac..f14577ce029 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1738,11 +1738,8 @@ function StepFinishPart(props: { last: boolean; part: StepFinishPart; message: A const { theme } = useTheme() const info = useContext(RoutedModelMeta.Context) const routed = createMemo(() => { - if (props.message.providerID !== "kilo") return undefined - if (!props.message.modelID.startsWith("kilo-auto/")) return undefined const model = props.part.model - if (!model) return undefined - if (model.providerID === props.message.providerID && model.modelID === props.message.modelID) return undefined + if (!RoutedModelMeta.routed(model, props.message)) return undefined return RoutedModelMeta.label(ctx.providers(), model) }) const consumed = createMemo(() => info().consumed.has(props.part.id)) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts b/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts index 2b91606c86c..ad1d0b58d93 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts @@ -299,7 +299,7 @@ function resolveMotion(text: string, pos: number, key: string, count: number, aw case "$": { let t = pos for (let i = 0; i < reps; i++) t = lineEnd(text, lineEnd(text, t) + (i === 0 ? 0 : 1)) - return { target: lineEnd(text, t), inclusive: true, linewise: false } + return { target: Math.max(lineStart(text, t), lineEnd(text, t) - 1), inclusive: true, linewise: false } } case "G": { let target = 0 diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx index 61b964b2707..12179aba523 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx @@ -39,10 +39,10 @@ export namespace RoutedModelMeta { return KiloRoutedModel.displayName(text) } - function routed(model: StepFinishPart["model"], message: Message) { + export function routed(model: StepFinishPart["model"], message: Message) { if (!model) return undefined if (message.providerID !== "kilo") return undefined - if (!message.modelID.startsWith("kilo-auto/")) return undefined + if (!KiloRoutedModel.routedSelection(message.modelID)) return undefined if (model.providerID === message.providerID && model.modelID === message.modelID) return undefined return model } diff --git a/packages/opencode/src/kilocode/session/routed-model.ts b/packages/opencode/src/kilocode/session/routed-model.ts index eace803049a..ba474dad40f 100644 --- a/packages/opencode/src/kilocode/session/routed-model.ts +++ b/packages/opencode/src/kilocode/session/routed-model.ts @@ -47,10 +47,15 @@ export namespace KiloRoutedModel { input: { providerID: ProviderID; modelID: string; selected?: string }, ) { if (input.providerID !== ProviderID.kilo) return undefined - if (!input.modelID.startsWith("kilo-auto/") && !input.modelID.includes("fable")) return undefined + if (!routedSelection(input.modelID)) return undefined const model = read(meta, input.providerID) if (!model) return undefined if (model.modelID === input.modelID || model.modelID === input.selected) return undefined return model } + + export function routedSelection(modelID: string) { + const id = modelID.trim() + return id.startsWith("kilo-auto/") || /(?:^|[/.])claude-fable(?:[-.:/]|$)/.test(id) + } } diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts index 43a2330d618..878ffed9537 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts @@ -199,6 +199,28 @@ describe("vim edits", () => { expect(doc.text).toBe("bar baz") }) + test("d$ deletes line content without consuming the newline", () => { + const doc = new MockDoc("abc\ndef", 0) + const state = createVimState("normal") + feed(doc, state, "d$") + expect(doc.text).toBe("\ndef") + }) + + test("c$ changes line content without consuming the newline", () => { + const doc = new MockDoc("abc\ndef", 0) + const state = createVimState("normal") + feed(doc, state, "c$") + expect(doc.text).toBe("\ndef") + expect(state.mode).toBe("insert") + }) + + test("y$ yanks line content without the newline", () => { + const doc = new MockDoc("abc\ndef", 0) + const state = createVimState("normal") + feed(doc, state, "y$") + expect(state.register).toEqual({ text: "abc", linewise: false }) + }) + test("dd deletes the current line", () => { const doc = new MockDoc("one\ntwo\nthree", 4) const state = createVimState("normal") diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 467282a3463..b8a41edcc6f 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -243,6 +243,51 @@ describe("kilocode indexing config", () => { }) describe("kilocode sandbox writable paths config", () => { + test("honors sandbox_writable_paths from global config", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { + $schema: "https://app.kilo.ai/config.json", + experimental: { sandbox_writable_paths: ["/tmp/global"] }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"]) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("drops sandbox_writable_paths from project config", async () => { + await using tmp = await tmpdir({ git: true }) + + await writeConfig(tmp.path, { + experimental: { sandbox_writable_paths: ["/tmp/project"] }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.experimental?.sandbox_writable_paths).toBeUndefined() + }, + }) + }) + test("honors sandbox_writable_paths from global config only, ignoring project config", async () => { await using globalTmp = await tmpdir() await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/kilocode/session-routed-model.test.ts b/packages/opencode/test/kilocode/session-routed-model.test.ts index 64981bad902..de86c5af4bc 100644 --- a/packages/opencode/test/kilocode/session-routed-model.test.ts +++ b/packages/opencode/test/kilocode/session-routed-model.test.ts @@ -103,7 +103,7 @@ describe("session routed model", () => { expect(KiloRoutedModel.displayName("o3")).toBe("o3") }) - test("shows compact labels only for Kilo auto selections", () => { + test("shows compact labels only for routed Kilo selections", () => { const model = { providerID: "openai", modelID: "gpt-5.5" } const parts = [reason, finish(model)] @@ -115,6 +115,13 @@ describe("session routed model", () => { expect(routed.footer).toBe("gpt-5.5") expect(routed.consumed.has("finish")).toBe(true) + const fable = RoutedModelMeta.info(undefined, parts, false, { + providerID: "kilo", + modelID: "anthropic/claude-fable-5", + }) + expect(fable.labels.get("reasoning")).toBe("gpt-5.5") + expect(fable.footer).toBe("gpt-5.5") + const explicit = RoutedModelMeta.info(undefined, parts, false, { providerID: "openai", modelID: "gpt-5.5", @@ -169,7 +176,7 @@ describe("session routed model", () => { expect(routed.consumed.has("last")).toBe(false) }) - test("reads routed model only for selected Kilo auto models", () => { + test("reads routed model only for selected Kilo routed models", () => { const meta = { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } } expect( @@ -182,6 +189,22 @@ describe("session routed model", () => { modelID: ModelID.make("openai/gpt-5.5-20260423"), }) + expect( + KiloRoutedModel.readAuto(meta, { + providerID: ProviderID.kilo, + modelID: "anthropic.claude-fable-5", + }), + ).toEqual({ + providerID: ProviderID.kilo, + modelID: ModelID.make("openai/gpt-5.5-20260423"), + }) + + expect( + KiloRoutedModel.readAuto(meta, { + providerID: ProviderID.kilo, + modelID: "openai/affable-model", + }), + ).toBeUndefined() expect( KiloRoutedModel.readAuto(meta, { providerID: ProviderID.kilo,